mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Extending widget
Summary: - Adding new option 'strictType' to widget mapping - Refreshing mappings when widget options are changed Test Plan: Updated Reviewers: JakubSerafin Reviewed By: JakubSerafin Differential Revision: https://phab.getgrist.com/D4061
This commit is contained in:
parent
572279916e
commit
a8e0f96813
@ -14,7 +14,10 @@ export class ColumnToMapImpl implements Required<ColumnToMap> {
|
|||||||
// If column is optional (used only on the UI).
|
// If column is optional (used only on the UI).
|
||||||
public optional: boolean;
|
public optional: boolean;
|
||||||
// Type of the column that widget expects. Might be a single or a comma separated list of types.
|
// 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;
|
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).
|
// Description of the type (used to show a placeholder).
|
||||||
public typeDesc: string;
|
public typeDesc: string;
|
||||||
// Allow multiple column assignment (like Series in Charts).
|
// Allow multiple column assignment (like Series in Charts).
|
||||||
@ -29,14 +32,26 @@ export class ColumnToMapImpl implements Required<ColumnToMap> {
|
|||||||
this.typeDesc = this.type.split(',')
|
this.typeDesc = this.type.split(',')
|
||||||
.map(t => String(UserType.typeDefs[t]?.label ?? "any").toLowerCase()).join(', ');
|
.map(t => String(UserType.typeDefs[t]?.label ?? "any").toLowerCase()).join(', ');
|
||||||
this.allowMultiple = typeof def === 'string' ? false : (def.allowMultiple ?? false);
|
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.
|
* 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) {
|
public canByMapped(pureType: string) {
|
||||||
return this.type.split(',').includes(pureType)
|
const isAny = pureType === "Any" || this.type === "Any";
|
||||||
|| pureType === "Any"
|
return this.type.split(',').includes(pureType) || (isAny && !this.strictType);
|
||||||
|| this.type === "Any";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -714,16 +714,23 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
if (widgetCol.allowMultiple) {
|
if (widgetCol.allowMultiple) {
|
||||||
// We expect a list of colRefs be mapped;
|
// We expect a list of colRefs be mapped;
|
||||||
if (!Array.isArray(mappedCol)) { continue; }
|
if (!Array.isArray(mappedCol)) { continue; }
|
||||||
result[widgetCol.name] = mappedCol
|
const columns = mappedCol
|
||||||
// Remove all colRefs saved but deleted
|
// Remove all colRefs saved but deleted
|
||||||
.filter(cId => colMap.has(cId))
|
.filter(cId => colMap.has(cId))
|
||||||
// And those with wrong type.
|
// And those with wrong type.
|
||||||
.filter(cId => widgetCol.canByMapped(colMap.get(cId)!.pureType()))
|
.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 {
|
} else {
|
||||||
// Widget expects a single value and existing column
|
// Widget expects a single value and existing column
|
||||||
if (Array.isArray(mappedCol) || !colMap.has(mappedCol)) { continue; }
|
if (Array.isArray(mappedCol) || !colMap.has(mappedCol)) { continue; }
|
||||||
const selectedColumn = colMap.get(mappedCol)!;
|
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;
|
result[widgetCol.name] = widgetCol.canByMapped(selectedColumn.pureType()) ? selectedColumn.colId() : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ export const ColumnToMap = t.iface([], {
|
|||||||
"type": t.opt("string"),
|
"type": t.opt("string"),
|
||||||
"optional": t.opt("boolean"),
|
"optional": t.opt("boolean"),
|
||||||
"allowMultiple": t.opt("boolean"),
|
"allowMultiple": t.opt("boolean"),
|
||||||
|
"strictType": t.opt("boolean"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ColumnsToMap = t.array(t.union("string", "ColumnToMap"));
|
export const ColumnsToMap = t.array(t.union("string", "ColumnToMap"));
|
||||||
|
@ -16,7 +16,8 @@ export interface ColumnToMap {
|
|||||||
*/
|
*/
|
||||||
description?: string|null,
|
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
|
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.
|
* Allow multiple column assignment, the result will be list of mapped table column names.
|
||||||
*/
|
*/
|
||||||
allowMultiple?: boolean,
|
allowMultiple?: boolean,
|
||||||
|
/**
|
||||||
|
* Match column type strictly, so "Any" will require "Any" and not any other type.
|
||||||
|
*/
|
||||||
|
strictType?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ColumnsToMap = (string|ColumnToMap)[];
|
export type ColumnsToMap = (string|ColumnToMap)[];
|
||||||
|
5
test/fixtures/sites/config/index.html
vendored
5
test/fixtures/sites/config/index.html
vendored
@ -39,5 +39,10 @@
|
|||||||
<button onclick="mappings()">mappings</button>
|
<button onclick="mappings()">mappings</button>
|
||||||
<button onclick="configure()">configure</button>
|
<button onclick="configure()">configure</button>
|
||||||
<button onclick="clearOptions()">clearOptions</button>
|
<button onclick="clearOptions()">clearOptions</button>
|
||||||
|
<button onclick="clearLog()">clearLog</button>
|
||||||
|
|
||||||
|
|
||||||
|
<div>meta columns:</div>
|
||||||
|
<textarea id="log"></textarea>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
11
test/fixtures/sites/config/page.js
vendored
11
test/fixtures/sites/config/page.js
vendored
@ -26,6 +26,12 @@ function setup() {
|
|||||||
document.getElementById('onRecords').innerHTML = JSON.stringify(data);
|
document.getElementById('onRecords').innerHTML = JSON.stringify(data);
|
||||||
document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings);
|
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) {
|
async function run(handler) {
|
||||||
@ -67,6 +73,11 @@ async function configure() {
|
|||||||
return run((options) => grist.sectionApi.configure(...options));
|
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 = () => {
|
window.onload = () => {
|
||||||
setup();
|
setup();
|
||||||
document.getElementById('ready').innerText = 'ready';
|
document.getElementById('ready').innerText = 'ready';
|
||||||
|
@ -256,6 +256,10 @@ describe('CustomWidgetsConfig', function () {
|
|||||||
const text = await this._read('#onRecordsMappings');
|
const text = await this._read('#onRecordsMappings');
|
||||||
return JSON.parse(text || 'null');
|
return JSON.parse(text || 'null');
|
||||||
}
|
}
|
||||||
|
public async log() {
|
||||||
|
const text = await this._read('#log');
|
||||||
|
return text || '';
|
||||||
|
}
|
||||||
// Wait for frame to close.
|
// Wait for frame to close.
|
||||||
public async waitForClose() {
|
public async waitForClose() {
|
||||||
await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000);
|
await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000);
|
||||||
@ -293,6 +297,9 @@ describe('CustomWidgetsConfig', function () {
|
|||||||
public async mappings() {
|
public async mappings() {
|
||||||
return await this.invokeOnWidget('mappings');
|
return await this.invokeOnWidget('mappings');
|
||||||
}
|
}
|
||||||
|
public async clearLog() {
|
||||||
|
return await this.invokeOnWidget('clearLog');
|
||||||
|
}
|
||||||
// Invoke method on a Custom Widget.
|
// Invoke method on a Custom Widget.
|
||||||
// Each method is available as a button with content that is equal to the method name.
|
// 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
|
// It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes
|
||||||
@ -679,7 +686,7 @@ describe('CustomWidgetsConfig', function () {
|
|||||||
createConfigUrl({
|
createConfigUrl({
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'M1', type: 'Date,DateTime'},
|
{name: 'M1', type: 'Date,DateTime'},
|
||||||
{name: 'M2', type: 'Date,DateTime', allowMultiple: true},
|
{name: 'M2', type: 'Date, DateTime ', allowMultiple: true},
|
||||||
],
|
],
|
||||||
requiredAccess: 'read table',
|
requiredAccess: 'read table',
|
||||||
})
|
})
|
||||||
@ -731,6 +738,94 @@ describe('CustomWidgetsConfig', function () {
|
|||||||
await revert();
|
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 () => {
|
it('should remove mapping when column is deleted', async () => {
|
||||||
const revert = await gu.begin();
|
const revert = await gu.begin();
|
||||||
await toggleWidgetMenu();
|
await toggleWidgetMenu();
|
||||||
|
Loading…
Reference in New Issue
Block a user