diff --git a/app/client/models/ColumnToMap.ts b/app/client/models/ColumnToMap.ts index 988d150d..748a61aa 100644 --- a/app/client/models/ColumnToMap.ts +++ b/app/client/models/ColumnToMap.ts @@ -14,7 +14,10 @@ export class ColumnToMapImpl implements Required { // If column is optional (used only on the UI). public optional: boolean; // Type of the column that widget expects. Might be a single or a comma separated list of types. + // "Any" means that any type is allowed (unless strictType is true). public type: string; + // If true, the column type is strict and cannot be any type. + public strictType: boolean; // Description of the type (used to show a placeholder). public typeDesc: string; // Allow multiple column assignment (like Series in Charts). @@ -29,14 +32,26 @@ export class ColumnToMapImpl implements Required { this.typeDesc = this.type.split(',') .map(t => String(UserType.typeDefs[t]?.label ?? "any").toLowerCase()).join(', '); this.allowMultiple = typeof def === 'string' ? false : (def.allowMultiple ?? false); + this.strictType = typeof def === 'string' ? false : (def.strictType ?? false); } /** * Does the column type matches this definition. + * + * Here are use case examples, for better understanding (Any is treated as a star): + * 1. Widget sets "Text", user can map to "Text" or "Any". + * 2. Widget sets "Any", user can map to "Int", "Toggle", "Any" and any other type. + * 3. Widget sets "Text,Int", user can map to "Text", "Int", "Any" + * + * With strictType, the Any in the widget is treated as Any, not a star. + * 1. Widget sets "Text", user can map to "Text". + * 2. Widget sets "Any", user can map to "Any". Not to "Text", "Int", etc. NOTICE: here Any in widget is not a star, + * widget expects Any as a type so "Toggle" column won't be allowed. + * 3. Widget sets "Text,Int", user can only map to "Text", "Int". + * 4. Widget sets "Text,Any", user can only map to "Text", "Any". */ public canByMapped(pureType: string) { - return this.type.split(',').includes(pureType) - || pureType === "Any" - || this.type === "Any"; + const isAny = pureType === "Any" || this.type === "Any"; + return this.type.split(',').includes(pureType) || (isAny && !this.strictType); } } diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index f7f1072d..3e6ad7d3 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -714,16 +714,23 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): if (widgetCol.allowMultiple) { // We expect a list of colRefs be mapped; if (!Array.isArray(mappedCol)) { continue; } - result[widgetCol.name] = mappedCol + const columns = mappedCol // Remove all colRefs saved but deleted .filter(cId => colMap.has(cId)) // And those with wrong type. .filter(cId => widgetCol.canByMapped(colMap.get(cId)!.pureType())) - .map(cId => colMap.get(cId)!.colId()); + .map(cId => colMap.get(cId)!); + + // Make a subscription to get notified when widget options are changed. + columns.forEach(c => c.widgetOptions()); + + result[widgetCol.name] = columns.map(c => c.colId()); } else { // Widget expects a single value and existing column if (Array.isArray(mappedCol) || !colMap.has(mappedCol)) { continue; } const selectedColumn = colMap.get(mappedCol)!; + // Make a subscription to the column to get notified when it changes. + void selectedColumn.widgetOptions(); result[widgetCol.name] = widgetCol.canByMapped(selectedColumn.pureType()) ? selectedColumn.colId() : null; } } diff --git a/app/plugin/CustomSectionAPI-ti.ts b/app/plugin/CustomSectionAPI-ti.ts index 7fb03df8..aea8a325 100644 --- a/app/plugin/CustomSectionAPI-ti.ts +++ b/app/plugin/CustomSectionAPI-ti.ts @@ -11,6 +11,7 @@ export const ColumnToMap = t.iface([], { "type": t.opt("string"), "optional": t.opt("boolean"), "allowMultiple": t.opt("boolean"), + "strictType": t.opt("boolean"), }); export const ColumnsToMap = t.array(t.union("string", "ColumnToMap")); diff --git a/app/plugin/CustomSectionAPI.ts b/app/plugin/CustomSectionAPI.ts index 1024c1b3..8f87769e 100644 --- a/app/plugin/CustomSectionAPI.ts +++ b/app/plugin/CustomSectionAPI.ts @@ -16,7 +16,8 @@ export interface ColumnToMap { */ description?: string|null, /** - * Column type, by default ANY. + * Column types (as comma separated list), by default "Any", what means that any type is + * allowed (unless strictType is true). */ type?: string, // GristType, TODO: ts-interface-checker doesn't know how to parse this /** @@ -27,6 +28,10 @@ export interface ColumnToMap { * Allow multiple column assignment, the result will be list of mapped table column names. */ allowMultiple?: boolean, + /** + * Match column type strictly, so "Any" will require "Any" and not any other type. + */ + strictType?: boolean, } export type ColumnsToMap = (string|ColumnToMap)[]; diff --git a/test/fixtures/sites/config/index.html b/test/fixtures/sites/config/index.html index 2c3cb8fa..a89c32a3 100644 --- a/test/fixtures/sites/config/index.html +++ b/test/fixtures/sites/config/index.html @@ -39,5 +39,10 @@ + + + +
meta columns:
+ diff --git a/test/fixtures/sites/config/page.js b/test/fixtures/sites/config/page.js index 3340f201..754c10dc 100644 --- a/test/fixtures/sites/config/page.js +++ b/test/fixtures/sites/config/page.js @@ -26,6 +26,12 @@ function setup() { document.getElementById('onRecords').innerHTML = JSON.stringify(data); document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings); }); + + grist.on('message', event => { + const existing = document.getElementById('log').textContent || ''; + const newContent = `${existing}\n${JSON.stringify(event)}`.trim(); + document.getElementById('log').innerHTML = newContent; + }); } async function run(handler) { @@ -67,6 +73,11 @@ async function configure() { return run((options) => grist.sectionApi.configure(...options)); } +// eslint-disable-next-line no-unused-vars +async function clearLog() { + return run(() => document.getElementById('log').textContent = ''); +} + window.onload = () => { setup(); document.getElementById('ready').innerText = 'ready'; diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 3e77f810..269c0893 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -256,6 +256,10 @@ describe('CustomWidgetsConfig', function () { const text = await this._read('#onRecordsMappings'); return JSON.parse(text || 'null'); } + public async log() { + const text = await this._read('#log'); + return text || ''; + } // Wait for frame to close. public async waitForClose() { await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000); @@ -293,6 +297,9 @@ describe('CustomWidgetsConfig', function () { public async mappings() { return await this.invokeOnWidget('mappings'); } + public async clearLog() { + return await this.invokeOnWidget('clearLog'); + } // Invoke method on a Custom Widget. // Each method is available as a button with content that is equal to the method name. // It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes @@ -679,7 +686,7 @@ describe('CustomWidgetsConfig', function () { createConfigUrl({ columns: [ {name: 'M1', type: 'Date,DateTime'}, - {name: 'M2', type: 'Date,DateTime', allowMultiple: true}, + {name: 'M2', type: 'Date, DateTime ', allowMultiple: true}, ], requiredAccess: 'read table', }) @@ -731,6 +738,94 @@ describe('CustomWidgetsConfig', function () { await revert(); }); + it('should support strictType setting', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + await gu.setWidgetUrl( + createConfigUrl({ + columns: [ + {name: 'Any', type: 'Any', strictType: true}, + {name: 'Date_Numeric', type: 'Date, Numeric', strictType: true}, + {name: 'Date_Any', type: 'Date, Any', strictType: true}, + {name: 'Date', type: 'Date', strictType: true}, + ], + requiredAccess: 'read table', + }) + ); + await accept(); + await widget.waitForFrame(); + await gu.sendActions([ + ['AddVisibleColumn', 'Table1', 'Any', {type: 'Any'}], + ['AddVisibleColumn', 'Table1', 'Date', {type: 'Date'}], + ['AddVisibleColumn', 'Table1', 'Numeric', {type: 'Numeric'}], + ]); + + await gu.selectSectionByTitle('Widget'); + // Make sure we have no mappings + assert.deepEqual(await widget.onRecordsMappings(), null); + + await toggleDrop(pickerDrop('Date')); + assert.deepEqual(await getOptions(), ['Date']); + + await toggleDrop(pickerDrop('Date_Any')); + assert.deepEqual(await getOptions(), ['Any', 'Date']); + + await toggleDrop(pickerDrop('Date_Numeric')); + assert.deepEqual(await getOptions(), ['Date', 'Numeric']); + + await toggleDrop(pickerDrop('Any')); + assert.deepEqual(await getOptions(), ['Any']); + + await revert(); + }); + + it('should react to widget options change', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + await gu.setWidgetUrl( + createConfigUrl({ + columns: [ + {name: 'Choice', type: 'Choice', strictType: true}, + ], + requiredAccess: 'read table', + }) + ); + + const widgetOptions = { + choices: ['A'], + choiceOptions: {A: {textColor: 'red'}} + }; + + await gu.sendActions([ + ['AddVisibleColumn', 'Table1', 'Choice', {type: 'Choice', widgetOptions: JSON.stringify(widgetOptions)}] + ]); + await accept(); + await widget.waitForFrame(); + + await gu.selectSectionByTitle('Widget'); + await toggleDrop(pickerDrop('Choice')); + await clickOption('Choice'); + + // Clear logs + await widget.clearLog(); + assert.isEmpty(await widget.log()); + + // Now update options in that one column; + widgetOptions.choiceOptions.A.textColor = 'blue'; + await gu.sendActions([ + ['ModifyColumn', 'Table1', 'Choice', {widgetOptions: JSON.stringify(widgetOptions)}] + ]); + + await gu.waitToPass(async () => { + // Make sure widget sees that mapping are changed. + assert.equal(await widget.log(), '{"tableId":"Table1","rowId":1,"dataChange":true,"mappingsChange":true}'); + }); + + await revert(); + }); + it('should remove mapping when column is deleted', async () => { const revert = await gu.begin(); await toggleWidgetMenu();