Skip to content

Commit

Permalink
allow bundled widgets to be hidden from dropdown, and nested (#714)
Browse files Browse the repository at this point in the history
This makes a few refinements to bundling widgets:

  * A widget with `published: false` is not shown in the
    custom widget dropdown in the UI. This is so widgets
    can be bundled with the app for "native" use (like the
    calendar widget) without immediately resulting in an
    extra listing in the UI. (There are improvements we'd
    like to make to the UI to better communicate widget
    provenance and quality eventually, which would be a
    helpful alternative to just a binary flag.)

  * A relative path to the custom widget manifest is
    respected. This will make the bundling process marginally
    neater.
  • Loading branch information
paulfitz authored Oct 31, 2023
1 parent 7c4d9e2 commit 07bb90b
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 129 deletions.
10 changes: 6 additions & 4 deletions app/client/ui/CustomSectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,10 +551,12 @@ export class CustomSectionConfig extends Disposable {
// Options for the select-box (all widgets definitions and Custom URL)
const options = Computed.create(holder, use => [
{label: 'Custom URL', value: 'custom'},
...(use(this._widgets) || []).map(w => ({
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
value: (w.source?.pluginId || '') + ':' + w.widgetId,
})),
...(use(this._widgets) || [])
.filter(w => w?.published !== false)
.map(w => ({
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
value: (w.source?.pluginId || '') + ':' + w.widgetId,
})),
]);
function buildPrompt(level: AccessLevel|null) {
if (!level) {
Expand Down
5 changes: 5 additions & 0 deletions app/common/CustomWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export interface ICustomWidget {
*/
renderAfterReady?: boolean;

/**
* If set to false, do not offer to user in UI.
*/
published?: boolean;

/**
* If the widget came from a plugin, we track that here.
*/
Expand Down
2 changes: 1 addition & 1 deletion app/server/lib/WidgetRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export function getWidgetsInPlugins(gristServer: GristServer,
gristServer.getTag() + '/widgets/' + plugin.id + '/';
places.push({
urlBase,
dir: plugin.path,
dir: path.resolve(plugin.path, path.dirname(components.widgets)),
file: path.join(plugin.path, components.widgets),
name: plugin.manifest.name || plugin.id,
pluginId: plugin.id,
Expand Down
269 changes: 145 additions & 124 deletions test/nbrowser/CustomWidgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,146 +734,167 @@ describe('CustomWidgets', function () {
oldEnv = new EnvironmentSnapshot();
});

after(async function() {
afterEach(async function() {
oldEnv.restore();
await server.restart();
await gu.reloadDoc();
});

it('can add widgets via plugins', async function () {
// Double-check that using one external widget, we see
// just that widget listed.
widgets = [widget1];
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name,
]);

// Get a temporary directory that will be cleaned up,
// and populated it as follows:
// plugins/
// my-widgets/
// manifest.yml # a plugin manifest, listing widgets.json
// widgets.json # a widget set manifest, grist-widget style
// p1.html # one of the widgets in widgets.json
// p2.html # another of the widgets in widgets.json
// grist-plugin-api.js # a dummy api file, to check it is overridden
const dir = await createTmpDir();
const pluginDir = path.join(dir, 'plugins', 'my-widgets');
await fse.mkdirp(pluginDir);

// A plugin, with some widgets in it.
await fse.writeFile(path.join(pluginDir, 'manifest.yml'), `
name: My Widgets
components:
widgets: widgets.json
`);

// A list of a pair of custom widgets, with the widget
// source in the same directory.
await fse.writeFile(
path.join(pluginDir, 'widgets.json'),
JSON.stringify([
{
widgetId: 'p1',
name: 'P1',
url: './p1.html',
},
{
widgetId: 'p2',
name: 'P2',
url: './p2.html',
},
]),
);
for (const variant of ['flat', 'nested'] as const) {
it(`can add widgets via plugins (${variant} layout)`, async function () {
// Double-check that using one external widget, we see
// just that widget listed.
widgets = [widget1];
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name,
]);

// Get a temporary directory that will be cleaned up,
// and populated it as follows ('flat' variant)
// plugins/
// my-widgets/
// manifest.yml # a plugin manifest, listing widgets.json
// widgets.json # a widget set manifest, grist-widget style
// p1.html # one of the widgets in widgets.json
// p2.html # another of the widgets in widgets.json
// grist-plugin-api.js # a dummy api file, to check it is overridden
// In 'nested' variant, widgets.json and the files it refers to are in
// a subdirectory.
const dir = await createTmpDir();
const pluginDir = path.join(dir, 'plugins', 'my-widgets');
const widgetDir = variant === 'nested' ? path.join(pluginDir, 'nested') : pluginDir;
await fse.mkdirp(pluginDir);
await fse.mkdirp(widgetDir);

// A plugin, with some widgets in it.
await fse.writeFile(
path.join(pluginDir, 'manifest.yml'),
`name: My Widgets\n` +
`components:\n` +
` widgets: ${variant === 'nested' ? 'nested/' : ''}widgets.json\n`
);

// The first widget - just contains the text P1.
await fse.writeFile(
path.join(pluginDir, 'p1.html'),
'<html><body>P1</body></html>',
);
// A list of a pair of custom widgets, with the widget
// source in the same directory.
await fse.writeFile(
path.join(widgetDir, 'widgets.json'),
JSON.stringify([
{
widgetId: 'p1',
name: 'P1',
url: './p1.html',
},
{
widgetId: 'p2',
name: 'P2',
url: './p2.html',
},
{
widgetId: 'p3',
name: 'P3',
url: './p3.html',
published: false,
},
]),
);

// The first widget - just contains the text P1.
await fse.writeFile(
path.join(widgetDir, 'p1.html'),
'<html><body>P1</body></html>',
);

// The second widget. This contains the text P2
// if grist is defined after loading grist-plugin-api.js
// (but the js bundled with the widget just throws an
// alert).
await fse.writeFile(
path.join(pluginDir, 'p2.html'),
`
<html>
<script src="./grist-plugin-api.js"></script>
<body>
// The second widget. This contains the text P2
// if grist is defined after loading grist-plugin-api.js
// (but the js bundled with the widget just throws an
// alert).
await fse.writeFile(
path.join(widgetDir, 'p2.html'),
`
<html>
<head><script src="./grist-plugin-api.js"></script></head>
<body>
<div id="readout"></div>
<script>
if (typeof grist !== 'undefined') {
document.getElementById('readout').innerText = 'P2';
}
if (typeof grist !== 'undefined') {
document.getElementById('readout').innerText = 'P2';
}
</script>
</body>
</html>
`
);
// A dummy grist-plugin-api.js - hopefully the actual
// js for the current version of Grist will be served in
// its place.
await fse.writeFile(
path.join(pluginDir, 'grist-plugin-api.js'),
'alert("Error: built in api version used");',
);
</body>
</html>
`
);

// Restart server and reload doc now plugins are in place.
process.env.GRIST_USER_ROOT = dir;
await server.restart();
await gu.reloadDoc();
// The third widget - just contains the text P3.
await fse.writeFile(
path.join(widgetDir, 'p3.html'),
'<html><body>P3</body></html>',
);

// Continue using one external widget.
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();
// A dummy grist-plugin-api.js - hopefully the actual
// js for the current version of Grist will be served in
// its place.
await fse.writeFile(
path.join(widgetDir, 'grist-plugin-api.js'),
'alert("Error: built in api version used");',
);

// Check we see one external widget and two bundled ones.
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name, 'P1 (My Widgets)', 'P2 (My Widgets)',
]);

// Prepare to check content of widgets.
async function getWidgetText(): Promise<string> {
return gu.doInIframe(await getCustomWidgetFrame(), () => {
return driver.executeScript(
() => document.body.innerText
);
});
}
// Restart server and reload doc now plugins are in place.
process.env.GRIST_USER_ROOT = dir;
await server.restart();
await gu.reloadDoc();

// Check built-in P1 works as expected.
await select(/P1/);
assert.equal(await current(), 'P1 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P1');
});
// Continue using one external widget.
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();

// Check external W1 works as expected.
await toggle();
await select(/W1/);
assert.equal(await current(), 'W1');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'W1');
});
// Check we see one external widget and two bundled ones.
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name, 'P1 (My Widgets)', 'P2 (My Widgets)',
]);

// Prepare to check content of widgets.
async function getWidgetText(): Promise<string> {
return gu.doInIframe(await getCustomWidgetFrame(), () => {
return driver.executeScript(
() => document.body.innerText
);
});
}

// Check build-in P2 works as expected.
await toggle();
await select(/P2/);
assert.equal(await current(), 'P2 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
});
// Check built-in P1 works as expected.
await select(/P1/);
assert.equal(await current(), 'P1 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P1');
});

// Make sure widget setting is sticky.
await gu.reloadDoc();
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
// Check external W1 works as expected.
await toggle();
await select(/W1/);
assert.equal(await current(), 'W1');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'W1');
});

// Check build-in P2 works as expected.
await toggle();
await select(/P2/);
assert.equal(await current(), 'P2 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
});

// Make sure widget setting is sticky.
await gu.reloadDoc();
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
});
});
});
}
});
});

0 comments on commit 07bb90b

Please sign in to comment.