mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Implement UI for trigger formulas.
Summary: - Implement UI with "Apply to new records" and "Apply on record changes" checkboxes, and options for selecting which changes to recalculate on. - For consistency, always represent empty RefList as None - Fix up generated SchemaTypes to remember that values are encoded. Included test cases for the main planned use cases: - Auto-filled UUID column - Data cleaning - NOW() formula for record's last-updated timestamp. - Updates that depend on other columns. Test Plan: Added a browser test. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D2885
This commit is contained in:
parent
e180641c7d
commit
b537539b73
@ -529,7 +529,7 @@ GridView.prototype._convertFormulasToData = function(selection) {
|
|||||||
// prevented by ACL rules).
|
// prevented by ACL rules).
|
||||||
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
|
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
|
||||||
if (!fields.length) { return null; }
|
if (!fields.length) { return null; }
|
||||||
return this.gristDoc.convertFormulasToData(fields.map(f => f.colRef.peek()));
|
return this.gristDoc.convertIsFormula(fields.map(f => f.colRef.peek()), {toFormula: false});
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype.selectAll = function() {
|
GridView.prototype.selectAll = function() {
|
||||||
|
@ -42,6 +42,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
|||||||
import {isSchemaAction} from 'app/common/DocActions';
|
import {isSchemaAction} from 'app/common/DocActions';
|
||||||
import {OpenLocalDocResult} from 'app/common/DocListAPI';
|
import {OpenLocalDocResult} from 'app/common/DocListAPI';
|
||||||
import {HashLink, IDocPage} from 'app/common/gristUrls';
|
import {HashLink, IDocPage} from 'app/common/gristUrls';
|
||||||
|
import {RecalcWhen} from 'app/common/gristTypes';
|
||||||
import {encodeQueryParams, waitObs} from 'app/common/gutil';
|
import {encodeQueryParams, waitObs} from 'app/common/gutil';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {TableData} from 'app/common/TableData';
|
import {TableData} from 'app/common/TableData';
|
||||||
@ -572,16 +573,20 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
['BulkUpdateRecord', colRefs, {
|
['BulkUpdateRecord', colRefs, {
|
||||||
isFormula: colRefs.map(f => true),
|
isFormula: colRefs.map(f => true),
|
||||||
formula: colRefs.map(f => ''),
|
formula: colRefs.map(f => ''),
|
||||||
|
// Set recalc settings to defaults when emptying a column.
|
||||||
|
recalcWhen: colRefs.map(f => RecalcWhen.DEFAULT),
|
||||||
|
recalcDeps: colRefs.map(f => null),
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the given columns to data, saving the calculated values and unsetting the formulas.
|
// Convert the given columns to data, saving the calculated values and unsetting the formulas.
|
||||||
public async convertFormulasToData(colRefs: number[]): Promise<void> {
|
public async convertIsFormula(colRefs: number[], opts: {toFormula: boolean, noRecalc?: boolean}): Promise<void> {
|
||||||
return this.docModel.columns.sendTableAction(
|
return this.docModel.columns.sendTableAction(
|
||||||
['BulkUpdateRecord', colRefs, {
|
['BulkUpdateRecord', colRefs, {
|
||||||
isFormula: colRefs.map(f => false),
|
isFormula: colRefs.map(f => opts.toFormula),
|
||||||
formula: colRefs.map(f => ''),
|
recalcWhen: colRefs.map(f => opts.noRecalc ? RecalcWhen.NEVER : RecalcWhen.DEFAULT),
|
||||||
|
recalcDeps: colRefs.map(f => null),
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import type {GristDoc} from 'app/client/components/GristDoc';
|
|||||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
import type {CursorPos} from "app/client/components/Cursor";
|
import type {CursorPos} from "app/client/components/Cursor";
|
||||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||||
|
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
|
||||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||||
@ -68,7 +69,8 @@ export function buildFormulaConfig(
|
|||||||
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
|
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
|
||||||
) {
|
) {
|
||||||
const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]);
|
const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]);
|
||||||
const convertToData = () => gristDoc.convertFormulasToData([origColumn.id.peek()]);
|
const convertIsFormula =
|
||||||
|
(opts: {toFormula: boolean, noRecalc?: boolean}) => gristDoc.convertIsFormula([origColumn.id.peek()], opts);
|
||||||
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
|
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
|
||||||
|
|
||||||
return dom.maybe(use => {
|
return dom.maybe(use => {
|
||||||
@ -98,7 +100,7 @@ export function buildFormulaConfig(
|
|||||||
return [
|
return [
|
||||||
buildHeader('EMPTY COLUMN', () => [
|
buildHeader('EMPTY COLUMN', () => [
|
||||||
menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
|
menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
|
||||||
menuItem(convertToData, 'Make into data column'),
|
menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'),
|
||||||
]),
|
]),
|
||||||
buildFormulaRow(),
|
buildFormulaRow(),
|
||||||
];
|
];
|
||||||
@ -106,17 +108,27 @@ export function buildFormulaConfig(
|
|||||||
return [
|
return [
|
||||||
buildHeader('FORMULA COLUMN', () => [
|
buildHeader('FORMULA COLUMN', () => [
|
||||||
menuItem(clearColumn, 'Clear column'),
|
menuItem(clearColumn, 'Clear column'),
|
||||||
menuItem(convertToData, 'Convert to data column'),
|
menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'),
|
||||||
]),
|
]),
|
||||||
buildFormulaRow(),
|
buildFormulaRow(),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
buildHeader('DATA COLUMN', () => [
|
buildHeader('DATA COLUMN', () => {
|
||||||
menuItem(clearColumn, 'Clear and make into formula'),
|
return origColumn.formula.peek() ? [
|
||||||
]),
|
// If there is a formula available, offer a separate option to convert to formula
|
||||||
buildFormulaRow('Default formula'),
|
// without clearing it.
|
||||||
cssHintRow('Default formula for new records'),
|
menuItem(clearColumn, 'Clear column'),
|
||||||
|
menuItem(() => convertIsFormula({toFormula: true}), 'Convert to formula column'),
|
||||||
|
] : [
|
||||||
|
menuItem(clearColumn, 'Clear and make into formula'),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
buildFormulaRow('Optional formula'),
|
||||||
|
dom.domComputed(use => Boolean(use(origColumn.formula)), (haveFormula) => haveFormula ?
|
||||||
|
dom.create(buildFormulaTriggers, origColumn) :
|
||||||
|
cssHintRow('For default values, automatic updates, and data-cleaning.')
|
||||||
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,7 +250,6 @@ const cssDropdownLabel = styled(cssInlineLabel, `
|
|||||||
const cssHintRow = styled('div', `
|
const cssHintRow = styled('div', `
|
||||||
margin: -4px 16px 8px 16px;
|
margin: -4px 16px 8px 16px;
|
||||||
color: ${colors.slate};
|
color: ${colors.slate};
|
||||||
text-align: center;
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssColLabelBlock = styled('div', `
|
const cssColLabelBlock = styled('div', `
|
||||||
|
271
app/client/ui/TriggerFormulas.ts
Normal file
271
app/client/ui/TriggerFormulas.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
|
import type {TableRec} from 'app/client/models/entities/TableRec';
|
||||||
|
import {reportError} from 'app/client/models/errors';
|
||||||
|
import {cssRow} from 'app/client/ui/RightPanel';
|
||||||
|
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||||
|
import {basicButton, primaryButton} from "app/client/ui2018/buttons";
|
||||||
|
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";
|
||||||
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from "app/client/ui2018/icons";
|
||||||
|
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
|
||||||
|
import {cssSelectBtn} from 'app/client/ui2018/select';
|
||||||
|
import {CellValue} from 'app/common/DocActions';
|
||||||
|
import {isEmptyList, RecalcWhen} from 'app/common/gristTypes';
|
||||||
|
import {nativeCompare} from 'app/common/gutil';
|
||||||
|
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||||
|
import {Computed, dom, IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
|
import {cssMenu, cssMenuItem, defaultMenuOptions, IOpenController, setPopupToCreateDom} from "popweasel";
|
||||||
|
import isEqual = require('lodash/isEqual');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build UI to select triggers for formulas in data columns (such for default values).
|
||||||
|
*/
|
||||||
|
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
|
||||||
|
// Set up observables to translate between the UI representation of triggers, and what we
|
||||||
|
// actually store.
|
||||||
|
// - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists
|
||||||
|
// the fields to depend on; in other cases, recalcDeps is not used.
|
||||||
|
// - We show two checkboxes:
|
||||||
|
// [] Apply to new records -- toggles between recalcWhen of NEVER and DEFAULT.
|
||||||
|
// [] Apply on record changes -- when turned on, allows selecting fields to depend on. When
|
||||||
|
// "Any field" is selected, it toggles between recalcWhen of MANUAL_UPDATES and DEFAULT.
|
||||||
|
|
||||||
|
function isApplyOnChangesChecked(recalcWhen: RecalcWhen, recalcDeps: CellValue): boolean {
|
||||||
|
return recalcWhen === RecalcWhen.MANUAL_UPDATES ||
|
||||||
|
(recalcWhen === RecalcWhen.DEFAULT && recalcDeps != null && !isEmptyList(recalcDeps));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleApplyOnChanges(value: boolean) {
|
||||||
|
// Whether turning on or off, we reset to the default state.
|
||||||
|
await setRecalc(RecalcWhen.DEFAULT, null);
|
||||||
|
forceApplyOnChanges.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state of "Apply to new records" checkbox. Only writable when applyOnChanges is false, so
|
||||||
|
// only controls if recalcWhen should be DEFAULT or NEVER.
|
||||||
|
const applyToNew = Computed.create(owner, use => use(column.recalcWhen) !== RecalcWhen.NEVER)
|
||||||
|
.onWrite(value => setRecalc(value ? RecalcWhen.DEFAULT : RecalcWhen.NEVER, null));
|
||||||
|
|
||||||
|
// If true, mark 'Apply on record changes' checkbox, overriding stored state.
|
||||||
|
const forceApplyOnChanges = Observable.create(owner, false);
|
||||||
|
|
||||||
|
// The actual state of the checkbox. Clicking it toggles forceApplyOnChanges, and also resets
|
||||||
|
// recalcWhen/recalcDeps to its default state.
|
||||||
|
const applyOnChanges = Computed.create(owner,
|
||||||
|
use => (use(forceApplyOnChanges) || isApplyOnChangesChecked(use(column.recalcWhen), use(column.recalcDeps))))
|
||||||
|
.onWrite(toggleApplyOnChanges);
|
||||||
|
|
||||||
|
// Helper to update column's recalcWhen and recalcDeps properties.
|
||||||
|
async function setRecalc(when: RecalcWhen, deps: number[]|null) {
|
||||||
|
if (when !== column.recalcWhen.peek() || deps !== column.recalcDeps.peek()) {
|
||||||
|
return column._table.sendTableAction(
|
||||||
|
["UpdateRecord", column.id.peek(), {recalcWhen: when, recalcDeps: encodeObject(deps)}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const docModel = column._table.docModel;
|
||||||
|
const summaryText = Computed.create(owner, use => {
|
||||||
|
if (use(column.recalcWhen) === RecalcWhen.MANUAL_UPDATES) {
|
||||||
|
return 'Any field';
|
||||||
|
}
|
||||||
|
const deps = decodeObject(use(column.recalcDeps)) as number[]|null;
|
||||||
|
if (!deps || deps.length === 0) { return ''; }
|
||||||
|
return deps.map(dep => use(docModel.columns.getRowModel(dep)?.label)).join(", ");
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
cssRow(
|
||||||
|
labeledSquareCheckbox(
|
||||||
|
applyToNew,
|
||||||
|
'Apply to new records',
|
||||||
|
dom.boolAttr('disabled', applyOnChanges),
|
||||||
|
testId('field-formula-apply-to-new'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssRow(
|
||||||
|
labeledSquareCheckbox(
|
||||||
|
applyOnChanges,
|
||||||
|
dom.text(use => use(applyOnChanges) ?
|
||||||
|
'Apply on changes to:' :
|
||||||
|
'Apply on record changes'
|
||||||
|
),
|
||||||
|
testId('field-formula-apply-on-changes'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dom.maybe(applyOnChanges, () =>
|
||||||
|
cssIndentedRow(
|
||||||
|
cssSelectBtn(
|
||||||
|
cssSelectSummary(dom.text(summaryText)),
|
||||||
|
icon('Dropdown'),
|
||||||
|
testId('field-triggers-select'),
|
||||||
|
elem => {
|
||||||
|
setPopupToCreateDom(elem, ctl => buildTriggerSelectors(ctl, column.table.peek(), column, setRecalc),
|
||||||
|
{...defaultMenuOptions, placement: 'bottom-end'});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTriggerSelectors(ctl: IOpenController, tableRec: TableRec, column: ColumnRec,
|
||||||
|
setRecalc: (when: RecalcWhen, deps: number[]|null) => Promise<void>
|
||||||
|
) {
|
||||||
|
// ctl may be used as an owner for disposable object. Just give is a clearer name for this.
|
||||||
|
const owner: IDisposableOwner = ctl;
|
||||||
|
|
||||||
|
// The initial set of selected columns (as a set of rowIds).
|
||||||
|
const initialDeps = new Set(decodeObject(column.recalcDeps.peek()) as number[]|null);
|
||||||
|
|
||||||
|
// State of the "Any field" checkbox.
|
||||||
|
const allUpdates = Observable.create(owner, column.recalcWhen.peek() === RecalcWhen.MANUAL_UPDATES);
|
||||||
|
|
||||||
|
// Collect all the ColumnRec objects for available columns in this table.
|
||||||
|
const showColumns = tableRec.columns.peek().peek().filter(col => !col.isHiddenCol.peek());
|
||||||
|
showColumns.sort((a, b) => nativeCompare(a.label.peek(), b.label.peek()));
|
||||||
|
|
||||||
|
// Array of observables for the checkbox for each column. There should never be so many
|
||||||
|
// columns as to make this a performance problem.
|
||||||
|
const columnsState = showColumns.map(col => Observable.create(owner, initialDeps.has(col.id.peek())));
|
||||||
|
|
||||||
|
// The "Current field" checkbox is merely one of the column checkboxes.
|
||||||
|
const current = columnsState.find((col, index) => showColumns[index].id.peek() === column.id.peek())!;
|
||||||
|
|
||||||
|
// If user checks the "Any field" checkbox, all the others should get unchecked.
|
||||||
|
owner.autoDispose(allUpdates.addListener(value => {
|
||||||
|
if (value) {
|
||||||
|
columnsState.forEach(obs => obs.set(false));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Computed results based on current selections.
|
||||||
|
const when = Computed.create(owner, use => use(allUpdates) ? RecalcWhen.MANUAL_UPDATES : RecalcWhen.DEFAULT);
|
||||||
|
const deps = Computed.create(owner, use => {
|
||||||
|
return use(allUpdates) ? null :
|
||||||
|
showColumns.filter((col, index) => use(columnsState[index])).map(col => col.id.peek());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whether the selections changed, i.e. warrant saving.
|
||||||
|
const isChanged = Computed.create(owner, (use) => {
|
||||||
|
return use(when) !== use(column.recalcWhen) || !isEqual(new Set(use(deps)), initialDeps);
|
||||||
|
});
|
||||||
|
|
||||||
|
let shouldSave = true;
|
||||||
|
function close(_shouldSave: boolean) {
|
||||||
|
shouldSave = _shouldSave;
|
||||||
|
ctl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (shouldSave && isChanged.get()) {
|
||||||
|
setRecalc(when.get(), deps.get()).catch(reportError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssSelectorMenu(
|
||||||
|
{ tabindex: '-1' }, // Allow menu to be focused
|
||||||
|
testId('field-triggers-dropdown'),
|
||||||
|
dom.cls(menuCssClass),
|
||||||
|
dom.onDispose(onClose),
|
||||||
|
dom.onKeyDown({
|
||||||
|
Enter: () => close(true),
|
||||||
|
Escape: () => close(false)
|
||||||
|
}),
|
||||||
|
// Set focus on open, so that keyboard events work.
|
||||||
|
elem => { setTimeout(() => elem.focus(), 0); },
|
||||||
|
|
||||||
|
cssItemsFixed(
|
||||||
|
cssSelectorItem(
|
||||||
|
labeledSquareCheckbox(current,
|
||||||
|
['Current field ', cssSelectorNote('(data cleaning)')],
|
||||||
|
dom.boolAttr('disabled', allUpdates),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuDivider(),
|
||||||
|
cssSelectorItem(
|
||||||
|
labeledSquareCheckbox(allUpdates,
|
||||||
|
['Any field ', cssSelectorNote('(except formulas)')]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssItemsList(
|
||||||
|
showColumns.map((col, index) =>
|
||||||
|
cssSelectorItem(
|
||||||
|
labeledSquareCheckbox(columnsState[index],
|
||||||
|
col.label.peek(),
|
||||||
|
dom.boolAttr('disabled', allUpdates),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssItemsFixed(
|
||||||
|
cssSelectorFooter(
|
||||||
|
dom.maybe(isChanged, () =>
|
||||||
|
primaryButton('OK',
|
||||||
|
dom.on('click', () => close(true)),
|
||||||
|
testId('trigger-deps-apply')
|
||||||
|
),
|
||||||
|
),
|
||||||
|
basicButton(dom.text(use => use(isChanged) ? 'Cancel' : 'Close'),
|
||||||
|
dom.on('click', () => close(false)),
|
||||||
|
testId('trigger-deps-cancel')
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssIndentedRow = styled(cssRow, `
|
||||||
|
margin-left: 40px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSelectSummary = styled('div', `
|
||||||
|
flex: 1 1 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&:empty::before {
|
||||||
|
content: "Select fields";
|
||||||
|
color: ${colors.slate};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
|
||||||
|
const cssSelectorMenu = styled(cssMenu, `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(max(300px, 95vh - 300px));
|
||||||
|
max-width: 400px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssItemsList = styled(shadowScroll, `
|
||||||
|
flex: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
border-top: 1px solid ${colors.darkGrey};
|
||||||
|
border-bottom: 1px solid ${colors.darkGrey};
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssItemsFixed = styled('div', `
|
||||||
|
flex: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSelectorItem = styled(cssMenuItem, `
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSelectorNote = styled('span', `
|
||||||
|
color: ${colors.slate};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSelectorFooter = styled(cssSelectorItem, `
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 3px 0;
|
||||||
|
`);
|
@ -3,6 +3,7 @@ import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
|||||||
import { textInput } from "app/client/ui2018/editableLabel";
|
import { textInput } from "app/client/ui2018/editableLabel";
|
||||||
import { icon } from "app/client/ui2018/icons";
|
import { icon } from "app/client/ui2018/icons";
|
||||||
import { isValidHex } from "app/common/gutil";
|
import { isValidHex } from "app/common/gutil";
|
||||||
|
import { cssSelectBtn } from 'app/client/ui2018/select';
|
||||||
import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
|
import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
|
||||||
import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
|
import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
|
||||||
|
|
||||||
@ -315,17 +316,5 @@ const cssColorSquare = styled('div', `
|
|||||||
|
|
||||||
const cssButtonIcon = styled(cssColorSquare, `
|
const cssButtonIcon = styled(cssColorSquare, `
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
`);
|
margin-left: 4px;
|
||||||
|
|
||||||
const cssSelectBtn = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 30px;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #D9D9D9;
|
|
||||||
padding: 5px 9px;
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: white;
|
|
||||||
`);
|
`);
|
||||||
|
@ -120,7 +120,7 @@ export const cssLabelText = styled('span', `
|
|||||||
type CheckboxArg = DomArg<HTMLInputElement>;
|
type CheckboxArg = DomArg<HTMLInputElement>;
|
||||||
|
|
||||||
function checkbox(
|
function checkbox(
|
||||||
obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: string = '', ...domArgs: CheckboxArg[]
|
obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: DomArg = '', ...domArgs: CheckboxArg[]
|
||||||
) {
|
) {
|
||||||
return cssLabel(
|
return cssLabel(
|
||||||
cssCheckbox(
|
cssCheckbox(
|
||||||
@ -141,11 +141,11 @@ export function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg
|
|||||||
return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
|
return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function labeledSquareCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
|
export function labeledSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
|
||||||
return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
|
return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function labeledCircleCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
|
export function labeledCircleCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
|
||||||
return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
|
return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {Command} from 'app/client/components/commands';
|
import {Command} from 'app/client/components/commands';
|
||||||
import {NeedUpgradeError, reportError} from 'app/client/models/errors';
|
import {NeedUpgradeError, reportError} from 'app/client/models/errors';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
|
import {cssSelectBtn} from 'app/client/ui2018/select';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
@ -280,24 +281,6 @@ const cssSelectBtnContainer = styled('div', `
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssSelectBtn = styled('div', `
|
|
||||||
width: 100%;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 16px;
|
|
||||||
background-color: white;
|
|
||||||
font-size: ${vars.mediumFontSize};
|
|
||||||
padding: 5px;
|
|
||||||
border: 1px solid ${colors.darkGrey};
|
|
||||||
color: ${colors.dark};
|
|
||||||
--icon-color: ${colors.dark};
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
display: flex;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssSelectBtnLink = styled('div', `
|
const cssSelectBtnLink = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
48
app/client/ui2018/select.ts
Normal file
48
app/client/ui2018/select.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||||
|
import {styled} from 'grainjs';
|
||||||
|
|
||||||
|
// Import popweasel so that the styles we define here are included later in CSS, and take priority
|
||||||
|
// over popweasel styles, when used together.
|
||||||
|
import 'popweasel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style for a select dropdown button.
|
||||||
|
*
|
||||||
|
* This incorporates styling from popweasel's select, so that it can be used to style buttons that
|
||||||
|
* don't use it.
|
||||||
|
*/
|
||||||
|
export const cssSelectBtn = styled('div', `
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 16px;
|
||||||
|
background-color: white;
|
||||||
|
color: ${colors.dark};
|
||||||
|
--icon-color: ${colors.dark};
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid ${colors.darkGrey};
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0px 0px 2px 2px #5E9ED6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: grey;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`);
|
@ -226,6 +226,17 @@ export function getGristType(pureType: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for values of columns' recalcWhen property, corresponding to Python definitions in
|
||||||
|
* schema.py.
|
||||||
|
*/
|
||||||
|
export enum RecalcWhen {
|
||||||
|
DEFAULT = 0, // Calculate on new records or when any field in recalcDeps changes.
|
||||||
|
NEVER = 1, // Don't calculate automatically (but user can trigger manually)
|
||||||
|
MANUAL_UPDATES = 2, // Calculate on new records and on manual updates to any data field.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts SQL type strings produced by the Sequelize library into its corresponding
|
* Converts SQL type strings produced by the Sequelize library into its corresponding
|
||||||
* Grist type. The list of types is based on an analysis of SQL type string outputs
|
* Grist type. The list of types is based on an analysis of SQL type string outputs
|
||||||
|
@ -31,7 +31,7 @@ export const schema = {
|
|||||||
summarySourceCol : "Ref:_grist_Tables_column",
|
summarySourceCol : "Ref:_grist_Tables_column",
|
||||||
displayCol : "Ref:_grist_Tables_column",
|
displayCol : "Ref:_grist_Tables_column",
|
||||||
visibleCol : "Ref:_grist_Tables_column",
|
visibleCol : "Ref:_grist_Tables_column",
|
||||||
recalcWhen : "Text",
|
recalcWhen : "Int",
|
||||||
recalcDeps : "RefList:_grist_Tables_column",
|
recalcDeps : "RefList:_grist_Tables_column",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -204,8 +204,8 @@ export interface SchemaTypes {
|
|||||||
summarySourceCol: number;
|
summarySourceCol: number;
|
||||||
displayCol: number;
|
displayCol: number;
|
||||||
visibleCol: number;
|
visibleCol: number;
|
||||||
recalcWhen: string;
|
recalcWhen: number;
|
||||||
recalcDeps: number[];
|
recalcDeps: ['L', ...number[]]|null;
|
||||||
};
|
};
|
||||||
|
|
||||||
"_grist_Imports": {
|
"_grist_Imports": {
|
||||||
|
@ -16,7 +16,7 @@ _ts_types = {
|
|||||||
"Int": "number",
|
"Int": "number",
|
||||||
"PositionNumber": "number",
|
"PositionNumber": "number",
|
||||||
"Ref": "number",
|
"Ref": "number",
|
||||||
"RefList": "number[]",
|
"RefList": "['L', ...number[]]|null", # Non-primitive values are encoded
|
||||||
"Text": "string",
|
"Text": "string",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ def get_ts_type(col_type):
|
|||||||
return _ts_types.get(col_type, "CellValue")
|
return _ts_types.get(col_type, "CellValue")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("""
|
print("""\
|
||||||
/*** THIS FILE IS AUTO-GENERATED BY %s ***/
|
/*** THIS FILE IS AUTO-GENERATED BY %s ***/
|
||||||
// tslint:disable:object-literal-key-quotes
|
// tslint:disable:object-literal-key-quotes
|
||||||
|
|
||||||
|
@ -457,7 +457,8 @@ class ReferenceList(BaseColumnType):
|
|||||||
assert value._table.table_id == self.table_id
|
assert value._table.table_id == self.table_id
|
||||||
return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
|
return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
|
||||||
elif not value:
|
elif not value:
|
||||||
return []
|
# Represent an empty ReferenceList as None (also its default value). Formulas will see [].
|
||||||
|
return None
|
||||||
return [Reference.do_convert(val) for val in value]
|
return [Reference.do_convert(val) for val in value]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
Loading…
Reference in New Issue
Block a user