gristlabs_grist-core/app/client/ui/WidgetTitle.ts
George Gevoian caf830db08 (core) Record Cards
Summary:
Adds a new Record Card view section to each non-summary table, which can be from opened from various parts of the Grist UI to view and edit records in a popup card view.

Work is still ongoing, so the feature is locked away behind a flag; follow-up work is planned to finish up the implementation and add end-to-end tests.

Test Plan: Python and server tests. Browser tests will be included in a follow-up.

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4114
2023-11-19 20:12:37 -05:00

371 lines
12 KiB
TypeScript

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<boolean>,
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<boolean>,
disabled?: boolean,
}
function buildRenamableTitle(
vs: ViewSectionRec,
title: Observable<string>,
description: Observable<string>,
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;
}
`);