2023-05-12 13:08:28 +00:00
|
|
|
import * as commands from 'app/client/components/commands';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2023-05-12 13:08:28 +00:00
|
|
|
import { FocusLayer } from 'app/client/lib/FocusLayer';
|
2022-04-27 17:46:24 +00:00
|
|
|
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
|
|
|
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
|
2023-05-12 13:08:28 +00:00
|
|
|
import { theme } from 'app/client/ui2018/cssVars';
|
2022-04-27 17:46:24 +00:00
|
|
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
|
|
|
import {ModalControl} from 'app/client/ui2018/modals';
|
2023-05-12 13:08:28 +00:00
|
|
|
import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs';
|
2023-11-20 00:46:32 +00:00
|
|
|
import {IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom} from 'popweasel';
|
2023-05-12 13:08:28 +00:00
|
|
|
import { descriptionInfoTooltip } from './tooltips';
|
|
|
|
import { autoGrow } from './forms';
|
|
|
|
import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles';
|
2022-04-27 17:46:24 +00:00
|
|
|
|
|
|
|
const testId = makeTestId('test-widget-title-');
|
2022-10-28 16:11:08 +00:00
|
|
|
const t = makeT('WidgetTitle');
|
2022-04-27 17:46:24 +00:00
|
|
|
|
|
|
|
interface WidgetTitleOptions {
|
|
|
|
tableNameHidden?: boolean,
|
|
|
|
widgetNameHidden?: boolean,
|
2023-11-20 00:46:32 +00:00
|
|
|
disabled?: boolean,
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
|
|
|
|
const title = Computed.create(null, use => use(vs.titleDef));
|
2023-05-12 13:08:28 +00:00
|
|
|
const description = Computed.create(null, use => use(vs.description));
|
2023-11-20 00:46:32 +00:00
|
|
|
return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args);
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
|
2023-11-20 00:46:32 +00:00
|
|
|
interface TableNameOptions {
|
|
|
|
isEditing: Observable<boolean>,
|
|
|
|
disabled?: boolean,
|
|
|
|
}
|
|
|
|
|
|
|
|
export function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) {
|
2022-04-27 17:46:24 +00:00
|
|
|
const title = Computed.create(null, use => use(use(vs.table).tableNameDef));
|
2023-05-12 13:08:28 +00:00
|
|
|
const description = Computed.create(null, use => use(vs.description));
|
2023-11-20 00:46:32 +00:00
|
|
|
return buildRenamableTitle(
|
|
|
|
vs,
|
|
|
|
title,
|
|
|
|
description,
|
|
|
|
{
|
|
|
|
openOnClick: false,
|
|
|
|
widgetNameHidden: true,
|
|
|
|
...options,
|
|
|
|
},
|
|
|
|
dom.autoDispose(title),
|
|
|
|
...args
|
|
|
|
);
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
|
2023-11-20 00:46:32 +00:00
|
|
|
interface RenamableTitleOptions {
|
|
|
|
tableNameHidden?: boolean,
|
|
|
|
widgetNameHidden?: boolean,
|
|
|
|
/** Defaults to true. */
|
|
|
|
openOnClick?: boolean,
|
|
|
|
isEditing?: Observable<boolean>,
|
|
|
|
disabled?: boolean,
|
|
|
|
}
|
|
|
|
|
|
|
|
function buildRenamableTitle(
|
2022-04-27 17:46:24 +00:00
|
|
|
vs: ViewSectionRec,
|
|
|
|
title: Observable<string>,
|
2023-05-12 13:08:28 +00:00
|
|
|
description: Observable<string>,
|
2023-11-20 00:46:32 +00:00
|
|
|
options: RenamableTitleOptions,
|
|
|
|
...args: DomElementArg[]
|
|
|
|
) {
|
|
|
|
const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options;
|
|
|
|
let popupControl: PopupControl | undefined;
|
2022-04-27 17:46:24 +00:00
|
|
|
return cssTitleContainer(
|
|
|
|
cssTitle(
|
|
|
|
testId('text'),
|
|
|
|
dom.text(title),
|
2023-11-20 00:46:32 +00:00
|
|
|
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(); }
|
|
|
|
}),
|
2022-04-27 17:46:24 +00:00
|
|
|
// In case titleDef is all blank space, make it visible on hover.
|
|
|
|
cssTitle.cls("-empty", use => !use(title)?.trim()),
|
2023-11-20 00:46:32 +00:00
|
|
|
cssTitle.cls("-open-on-click", openOnClick),
|
|
|
|
cssTitle.cls("-disabled", disabled),
|
2022-04-27 17:46:24 +00:00
|
|
|
elem => {
|
2023-11-20 00:46:32 +00:00
|
|
|
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);
|
|
|
|
}, {
|
2022-04-27 17:46:24 +00:00
|
|
|
placement: 'bottom-start',
|
2023-11-20 00:46:32 +00:00
|
|
|
trigger,
|
2022-04-27 17:46:24 +00:00
|
|
|
attach: 'body',
|
|
|
|
boundaries: 'viewport',
|
|
|
|
});
|
2022-07-18 16:24:26 +00:00
|
|
|
},
|
2023-11-20 00:46:32 +00:00
|
|
|
openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,
|
2022-04-27 17:46:24 +00:00
|
|
|
),
|
2023-05-12 13:08:28 +00:00
|
|
|
dom.maybe(description, () => [
|
|
|
|
descriptionInfoTooltip(description.get(), "widget")
|
|
|
|
]),
|
2022-04-27 17:46:24 +00:00
|
|
|
...args
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-11-20 00:46:32 +00:00
|
|
|
function buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) {
|
2022-04-27 17:46:24 +00:00
|
|
|
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.
|
2022-05-04 09:54:30 +00:00
|
|
|
const inputWidgetTitle = Observable.create(ctrl, vs.title.peek() ?? '');
|
2022-04-27 17:46:24 +00:00
|
|
|
// 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.
|
2022-12-06 13:57:29 +00:00
|
|
|
const inputWidgetPlaceholder = !vs.title.peek() ? t("Override widget title") : vs.defaultWidgetTitle.peek();
|
2022-04-27 17:46:24 +00:00
|
|
|
|
2023-05-12 13:08:28 +00:00
|
|
|
// User input for widget description
|
|
|
|
const inputWidgetDesc = Observable.create(ctrl, vs.description.peek() ?? '');
|
|
|
|
|
2022-05-04 09:54:30 +00:00
|
|
|
const disableSave = Computed.create(ctrl, (use) => {
|
|
|
|
const newTableName = use(inputTableName)?.trim() ?? '';
|
|
|
|
const newWidgetTitle = use(inputWidgetTitle)?.trim() ?? '';
|
2023-05-12 13:08:28 +00:00
|
|
|
const newWidgetDesc = use(inputWidgetDesc)?.trim() ?? '';
|
2022-05-04 09:54:30 +00:00
|
|
|
// Can't save when table name is empty or there wasn't any change.
|
2023-05-12 13:08:28 +00:00
|
|
|
return !newTableName || (
|
|
|
|
newTableName === tableName
|
|
|
|
&& newWidgetTitle === use(vs.title)
|
|
|
|
&& newWidgetDesc === use(vs.description)
|
|
|
|
);
|
2022-05-04 09:54:30 +00:00
|
|
|
});
|
2022-04-27 17:46:24 +00:00
|
|
|
|
|
|
|
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 () => {
|
2022-05-04 09:54:30 +00:00
|
|
|
const newTitle = inputWidgetTitle.get()?.trim() ?? '';
|
2022-04-27 17:46:24 +00:00
|
|
|
// If value was changed.
|
2022-05-04 09:54:30 +00:00
|
|
|
if (newTitle !== vs.title.peek()) {
|
|
|
|
await vs.title.saveOnly(newTitle);
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
};
|
2023-05-12 13:08:28 +00:00
|
|
|
|
|
|
|
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([
|
2022-04-27 17:46:24 +00:00
|
|
|
saveTableName(),
|
2023-05-12 13:08:28 +00:00
|
|
|
saveWidgetTitle(),
|
|
|
|
saveWidgetDesc()
|
|
|
|
]);
|
2022-04-27 17:46:24 +00:00
|
|
|
|
|
|
|
function initialFocus() {
|
2022-05-04 09:54:30 +00:00
|
|
|
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);
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-12 13:08:28 +00:00
|
|
|
// 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);
|
|
|
|
|
2022-04-27 17:46:24 +00:00
|
|
|
let tableInput: HTMLInputElement|undefined;
|
|
|
|
let widgetInput: HTMLInputElement|undefined;
|
2023-05-12 13:08:28 +00:00
|
|
|
let descInput: HTMLTextAreaElement | undefined;
|
2022-04-27 17:46:24 +00:00
|
|
|
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.
|
2023-05-12 13:08:28 +00:00
|
|
|
elem => { FocusLayer.create(ctrl, { defaultFocusElem: elem, pauseMousetrap: false }); },
|
|
|
|
dom.onDispose(onClose),
|
|
|
|
dom.autoDispose(commandGroup),
|
2022-04-27 17:46:24 +00:00
|
|
|
testId('popup'),
|
|
|
|
dom.cls(menuCssClass),
|
|
|
|
dom.maybe(!options.tableNameHidden, () => [
|
2022-12-06 13:57:29 +00:00
|
|
|
cssLabel(t("DATA TABLE NAME")),
|
2022-04-27 17:46:24 +00:00
|
|
|
// Update tableName on key stroke - this will show the default widget name as we type.
|
|
|
|
// above this modal.
|
|
|
|
tableInput = cssInput(
|
|
|
|
inputTableName,
|
|
|
|
updateOnKey,
|
2022-12-06 13:57:29 +00:00
|
|
|
{disabled: isSummary, placeholder: t("Provide a table name")},
|
2023-05-12 13:08:28 +00:00
|
|
|
testId('table-name-input'),
|
|
|
|
commandGroup.attach(),
|
2022-04-27 17:46:24 +00:00
|
|
|
),
|
|
|
|
]),
|
|
|
|
dom.maybe(!options.widgetNameHidden, () => [
|
2022-12-06 13:57:29 +00:00
|
|
|
cssLabel(t("WIDGET TITLE")),
|
2022-04-27 17:46:24 +00:00
|
|
|
widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder},
|
2023-05-12 13:08:28 +00:00
|
|
|
testId('section-name-input'),
|
|
|
|
commandGroup.attach(),
|
2022-04-27 17:46:24 +00:00
|
|
|
),
|
|
|
|
]),
|
2023-05-12 13:08:28 +00:00
|
|
|
cssLabel(t("WIDGET DESCRIPTION")),
|
|
|
|
descInput = cssTextArea(inputWidgetDesc, updateOnKey,
|
|
|
|
testId('section-description-input'),
|
|
|
|
commandGroup.attach(),
|
|
|
|
autoGrow(inputWidgetDesc),
|
|
|
|
),
|
2022-04-27 17:46:24 +00:00
|
|
|
cssButtons(
|
2022-12-06 13:57:29 +00:00
|
|
|
primaryButton(t("Save"),
|
2023-05-12 13:08:28 +00:00
|
|
|
dom.on('click', close),
|
2022-04-27 17:46:24 +00:00
|
|
|
dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)),
|
|
|
|
testId('save'),
|
|
|
|
),
|
2022-12-06 13:57:29 +00:00
|
|
|
basicButton(t("Cancel"),
|
2022-04-27 17:46:24 +00:00
|
|
|
testId('cancel'),
|
2023-05-12 13:08:28 +00:00
|
|
|
dom.on('click', cancel)
|
2022-04-27 17:46:24 +00:00
|
|
|
),
|
|
|
|
),
|
|
|
|
dom.onKeyDown({
|
2023-05-12 13:08:28 +00:00
|
|
|
Enter$: e => {
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
close();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
2022-04-27 17:46:24 +00:00
|
|
|
}),
|
|
|
|
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;
|
2023-05-12 13:08:28 +00:00
|
|
|
.info_toggle_icon {
|
|
|
|
width: 13px;
|
|
|
|
height: 13px;
|
|
|
|
}
|
2022-04-27 17:46:24 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
const cssTitle = styled('div', `
|
|
|
|
overflow: hidden;
|
|
|
|
border-radius: 3px;
|
|
|
|
margin: -4px;
|
|
|
|
padding: 4px;
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
align-self: start;
|
2023-11-20 00:46:32 +00:00
|
|
|
&-open-on-click:not(&-disabled) {
|
|
|
|
cursor: pointer;
|
|
|
|
}
|
|
|
|
&-open-on-click:not(&-disabled):hover {
|
2022-09-06 01:51:57 +00:00
|
|
|
background-color: ${theme.hover};
|
2022-04-27 17:46:24 +00:00
|
|
|
}
|
|
|
|
&-empty {
|
|
|
|
min-width: 48px;
|
|
|
|
min-height: 23px;
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssButtons = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
margin-top: 16px;
|
|
|
|
& > .${cssButton.className}:not(:first-child) {
|
|
|
|
margin-left: 8px;
|
|
|
|
}
|
|
|
|
`);
|