mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
a424450cbe
Summary: A new widget type Forms. For now hidden behind GRIST_EXPERIMENTAL_PLUGINS(). This diff contains all the core moving parts as a serves as a base to extend this functionality further. Test Plan: New test added Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4130
1001 lines
34 KiB
TypeScript
1001 lines
34 KiB
TypeScript
import {allCommands} from 'app/client/components/commands';
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import GridView from 'app/client/components/GridView';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {ColumnRec} from "app/client/models/entities/ColumnRec";
|
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
|
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
|
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
|
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {IconName} from "app/client/ui2018/IconList";
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {
|
|
menuCssClass,
|
|
menuDivider,
|
|
menuIcon,
|
|
menuItem,
|
|
menuItemCmd,
|
|
menuItemSubmenu,
|
|
menuItemTrimmed,
|
|
menuSubHeader,
|
|
menuSubHeaderMenu,
|
|
menuText,
|
|
searchableMenu,
|
|
SearchableMenuItem,
|
|
} from 'app/client/ui2018/menus';
|
|
import * as UserType from "app/client/widgets/UserType";
|
|
import {isListType, RecalcWhen} from "app/common/gristTypes";
|
|
import {Sort} from 'app/common/SortSpec';
|
|
import {dom, DomElementArg, styled} from 'grainjs';
|
|
import * as weasel from 'popweasel';
|
|
import * as commands from "../components/commands";
|
|
import isEqual = require('lodash/isEqual');
|
|
|
|
const t = makeT('GridViewMenus');
|
|
|
|
export function buildAddColumnMenu(gridView: GridView, index?: number) {
|
|
const isSummaryTable = Boolean(gridView.viewSection.table().summarySourceTable());
|
|
return [
|
|
buildAddNewColumMenuSection(gridView, index),
|
|
buildHiddenColumnsMenuItems(gridView, index),
|
|
isSummaryTable ? null : [
|
|
buildLookupSection(gridView, index),
|
|
buildShortcutsMenuItems(gridView, index),
|
|
],
|
|
];
|
|
}
|
|
|
|
export function getColumnTypes(gristDoc: GristDoc, tableId: string, pure = false) {
|
|
const typeNames = [
|
|
"Text",
|
|
"Numeric",
|
|
"Int",
|
|
"Bool",
|
|
"Date",
|
|
`DateTime:${gristDoc.docModel.docInfoRow.timezone()}`,
|
|
"Choice",
|
|
"ChoiceList",
|
|
`Ref:${tableId}`,
|
|
`RefList:${tableId}`,
|
|
"Attachments"];
|
|
return typeNames.map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
|
|
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
|
|
displayName: t(ct.obj.label),
|
|
colType: ct.type,
|
|
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
|
|
icon: ct.obj.icon
|
|
})).map(ct => {
|
|
if (!pure) { return ct; }
|
|
else {
|
|
return {
|
|
...ct,
|
|
colType: ct.colType.split(':')[0]
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {
|
|
function buildEmptyNewColumMenuItem() {
|
|
return menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(null, {index});
|
|
},
|
|
t("Add column"),
|
|
testId('new-columns-menu-add-new'),
|
|
);
|
|
}
|
|
|
|
function BuildNewColumnWithTypeSubmenu() {
|
|
const columnTypes = getColumnTypes(gridView.gristDoc, gridView.tableModel.tableMetaRow.tableId());
|
|
|
|
return menuItemSubmenu(
|
|
(ctl) => [
|
|
...columnTypes.map((colType) =>
|
|
menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(null, {index, colInfo: {type: colType.colType}});
|
|
},
|
|
menuIcon(colType.icon as IconName),
|
|
colType.displayName === 'Reference'?
|
|
gridView.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', {
|
|
popupOptions: {
|
|
attach: `.${menuCssClass}`,
|
|
placement: 'left-start',
|
|
}
|
|
}):null,
|
|
colType.displayName,
|
|
testId(`new-columns-menu-add-${colType.testIdName}`)),
|
|
),
|
|
testId('new-columns-menu-add-with-type-submenu'),
|
|
],
|
|
{allowNothingSelected: false},
|
|
t('Add column with type'),
|
|
testId('new-columns-menu-add-with-type')
|
|
);
|
|
}
|
|
|
|
function buildNewFunctionColumnMenuItem() {
|
|
return menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(null, {index, skipPopup: true, colInfo: {isFormula: true}});
|
|
gridView.activateEditorAtCursor();
|
|
commands.allCommands.makeFormula.run();
|
|
commands.allCommands.detachEditor.run();
|
|
},
|
|
withInfoTooltip(
|
|
t('Add formula column'),
|
|
GristTooltips.formulaColumn(),
|
|
{variant: 'hover'}
|
|
),
|
|
testId('new-columns-menu-add-formula'),
|
|
);
|
|
}
|
|
|
|
return [
|
|
buildEmptyNewColumMenuItem(),
|
|
BuildNewColumnWithTypeSubmenu(),
|
|
buildNewFunctionColumnMenuItem()
|
|
];
|
|
}
|
|
|
|
function buildHiddenColumnsMenuItems(gridView: GridView, index?: number) {
|
|
const {viewSection} = gridView;
|
|
const hiddenColumns = viewSection.hiddenColumns();
|
|
if (hiddenColumns.length === 0) { return null; }
|
|
|
|
if (hiddenColumns.length <= 5) {
|
|
return [
|
|
menuDivider(),
|
|
menuSubHeader(t('Hidden Columns'), testId('new-columns-menu-hidden-columns-header')),
|
|
hiddenColumns.map((col: ColumnRec) =>
|
|
menuItem(
|
|
async () => {
|
|
await gridView.showColumn(col.id(), index);
|
|
},
|
|
col.label(),
|
|
testId('new-columns-menu-hidden-column-inlined'),
|
|
)
|
|
),
|
|
];
|
|
} else {
|
|
return [
|
|
menuDivider(),
|
|
menuSubHeaderMenu(
|
|
() => {
|
|
return searchableMenu(
|
|
hiddenColumns.map((col) => ({
|
|
cleanText: col.label().trim().toLowerCase(),
|
|
builder: () => menuItemTrimmed(
|
|
() => gridView.showColumn(col.id(), index),
|
|
col.label(),
|
|
testId('new-columns-menu-hidden-column-collapsed'),
|
|
)
|
|
})),
|
|
{searchInputPlaceholder: t('Search columns')}
|
|
);
|
|
},
|
|
{allowNothingSelected: true},
|
|
t('Hidden Columns'),
|
|
testId('new-columns-menu-hidden-columns-menu')
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
function buildShortcutsMenuItems(gridView: GridView, index?: number) {
|
|
return [
|
|
menuDivider(),
|
|
menuSubHeader(t("Shortcuts"), testId('new-columns-menu-shortcuts')),
|
|
buildTimestampMenuItems(gridView, index),
|
|
buildAuthorshipMenuItems(gridView, index),
|
|
buildDetectDuplicatesMenuItems(gridView, index),
|
|
buildUUIDMenuItem(gridView, index),
|
|
];
|
|
}
|
|
|
|
function buildTimestampMenuItems(gridView: GridView, index?: number) {
|
|
return menuItemSubmenu(() => [
|
|
menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(t('Created at'), {
|
|
colInfo: {
|
|
label: t('Created at'),
|
|
type: `DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,
|
|
isFormula: false,
|
|
formula: 'NOW()',
|
|
recalcWhen: RecalcWhen.DEFAULT,
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
},
|
|
t("Apply to new records"),
|
|
testId('new-columns-menu-shortcuts-timestamp-new'),
|
|
),
|
|
menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(t('Last updated at'), {
|
|
colInfo: {
|
|
label: t('Last updated at'),
|
|
type: `DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,
|
|
isFormula: false,
|
|
formula: 'NOW()',
|
|
recalcWhen: RecalcWhen.MANUAL_UPDATES,
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
},
|
|
t("Apply on record changes"),
|
|
testId('new-columns-menu-shortcuts-timestamp-change'),
|
|
),
|
|
], {},
|
|
t("Timestamp"),
|
|
testId('new-columns-menu-shortcuts-timestamp')
|
|
);
|
|
}
|
|
|
|
function buildAuthorshipMenuItems(gridView: GridView, index?: number) {
|
|
return menuItemSubmenu(() => [
|
|
menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(t('Created by'), {
|
|
colInfo: {
|
|
label: t('Created by'),
|
|
type: 'Text',
|
|
isFormula: false,
|
|
formula: 'user.Name',
|
|
recalcWhen: RecalcWhen.DEFAULT,
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
},
|
|
t("Apply to new records"),
|
|
testId('new-columns-menu-shortcuts-author-new')
|
|
),
|
|
menuItem(
|
|
async () => {
|
|
await gridView.insertColumn(t('Last updated by'), {
|
|
colInfo: {
|
|
label: t('Last updated by'),
|
|
type: 'Text',
|
|
isFormula: false,
|
|
formula: 'user.Name',
|
|
recalcWhen: RecalcWhen.MANUAL_UPDATES,
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
},
|
|
t("Apply on record changes"),
|
|
testId('new-columns-menu-shortcuts-author-change')
|
|
),
|
|
], {}, t("Authorship"), testId('new-columns-menu-shortcuts-author'));
|
|
}
|
|
|
|
function buildDetectDuplicatesMenuItems(gridView: GridView, index?: number) {
|
|
const {viewSection} = gridView;
|
|
return menuItemSubmenu(
|
|
() => searchableMenu(
|
|
viewSection.columns().map((col) => {
|
|
function buildFormula() {
|
|
if (isListType(col.type())) {
|
|
return `any([len(${col.table().tableId()}.lookupRecords(${col.colId()}` +
|
|
`=CONTAINS(x))) > 1 for x in $${col.colId()}])`;
|
|
} else {
|
|
return `$${col.colId()} != "" and $${col.colId()} is not None and ` +
|
|
`len(${col.table().tableId()}.lookupRecords(` +
|
|
`${col.colId()}=$${col.colId()})) > 1`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
cleanText: col.label().trim().toLowerCase(),
|
|
label: col.label(),
|
|
action: async () => {
|
|
await gridView.gristDoc.docData.bundleActions(t('Adding duplicates column'), async () => {
|
|
const newColInfo = await gridView.insertColumn(
|
|
t('Duplicate in {{- label}}', {label: col.label()}),
|
|
{
|
|
colInfo: {
|
|
label: t('Duplicate in {{- label}}', {label: col.label()}),
|
|
type: 'Bool',
|
|
isFormula: 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}`);
|
|
}
|
|
|
|
await newField.addEmptyRule();
|
|
const newRule = newField.rulesCols()[0];
|
|
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')}
|
|
),
|
|
{allowNothingSelected: true},
|
|
t('Detect duplicates in...'),
|
|
testId('new-columns-menu-shortcuts-duplicates'),
|
|
);
|
|
}
|
|
|
|
function buildUUIDMenuItem(gridView: GridView, index?: number) {
|
|
return menuItem(
|
|
async () => {
|
|
await gridView.gristDoc.docData.bundleActions(t('Adding UUID column'), async () => {
|
|
// First create a formula column so that UUIDs are computed for existing cells.
|
|
const {colRef} = await gridView.insertColumn(t('UUID'), {
|
|
colInfo: {
|
|
label: t('UUID'),
|
|
type: 'Text',
|
|
isFormula: true,
|
|
formula: 'UUID()',
|
|
recalcWhen: RecalcWhen.DEFAULT,
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
|
|
// Then convert it to a trigger formula, so that UUIDs aren't re-computed.
|
|
//
|
|
// TODO: remove this step and do it as part of the AddColumn action.
|
|
await gridView.gristDoc.convertToTrigger(colRef, 'UUID()');
|
|
}, {nestInActiveBundle: true});
|
|
},
|
|
withInfoTooltip(
|
|
t('UUID'),
|
|
GristTooltips.uuid(),
|
|
{variant: 'hover'}
|
|
),
|
|
testId('new-columns-menu-shortcuts-uuid'),
|
|
);
|
|
}
|
|
|
|
function menuLabelWithBadge(label: string, toast: string) {
|
|
return cssListLabel(
|
|
cssListCol(label),
|
|
cssListFun(toast));
|
|
}
|
|
|
|
|
|
function buildLookupSection(gridView: GridView, index?: number){
|
|
function suggestAggregation(col: ColumnRec) {
|
|
if (col.pureType() === 'Int' || col.pureType() === 'Numeric') {
|
|
return [
|
|
'sum', 'average', 'min', 'max',
|
|
];
|
|
} else if (col.pureType() === 'Bool') {
|
|
return [
|
|
'count', 'percent'
|
|
];
|
|
} else if (col.pureType() === 'Date' || col.pureType() === 'DateTime') {
|
|
return [
|
|
'list', 'min', 'max',
|
|
];
|
|
} else {
|
|
return [
|
|
'list'
|
|
];
|
|
}
|
|
}
|
|
//colTypeOverload allow to change created column type if default is wrong.
|
|
function buildColumnInfo(
|
|
fun: string,
|
|
referenceToSource: string,
|
|
col: ColumnRec) {
|
|
function formula() {
|
|
switch(fun) {
|
|
case 'list': return `${referenceToSource}.${col.colId()}`;
|
|
case 'average': return `AVERAGE(${referenceToSource}.${col.colId()})`;
|
|
case 'min': return `MIN(${referenceToSource}.${col.colId()})`;
|
|
case 'max': return `MAX(${referenceToSource}.${col.colId()})`;
|
|
case 'count':
|
|
case 'sum': return `SUM(${referenceToSource}.${col.colId()})`;
|
|
case 'percent':
|
|
return `AVERAGE(map(int, ${referenceToSource}.${col.colId()})) if ${referenceToSource} else None`;
|
|
default: return `${referenceToSource}`;
|
|
}
|
|
}
|
|
|
|
function type() {
|
|
switch(fun) {
|
|
case 'average': return 'Numeric';
|
|
case 'min': return col.type();
|
|
case 'max': return col.type();
|
|
case 'count': return 'Int';
|
|
case 'sum': return col.type();
|
|
case 'percent': return 'Numeric';
|
|
case 'list': return 'Any';
|
|
default: return 'Any';
|
|
}
|
|
}
|
|
|
|
function widgetOptions() {
|
|
switch(fun) {
|
|
case 'percent': return {numMode: 'percent'};
|
|
default: return {};
|
|
}
|
|
}
|
|
|
|
return {
|
|
formula: formula(),
|
|
type: type(),
|
|
widgetOptions: JSON.stringify(widgetOptions()),
|
|
isFormula: true,
|
|
};
|
|
}
|
|
|
|
function buildLookupsMenuItems() {
|
|
// 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
|
|
// our column is RefList.
|
|
function buildRefColMenu(ref: ColumnRec, col: ColumnRec): SearchableMenuItem {
|
|
// Helper for searching for this entry.
|
|
const cleanText = col.label().trim().toLowerCase();
|
|
|
|
// Next the label we will show.
|
|
let label: string|HTMLElement;
|
|
// For Ref column we will just show the column name.
|
|
if (ref.pureType() === 'Ref') {
|
|
label = col.label();
|
|
} else {
|
|
// For RefList column we will show the column name and the aggregation function which is the first
|
|
// on of suggested action (and a default action).
|
|
label = menuLabelWithBadge(col.label(), suggestAggregation(col)[0]);
|
|
}
|
|
|
|
return {
|
|
cleanText,
|
|
builder: buildItem
|
|
};
|
|
|
|
function buildItem() {
|
|
if (ref.pureType() === 'Ref') {
|
|
// Just insert a plain menu item that will insert a formula column with lookup.
|
|
return menuItemTrimmed(
|
|
() => insertPlainLookup(), col.label(),
|
|
testId(`new-columns-menu-lookup-column`),
|
|
testId(`new-columns-menu-lookup-column-${col.colId()}`),
|
|
);
|
|
} else {
|
|
// Depending on the number of aggregation functions we will either create a plain menu item
|
|
// or submenu with all the functions.
|
|
const functions = suggestAggregation(col);
|
|
if (functions.length === 1) {
|
|
const action = () => insertAggLookup(functions[0]);
|
|
return menuItem(action, label,
|
|
testId(`new-columns-menu-lookup-column`),
|
|
testId(`new-columns-menu-lookup-column-${col.colId()}`)
|
|
);
|
|
} else {
|
|
return menuItemSubmenu(
|
|
() => functions.map((fun) => menuItem(
|
|
() => insertAggLookup(fun), fun,
|
|
testId(`new-columns-menu-lookup-submenu-function`),
|
|
testId(`new-columns-menu-lookup-submenu-function-${fun}`),
|
|
)),
|
|
{
|
|
action: () => insertAggLookup(suggestAggregation(col)[0]),
|
|
},
|
|
label,
|
|
testId(`new-columns-menu-lookup-submenu`),
|
|
testId(`new-columns-menu-lookup-submenu-${col.colId()}`),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function insertAggLookup(fun: string) {
|
|
return gridView.insertColumn(`${ref.label()}_${col.label()}`, {
|
|
colInfo: {
|
|
label: `${ref.label()}_${col.label()}`,
|
|
...buildColumnInfo(
|
|
fun,
|
|
`$${ref.colId()}`,
|
|
col,
|
|
),
|
|
recalcDeps: null,
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
}
|
|
|
|
function insertPlainLookup() {
|
|
return gridView.insertColumn(`${ref.label()}_${col.label()}`, {
|
|
colInfo: {
|
|
label: `${ref.label()}_${col.label()}`,
|
|
isFormula: true,
|
|
formula: `$${ref.colId()}.${col.colId()}`,
|
|
recalcDeps: null,
|
|
type: col.type(),
|
|
widgetOptions: col.cleanWidgetOptionsJson()
|
|
},
|
|
index,
|
|
skipPopup: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const {viewSection} = gridView;
|
|
const columns = viewSection.columns();
|
|
const onlyRefOrRefList = (c: ColumnRec) => c.pureType() === 'Ref' || c.pureType() === 'RefList';
|
|
const references = columns.filter(onlyRefOrRefList);
|
|
|
|
return references.map((ref) => menuItemSubmenu(
|
|
() => searchableMenu(
|
|
ref.refTable()?.visibleColumns().map(buildRefColMenu.bind(null, ref)) ?? [],
|
|
{
|
|
searchInputPlaceholder: t('Search columns')
|
|
}
|
|
),
|
|
{allowNothingSelected: true},
|
|
`${ref.refTable()?.tableNameDef()} [${ref.label()}]`,
|
|
testId(`new-columns-menu-lookup-${ref.colId()}`),
|
|
testId(`new-columns-menu-lookup`),
|
|
));
|
|
}
|
|
|
|
interface RefTable {
|
|
tableId: string,
|
|
tableName: string,
|
|
columns: ColumnRec[],
|
|
referenceFields: ColumnRec[]
|
|
}
|
|
|
|
function buildReverseLookupsMenuItems() {
|
|
|
|
const getReferencesToThisTable = (): RefTable[] => {
|
|
const {viewSection} = gridView;
|
|
const otherTables = gridView.gristDoc.docModel.allTables.all().filter((tab) =>
|
|
tab.summarySourceTable() === 0 && tab.tableId.peek() !== viewSection.tableId());
|
|
return otherTables.map((tab) => {
|
|
return {
|
|
tableId: tab.tableId(),
|
|
tableName: tab.tableNameDef(),
|
|
columns: tab.visibleColumns(),
|
|
referenceFields:
|
|
tab.visibleColumns.peek().filter((c) => (c.pureType() === 'Ref' || c.pureType() == 'RefList') &&
|
|
c.refTable()?.tableId() === viewSection.tableId())
|
|
};
|
|
})
|
|
.filter((tab) => tab.referenceFields.length > 0);
|
|
};
|
|
|
|
const insertColumn = async (tab: RefTable, col: ColumnRec, refCol: ColumnRec, aggregate: string) => {
|
|
const formula =
|
|
`${tab.tableId}.lookupRecords(${refCol.colId()}=${refCol.pureType() == 'RefList' ? 'CONTAINS($id)' : '$id'})`;
|
|
await gridView.insertColumn(`${tab.tableId}_${col.label()}`, {
|
|
colInfo: {
|
|
label: `${tab.tableId}_${col.label()}`,
|
|
...buildColumnInfo(aggregate,
|
|
formula,
|
|
col)
|
|
},
|
|
index,
|
|
skipPopup: true
|
|
});
|
|
};
|
|
|
|
const tablesWithAnyRefColumn = getReferencesToThisTable();
|
|
return tablesWithAnyRefColumn.map((tab: RefTable) => tab.referenceFields.map((refCol) => {
|
|
const buildSubmenuForRevLookupMenuItem = (col: ColumnRec): SearchableMenuItem => {
|
|
const aggregationList = suggestAggregation(col);
|
|
const firstAggregation = aggregationList[0];
|
|
if (!firstAggregation) {
|
|
throw new Error(`No aggregation suggested for column ${col.label()}`);
|
|
}
|
|
return {
|
|
cleanText: col.label().trim().toLowerCase(),
|
|
builder: () => {
|
|
const content = menuLabelWithBadge(col.label(), firstAggregation);
|
|
// In case we have only one suggested column we will just insert it, and there is no,
|
|
// need for submenu.
|
|
if (aggregationList.length === 1) {
|
|
const action = () => insertColumn(tab, col, refCol, firstAggregation);
|
|
return menuItem(action, content, testId('new-columns-menu-revlookup-column'));
|
|
} else {
|
|
// We have some other suggested columns, we will build submenu for them.
|
|
const submenu = () => {
|
|
const items = aggregationList.map((fun) => {
|
|
const action = () => insertColumn(tab, col, refCol, fun);
|
|
return menuItem(action, fun, testId('new-columns-menu-revlookup-column-function'));
|
|
});
|
|
return items;
|
|
};
|
|
const options = {};
|
|
return menuItemSubmenu(
|
|
submenu,
|
|
options,
|
|
content,
|
|
testId('new-columns-menu-revlookup-submenu'),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
const label = `${tab.tableName} [← ${refCol.label()}]`;
|
|
const options = {allowNothingSelected: true};
|
|
const submenu = () => {
|
|
const subItems = tab.columns.map(buildSubmenuForRevLookupMenuItem);
|
|
return searchableMenu(subItems, {searchInputPlaceholder: t('Search columns')});
|
|
};
|
|
return menuItemSubmenu(submenu, options, label, testId('new-columns-menu-revlookup'));
|
|
}));
|
|
}
|
|
|
|
const lookupMenu = buildLookupsMenuItems();
|
|
const reverseLookupMenu = buildReverseLookupsMenuItems();
|
|
|
|
const menuContent = (lookupMenu.length === 0 && reverseLookupMenu.length === 0)
|
|
? [ menuText(
|
|
t('No reference columns.'),
|
|
testId('new-columns-menu-lookups-none'),
|
|
)]
|
|
: [lookupMenu, reverseLookupMenu];
|
|
|
|
return [
|
|
menuDivider(),
|
|
menuSubHeader(
|
|
withInfoTooltip(
|
|
t('Lookups'),
|
|
GristTooltips.lookups(),
|
|
{variant: 'hover'}
|
|
),
|
|
testId('new-columns-menu-lookups'),
|
|
),
|
|
...menuContent
|
|
];
|
|
}
|
|
|
|
export interface IMultiColumnContextMenu {
|
|
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
|
|
// true for some columns, but not all.
|
|
numColumns: number;
|
|
numFrozen: number;
|
|
disableModify: boolean|'mixed'; // If the columns are read-only. Mixed for multiple columns where some are read-only.
|
|
isReadonly: boolean;
|
|
isRaw: boolean;
|
|
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
|
|
isFormula: boolean|'mixed';
|
|
columnIndices: number[];
|
|
totalColumnCount: number;
|
|
disableFrozenMenu: boolean;
|
|
}
|
|
|
|
interface IColumnContextMenu extends IMultiColumnContextMenu {
|
|
filterOpenFunc: () => void;
|
|
sortSpec: Sort.SortSpec;
|
|
colId: number;
|
|
}
|
|
|
|
export function calcFieldsCondition(fields: ViewFieldRec[], condition: (f: ViewFieldRec) => boolean): boolean|"mixed" {
|
|
return fields.every(condition) ? true : (fields.some(condition) ? "mixed" : false);
|
|
}
|
|
|
|
export function buildColumnContextMenu(options: IColumnContextMenu) {
|
|
const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly } = options;
|
|
|
|
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
|
|
|
|
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
|
|
|
|
return [
|
|
menuItemCmd(allCommands.fieldTabOpen, t("Column Options")),
|
|
menuItem(filterOpenFunc, t("Filter Data")),
|
|
menuDivider({style: 'margin-bottom: 0;'}),
|
|
cssRowMenuItem(
|
|
customMenuItem(
|
|
allCommands.sortAsc.run,
|
|
dom('span', t("Sort"), {style: 'flex: 1 0 auto; margin-right: 8px;'},
|
|
testId('sort-label')),
|
|
icon('Sort', dom.style('transform', 'scaley(-1)')),
|
|
'A-Z',
|
|
dom.style('flex', ''),
|
|
cssCustomMenuItem.cls('-selected', Sort.containsOnly(sortSpec, colId, Sort.ASC)),
|
|
testId('sort-asc'),
|
|
),
|
|
customMenuItem(
|
|
allCommands.sortDesc.run,
|
|
icon('Sort'),
|
|
'Z-A',
|
|
cssCustomMenuItem.cls('-selected', Sort.containsOnly(sortSpec, colId, Sort.DESC)),
|
|
testId('sort-dsc'),
|
|
),
|
|
testId('sort'),
|
|
),
|
|
addToSortLabel ? [
|
|
cssRowMenuItem(
|
|
customMenuItem(
|
|
allCommands.addSortAsc.run,
|
|
cssRowMenuLabel(addToSortLabel, testId('add-to-sort-label')),
|
|
icon('Sort', dom.style('transform', 'scaley(-1)')),
|
|
'A-Z',
|
|
cssCustomMenuItem.cls('-selected', Sort.contains(sortSpec, colId, Sort.ASC)),
|
|
testId('add-to-sort-asc'),
|
|
),
|
|
customMenuItem(
|
|
allCommands.addSortDesc.run,
|
|
icon('Sort'),
|
|
'Z-A',
|
|
cssCustomMenuItem.cls('-selected', Sort.contains(sortSpec, colId, Sort.DESC)),
|
|
testId('add-to-sort-dsc'),
|
|
),
|
|
testId('add-to-sort'),
|
|
),
|
|
] : null,
|
|
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
|
|
menuItem(allCommands.sortFilterTabOpen.run, t("More sort options ..."), testId('more-sort-options')),
|
|
menuDivider({style: 'margin-top: 0;'}),
|
|
menuItemCmd(allCommands.renameField, t("Rename column"), disableForReadonlyColumn),
|
|
freezeMenuItemCmd(options),
|
|
menuDivider(),
|
|
buildMultiColumnMenu((options.disableFrozenMenu = true, options)),
|
|
testId('column-menu'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Note about available options. There is a difference between clearing values (writing empty
|
|
* string, which makes cells blank, including Numeric cells) and converting a column to an empty
|
|
* column (i.e. column with empty formula; in this case a Numeric column becomes all 0s today).
|
|
*
|
|
* We offer both options if data columns are selected. If only formulas, only the second option
|
|
* makes sense.
|
|
*/
|
|
export function buildMultiColumnMenu(options: IMultiColumnContextMenu) {
|
|
const disableForReadonlyColumn = dom.cls('disabled', Boolean(options.disableModify) || options.isReadonly);
|
|
const disableForReadonlyView = dom.cls('disabled', options.isReadonly);
|
|
const num: number = options.numColumns;
|
|
const nameClearColumns = options.isFiltered ?
|
|
t('Reset {{count}} entire columns', {count: num}) :
|
|
t('Reset {{count}} columns', {count: num});
|
|
const nameDeleteColumns = t('Delete {{count}} columns', {count: num});
|
|
const nameHideColumns = t('Hide {{count}} columns', {count: num});
|
|
const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);
|
|
return [
|
|
frozenMenu ? [frozenMenu, menuDivider()]: null,
|
|
// Offered only when selection includes formula columns, and converts only those.
|
|
(options.isFormula ?
|
|
menuItemCmd(allCommands.convertFormulasToData, t("Convert formula to data"),
|
|
disableForReadonlyColumn) : null),
|
|
|
|
// With data columns selected, offer an additional option to clear out selected cells.
|
|
(options.isFormula !== true ?
|
|
menuItemCmd(allCommands.clearValues, t("Clear values"), disableForReadonlyColumn) : null),
|
|
|
|
(!options.isRaw ? menuItemCmd(allCommands.hideFields, nameHideColumns, disableForReadonlyView) : null),
|
|
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
|
|
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
|
|
|
|
menuDivider(),
|
|
menuItemCmd(allCommands.insertFieldBefore, t("Insert column to the left"), disableForReadonlyView),
|
|
menuItemCmd(allCommands.insertFieldAfter, t("Insert column to the right"), disableForReadonlyView)
|
|
];
|
|
}
|
|
|
|
export function freezeAction(options: IMultiColumnContextMenu): { text: string; numFrozen: number; } | null {
|
|
/**
|
|
* When user clicks last column - don't offer freezing
|
|
* When user clicks on a normal column - offer him to freeze all the columns to the
|
|
* left (inclusive).
|
|
* When user clicks on a frozen column - offer him to unfreeze all the columns to the
|
|
* right (inclusive)
|
|
* When user clicks on a set of columns then:
|
|
* - If the set of columns contains the last columns that are frozen - offer unfreezing only those columns
|
|
* - If the set of columns is right after the frozen columns or spans across - offer freezing only those columns
|
|
*
|
|
* All of the above are a single command - toggle freeze
|
|
*/
|
|
|
|
const length = options.numColumns;
|
|
|
|
// make some assertions - number of columns selected should always be > 0
|
|
if (length === 0) { return null; }
|
|
|
|
const indices = options.columnIndices;
|
|
const firstColumnIndex = indices[0];
|
|
const lastColumnIndex = indices[indices.length - 1];
|
|
const numFrozen = options.numFrozen;
|
|
|
|
// if set has last column in it - don't offer freezing
|
|
if (lastColumnIndex == options.totalColumnCount - 1) {
|
|
return null;
|
|
}
|
|
|
|
const isNormalColumn = length === 1 && (firstColumnIndex + 1) > numFrozen;
|
|
const isFrozenColumn = length === 1 && (firstColumnIndex+ 1) <= numFrozen;
|
|
const isSet = length > 1;
|
|
const isLastFrozenSet = isSet && lastColumnIndex + 1 === numFrozen;
|
|
const isFirstNormalSet = isSet && firstColumnIndex === numFrozen;
|
|
const isSpanSet = isSet && firstColumnIndex <= numFrozen && lastColumnIndex >= numFrozen;
|
|
|
|
let text = '';
|
|
|
|
if (!isSet) {
|
|
if (isNormalColumn) {
|
|
// text to show depends on what user selected and how far are we from
|
|
// last frozen column
|
|
|
|
// if user clicked the first column or a column just after frozen set
|
|
if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) {
|
|
text = t('Freeze {{count}} columns', {count: 1});
|
|
} else {
|
|
// else user clicked any other column that is farther, offer to freeze
|
|
// proper number of column
|
|
const properNumber = firstColumnIndex - numFrozen + 1;
|
|
text = numFrozen ?
|
|
t('Freeze {{count}} more columns', {count: properNumber}) :
|
|
t('Freeze {{count}} columns', {count: properNumber});
|
|
}
|
|
return {
|
|
text,
|
|
numFrozen : firstColumnIndex + 1
|
|
};
|
|
} else if (isFrozenColumn) {
|
|
// when user clicked last column in frozen set - offer to unfreeze this column
|
|
if (firstColumnIndex + 1 === numFrozen) {
|
|
text = t('Unfreeze {{count}} columns', {count: 1});
|
|
} else {
|
|
// else user clicked column that is not the last in a frozen set
|
|
// offer to unfreeze proper number of columns
|
|
const properNumber = numFrozen - firstColumnIndex;
|
|
text = properNumber === numFrozen ?
|
|
t('Unfreeze all columns') :
|
|
t('Unfreeze {{count}} columns', {count: properNumber});
|
|
}
|
|
return {
|
|
text,
|
|
numFrozen : indices[0]
|
|
};
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
if (isLastFrozenSet) {
|
|
text = t('Unfreeze {{count}} columns', {count: length});
|
|
return {
|
|
text,
|
|
numFrozen : numFrozen - length
|
|
};
|
|
} else if (isFirstNormalSet) {
|
|
text = t('Freeze {{count}} columns', {count: length});
|
|
return {
|
|
text,
|
|
numFrozen : numFrozen + length
|
|
};
|
|
} else if (isSpanSet) {
|
|
const toFreeze = lastColumnIndex + 1 - numFrozen;
|
|
text = t('Freeze {{count}} more columns', {count: toFreeze});
|
|
return {
|
|
text,
|
|
numFrozen : numFrozen + toFreeze
|
|
};
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function freezeMenuItemCmd(options: IMultiColumnContextMenu) {
|
|
// calculate action available for this options
|
|
const toggle = freezeAction(options);
|
|
// if we can't offer freezing - don't create a menu at all
|
|
// this shouldn't happen - as current design offers some action on every column
|
|
if (!toggle) { return null; }
|
|
// create menu item if we have something to offer
|
|
return menuItemCmd(allCommands.toggleFreeze, toggle.text);
|
|
}
|
|
|
|
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
|
|
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
|
|
// the position (1 based) of colId in the spec.
|
|
function getAddToSortLabel(sortSpec: Sort.SortSpec, colId: number): string|undefined {
|
|
const columnsInSpec = sortSpec.map((n) =>Sort.getColRef(n));
|
|
if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {
|
|
const index = columnsInSpec.indexOf(colId);
|
|
if (index > -1) {
|
|
return t("Sorted (#{{count}})", {count: index + 1});
|
|
} else {
|
|
return t("Add to sort");
|
|
}
|
|
}
|
|
}
|
|
|
|
const cssRowMenuItem = styled((...args: DomElementArg[]) => dom('li', {tabindex: '-1'}, ...args), `
|
|
display: flex;
|
|
outline: none;
|
|
`);
|
|
|
|
const cssRowMenuLabel = styled('div', `
|
|
margin-right: 8px;
|
|
flex: 1 0 auto;
|
|
`);
|
|
|
|
const cssCustomMenuItem = styled('div', `
|
|
padding: 8px 8px;
|
|
display: flex;
|
|
&:not(:hover) {
|
|
background-color: ${theme.menuBg};
|
|
color: ${theme.menuItemFg};
|
|
--icon-color: ${theme.menuItemFg};
|
|
}
|
|
&:last-of-type {
|
|
padding-right: 24px;
|
|
flex: 0 0 auto;
|
|
}
|
|
&:first-of-type {
|
|
padding-left: 24px;
|
|
flex: 1 0 auto;
|
|
}
|
|
&-selected, &-selected:not(:hover) {
|
|
background-color: ${theme.menuItemSelectedBg};
|
|
color: ${theme.menuItemSelectedFg};
|
|
--icon-color: ${theme.menuItemSelectedFg};
|
|
}
|
|
`);
|
|
|
|
function customMenuItem(action: () => void, ...args: DomElementArg[]) {
|
|
const element: HTMLElement = cssCustomMenuItem(
|
|
...args,
|
|
dom.on('click', () => action()),
|
|
);
|
|
return element;
|
|
}
|
|
|
|
const cssListLabel = styled('div', `
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
flex: 1;
|
|
`);
|
|
|
|
const cssListCol = styled('div', `
|
|
flex: 1 0 auto;
|
|
`);
|
|
|
|
const cssListFun = styled('div', `
|
|
flex: 0 0 auto;
|
|
margin-left: 8px;
|
|
text-transform: lowercase;
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
background-color: ${theme.choiceTokenBg};
|
|
font-size: ${vars.xsmallFontSize};
|
|
min-width: 28px;
|
|
text-align: center;
|
|
.${weasel.cssMenuItem.className}-sel & {
|
|
color: ${theme.choiceTokenFg};
|
|
}
|
|
`);
|