import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; import { FocusLayer } from 'app/client/lib/FocusLayer'; import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec'; import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons'; import { theme } from 'app/client/ui2018/cssVars'; import {menuCssClass} from 'app/client/ui2018/menus'; import {ModalControl} from 'app/client/ui2018/modals'; import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs'; import {IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom} from 'popweasel'; import { descriptionInfoTooltip } from './tooltips'; import { autoGrow } from './forms'; import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles'; const testId = makeTestId('test-widget-title-'); const t = makeT('WidgetTitle'); interface WidgetTitleOptions { tableNameHidden?: boolean, widgetNameHidden?: boolean, disabled?: boolean, } export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) { const title = Computed.create(null, use => use(vs.titleDef)); const description = Computed.create(null, use => use(vs.description)); return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args); } interface TableNameOptions { isEditing: Observable, disabled?: boolean, } export function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) { const title = Computed.create(null, use => use(use(vs.table).tableNameDef)); const description = Computed.create(null, use => use(vs.description)); return buildRenamableTitle( vs, title, description, { openOnClick: false, widgetNameHidden: true, ...options, }, dom.autoDispose(title), ...args ); } interface RenamableTitleOptions { tableNameHidden?: boolean, widgetNameHidden?: boolean, /** Defaults to true. */ openOnClick?: boolean, isEditing?: Observable, disabled?: boolean, } function buildRenamableTitle( vs: ViewSectionRec, title: Observable, description: Observable, options: RenamableTitleOptions, ...args: DomElementArg[] ) { const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options; let popupControl: PopupControl | undefined; return cssTitleContainer( cssTitle( testId('text'), dom.text(title), dom.on('click', () => { // The popup doesn't close if `openOnClick` is false and the title is // clicked. Make sure that it does. if (!openOnClick) { popupControl?.close(); } }), // In case titleDef is all blank space, make it visible on hover. cssTitle.cls("-empty", use => !use(title)?.trim()), cssTitle.cls("-open-on-click", openOnClick), cssTitle.cls("-disabled", disabled), elem => { if (disabled) { return; } // The widget title popup can be configured to open in up to two ways: // 1. When the title is clicked - done by setting `openOnClick` to `true`. // 2. When `isEditing` is set to true - done by setting `isEditing` to `true`. // // Typically, the former should be set. The latter is useful for triggering the // popup from a different part of the UI, like a menu item. const trigger: IPopupOptions['trigger'] = []; if (openOnClick) { trigger.push('click'); } if (isEditing) { trigger.push((_: Element, ctl: PopupControl) => { popupControl = ctl; ctl.autoDispose(isEditing.addListener((editing) => { if (editing) { ctl.open(); } else if (!ctl.isDisposed()) { ctl.close(); } })); }); } setPopupToCreateDom(elem, ctl => { if (isEditing) { ctl.onDispose(() => isEditing.set(false)); } return buildRenameTitlePopup(ctl, vs, renameTitleOptions); }, { placement: 'bottom-start', trigger, attach: 'body', boundaries: 'viewport', }); }, openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null, ), dom.maybe(description, () => [ descriptionInfoTooltip(description.get(), "widget") ]), ...args ); } function buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) { const tableRec = vs.table.peek(); // If the table is a summary table. const isSummary = Boolean(tableRec.summarySourceTable.peek()); // Table name, for summary table it contains also a grouping description, but it is not editable. // Example: Table1 or Table1 [by B, C] const tableName = [tableRec.tableNameDef.peek(), tableRec.groupDesc.peek()] .filter(p => Boolean(p?.trim())).join(' '); // User input for table name. const inputTableName = Observable.create(ctrl, tableName); // User input for widget title. const inputWidgetTitle = Observable.create(ctrl, vs.title.peek() ?? ''); // Placeholder for widget title: // - when widget title is empty shows a default widget title (what would be shown when title is empty) // - when widget title is set, shows just a text to override it. const inputWidgetPlaceholder = !vs.title.peek() ? t("Override widget title") : vs.defaultWidgetTitle.peek(); // User input for widget description const inputWidgetDesc = Observable.create(ctrl, vs.description.peek() ?? ''); const disableSave = Computed.create(ctrl, (use) => { const newTableName = use(inputTableName)?.trim() ?? ''; const newWidgetTitle = use(inputWidgetTitle)?.trim() ?? ''; const newWidgetDesc = use(inputWidgetDesc)?.trim() ?? ''; // Can't save when table name is empty or there wasn't any change. return !newTableName || ( newTableName === tableName && newWidgetTitle === use(vs.title) && newWidgetDesc === use(vs.description) ); }); const modalCtl = ModalControl.create(ctrl, () => ctrl.close()); const saveTableName = async () => { // For summary table ignore - though we could rename primary table. if (isSummary) { return; } // Can't save an empty name - there are actually no good reasons why we can't have empty table name, // unfortunately there are some use cases that really on the empty name: // - For ACL we sometimes may check if tableId is empty (and sometimes if table name). // - Pages with empty name are not visible by default (and pages are renamed with a table - if their name match). if (!inputTableName.get().trim()) { return; } // If value was changed. if (inputTableName.get() !== tableRec.tableNameDef.peek()) { await tableRec.tableNameDef.saveOnly(inputTableName.get()); } }; const saveWidgetTitle = async () => { const newTitle = inputWidgetTitle.get()?.trim() ?? ''; // If value was changed. if (newTitle !== vs.title.peek()) { await vs.title.saveOnly(newTitle); } }; const saveWidgetDesc = async () => { const newWidgetDesc = inputWidgetDesc.get().trim() ?? ''; // If value was changed. if (newWidgetDesc !== vs.description.peek()) { await vs.description.saveOnly(newWidgetDesc); } }; const save = () => Promise.all([ saveTableName(), saveWidgetTitle(), saveWidgetDesc() ]); function initialFocus() { const isRawView = !widgetInput; const isWidgetTitleEmpty = !vs.title.peek(); function focus(inputEl?: HTMLInputElement) { inputEl?.focus(); inputEl?.select(); } if (isSummary) { focus(widgetInput); } else if (isRawView) { focus(tableInput); } else if (isWidgetTitleEmpty) { focus(tableInput); } else { focus(widgetInput); } } // When the popup is closing we will save everything, unless the user has pressed the cancel button. let cancelled = false; // Function to close the popup with saving. const close = () => ctrl.close(); // Function to close the popup without saving. const cancel = () => { cancelled = true; close(); }; // Function that is called when popup is closed. const onClose = () => { if (!cancelled) { save().catch(reportError); } }; // User interface for the popup. const myCommands = { // Escape key: just close the popup. cancel, // Enter key: save and close the popup, unless the description input is focused. // There is also a variant for Ctrl+Enter which will always save. accept: () => { // Enters are ignored in the description input (unless ctrl is pressed) if (document.activeElement === descInput) { return true; } close(); }, // ArrowUp cursorUp: () => { // moves focus to the widget title input if it is already at the top of widget description if (document.activeElement === descInput && descInput?.selectionStart === 0) { widgetInput?.focus(); widgetInput?.select(); } else if (document.activeElement === widgetInput) { tableInput?.focus(); tableInput?.select(); } else { return true; } }, // ArrowDown cursorDown: () => { if (document.activeElement === tableInput) { widgetInput?.focus(); widgetInput?.select(); } else if (document.activeElement === widgetInput) { descInput?.focus(); descInput?.select(); } else { return true; } } }; // Create this group and attach it to the popup and all inputs. const commandGroup = commands.createGroup({ ...myCommands }, ctrl, true); let tableInput: HTMLInputElement|undefined; let widgetInput: HTMLInputElement|undefined; let descInput: HTMLTextAreaElement | undefined; return cssRenamePopup( // Create a FocusLayer to keep focus in this popup while it's active, and prevent keyboard // shortcuts from being seen by the view underneath. elem => { FocusLayer.create(ctrl, { defaultFocusElem: elem, pauseMousetrap: false }); }, dom.onDispose(onClose), dom.autoDispose(commandGroup), testId('popup'), dom.cls(menuCssClass), dom.maybe(!options.tableNameHidden, () => [ cssLabel(t("DATA TABLE NAME")), // Update tableName on key stroke - this will show the default widget name as we type. // above this modal. tableInput = cssInput( inputTableName, updateOnKey, {disabled: isSummary, placeholder: t("Provide a table name")}, testId('table-name-input'), commandGroup.attach(), ), ]), dom.maybe(!options.widgetNameHidden, () => [ cssLabel(t("WIDGET TITLE")), widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder}, testId('section-name-input'), commandGroup.attach(), ), ]), cssLabel(t("WIDGET DESCRIPTION")), descInput = cssTextArea(inputWidgetDesc, updateOnKey, testId('section-description-input'), commandGroup.attach(), autoGrow(inputWidgetDesc), ), cssButtons( primaryButton(t("Save"), dom.on('click', close), dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)), testId('save'), ), basicButton(t("Cancel"), testId('cancel'), dom.on('click', cancel) ), ), dom.onKeyDown({ Enter$: e => { if (e.ctrlKey || e.metaKey) { close(); return false; } } }), elem => { setTimeout(initialFocus, 0); }, ); } const updateOnKey = {onInput: true}; // Leave class for tests. const cssTitleContainer = styled('div', ` flex: 1 1 0px; min-width: 0px; display: flex; .info_toggle_icon { width: 13px; height: 13px; } `); const cssTitle = styled('div', ` overflow: hidden; border-radius: 3px; margin: -4px; padding: 4px; text-overflow: ellipsis; align-self: start; &-open-on-click:not(&-disabled) { cursor: pointer; } &-open-on-click:not(&-disabled):hover { background-color: ${theme.hover}; } &-empty { min-width: 48px; min-height: 23px; } `); const cssButtons = styled('div', ` display: flex; margin-top: 16px; & > .${cssButton.className}:not(:first-child) { margin-left: 8px; } `);