(core) Tweak Add Column menu

Summary:
Tweaking behavior of the unreleased Add Column menu per feedback from
Anais and Dmitry.

Test Plan: WIP

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4089
This commit is contained in:
George Gevoian 2023-10-24 14:32:33 -04:00
parent de33c5a3c6
commit fed697e676
4 changed files with 130 additions and 83 deletions

View File

@ -310,19 +310,17 @@ GridView.gridCommands = {
// Re-define editField after fieldEditSave to make it take precedence for the Enter key. // Re-define editField after fieldEditSave to make it take precedence for the Enter key.
editField: function() { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor(); }, editField: function() { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor(); },
insertFieldBefore: function() { insertFieldBefore: function(maybeKeyboardEvent) {
if (GRIST_NEW_COLUMN_MENU()) { if (GRIST_NEW_COLUMN_MENU() && !maybeKeyboardEvent) {
this._openInsertColumnMenu(this.cursor.fieldIndex()); this._openInsertColumnMenu(this.cursor.fieldIndex());
} else { } else {
// FIXME: remove once New Column menu is enabled by default.
this.insertColumn(null, {index: this.cursor.fieldIndex()}); this.insertColumn(null, {index: this.cursor.fieldIndex()});
} }
}, },
insertFieldAfter: function() { insertFieldAfter: function(maybeKeyboardEvent) {
if (GRIST_NEW_COLUMN_MENU()) { if (GRIST_NEW_COLUMN_MENU() && !maybeKeyboardEvent) {
this._openInsertColumnMenu(this.cursor.fieldIndex() + 1); this._openInsertColumnMenu(this.cursor.fieldIndex() + 1);
} else { } else {
// FIXME: remove once New Column menu is enabled by default.
this.insertColumn(null, {index: this.cursor.fieldIndex() + 1}); this.insertColumn(null, {index: this.cursor.fieldIndex() + 1});
} }
}, },

View File

@ -22,7 +22,7 @@ import {
} from 'app/client/ui2018/menus'; } from 'app/client/ui2018/menus';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {dom, DomElementArg, styled} from 'grainjs'; import {dom, DomElementArg, styled} from 'grainjs';
import {RecalcWhen} from "app/common/gristTypes"; import {isListType, RecalcWhen} from "app/common/gristTypes";
import {ColumnRec} from "app/client/models/entities/ColumnRec"; import {ColumnRec} from "app/client/models/entities/ColumnRec";
import * as weasel from 'popweasel'; import * as weasel from 'popweasel';
import isEqual = require('lodash/isEqual'); import isEqual = require('lodash/isEqual');
@ -42,6 +42,7 @@ export function buildOldAddColumnMenu(gridView: GridView, viewSection: ViewSecti
} }
export function buildAddColumnMenu(gridView: GridView, index?: number) { export function buildAddColumnMenu(gridView: GridView, index?: number) {
const isSummaryTable = Boolean(gridView.viewSection.table().summarySourceTable());
return [ return [
menuItem( menuItem(
async () => { await gridView.insertColumn(null, {index}); }, async () => { await gridView.insertColumn(null, {index}); },
@ -50,8 +51,10 @@ export function buildAddColumnMenu(gridView: GridView, index?: number) {
testId('new-columns-menu-add-new'), testId('new-columns-menu-add-new'),
), ),
buildHiddenColumnsMenuItems(gridView, index), buildHiddenColumnsMenuItems(gridView, index),
buildLookupSection(gridView, index), isSummaryTable ? null : [
buildShortcutsMenuItems(gridView, index), buildLookupSection(gridView, index),
buildShortcutsMenuItems(gridView, index),
],
]; ];
} }
@ -190,51 +193,63 @@ function buildDetectDuplicatesMenuItems(gridView: GridView, index?: number) {
const {viewSection} = gridView; const {viewSection} = gridView;
return menuItemSubmenu( return menuItemSubmenu(
() => searchableMenu( () => searchableMenu(
viewSection.columns().map((col) => ({ viewSection.columns().map((col) => {
cleanText: col.label().trim().toLowerCase(), function buildFormula() {
label: col.label(), if (isListType(col.type())) {
action: async () => { return `any([len(${col.table().tableId()}.lookupRecords(${col.colId()}` +
await gridView.gristDoc.docData.bundleActions(t('Adding duplicates column'), async () => { `=CONTAINS(x))) > 1 for x in $${col.colId()}])`;
const newColInfo = await gridView.insertColumn( } else {
t('Duplicate in {{- label}}', {label: col.label()}), return `$${col.colId()} != "" and $${col.colId()} is not None and ` +
{ `len(${col.table().tableId()}.lookupRecords(` +
colInfo: { `${col.colId()}=$${col.colId()})) > 1`;
label: t('Duplicate in {{- label}}', {label: col.label()}), }
type: 'Bool', }
isFormula: true,
formula: `True if len(${col.table().tableId()}.lookupRecords(` + return {
`${col.colId()}=$${col.colId()})) > 1 else False`, cleanText: col.label().trim().toLowerCase(),
recalcWhen: RecalcWhen.DEFAULT, label: col.label(),
recalcDeps: null, action: async () => {
widgetOptions: JSON.stringify({ await gridView.gristDoc.docData.bundleActions(t('Adding duplicates column'), async () => {
rulesOptions: [{ const newColInfo = await gridView.insertColumn(
fillColor: '#ffc23d', t('Duplicate in {{- label}}', {label: col.label()}),
textColor: '#262633', {
}], colInfo: {
}), label: t('Duplicate in {{- label}}', {label: col.label()}),
}, type: 'Bool',
index, isFormula: true,
skipPopup: true, formula: buildFormula(),
recalcWhen: RecalcWhen.DEFAULT,
recalcDeps: null,
widgetOptions: JSON.stringify({
rulesOptions: [{
fillColor: '#ffc23d',
textColor: '#262633',
}],
}),
},
index,
skipPopup: true,
}
);
// TODO: do the steps below as part of the AddColumn action.
const newField = viewSection.viewFields().all()
.find(field => field.colId() === newColInfo.colId);
if (!newField) {
throw new Error(`Unable to find field for column ${newColInfo.colId}`);
} }
);
// TODO: do the steps below as part of the AddColumn action. await newField.addEmptyRule();
const newField = viewSection.viewFields().all() const newRule = newField.rulesCols()[0];
.find(field => field.colId() === newColInfo.colId); if (!newRule) {
if (!newField) { throw new Error(`Unable to find conditional rule for field ${newField.label()}`);
throw new Error(`Unable to find field for column ${newColInfo.colId}`); }
}
await newField.addEmptyRule(); await newRule.formula.setAndSave(`$${newColInfo.colId}`);
const newRule = newField.rulesCols()[0]; }, {nestInActiveBundle: true});
if (!newRule) { },
throw new Error(`Unable to find conditional rule for field ${newField.label()}`); };
} }),
await newRule.formula.setAndSave(`$${newColInfo.colId}`);
}, {nestInActiveBundle: true});
},
})),
{searchInputPlaceholder: t('Search columns')} {searchInputPlaceholder: t('Search columns')}
), ),
{allowNothingSelected: true}, {allowNothingSelected: true},
@ -354,10 +369,9 @@ function buildLookupSection(gridView: GridView, index?: number){
function buildLookupsMenuItems() { function buildLookupsMenuItems() {
// Function that builds a menu for one of our Ref columns, we will show all columns // Function that builds a menu for one of our Ref columns, we will show all columns
// from the referenced table and offer to create a formula column with aggregation in case // from the referenced table and offer to create a formula column with aggregation in case
// our column is RefList. // our column is RefList.
function buildRefColMenu( function buildRefColMenu(ref: ColumnRec, col: ColumnRec): SearchableMenuItem {
ref: ColumnRec, col: ColumnRec): SearchableMenuItem {
// Helper for searching for this entry. // Helper for searching for this entry.
const cleanText = col.label().trim().toLowerCase(); const cleanText = col.label().trim().toLowerCase();
@ -422,40 +436,41 @@ function buildLookupSection(gridView: GridView, index?: number){
}); });
} }
} }
const {viewSection} = gridView; const {viewSection} = gridView;
const columns = viewSection.columns(); const columns = viewSection.columns();
const onlyRefOrRefList = (c: ColumnRec) => c.pureType() === 'Ref' || c.pureType() === 'RefList'; const onlyRefOrRefList = (c: ColumnRec) => c.pureType() === 'Ref' || c.pureType() === 'RefList';
const references = columns.filter(onlyRefOrRefList); const references = columns.filter(onlyRefOrRefList);
return references.map((ref) => menuItemSubmenu( return references.map((ref) => menuItemSubmenu(
() => searchableMenu( () => searchableMenu(
ref.refTable()?.visibleColumns().map(buildRefColMenu.bind(null, ref)) ?? [], ref.refTable()?.visibleColumns().map(buildRefColMenu.bind(null, ref)) ?? [],
{ {
searchInputPlaceholder: t('Search columns') searchInputPlaceholder: t('Search columns')
} }
), ),
{allowNothingSelected: true}, {allowNothingSelected: true},
ref.label(), `${ref.refTable()?.tableNameDef()} [${ref.label()}]`,
testId(`new-columns-menu-lookups-${ref.colId()}`), testId(`new-columns-menu-lookups-${ref.colId()}`),
) ));
);
} }
interface RefTable {
tableId: string,
tableName: string,
columns: ColumnRec[],
referenceFields: ColumnRec[]
}
function buildReverseLookupsMenuItems() { function buildReverseLookupsMenuItems() {
interface refTable { const getReferencesToThisTable = (): RefTable[] => {
tableId: string,
columns: ColumnRec[],
referenceFields: ColumnRec[]
}
const getReferencesToThisTable = (): refTable[] => {
const {viewSection} = gridView; const {viewSection} = gridView;
const otherTables = gridView.gristDoc.docModel.allTables.all() const otherTables = gridView.gristDoc.docModel.allTables.all().filter((tab) =>
.filter((tab) => tab.tableId.peek() != viewSection.tableId()); tab.summarySourceTable() === 0 && tab.tableId.peek() !== viewSection.tableId());
return otherTables.map((tab) => { return otherTables.map((tab) => {
return { return {
tableId: tab.tableId(), tableId: tab.tableId(),
tableName: tab.tableNameDef(),
columns: tab.visibleColumns(), columns: tab.visibleColumns(),
referenceFields: referenceFields:
tab.columns().peek().filter((c) => (c.pureType() === 'Ref' || c.pureType() == 'RefList') && tab.columns().peek().filter((c) => (c.pureType() === 'Ref' || c.pureType() == 'RefList') &&
@ -465,7 +480,7 @@ function buildLookupSection(gridView: GridView, index?: number){
.filter((tab) => tab.referenceFields.length > 0); .filter((tab) => tab.referenceFields.length > 0);
}; };
const buildColumn = async (tab: refTable, col: any, refCol: any, aggregate: string) => { const buildColumn = async (tab: RefTable, col: ColumnRec, refCol: ColumnRec, aggregate: string) => {
const formula = `${tab.tableId}.lookupRecords(${refCol.colId()}= const formula = `${tab.tableId}.lookupRecords(${refCol.colId()}=
${refCol.pureType() == 'RefList' ? 'CONTAINS($id)' : '$id'})`; ${refCol.pureType() == 'RefList' ? 'CONTAINS($id)' : '$id'})`;
await gridView.insertColumn(`${tab.tableId}_${col.label()}`, { await gridView.insertColumn(`${tab.tableId}_${col.label()}`, {
@ -480,7 +495,7 @@ function buildLookupSection(gridView: GridView, index?: number){
}); });
}; };
const buildSubmenuForRevLookup = (tab: refTable, refCol: any) => { const buildSubmenuForRevLookup = (tab: RefTable, refCol: any) => {
const buildSubmenuForRevLookupMenuItem = (col: ColumnRec): SearchableMenuItem => { const buildSubmenuForRevLookupMenuItem = (col: ColumnRec): SearchableMenuItem => {
const suggestedColumns = suggestAggregation(col); const suggestedColumns = suggestAggregation(col);
const primarySuggestedColumn = suggestedColumns[0]; const primarySuggestedColumn = suggestedColumns[0];
@ -508,11 +523,11 @@ function buildLookupSection(gridView: GridView, index?: number){
tab.columns.map(col => buildSubmenuForRevLookupMenuItem(col)), tab.columns.map(col => buildSubmenuForRevLookupMenuItem(col)),
{searchInputPlaceholder: t('Search columns')} {searchInputPlaceholder: t('Search columns')}
), ),
{allowNothingSelected: true}, `${tab.tableId} By ${refCol.label()}`); {allowNothingSelected: true}, `${tab.tableName} [← ${refCol.label()}]`);
}; };
const tablesWithAnyRefColumn = getReferencesToThisTable(); const tablesWithAnyRefColumn = getReferencesToThisTable();
return tablesWithAnyRefColumn.map((tab: refTable) => tab.referenceFields.map((refCol) => return tablesWithAnyRefColumn.map((tab: RefTable) => tab.referenceFields.map((refCol) =>
buildSubmenuForRevLookup(tab, refCol) buildSubmenuForRevLookup(tab, refCol)
)); ));
} }

View File

@ -576,7 +576,7 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) { export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
return menuItem( return menuItem(
cmd.run, () => cmd.run(),
dom('span', label, testId('cmd-name')), dom('span', label, testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null, cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles cssMenuItemCmd.cls(''), // overrides some menu item styles

View File

@ -119,6 +119,22 @@ describe('GridViewNewColumnMenu', function () {
assert.deepEqual(columns, ['A', 'B', 'C', 'D']); assert.deepEqual(columns, ['A', 'B', 'C', 'D']);
await gu.undo(); await gu.undo();
}); });
it('should skip showing menu when inserting with keyboard shortcuts', async function () {
await gu.sendKeys(Key.chord(Key.ALT, '='));
await gu.waitForServer();
assert.isFalse(await driver.find('.test-new-columns-menu').isPresent());
await gu.sendKeys(Key.ENTER);
let columns = await gu.getColumnNames();
assert.deepEqual(columns, ['A', 'B', 'C', 'D']);
await gu.sendKeys(Key.chord(Key.SHIFT, Key.ALT, '='));
await gu.waitForServer();
assert.isFalse(await driver.find('.test-new-columns-menu').isPresent());
await gu.sendKeys(Key.ENTER);
columns = await gu.getColumnNames();
assert.deepEqual(columns, ['A', 'B', 'C', 'E', 'D']);
await gu.undo(2);
});
}); });
describe('hidden columns', function () { describe('hidden columns', function () {
@ -245,7 +261,7 @@ describe('GridViewNewColumnMenu', function () {
}); });
it('should create new column that checks for duplicates in the specified column', async function () { it('should create new column that checks for duplicates in the specified column', async function () {
const menu = await openAddColumnIcon(); let menu = await openAddColumnIcon();
await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove(); await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove();
await driver.findContentWait('.test-searchable-menu li', 'A', 500).click(); await driver.findContentWait('.test-searchable-menu li', 'A', 500).click();
await gu.waitForServer(); await gu.waitForServer();
@ -254,12 +270,30 @@ describe('GridViewNewColumnMenu', function () {
// Just checking the formula looks plausible - correctness is best left to a python test. // Just checking the formula looks plausible - correctness is best left to a python test.
assert.equal( assert.equal(
await driver.find('.test-formula-editor').getText(), await driver.find('.test-formula-editor').getText(),
'True if len(Table1.lookupRecords(A=$A)) > 1 else False' '$A != "" and $A is not None and len(Table1.lookupRecords(A=$A)) > 1'
); );
await gu.sendKeys(Key.ESCAPE); await gu.sendKeys(Key.ESCAPE);
const columns = await gu.getColumnNames(); let columns = await gu.getColumnNames();
assert.deepEqual(columns, ['A', 'B', 'C', 'Duplicate in A']); assert.deepEqual(columns, ['A', 'B', 'C', 'Duplicate in A']);
await gu.undo(); await gu.undo();
// Try it with list-based columns; the formula should look a little different.
for (const [label, type] of [['Choice', 'Choice List'], ['Ref', 'Reference List']]) {
await gu.addColumn(label, type);
menu = await openAddColumnIcon();
await menu.findWait('.test-new-columns-menu-shortcuts-duplicates', 100).mouseMove();
await driver.findContentWait('.test-searchable-menu li', label, 500).click();
await gu.waitForServer();
await gu.sendKeys(Key.ENTER);
assert.equal(
await driver.find('.test-formula-editor').getText(),
`any([len(Table1.lookupRecords(${label}=CONTAINS(x))) > 1 for x in $${label}])`
);
await gu.sendKeys(Key.ESCAPE);
columns = await gu.getColumnNames();
assert.deepEqual(columns, ['A', 'B', 'C', label, `Duplicate in ${label}`]);
await gu.undo(4);
}
}); });
}); });