(core) Adding description icon and tooltip in the GridView

Summary: Column description and new renaming popup for the GridView.

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3838
This commit is contained in:
Jarosław Sadziński
2023-04-19 12:17:22 +02:00
parent 3aac027a13
commit b13fb1d97e
11 changed files with 814 additions and 91 deletions

View File

@@ -0,0 +1,370 @@
import * as Clipboard from 'app/client/components/Clipboard';
import * as commands from 'app/client/components/commands';
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {setTestState} from 'app/client/lib/testState';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {textarea} from 'app/client/ui/inputs';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {basicButton, cssButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssTextInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass} from 'app/client/ui2018/menus';
import {Computed, dom, IInputOptions, input, makeTestId, Observable, styled} from 'grainjs';
import * as ko from 'knockout';
import {IOpenController, PopupControl, setPopupToCreateDom} from 'popweasel';
const testId = makeTestId('test-column-title-');
const t = makeT('ColumnTitle');
interface IColumnTitleOptions {
field: ViewFieldRec;
isEditing: ko.Computed<boolean>;
optCommands?: any;
}
export function buildRenameColumn(options: IColumnTitleOptions) {
return (elem: Element) => {
// To open the popup we will listen to the isEditing observable, and open the popup when it
// it is changed. This can be changed either by us, but also by an external source.
const trigger = (triggerElem: Element, ctl: PopupControl) => {
ctl.autoDispose(options.isEditing.subscribe((editing) => {
if (editing) {
ctl.open();
} else if (!ctl.isDisposed()) {
ctl.close();
}
}));
};
setPopupToCreateDom(elem, ctl => buildColumnRenamePopup(ctl, options), {
placement: 'bottom-start',
trigger: [trigger],
attach: 'body',
boundaries: 'viewport',
});
};
}
function buildColumnRenamePopup(
ctrl: IOpenController, {field, isEditing, optCommands}: IColumnTitleOptions
) {
// Store temporary values for the label and description.
const editedLabel = Observable.create(ctrl, field.displayLabel.peek());
const editedDesc = Observable.create(ctrl, field.description.peek());
// Col id is static, as we can't forsee if it will change and what it will
// change to (it may overlap with another column)
const colId = '$' + field.colId.peek();
// Flag that indicates if something has changed (controls the save button).
const disableSave = Computed.create(ctrl, (use) => {
return (
use(editedLabel)?.trim() === field.displayLabel.peek()
&& use(editedDesc)?.trim() === field.description.peek()
);
});
// Function to change a column name.
const saveColumnLabel = async () => {
// Trim new label and make sure it is a string (not null).
const newLabel = editedLabel.get()?.trim() ?? '';
// Save only when it is not empty and different from the current value.
if (newLabel && newLabel !== field.displayLabel.peek()) {
await field.displayLabel.setAndSave(newLabel);
}
};
// Function to change a column description.
const saveColumnDesc = async () => {
const newDesc = editedDesc.get()?.trim() ?? '';
if (newDesc !== field.description.peek()) {
await field.description.saveOnly(newDesc);
}
};
// Function save column name and description and close the popup.
const save = () => Promise.all([
saveColumnLabel(),
saveColumnDesc()
]);
// 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);
}
// Reset the isEditing flag. It will set the editIndex in GridView to -1 if this is active column.
// It can happen that we will be open even if the column is not active (as the isEditing flag is asynchronous).
isEditing(false);
};
// 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();
},
// Tab: save and close the popup, and move to the next field.
nextField: () => {
close();
optCommands?.nextField?.();
},
// Shift + Tab: save and close the popup, and move to the previous field.
prevField: () => {
close();
optCommands?.prevField?.();
},
// ArrowUp: moves focus to the label if it is already at the top
cursorUp: () => {
if (document.activeElement === descInput && descInput?.selectionStart === 0) {
labelInput?.focus();
labelInput?.select();
} else {
return true;
}
},
// ArrowDown: move to the description input, only if the label input is focused.
cursorDown: () => {
if (document.activeElement === labelInput) {
const focus = () => {
descInput?.focus();
descInput?.select();
};
showDesc.set(true);
focus();
} else {
return true;
}
}
};
// Create this group and attach it to the popup and both inputs.
const commandGroup = commands.createGroup({...optCommands, ...myCommands}, ctrl, true);
// We will still focus from other elements and restore it on either the label or description input.
let lastFocus: HTMLElement | undefined;
const rememberFocus = (el: HTMLElement) => dom.on('focus', () => lastFocus = el);
const restoreFocus = (el: HTMLElement) => dom.on('focus', () => lastFocus?.focus());
const showDesc = Observable.create(null, Boolean(field.description.peek() !== ''));
let labelInput: HTMLInputElement | undefined;
let descInput: HTMLTextAreaElement | undefined;
return cssRenamePopup(
dom.onDispose(onClose),
dom.autoDispose(commandGroup),
dom.autoDispose(showDesc),
testId('popup'),
dom.cls(menuCssClass),
cssLabel(t("Column label")),
cssColLabelBlock(
labelInput = cssInput(
editedLabel,
updateOnKey,
{ placeholder: t("Provide a column label") },
testId('label'),
commandGroup.attach(),
rememberFocus,
),
cssColId(
t("COLUMN ID: "),
colId,
dom.on('click', async (e, d) => {
e.stopImmediatePropagation();
e.preventDefault();
showTransientTooltip(d, t("Column ID copied to clipboard"), {
key: 'copy-column-id'
});
await copyToClipboard(colId);
setTestState({clipboard: colId});
}),
testId('colid'),
),
),
dom.maybe(use => !use(showDesc), () => cssAddDescription(
textButton(
icon('Plus'),
t("Add description"),
dom.on('click', () => {
showDesc.set(true);
descInput?.focus();
setTimeout(() => descInput?.focus(), 0);
}),
testId('add-description'),
),
)),
dom.maybe(showDesc, () => [
cssLabel(t("Column description")),
descInput = cssTextArea(editedDesc, updateOnKey,
testId('description'),
commandGroup.attach(),
rememberFocus,
autoGrow(editedDesc),
),
]),
dom.onKeyDown({
Enter$: e => {
if (e.ctrlKey || e.metaKey) {
close();
return false;
}
}
}),
cssButtons(
primaryButton(t("Save"),
dom.on('click', close),
dom.boolAttr('disabled', use => use(disableSave)),
testId('save'),
),
basicButton(t("Cancel"),
testId('cancel'),
dom.on('click', cancel),
),
),
// After showing the popup, focus the label input and select it's content.
elem => { setTimeout(() => {
if (ctrl.isDisposed()) { return; }
labelInput?.focus();
labelInput?.select();
}, 0); },
// Create a FocusLayer to keep focus in this popup while it's active, by default when focus is stolen
// by someone else, we will bring back it to the label element. Clicking anywhere outside the popup
// will close it, but not when we click on the header itself (as it will reopen it). So this one
// makes sure that the focus is restored in the label.
elem => { FocusLayer.create(ctrl, {
defaultFocusElem: elem,
pauseMousetrap: false,
allowFocus: Clipboard.allowFocus
}); },
restoreFocus
);
}
const updateOnKey = { onInput: true };
const cssAddDescription = styled('div', `
display: flex;
padding-top: 14px;
padding-bottom: 4px;
& button {
display: flex;
align-items: center;
gap: 8px;
}
`);
const cssRenamePopup = styled('div', `
display: flex;
flex-direction: column;
min-width: 280px;
padding: 16px;
background-color: ${theme.popupBg};
border-radius: 2px;
outline: none;
`);
const cssColLabelBlock = styled('div', `
display: flex;
flex-direction: column;
flex: auto;
min-width: 80px;
`);
const cssLabel = styled('label', `
color: ${theme.text};
font-size: ${vars.xsmallFontSize};
font-weight: ${vars.bigControlTextWeight};
text-transform: uppercase;
margin: 0 0 8px 0;
&:not(:first-child) {
margin-top: 16px;
}
`);
const cssColId = styled('div', `
font-size: ${vars.xsmallFontSize};
font-weight: ${vars.bigControlTextWeight};
margin-top: 8px;
color: ${theme.lightText};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
align-self: start;
`);
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.inputBorder};
width: 100%;
padding: 3px 7px;
outline: none;
max-width: 100%;
min-width: calc(280px - 16px*2);
max-height: 500px;
min-height: calc(3em * 1.5);
resize: none;
border-radius: 3px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
&[readonly] {
background-color: ${theme.inputDisabledBg};
color: ${theme.inputDisabledFg};
}
`);
const cssButtons = styled('div', `
display: flex;
margin-top: 16px;
& > .${cssButton.className}:not(:first-child) {
margin-left: 8px;
}
`);
const cssInputWithIcon = styled('div', `
position: relative;
display: flex;
flex-direction: column;
`);
const cssInput = styled((
obs: Observable<string>,
opts: IInputOptions,
...args) => input(obs, opts, cssTextInput.cls(''), ...args), `
text-overflow: ellipsis;
color: ${theme.inputFg};
background-color: transparent;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
pointer-events: none;
}
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
.${cssInputWithIcon.className} > &:disabled {
padding-right: 28px;
}
`);

View File

@@ -1,6 +1,7 @@
import {CursorPos} from 'app/client/components/Cursor';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {textarea} from 'app/client/ui/inputs';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {testId, theme} from 'app/client/ui2018/cssVars';
@@ -36,6 +37,7 @@ export function buildDescriptionConfig(
await origColumn.description.saveOnly(elem.value);
}),
testId('column-description'),
autoGrow(fromKo(origColumn.description))
)
),
];
@@ -49,6 +51,7 @@ const cssTextArea = styled(textarea, `
outline: none;
border-radius: 3px;
padding: 3px 7px;
min-height: calc(3em * 1.5);
&::placeholder {
color: ${theme.inputPlaceholderFg};

View File

@@ -15,7 +15,7 @@
* );
*/
import {cssCheckboxSquare, cssLabel} from 'app/client/ui2018/checkbox';
import {dom, DomArg, DomElementArg, styled} from 'grainjs';
import {dom, DomArg, DomElementArg, Observable, styled} from 'grainjs';
export {
form,
@@ -77,6 +77,26 @@ export function hasValue(formData: FormData, nameOrPrefix: string): boolean {
}
}
function resize(el: HTMLTextAreaElement) {
el.style.height = '5px'; // hack for triggering style update.
const border = getComputedStyle(el, null).borderTopWidth || "0";
el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`;
}
export function autoGrow(text: Observable<string>) {
return (el: HTMLTextAreaElement) => {
el.addEventListener('input', () => resize(el));
setTimeout(() => resize(el), 10);
dom.autoDisposeElem(el, text.addListener(val => {
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)
// So we need to manually update the textarea when the text is cleared.
if (!val) {
el.style.height = '5px'; // there is a min-height css attribute, so this is only to trigger a style update.
}
}));
};
}
const cssForm = styled('form', `
margin-bottom: 32px;
font-size: 14px;

View File

@@ -352,45 +352,33 @@ export function withInfoTooltip(
export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) {
return cssColumnInfoTooltipButton(
icon('Info', dom.cls("info_toggle_icon")),
(elem) => {
setPopupToCreateDom(
elem,
(ctl) => {
return cssInfoTooltipPopup(
cssInfoTooltipPopupCloseButton(
icon('CrossSmall'),
dom.on('click', () => ctl.close()),
testId('column-info-tooltip-close'),
),
cssInfoTooltipPopupBody(
content,
{ style: 'white-space: pre-wrap;' },
testId('column-info-tooltip-popup-body'),
),
dom.cls(menuCssClass),
dom.cls(cssMenu.className),
dom.onKeyDown({
Enter: () => ctl.close(),
Escape: () => ctl.close(),
}),
(popup) => { setTimeout(() => popup.focus(), 0); },
testId('column-info-tooltip-popup'),
);
},
{ ...defaultMenuOptions, ...{ placement: 'bottom-end' }, ...menuOptions },
);
},
testId('column-info-tooltip'),
dom.on('mousedown', (e) => e.stopPropagation()),
dom.on('click', (e) => e.stopPropagation()),
hoverTooltip(() => cssColumnInfoTooltip(content, testId('column-info-tooltip-popup')), {
closeDelay: 200,
key: 'columnDescription',
openOnClick: true,
}),
dom.cls("info_toggle_icon_wrapper"),
...domArgs,
);
}
const cssColumnInfoTooltip = styled('div', `
white-space: pre-wrap;
text-align: left;
text-overflow: ellipsis;
overflow: hidden;
max-width: min(500px, calc(100vw - 80px)); /* can't use 100%, 500px and 80px are picked by hand */
`);
const cssColumnInfoTooltipButton = styled('div', `
cursor: pointer;
--icon-color: ${theme.infoButtonFg};
border-radius: 50%;
display: inline-block;
margin-left: 5px;
padding-left: 5px;
line-height: 0px;
&:hover {