From e8e614c584c0b809d0ca19ca6e8c70ea178e9814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Fri, 5 Nov 2021 11:25:05 +0100 Subject: [PATCH] (core) Formula UI redesign Summary: Redesigning column type section to make it more user-friendly. Introducing column behavior concept. Column can be either: - Empty Formula Column: initial state (user can convert to Formula/Data Column) - Data Column: non formula column with or without trigger (with option to add trigger, or convert to formula) - Formula Column: pure formula column, with an option to convert to data column with a trigger. Test Plan: Existing tests. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3092 --- app/client/components/GristDoc.ts | 24 +++ app/client/ui/FieldConfig.ts | 330 ++++++++++++++++++++--------- app/client/ui/RightPanel.ts | 16 +- app/client/ui/TriggerFormulas.ts | 5 +- app/client/ui2018/IconList.ts | 4 + app/client/ui2018/buttons.ts | 10 + app/client/ui2018/menus.ts | 89 ++++++-- app/client/widgets/FieldBuilder.ts | 11 +- app/client/widgets/FieldEditor.ts | 17 +- static/icons/icons.css | 2 + static/ui-icons/UI/Database.svg | 67 ++++++ static/ui-icons/UI/Script.svg | 83 ++++++++ 12 files changed, 532 insertions(+), 126 deletions(-) create mode 100644 static/ui-icons/UI/Database.svg create mode 100644 static/ui-icons/UI/Script.svg diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 3844dd1f..e89d0deb 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -625,6 +625,30 @@ export class GristDoc extends DisposableWithEvents { ); } + // Convert column to pure formula column. + public async convertToFormula(colRefs: number, formula: string): Promise { + return this.docModel.columns.sendTableAction( + ['UpdateRecord', colRefs, { + isFormula: true, + formula, + recalcWhen: RecalcWhen.DEFAULT, + recalcDeps: null, + }] + ); + } + + // Convert column to data column with a trigger formula + public async convertToTrigger(colRefs: number, formula: string): Promise { + return this.docModel.columns.sendTableAction( + ['UpdateRecord', colRefs, { + isFormula: false, + formula, + recalcWhen: RecalcWhen.DEFAULT, + recalcDeps: null, + }] + ); + } + public getCsvLink() { const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({ colRef : field.colRef.peek(), diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 9c221c1e..f1c6f243 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -1,17 +1,20 @@ -import type {GristDoc} from 'app/client/components/GristDoc'; -import type {ColumnRec} from 'app/client/models/entities/ColumnRec'; -import type {CursorPos} from "app/client/components/Cursor"; +import {CursorPos} from 'app/client/components/Cursor'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ColumnRec} from 'app/client/models/entities/ColumnRec'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; +import {cssEmptySeparator, cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; -import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; -import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {textButton} from 'app/client/ui2018/buttons'; +import {colors, testId} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; import {cssIconButton, icon} from 'app/client/ui2018/icons'; -import {menu, menuItem} from 'app/client/ui2018/menus'; +import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus'; import {sanitizeIdent} from 'app/common/gutil'; -import {Computed, dom, fromKo, MultiHolder, Observable, styled, subscribe} from 'grainjs'; +import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable, + styled, subscribe} from 'grainjs'; import * as ko from 'knockout'; import debounce = require('lodash/debounce'); +import {IconName} from 'app/client/ui2018/IconList'; export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed) { const untieColId = origColumn.untieColIdFromLabel; @@ -63,86 +66,237 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso ]; } -type BuildEditor = (cellElem: Element) => void; +type BuildEditor = ( + cellElem: Element, + editValue?: string, + onSave?: (formula: string) => Promise, + onCancel?: () => void) => void; + +type BEHAVIOR = "empty"|"formula"|"data"; export function buildFormulaConfig( owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor ) { - const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]); - const convertIsFormula = - (opts: {toFormula: boolean, noRecalc?: boolean}) => gristDoc.convertIsFormula([origColumn.id.peek()], opts); - const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn); - return dom.maybe(use => { - if (!use(origColumn.id)) { return null; } // Invalid column, show nothing. - if (use(origColumn.isEmpty)) { return "empty"; } - return use(origColumn.isFormula) ? "formula" : "data"; - }, - (type: "empty"|"formula"|"data") => { - function buildHeader(label: string, menuFunc: () => Element[]) { - return cssRow( - cssInlineLabel(label, - testId('field-is-formula-label'), - ), - cssDropdownLabel('Actions', icon('Dropdown'), menu(menuFunc), - cssDropdownLabel.cls('-disabled', origColumn.disableModify), - testId('field-actions-menu'), - ) - ); - } - function buildFormulaRow(placeholder = 'Enter formula') { - return [ - cssRow(dom.create(buildFormula, origColumn, buildEditor, placeholder)), - dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))), - ]; - } - if (type === "empty") { - return [ - buildHeader('EMPTY COLUMN', () => [ - menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)), - menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'), - ]), - buildFormulaRow(), - ]; - } else if (type === "formula") { - return [ - buildHeader('FORMULA COLUMN', () => [ - menuItem(clearColumn, 'Clear column'), - menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'), - ]), - buildFormulaRow(), - ]; - } else { - return [ - buildHeader('DATA COLUMN', () => { - return origColumn.formula.peek() ? [ - // If there is a formula available, offer a separate option to convert to formula - // without clearing it. - 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.') - ) - ]; - } + // Intermediate state - user wants to specify formula, but haven't done yet + const maybeFormula = Observable.create(owner, false); + + // Intermediate state - user wants to specify formula, but haven't done yet + const maybeTrigger = Observable.create(owner, false); + + // Column behaviour. There are 3 types of behaviors: + // - empty: isFormula and formula == '' + // - formula: isFormula and formula != '' + // - data: not isFormula nd formula == '' + const behavior = Computed.create(owner, (use) => { + // When no id column is invalid, show nothing. + if (!use(origColumn.id)) { return null; } + // Column is a formula column, when it is a formula column with valid formula or will be a formula. + if (use(origColumn.isRealFormula) || use(maybeFormula)) { return "formula"; } + // If column is not empty, or empty but wants to be a trigger + if (use(maybeTrigger) || !use(origColumn.isEmpty)) { return "data"; } + return "empty"; + }); + + // Reference to current editor, we will open it when user wants to specify a formula or trigger. + // And close it dispose it when user opens up behavior menu. + let formulaField: HTMLElement|null = null; + + // Helper function to clear temporary state (will be called when column changes or formula editor closes) + const clearState = () => bundleChanges(() => { + maybeFormula.set(false); + maybeTrigger.set(false); + formulaField = null; + }); + + // Clear state when column has changed + owner.autoDispose(origColumn.id.subscribe(clearState)); + + // Menu helper that will show normal menu with some default options + const menu = (label: DomContents, options: DomElementArg[]) => + cssRow( + selectMenu( + label, + () => options, + testId("field-behaviour"), + // HACK: Menu helper will add tabindex to this element, which will make + // this element focusable and will steal focus from clipboard. This in turn, + // will not dispose the formula editor when menu is clicked. + (el) => el.removeAttribute("tabindex"), + dom.cls("disabled", origColumn.disableModify)), + ); + + // Behaviour label + const behaviorName = Computed.create(owner, behavior, (use, type) => { + if (type === 'formula') { return "Formula Column"; } + if (type === 'data') { return "Data Column"; } + return "Empty Column"; + }); + const behaviorIcon = Computed.create(owner, (use) => { + return use(behaviorName) === "Data Column" ? "Database" : "Script"; + }); + const behaviourLabel = () => selectTitle(behaviorName, behaviorIcon); + + // Actions on select menu: + + // Converts data column to formula column. + const convertDataColumnToFormulaOption = () => selectOption( + () => (maybeFormula.set(true), formulaField?.focus()), + 'Clear and make into formula', 'Script'); + + // Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula) + const convertTriggerToFormulaOption = () => selectOption( + () => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: true, noRecalc: true}), + 'Clear and make into formula', 'Script'); + + // Convert column to data. + // This method is also available through a text button. + const convertToData = () => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: true}); + const convertToDataOption = () => selectOption( + convertToData, + 'Convert column to data', 'Database'); + + // Clears the column + const clearAndResetOption = () => selectOption( + () => gristDoc.clearColumns([origColumn.id.peek()]), + 'Clear and reset', 'CrossSmall'); + + // Actions on text buttons: + + // Tries to convert data column to a trigger column. + const convertDataColumnToTriggerColumn = () => { + maybeTrigger.set(true); + // Open the formula editor. + formulaField?.focus(); + }; + + // Converts formula column to trigger formula column. + const convertFormulaToTrigger = () => + gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: false}); + + const setFormula = () => (maybeFormula.set(true), formulaField?.focus()); + const setTrigger = () => (maybeTrigger.set(true), formulaField?.focus()); + + // Actions on save formula + + // Converts column to formula column or updates formula on a formula column. + const onSaveConvertToFormula = async (formula: string) => { + const notBlank = Boolean(formula); + const trueFormula = !maybeFormula.get(); + if (notBlank || trueFormula) { await gristDoc.convertToFormula(origColumn.id.peek(), formula); } + clearState(); + }; + + // Updates formula or convert column to trigger formula column if necessary. + const onSaveConvertToTrigger = async (formula: string) => { + if (formula && maybeTrigger.get()) { + // Convert column to trigger + await gristDoc.convertToTrigger(origColumn.id.peek(), formula); + } else if (origColumn.hasTriggerFormula.peek()) { + // This is true trigger formula, just update the formula (or make it blank) + await origColumn.formula.setAndSave(formula); } - ); + clearState(); + }; + + const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn); + // Helper that will create different flavors for formula builder. + const formulaBuilder = (onSave: (formula: string) => Promise) => [ + cssRow(formulaField = buildFormula( + origColumn, + buildEditor, + "Enter formula", + onSave, + clearState)), + dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))), + ]; + + return dom.maybe(behavior, (type: BEHAVIOR) => [ + cssLabel('COLUMN BEHAVIOR'), + ...(type === "empty" ? [ + menu(behaviourLabel(), [ + convertToDataOption() + ]), + cssEmptySeparator(), + cssRow(textButton( + "Set formula", + dom.on("click", setFormula), + dom.prop("disabled", origColumn.disableModify), + testId("field-set-formula") + )), + cssRow(textButton( + "Set trigger formula", + dom.on("click", setTrigger), + dom.prop("disabled", origColumn.disableModify), + testId("field-set-trigger") + )), + cssRow(textButton( + "Make into data column", + dom.on("click", convertToData), + dom.prop("disabled", origColumn.disableModify), + testId("field-set-data") + )) + ] : type === "formula" ? [ + menu(behaviourLabel(), [ + convertToDataOption(), + clearAndResetOption(), + ]), + formulaBuilder(onSaveConvertToFormula), + cssEmptySeparator(), + cssRow(textButton( + "Convert to trigger formula", + dom.on("click", convertFormulaToTrigger), + dom.hide(maybeFormula), + dom.prop("disabled", origColumn.disableModify), + testId("field-set-trigger") + )) + ] : /* type == 'data' */ [ + menu(behaviourLabel(), + [ + dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ? + // If we have trigger, we will convert it directly to a formula column + convertTriggerToFormulaOption() : + // else we will convert to empty column and open up the editor + convertDataColumnToFormulaOption() + ), + clearAndResetOption(), + ] + ), + // If data column is or wants to be a trigger formula: + dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [ + cssLabel('TRIGGER FORMULA'), + formulaBuilder(onSaveConvertToTrigger), + dom.create(buildFormulaTriggers, origColumn, maybeTrigger) + ]), + // Else offer a way to convert to trigger formula. + dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [ + cssEmptySeparator(), + cssRow(textButton( + "Set trigger formula", + dom.on("click", convertDataColumnToTriggerColumn), + dom.prop("disabled", origColumn.disableModify), + testId("field-set-trigger") + )) + ]) + ]) + ]); } -function buildFormula(owner: MultiHolder, column: ColumnRec, buildEditor: BuildEditor, placeholder: string) { +function buildFormula( + column: ColumnRec, + buildEditor: BuildEditor, + placeholder: string, + onSave?: (formula: string) => Promise, + onCancel?: () => void) { return cssFieldFormula(column.formula, {placeholder, maxLines: 2}, dom.cls('formula_field_sidepane'), cssFieldFormula.cls('-disabled', column.disableModify), cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)), dom.cls('disabled'), {tabIndex: '-1'}, - dom.on('focus', (ev, elem) => buildEditor(elem)), + // Focus event use used by a user to edit an existing formula. + // It can also be triggered manually to open up the editor. + dom.on('focus', (_, elem) => buildEditor(elem, undefined, onSave, onCancel)), ); } @@ -223,36 +377,6 @@ const cssToggleButton = styled(cssIconButton, ` } `); -const cssInlineLabel = styled(cssLabel, ` - padding: 4px 8px; - margin: 4px 0 -4px -8px; -`); - -const cssDropdownLabel = styled(cssInlineLabel, ` - margin-left: auto; - display: flex; - align-items: center; - border-radius: ${vars.controlBorderRadius}; - cursor: pointer; - - color: ${colors.lightGreen}; - --icon-color: ${colors.lightGreen}; - - &:hover, &:focus, &.weasel-popup-open { - background-color: ${colors.mediumGrey}; - } - &-disabled { - color: ${colors.slate}; - --icon-color: ${colors.slate}; - pointer-events: none; - } -`); - -const cssHintRow = styled('div', ` - margin: -4px 16px 8px 16px; - color: ${colors.slate}; -`); - const cssColLabelBlock = styled('div', ` display: flex; flex-direction: column; diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 08102c23..1ad6f0f5 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -230,11 +230,19 @@ export class RightPanel extends Disposable { } // Helper to activate the side-pane formula editor over the given HTML element. - private _activateFormulaEditor(refElem: Element) { + private _activateFormulaEditor( + // Element to attach to. + refElem: Element, + // Simulate user typing on the cell - open editor with an initial value. + editValue?: string, + // Custom save handler. + onSave?: (formula: string) => Promise, + // Custom cancel handler. + onCancel?: () => void,) { const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); if (!vsi) { return; } const editRowModel = vsi.moveEditRowToCursor(); - vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem); + return vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem, editValue, onSave, onCancel); } private _buildPageWidgetContent(_owner: MultiHolder) { @@ -657,6 +665,10 @@ export const cssSeparator = styled('div', ` margin-top: 16px; `); +export const cssEmptySeparator = styled('div', ` + margin-top: 16px; +`); + const cssConfigContainer = styled('div', ` overflow: auto; --color-list-item: none; diff --git a/app/client/ui/TriggerFormulas.ts b/app/client/ui/TriggerFormulas.ts index 3a0c2467..b5a15f62 100644 --- a/app/client/ui/TriggerFormulas.ts +++ b/app/client/ui/TriggerFormulas.ts @@ -20,7 +20,7 @@ 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) { +export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disable: Observable|null = null) { // 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 @@ -79,7 +79,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) { labeledSquareCheckbox( applyToNew, 'Apply to new records', - dom.boolAttr('disabled', applyOnChanges), + dom.boolAttr('disabled', (use) => (disable && use(disable)) || use(applyOnChanges)), testId('field-formula-apply-to-new'), ), ), @@ -90,6 +90,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) { 'Apply on changes to:' : 'Apply on record changes' ), + dom.boolAttr('disabled', (use) => disable ? use(disable) : false), testId('field-formula-apply-on-changes'), ), ), diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index ef4bb90c..6a354a26 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -38,6 +38,7 @@ export type IconName = "ChartArea" | "Copy" | "CrossBig" | "CrossSmall" | + "Database" | "Dots" | "Download" | "DragDrop" | @@ -79,6 +80,7 @@ export type IconName = "ChartArea" | "Repl" | "ResizePanel" | "RightAlign" | + "Script" | "Search" | "Settings" | "Share" | @@ -133,6 +135,7 @@ export const IconList: IconName[] = ["ChartArea", "Copy", "CrossBig", "CrossSmall", + "Database", "Dots", "Download", "DragDrop", @@ -174,6 +177,7 @@ export const IconList: IconName[] = ["ChartArea", "Repl", "ResizePanel", "RightAlign", + "Script", "Search", "Settings", "Share", diff --git a/app/client/ui2018/buttons.ts b/app/client/ui2018/buttons.ts index ce6cccfd..84a88108 100644 --- a/app/client/ui2018/buttons.ts +++ b/app/client/ui2018/buttons.ts @@ -100,6 +100,16 @@ export const bigBasicButtonLink = tbind(button, null, {link: true, large: true}) export const primaryButtonLink = tbind(button, null, {link: true, primary: true}); export const bigPrimaryButtonLink = tbind(button, null, {link: true, large: true, primary: true}); +// Button that looks like a link (have no background and no border). +export const textButton = styled(cssButton, ` + border: none; + padding: 0px; + background-color: inherit !important; + &:disabled { + color: ${colors.inactiveCursor}; + } +`); + const cssButtonLink = styled('a', ` display: inline-block; &, &:hover, &:focus { diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index c551ce07..494f5527 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -1,14 +1,14 @@ -import {Command} from 'app/client/components/commands'; -import {NeedUpgradeError, reportError} from 'app/client/models/errors'; -import {colors, testId, vars} from 'app/client/ui2018/cssVars'; -import {cssSelectBtn} from 'app/client/ui2018/select'; -import {IconName} from 'app/client/ui2018/IconList'; -import {icon} from 'app/client/ui2018/icons'; -import {commonUrls} from 'app/common/gristUrls'; -import {Computed, dom, DomElementArg, DomElementMethod, MaybeObsArray, MutableObsArray, Observable, - styled} from 'grainjs'; +import { Command } from 'app/client/components/commands'; +import { NeedUpgradeError, reportError } from 'app/client/models/errors'; +import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox'; +import { colors, testId, vars } from 'app/client/ui2018/cssVars'; +import { IconName } from 'app/client/ui2018/IconList'; +import { icon } from 'app/client/ui2018/icons'; +import { cssSelectBtn } from 'app/client/ui2018/select'; +import { commonUrls } from 'app/common/gristUrls'; +import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs, + MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs'; import * as weasel from 'popweasel'; -import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox'; export interface IOptionFull { value: T; @@ -304,6 +304,71 @@ export function autocomplete( }); } +/** + * Creates simple (not reactive) static menu that looks like a select-box. + * Primary usage is for menus, where you want to control how the options and a default + * label will look. Label is not updated or changed when one of the option is clicked, for those + * use cases use a select component. + * Icons are optional, can use custom elements instead of labels and options. + * + * Usage: + * + * selectMenu(selectTitle("Title", "Script"), () => [ + * selectOption(() => ..., "Option1", "Database"), + * selectOption(() => ..., "Option2", "Script"), + * ]); + * + * // Control disabled state (if the menu will be opened or not) + * + * const disabled = observable(false); + * selectMenu(selectTitle("Title", "Script"), () => [ + * selectOption(() => ..., "Option1", "Database"), + * selectOption(() => ..., "Option2", "Script"), + * ], disabled); + * + */ +export function selectMenu( + label: DomElementArg, + items: () => DomElementArg[], + ...args: IDomArgs +) { + return cssSelectBtn( + label, + icon('Dropdown'), + menu( + items, + { + ...weasel.defaultMenuOptions, + menuCssClass: cssSelectMenuElem.className + ' grist-floating-menu', + stretchToSelector : `.${cssSelectBtn.className}`, + trigger : [(triggerElem, ctl) => { + const isDisabled = () => triggerElem.classList.contains('disabled'); + dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle()); + dom.onKeyElem(triggerElem as HTMLElement, 'keydown', { + ArrowDown: () => isDisabled() || ctl.open(), + ArrowUp: () => isDisabled() || ctl.open() + }); + }] + }, + ), + ...args, + ); +} + +export function selectTitle(label: BindableValue, iconName?: BindableValue) { + return cssOptionRow( + iconName ? dom.domComputed(iconName, (name) => cssOptionRowIcon(name)) : null, + dom.text(label) + ); +} + +export function selectOption( + action: (item: HTMLElement) => void, + label: BindableValue, + iconName?: BindableValue) { + return menuItem(action, selectTitle(label, iconName)); +} + export const menuSubHeader = styled('div', ` font-size: ${vars.xsmallFontSize}; text-transform: uppercase; @@ -404,13 +469,13 @@ const cssOptionIcon = styled(icon, ` margin: -3px 8px 0 2px; `); -const cssOptionRow = styled('span', ` +export const cssOptionRow = styled('span', ` display: flex; align-items: center; width: 100%; `); -const cssOptionRowIcon = styled(cssOptionIcon, ` +export const cssOptionRowIcon = styled(cssOptionIcon, ` margin: 0 8px 0 0; flex: none; diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 6030b3e2..8ebaf910 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -517,13 +517,22 @@ export class FieldBuilder extends Disposable { /** * Open the formula editor in the side pane. It will be positioned over refElem. */ - public openSideFormulaEditor(editRow: DataRowModel, refElem: Element) { + public openSideFormulaEditor( + editRow: DataRowModel, + refElem: Element, + editValue?: string, + onSave?: (formula: string) => Promise, + onCancel?: () => void) { const editorHolder = openSideFormulaEditor({ gristDoc: this.gristDoc, field: this.field, editRow, refElem, + editValue, + onSave, + onCancel }); + // Add editor to document holder - this will prevent multiple formula editor instances. this.gristDoc.fieldEditorHolder.autoDispose(editorHolder); } } diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index f54cb4b9..859a0a35 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -13,7 +13,7 @@ import {asyncOnce} from "app/common/AsyncCreate"; import {CellValue} from "app/common/DocActions"; import {isRaisedException} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; -import {Disposable, Emitter, Holder, IDisposable, MultiHolder, Observable} from 'grainjs'; +import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs'; import isEqual = require('lodash/isEqual'); import { CellPosition } from "app/client/components/CellPosition"; @@ -380,7 +380,10 @@ export function openSideFormulaEditor(options: { field: ViewFieldRec, editRow: DataRowModel, // Needed to get exception value, if any. refElem: Element, // Element in the side pane over which to position the editor. -}): IDisposable { + editValue?: string, + onSave?: (formula: string) => Promise, + onCancel?: () => void, +}): Disposable { const {gristDoc, field, editRow, refElem} = options; const holder = MultiHolder.create(null); const column = field.column(); @@ -388,17 +391,19 @@ export function openSideFormulaEditor(options: { // AsyncOnce ensures it's called once even if triggered multiple times. const saveEdit = asyncOnce(async () => { const formula = editor.getCellValue(); - if (formula !== column.formula.peek()) { + if (options.onSave) { + await options.onSave(formula as string); + } else if (formula !== column.formula.peek()) { await column.updateColValues({formula}); } - holder.dispose(); // Deactivate the editor. + holder.dispose(); }); // These are the commands for while the editor is active. const editCommands = { fieldEditSave: () => { saveEdit().catch(reportError); }, fieldEditSaveHere: () => { saveEdit().catch(reportError); }, - fieldEditCancel: () => { holder.dispose(); }, + fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); }, }; // Replace the item in the Holder with a new one, disposing the previous one. @@ -407,7 +412,7 @@ export function openSideFormulaEditor(options: { field, cellValue: column.formula(), formulaError: getFormulaError(gristDoc, editRow, column), - editValue: undefined, + editValue: options.editValue, cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. commands: editCommands, cssClass: 'formula_editor_sidepane', diff --git a/static/icons/icons.css b/static/icons/icons.css index bfccf69c..ff437923 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -39,6 +39,7 @@ --icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLDMgTDExLDE1IEwyLDE1IEwyLDMgTDExLDMgWiBNMTAsMy45OTkgTDMsMy45OTkgTDMsMTMuOTk5IEwxMCwxMy45OTkgTDEwLDMuOTk5IFogTTE0LDAgTDE0LDEyIEwxMS41LDEyIEwxMS41LDExIEwxMywxMSBMMTMsMSBMNiwxIEw2LDIuNSBMNSwyLjUgTDUsMCBMMTQsMCBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjQxNDIxMzYsMTIgTDE5LjcwNzEwNjgsMTguMjkyODkzMiBDMjAuMDk3NjMxMSwxOC42ODM0MTc1IDIwLjA5NzYzMTEsMTkuMzE2NTgyNSAxOS43MDcxMDY4LDE5LjcwNzEwNjggQzE5LjMxNjU4MjUsMjAuMDk3NjMxMSAxOC42ODM0MTc1LDIwLjA5NzYzMTEgMTguMjkyODkzMiwxOS43MDcxMDY4IEwxMiwxMy40MTQyMTM2IEw1LjcwNzEwNjc4LDE5LjcwNzEwNjggQzUuMzE2NTgyNDksMjAuMDk3NjMxMSA0LjY4MzQxNzUxLDIwLjA5NzYzMTEgNC4yOTI4OTMyMiwxOS43MDcxMDY4IEMzLjkwMjM2ODkzLDE5LjMxNjU4MjUgMy45MDIzNjg5MywxOC42ODM0MTc1IDQuMjkyODkzMjIsMTguMjkyODkzMiBMMTAuNTg1Nzg2NCwxMiBMNC4yOTI4OTMyMiw1LjcwNzEwNjc4IEMzLjkwMjM2ODkzLDUuMzE2NTgyNDkgMy45MDIzNjg5Myw0LjY4MzQxNzUxIDQuMjkyODkzMjIsNC4yOTI4OTMyMiBDNC42ODM0MTc1MSwzLjkwMjM2ODkzIDUuMzE2NTgyNDksMy45MDIzNjg5MyA1LjcwNzEwNjc4LDQuMjkyODkzMjIgTDEyLDEwLjU4NTc4NjQgTDE4LjI5Mjg5MzIsNC4yOTI4OTMyMiBDMTguNjgzNDE3NSwzLjkwMjM2ODkzIDE5LjMxNjU4MjUsMy45MDIzNjg5MyAxOS43MDcxMDY4LDQuMjkyODkzMjIgQzIwLjA5NzYzMTEsNC42ODM0MTc1MSAyMC4wOTc2MzExLDUuMzE2NTgyNDkgMTkuNzA3MTA2OCw1LjcwNzEwNjc4IEwxMy40MTQyMTM2LDEyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEyLjUsNy41IEMxMi43NzYxNDI0LDcuNSAxMyw3LjcyMzg1NzYzIDEzLDggQzEzLDguMjc2MTQyMzcgMTIuNzc2MTQyNCw4LjUgMTIuNSw4LjUgTDguNSw4LjUgTDguNSwxMi41IEM4LjUsMTIuNzc2MTQyNCA4LjI3NjE0MjM3LDEzIDgsMTMgQzcuNzIzODU3NjMsMTMgNy41LDEyLjc3NjE0MjQgNy41LDEyLjUgTDcuNSw4LjUgTDMuNSw4LjUgQzMuMjIzODU3NjMsOC41IDMsOC4yNzYxNDIzNyAzLDggQzMsNy43MjM4NTc2MyAzLjIyMzg1NzYzLDcuNSAzLjUsNy41IEw3LjUsNy41IEw3LjUsMy41IEM3LjUsMy4yMjM4NTc2MyA3LjcyMzg1NzYzLDMgOCwzIEM4LjI3NjE0MjM3LDMgOC41LDMuMjIzODU3NjMgOC41LDMuNSBMOC41LDcuNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+'); + --icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48ZWxsaXBzZSBjeD0iMTIiIGN5PSI1IiByeD0iOSIgcnk9IjMiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHBhdGggZD0iTTMgNXYxNGMwIDEuNjYgNCAzIDkgM3M5LTEuMzQgOS0zVjUiIHN0cm9rZS13aWR0aD0iMS41Ii8+PC9zdmc+'); --icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS41IEM3LjE3MTU3Mjg4LDkuNSA2LjUsOC44Mjg0MjcxMiA2LjUsOCBDNi41LDcuMTcxNTcyODggNy4xNzE1NzI4OCw2LjUgOCw2LjUgQzguODI4NDI3MTIsNi41IDkuNSw3LjE3MTU3Mjg4IDkuNSw4IEM5LjUsOC44Mjg0MjcxMiA4LjgyODQyNzEyLDkuNSA4LDkuNSBaIE0xMi41LDkuNSBDMTEuNjcxNTcyOSw5LjUgMTEsOC44Mjg0MjcxMiAxMSw4IEMxMSw3LjE3MTU3Mjg4IDExLjY3MTU3MjksNi41IDEyLjUsNi41IEMxMy4zMjg0MjcxLDYuNSAxNCw3LjE3MTU3Mjg4IDE0LDggQzE0LDguODI4NDI3MTIgMTMuMzI4NDI3MSw5LjUgMTIuNSw5LjUgWiBNMy41LDkuNSBDMi42NzE1NzI4OCw5LjUgMiw4LjgyODQyNzEyIDIsOCBDMiw3LjE3MTU3Mjg4IDIuNjcxNTcyODgsNi41IDMuNSw2LjUgQzQuMzI4NDI3MTIsNi41IDUsNy4xNzE1NzI4OCA1LDggQzUsOC44Mjg0MjcxMiA0LjMyODQyNzEyLDkuNSAzLjUsOS41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Download: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyLDEzLjI5Mjg5MzIgTDE0LjE0NjQ0NjYsMTEuMTQ2NDQ2NiBDMTQuMzQxNzA4OCwxMC45NTExODQ1IDE0LjY1ODI5MTIsMTAuOTUxMTg0NSAxNC44NTM1NTM0LDExLjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTEuMzQxNzA4OCAxNS4wNDg4MTU1LDExLjY1ODI5MTIgMTQuODUzNTUzNCwxMS44NTM1NTM0IEwxMS44NTM1NTM0LDE0Ljg1MzU1MzQgQzExLjY1ODI5MTIsMTUuMDQ4ODE1NSAxMS4zNDE3MDg4LDE1LjA0ODgxNTUgMTEuMTQ2NDQ2NiwxNC44NTM1NTM0IEw4LjE0NjQ0NjYxLDExLjg1MzU1MzQgQzcuOTUxMTg0NDYsMTEuNjU4MjkxMiA3Ljk1MTE4NDQ2LDExLjM0MTcwODggOC4xNDY0NDY2MSwxMS4xNDY0NDY2IEM4LjM0MTcwODc2LDEwLjk1MTE4NDUgOC42NTgyOTEyNCwxMC45NTExODQ1IDguODUzNTUzMzksMTEuMTQ2NDQ2NiBMMTEsMTMuMjkyODkzMiBMMTEsNy41IEMxMSw3LjIyMzg1NzYzIDExLjIyMzg1NzYsNyAxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw3LjIyMzg1NzYzIDEyLDcuNSBMMTIsMTMuMjkyODkzMiBaIE0xLjA4NTM1Mjg1LDExIEMxLjI5MTI3MTA2LDExLjU4MjU5NjIgMS44NDY4OTA1OSwxMiAyLjUsMTIgTDYuNSwxMiBDNi43NzYxNDIzNywxMiA3LDEyLjIyMzg1NzYgNywxMi41IEM3LDEyLjc3NjE0MjQgNi43NzYxNDIzNywxMyA2LjUsMTMgTDIuNSwxMyBDMS4xMTkyODgxMywxMyAxLjM4Nzc3ODc4ZS0xNiwxMS44ODA3MTE5IDAsMTAuNSBDMCwxMC4yMjM4NTc2IDAuMjIzODU3NjI1LDEwIDAuNSwxMCBMNi41LDEwIEM2Ljc3NjE0MjM3LDEwIDcsMTAuMjIzODU3NiA3LDEwLjUgQzcsMTAuNzc2MTQyNCA2Ljc3NjE0MjM3LDExIDYuNSwxMSBMMS4wODUzNTI4NSwxMSBaIE0yLDguNSBDMiw4Ljc3NjE0MjM3IDEuNzc2MTQyMzcsOSAxLjUsOSBDMS4yMjM4NTc2Myw5IDEsOC43NzYxNDIzNyAxLDguNSBMMSwyLjUgQzEsMS42NzE1NzI4OCAxLjY3MTU3Mjg4LDEgMi41LDEgTDEzLjUsMSBDMTQuMzI4NDI3MSwxIDE1LDEuNjcxNTcyODggMTUsMi41IEwxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBDMTQuMjIzODU3Niw5IDE0LDguNzc2MTQyMzcgMTQsOC41IEwxNCwyLjUgQzE0LDIuMjIzODU3NjMgMTMuNzc2MTQyNCwyIDEzLjUsMiBMMi41LDIgQzIuMjIzODU3NjMsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsOC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); @@ -80,6 +81,7 @@ --icon-Repl: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjI5Mjg5MzIsMTIgTDEyLjE0NjQ0NjYsOS44NTM1NTMzOSBDMTEuOTUxMTg0NSw5LjY1ODI5MTI0IDExLjk1MTE4NDUsOS4zNDE3MDg3NiAxMi4xNDY0NDY2LDkuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsOC45NTExODQ0NiAxMi42NTgyOTEyLDguOTUxMTg0NDYgMTIuODUzNTUzNCw5LjE0NjQ0NjYxIEwxNS44NTM1NTM0LDEyLjE0NjQ0NjYgQzE2LjA0ODgxNTUsMTIuMzQxNzA4OCAxNi4wNDg4MTU1LDEyLjY1ODI5MTIgMTUuODUzNTUzNCwxMi44NTM1NTM0IEwxMi44NTM1NTM0LDE1Ljg1MzU1MzQgQzEyLjY1ODI5MTIsMTYuMDQ4ODE1NSAxMi4zNDE3MDg4LDE2LjA0ODgxNTUgMTIuMTQ2NDQ2NiwxNS44NTM1NTM0IEMxMS45NTExODQ1LDE1LjY1ODI5MTIgMTEuOTUxMTg0NSwxNS4zNDE3MDg4IDEyLjE0NjQ0NjYsMTUuMTQ2NDQ2NiBMMTQuMjkyODkzMiwxMyBMMTIuMjk3LDEzIEMxMS4wMTE0MDE0LDEzIDkuNzg3MzEwNDYsMTIuNDUwMDg5NiA4LjkzMzM4NDQzLDExLjQ4OTI4MjEgTDcuOTA0Mzg0NDMsMTAuMzMyMjgyMSBDNy43MjA4NzAwOSwxMC4xMjU5Mzk5IDcuNzM5Mzc1Nyw5LjgwOTg5ODc4IDcuOTQ1NzE3ODgsOS42MjYzODQ0MyBDOC4xNTIwNjAwNiw5LjQ0Mjg3MDA5IDguNDY4MTAxMjIsOS40NjEzNzU3IDguNjUxNjE1NTcsOS42Njc3MTc4OCBMOS42ODA3Mjg4LDEwLjgyNDg0NTIgQzEwLjM0NTAyNTUsMTEuNTcyMjg4NCAxMS4yOTcxMDQsMTIgMTIuMjk3LDEyIEwxNC4yOTI4OTMyLDEyIFogTTAuNSw0IEMwLjIyMzg1NzYyNSw0IC01LjE5NTg0Mzc2ZS0xNCwzLjc3NjE0MjM3IC01LjE5NTg0Mzc2ZS0xNCwzLjUgQy01LjE5NTg0Mzc2ZS0xNCwzLjIyMzg1NzYzIDAuMjIzODU3NjI1LDMgMC41LDMgTDAuNzA0LDMgQzEuOTg5NTk4NTUsMyAzLjIxMzY4OTU0LDMuNTQ5OTEwNDIgNC4wNjc3NzU5NSw0LjUxMDg5ODMgTDUuMDk1Nzc1OTUsNS42Njc4OTgzIEM1LjI3OTE5MDY1LDUuODc0MzI5MDUgNS4yNjA1MzI0Niw2LjE5MDM2MTI0IDUuMDU0MTAxNyw2LjM3Mzc3NTk1IEM0Ljg0NzY3MDk1LDYuNTU3MTkwNjUgNC41MzE2Mzg3Niw2LjUzODUzMjQ2IDQuMzQ4MjI0MDUsNi4zMzIxMDE3IEwzLjMyMDI3MTIsNS4xNzUxNTQ3NiBDMi42NTU5NzQ1Miw0LjQyNzcxMTU5IDEuNzAzODk1OTksNCAwLjcwNCw0IEwwLjUsNCBaIE0xNC4yOTE4OTMyLDIuOTk5IEwxMi4xNDY0NDY2LDAuODUzNTUzMzkxIEMxMS45NTExODQ1LDAuNjU4MjkxMjQ1IDExLjk1MTE4NDUsMC4zNDE3MDg3NTUgMTIuMTQ2NDQ2NiwwLjE0NjQ0NjYwOSBDMTIuMzQxNzA4OCwtMC4wNDg4MTU1MzY1IDEyLjY1ODI5MTIsLTAuMDQ4ODE1NTM2NSAxMi44NTM1NTM0LDAuMTQ2NDQ2NjA5IEwxNS44MzQxOTg2LDMuMTI3MDkxODMgQzE1LjkzMTE4OSwzLjIxNDMwNTE3IDE1Ljk5Mzg4MDQsMy4zMzg5MTI3OCAxNS45OTk1NzU5LDMuNDc4MjE3NzQgQzE1Ljk5OTgzNzEsMy40ODUzNzc4MyAxNS45OTk5OTI4LDMuNDkyNDM5ODEgMTUuOTk5OTk5OCwzLjQ5OTUwMTkyIEMxNS45OTk5ODg0LDMuNTExMDYxMTMgMTUuOTk5NTg0OCwzLjUyMjUyODQ5IDE1Ljk5ODgwMTUsMy41MzM4OTE0MyBDMTUuOTkwOTk0NCwzLjY1MDMzMjU2IDE1Ljk0MjU1OTgsMy43NjQ1NDcwMiAxNS44NTM1NTM0LDMuODUzNTUzMzkgTDEyLjg1MzU1MzQsNi44NTM1NTMzOSBDMTIuNjU4MjkxMiw3LjA0ODgxNTU0IDEyLjM0MTcwODgsNy4wNDg4MTU1NCAxMi4xNDY0NDY2LDYuODUzNTUzMzkgQzExLjk1MTE4NDUsNi42NTgyOTEyNCAxMS45NTExODQ1LDYuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw2LjE0NjQ0NjYxIEwxNC4yOTM4OTMyLDMuOTk5IEwxMi4yOTcsMy45OTkgQzExLjI5NzEwNCwzLjk5OSAxMC4zNDUwMjU1LDQuNDI2NzExNTkgOS42ODA3MTQ0NSw1LjE3NDE3MDkgTDQuMDY3NzI4OCwxMS40ODkxNTQ4IEMzLjIxMzY4OTU0LDEyLjQ1MDA4OTYgMS45ODk1OTg1NSwxMyAwLjcwNCwxMyBMMC41LDEzIEMwLjIyMzg1NzYyNSwxMyAtMi4yNjQ4NTQ5N2UtMTMsMTIuNzc2MTQyNCAtMi4yNjQ4NTQ5N2UtMTMsMTIuNSBDLTIuMjY0ODU0OTdlLTEzLDEyLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTIgMC41LDEyIEwwLjcwNCwxMiBDMS43MDM4OTU5OSwxMiAyLjY1NTk3NDUyLDExLjU3MjI4ODQgMy4zMjAyODU1NSwxMC44MjQ4MjkxIEw4LjkzMzI3MTIsNC41MDk4NDUyNCBDOS43ODczMTA0NiwzLjU0ODkxMDQyIDExLjAxMTQwMTQsMi45OTkgMTIuMjk3LDIuOTk5IEwxNC4yOTE4OTMyLDIuOTk5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4='); --icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); + --icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4='); --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Share: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNzc0MTEwMDUsOS4xNDQxMzAyOSBMOS43NTY4Nzg3NiwxMS4wMDc4NjUxIEMxMC4zMDY0MDY1LDEwLjM4OTU1MjMgMTEuMTA3NzEwOSwxMCAxMiwxMCBDMTMuNjU2ODU0MiwxMCAxNSwxMS4zNDMxNDU4IDE1LDEzIEMxNSwxNC42NTY4NTQyIDEzLjY1Njg1NDIsMTYgMTIsMTYgQzEwLjM0MzE0NTgsMTYgOSwxNC42NTY4NTQyIDksMTMgQzksMTIuNTk0NjU3MiA5LjA4MDM4OTUzLDEyLjIwODA5MDQgOS4yMjYwOTUwNywxMS44NTUzNzMgTDYuMjQzNDc4MjQsOS45OTE3MzMxNyBDNS42OTM5NDA1NSwxMC42MTAyNzkgNC44OTI0ODIzOSwxMSA0LDExIEMyLjM0MzE0NTc1LDExIDEsOS42NTY4NTQyNSAxLDggQzEsNi4zNDMxNDU3NSAyLjM0MzE0NTc1LDUgNCw1IEM0Ljg5MjQ4MjM5LDUgNS42OTM5NDA1NSw1LjM4OTcyMTAzIDYuMjQzNDc4MjQsNi4wMDgyNjY4MyBMOS4yMjYwOTUwNyw0LjE0NDYyNjk2IEM5LjA4MDM4OTUzLDMuNzkxOTA5NjMgOSwzLjQwNTM0MjggOSwzIEM5LDEuMzQzMTQ1NzUgMTAuMzQzMTQ1OCwwIDEyLDAgQzEzLjY1Njg1NDIsMCAxNSwxLjM0MzE0NTc1IDE1LDMgQzE1LDQuNjU2ODU0MjUgMTMuNjU2ODU0Miw2IDEyLDYgQzExLjEwNzcxMDksNiAxMC4zMDY0MDY1LDUuNjEwNDQ3NzMgOS43NTY4Nzg3Niw0Ljk5MjEzNDkzIEw2Ljc3NDExMDA1LDYuODU1ODY5NzEgQzYuOTE5Njg1OTIsNy4yMDg0NTMyNSA3LDcuNTk0ODQ3NDQgNyw4IEM3LDguNDA1MTUyNTYgNi45MTk2ODU5Miw4Ljc5MTU0Njc1IDYuNzc0MTEwMDUsOS4xNDQxMzAyOSBaIE01LjcwNDEyNDg1LDkuMDQ3NDE3MTYgQzUuODkxNzYwNjYsOC43NDI3ODczNCA2LDguMzg0MDM0IDYsOCBDNiw3LjYxNTk2NiA1Ljg5MTc2MDY2LDcuMjU3MjEyNjYgNS43MDQxMjQ4NSw2Ljk1MjU4Mjg0IEM1LjcwMTM1MzQ2LDYuOTQ4NDI1NzcgNS42OTg2MzQ0Miw2Ljk0NDIxNDMyIDUuNjk1OTY5MTgsNi45Mzk5NDg4IEM1LjY5MzQ2MjQ3LDYuOTM1OTM2OTkgNS42OTEwMTk1OSw2LjkzMTkwMzM4IDUuNjg4NjQwMjksNi45Mjc4NDkxNSBDNS4zMzM3NDk5MSw2LjM3MDA2MTY1IDQuNzEwMDkxMjIsNiA0LDYgQzIuODk1NDMwNSw2IDIsNi44OTU0MzA1IDIsOCBDMiw5LjEwNDU2OTUgMi44OTU0MzA1LDEwIDQsMTAgQzQuNzEwMDkxMjIsMTAgNS4zMzM3NDk5MSw5LjYyOTkzODM1IDUuNjg4NjQwMjksOS4wNzIxNTA4NSBDNS42OTEwMTk1OSw5LjA2ODA5NjYyIDUuNjkzNDYyNDcsOS4wNjQwNjMwMSA1LjY5NTk2OTE4LDkuMDYwMDUxMiBDNS42OTg2MzQ0Miw5LjA1NTc4NTY4IDUuNzAxMzUzNDYsOS4wNTE1NzQyMyA1LjcwNDEyNDg1LDkuMDQ3NDE3MTYgWiBNMTAuMzE5OTA5MiwxMS45MTQ1MjgzIEMxMC4zMTUyMjY4LDExLjkyMzA4OTcgMTAuMzEwMjY4MSwxMS45MzE1NjY5IDEwLjMwNTAzMDgsMTEuOTM5OTQ4OCBDMTAuMjk5NTk2LDExLjk0ODY0NjggMTAuMjkzOTM3NiwxMS45NTcxMTk5IDEwLjI4ODA2NzYsMTEuOTY1MzY1NSBDMTAuMTA1MjQsMTIuMjY3MjI4NiAxMCwxMi42MjEzMjQzIDEwLDEzIEMxMCwxNC4xMDQ1Njk1IDEwLjg5NTQzMDUsMTUgMTIsMTUgQzEzLjEwNDU2OTUsMTUgMTQsMTQuMTA0NTY5NSAxNCwxMyBDMTQsMTEuODk1NDMwNSAxMy4xMDQ1Njk1LDExIDEyLDExIEMxMS4yOTU1NzY3LDExIDEwLjY3NjIxMTcsMTEuMzY0MTc3NSAxMC4zMTk5MDkyLDExLjkxNDUyODMgWiBNMTAuMjg4MDY3Niw0LjAzNDYzNDU0IEMxMC4yOTM5Mzc2LDQuMDQyODgwMDkgMTAuMjk5NTk2LDQuMDUxMzUzMjUgMTAuMzA1MDMwOCw0LjA2MDA1MTIgQzEwLjMxMDI2ODEsNC4wNjg0MzMxNSAxMC4zMTUyMjY4LDQuMDc2OTEwMjUgMTAuMzE5OTA5Miw0LjA4NTQ3MTY4IEMxMC42NzYyMTE3LDQuNjM1ODIyNDUgMTEuMjk1NTc2Nyw1IDEyLDUgQzEzLjEwNDU2OTUsNSAxNCw0LjEwNDU2OTUgMTQsMyBDMTQsMS44OTU0MzA1IDEzLjEwNDU2OTUsMSAxMiwxIEMxMC44OTU0MzA1LDEgMTAsMS44OTU0MzA1IDEwLDMgQzEwLDMuMzc4Njc1NzQgMTAuMTA1MjQsMy43MzI3NzEzNyAxMC4yODgwNjc2LDQuMDM0NjM0NTQgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); diff --git a/static/ui-icons/UI/Database.svg b/static/ui-icons/UI/Database.svg new file mode 100644 index 00000000..80048a70 --- /dev/null +++ b/static/ui-icons/UI/Database.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/ui-icons/UI/Script.svg b/static/ui-icons/UI/Script.svg new file mode 100644 index 00000000..d3152df3 --- /dev/null +++ b/static/ui-icons/UI/Script.svg @@ -0,0 +1,83 @@ + + + + + + image/svg+xml + + + + + + + + + + + + +