gristlabs_grist-core/test/nbrowser/SelectBySummary.ts
Alex Hall bd52665f96 (core) Allow adding rows to widgets filtered by a link using a formula column
Summary:
When a widget `A` is selected by a widget `B` so that `A` is filtered, adding a new row to `A` uses the values in the selected row of `B` and the columns relevant to the linking as default values for the new row. This ensures that the new row matches the current linking filter and remains visible. However this would previously cause a sandbox error when one of the linking columns was a formula column, which doesn't allow setting values. This diff ignores formula columns when picking default values.

Since the value of the formula column in the new row typically won't match the linking filter, extra measures are needed to avoid the new row immediately disappearing. Regular filters already have a mechanism for this, but I didn't manage to extend it to also work for linking. Thanks @dsagal for creating `UnionRowSource` (originally in D4017) which is now used as the solution for temporarily exempting rows from both kinds of filtering.

While testing, I also came across another bug in linking summary tables that caused incorrect filtering, which I fixed with some changes to `DynamicQuerySet`.

Test Plan: Extended an nbrowser test, which both tests for the main change as well as the secondary bugfix.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4135
2023-12-18 20:28:41 +02:00

327 lines
9.5 KiB
TypeScript

import * as _ from 'lodash';
import {assert, driver} from 'mocha-webdriver';
import {enterRulePart, findDefaultRuleSet} from 'test/nbrowser/aclTestUtils';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('SelectBySummary', function() {
this.timeout(50000);
const cleanup = setupTestSuite();
let headers: Record<string, string>;
gu.bigScreen();
before(async function() {
const session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'SelectBySummary.grist');
headers = {
Authorization: `Bearer ${session.getApiKey()}`
};
});
it('should filter a source table selected by a summary table (first option)', async function() {
await checkSelectingRecords(
headers,
['onetwo'],
[
'1', '16',
'2', '20',
],
[
[
'1', 'a', '1',
'1', 'b', '3',
'1', 'a\nb', '5',
'1', '', '7',
],
[
'2', 'a', '2',
'2', 'b', '4',
'2', 'a\nb', '6',
'2', '', '8',
],
],
);
});
it('should filter a source table selected by a summary table (second option)', async function() {
await checkSelectingRecords(
headers,
['choices'],
[
'a', '14',
'b', '18',
'', '15',
],
[
[
'1', 'a', '1',
'2', 'a', '2',
'1', 'a\nb', '5',
'2', 'a\nb', '6',
],
[
'1', 'b', '3',
'2', 'b', '4',
'1', 'a\nb', '5',
'2', 'a\nb', '6',
],
[
'1', '', '7',
'2', '', '8',
],
],
);
});
it('should filter a source table selected by a summary table (both options)', async function() {
await checkSelectingRecords(
headers,
['onetwo', 'choices'],
[
'1', 'a', '6',
'2', 'a', '8',
'1', 'b', '8',
'2', 'b', '10',
'1', '', '7',
'2', '', '8',
],
[
[
'1', 'a', '1',
'1', 'a\nb', '5',
],
[
'2', 'a', '2',
'2', 'a\nb', '6',
],
[
'1', 'b', '3',
'1', 'a\nb', '5',
],
[
'2', 'b', '4',
'2', 'a\nb', '6',
],
[
'1', '', '7',
],
[
'2', '', '8',
],
],
);
});
it('should create new rows in the source table (link target) with correct default values',
gu.revertChanges(async function() {
// Select the record with ['2', 'a'] in the summary table
// so those values will be used as defaults in the source table
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click();
// Create new records with rownum = 99 and 100
await gu.getCell({section: 'TABLE1', col: 'rownum', rowNum: 3}).click();
await gu.enterCell('99');
await gu.enterCell('100');
assert.deepEqual(
await gu.getVisibleGridCells({
section: 'TABLE1',
cols: ['onetwo', 'choices', 'rownum'],
rowNums: [1, 2, 3, 4, 5],
}),
[
'2', 'a', '2',
'2', 'a\nb', '6',
// The two rows we just added.
// The filter link sets the default value 'a'.
// It can't set a default value for 'onetwo' because that's a formula column.
// This first row doesn't match the filter link, but it still shows temporarily.
'1', 'a', '99',
'2', 'a', '100',
'', '', '', // new row
],
);
// Select a different record in the summary table, sanity check the linked table.
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 3}).click();
assert.deepEqual(
await gu.getVisibleGridCells({
section: 'TABLE1',
cols: ['onetwo', 'choices', 'rownum'],
rowNums: [1, 2, 3],
}),
[
'1', 'b', '3',
'1', 'a\nb', '5',
'', '', '', // new row
],
);
// Now go back to the previously selected summary table row.
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click();
assert.deepEqual(
await gu.getVisibleGridCells({
section: 'TABLE1',
cols: ['onetwo', 'choices', 'rownum'],
rowNums: [1, 2, 3, 4],
}),
[
'2', 'a', '2',
'2', 'a\nb', '6',
// The row ['1', 'a', '99'] is now filtered out as normal.
'2', 'a', '100',
'', '', '', // new row
],
);
})
);
it('should filter a summary table selected by a less detailed summary table', async function() {
// Delete the Table1 widget so that we can hide the table in ACL without hiding the whole page.
const menu = await gu.openSectionMenu('viewLayout', 'TABLE1');
await menu.findContent('.test-cmd-name', 'Delete widget').click();
await gu.waitForServer();
// Open the ACL UI
await driver.find('.test-tools-access-rules').click();
await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete.
// Deny all access to Table1.
await driver.findContentWait('button', /Add Table Rules/, 2000).click();
await driver.findContentWait('.grist-floating-menu li', /Table1/, 3000).click();
const ruleSet = findDefaultRuleSet(/Table1/);
await enterRulePart(ruleSet, 1, null, 'Deny All');
await driver.find('.test-rules-save').click();
await gu.waitForServer();
// Go back to the main page.
await gu.getPageItem('Table1').click();
// Now check filter linking, but with the detailed summary 'TABLE1 [by onetwo, choices]' as the target,
// selecting by the two less detailed summaries.
// There was a bug previously that this would not work while the summary source table (Table1) was hidden.
await checkSelectingRecords(
headers,
['onetwo'],
[
'1', '16',
'2', '20',
],
[
[
'1', 'a', '6',
'1', 'b', '8',
'1', '', '7',
],
[
'2', 'a', '8',
'2', 'b', '10',
'2', '', '8',
],
],
// This argument was not used in the previous test, as TABLE1 is the default.
'TABLE1 [by onetwo, choices]',
);
await checkSelectingRecords(
headers,
['choices'],
[
'a', '14',
'b', '18',
'', '15',
],
[
[
'1', 'a', '6',
'2', 'a', '8',
],
[
'1', 'b', '8',
'2', 'b', '10',
],
[
'1', '', '7',
'2', '', '8',
],
],
'TABLE1 [by onetwo, choices]',
);
});
});
/**
* Makes `targetSection` select by the existing summary table grouped by groubyColumns.
* Asserts that the summary table has the data summaryData under groubyColumns and rownum.
* Asserts that clicking each row in the summary table filters the target section
* to the corresponding subarray of `targetData`.
*/
async function checkSelectingRecords(
headers: Record<string, string>,
groubyColumns: string[],
summaryData: string[],
targetData: string[][],
targetSection: string = 'TABLE1',
) {
const summarySection = `TABLE1 [by ${groubyColumns.join(', ')}]`;
await gu.openSelectByForSection(targetSection);
await driver.findContent('.test-select-row', summarySection).click();
await gu.waitForServer();
assert.deepEqual(
await gu.getVisibleGridCells({
section: summarySection,
cols: [...groubyColumns, 'rownum'],
rowNums: _.range(1, targetData.length + 1)
}),
summaryData,
);
async function checkTargetGroup(targetGroupIndex: number) {
const targetGroup = targetData[targetGroupIndex];
const countCell = await gu.getCell({section: summarySection, col: 'count', rowNum: targetGroupIndex + 1});
const numTargetRows = targetGroup.length / 3;
await countCell.click();
assert.deepEqual(
await gu.getVisibleGridCells({
section: targetSection,
cols: ['onetwo', 'choices', 'rownum'],
rowNums: _.range(1, numTargetRows + 1),
}),
targetGroup
);
if (targetSection === 'TABLE1') {
assert.equal(await countCell.getText(), numTargetRows.toString());
const csvCells = await gu.downloadSectionCsvGridCells(targetSection, headers);
// visible cells text uses newlines to separate list items, CSV export uses commas
const expectedCsvCells = targetGroup.map(s => s.replace("\n", ", "));
assert.deepEqual(csvCells, expectedCsvCells);
}
}
for (let i = 0; i < targetData.length; i++) {
await checkTargetGroup(i);
}
if (targetSection === 'TABLE1') {
// Check recursiveMoveToCursorPos
for (let rowNum = 1; rowNum <= 8; rowNum++) {
// Click an anchor link
const anchorCell = gu.getCell({section: "Anchors", rowNum, col: 1});
await driver.withActions(a => a.click(anchorCell.find('.test-tb-link')));
// Check that navigation to the link target worked
assert.equal(await gu.getActiveSectionTitle(), "TABLE1");
assert.equal(await gu.getActiveCell().getText(), String(rowNum));
// Check that the link target is still filtered correctly by the link source,
// which should imply that the link source cursor is in the right place
await gu.selectSectionByTitle(summarySection);
const summaryRowNum = await gu.getSelectedRowNum();
await checkTargetGroup(summaryRowNum - 1);
}
}
}