From b13fb1d97e6265d8cef9cb93a6220775bbc1f927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 19 Apr 2023 12:17:22 +0200 Subject: [PATCH] (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 --- app/client/components/GridView.css | 23 ++ app/client/components/GridView.js | 51 ++- app/client/lib/koForm.js | 7 +- app/client/models/entities/ViewFieldRec.ts | 7 +- app/client/ui/ColumnTitle.ts | 370 +++++++++++++++++++++ app/client/ui/DescriptionConfig.ts | 3 + app/client/ui/forms.ts | 22 +- app/client/ui/tooltips.ts | 46 +-- app/client/widgets/DiscussionEditor.ts | 21 +- test/nbrowser/DescriptionColumn.ts | 351 +++++++++++++++++-- test/nbrowser/gristUtils.ts | 4 +- 11 files changed, 814 insertions(+), 91 deletions(-) create mode 100644 app/client/ui/ColumnTitle.ts diff --git a/app/client/components/GridView.css b/app/client/components/GridView.css index 5c361cdd..dc596c4d 100644 --- a/app/client/components/GridView.css +++ b/app/client/components/GridView.css @@ -397,3 +397,26 @@ min-width: 40px; padding-right: 12px; } + +.g-column-label { + display: flex; + align-items: center; + justify-content: center; +} + +.g-column-label .info_toggle_icon { + width: 13px; + height: 13px; + margin-right: 4px; +} + +.g-column-label .kf_editable_label { + padding-left: 1px; + padding-right: 1px; +} + +.g-column-label-spacer { + width: calc(13px + 4px + 4px); + height: 17px; + flex-shrink: 100; +} diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 0b4722ca..b35089cf 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -44,10 +44,11 @@ const {testId, isNarrowScreen} = require('app/client/ui2018/cssVars'); const {contextMenu} = require('app/client/ui/contextMenu'); const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); const {menuToggle} = require('app/client/ui/MenuToggle'); -const {showTooltip} = require('app/client/ui/tooltips'); +const {columnInfoTooltip, showTooltip} = require('app/client/ui/tooltips'); const {parsePasteForView} = require("./BaseView2"); const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter'); const {CombinedStyle} = require("app/client/models/Styles"); +const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); // A threshold for interpreting a motionless click as a click rather than a drag. // Anything longer than this time (in milliseconds) should be interpreted as a drag @@ -130,15 +131,19 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { })); this.autoDispose(this.cursor.fieldIndex.subscribe(idx => { + // If there are some frozen columns. + if (this.numFrozen.peek() && idx < this.numFrozen.peek()) { return; } + const offset = this.colRightOffsets.peek().getSumTo(idx); const rowNumsWidth = this._cornerDom.clientWidth; const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border - // Left and right pixel edge of 'viewport', starting from edge of row nums - const leftEdge = this.scrollPane.scrollLeft; - const rightEdge = leftEdge + viewWidth; + // Left and right pixel edge of 'viewport', starting from edge of row nums. + const frozenWidth = this.frozenWidth.peek(); + const leftEdge = this.scrollPane.scrollLeft + frozenWidth; + const rightEdge = leftEdge + (viewWidth - frozenWidth); //If cell doesn't fit onscreen, scroll to fit const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); @@ -243,7 +248,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { //-------------------------------------------------- // Set up DOM event handling. - onDblClickMatchElem(this.scrollPane, '.field', () => this.activateEditorAtCursor()); + onDblClickMatchElem(this.scrollPane, '.field:not(.column_name)', () => this.activateEditorAtCursor()); if (!this.isPreview) { grainjsDom.onMatchElem(this.scrollPane, '.field:not(.column_name)', 'contextmenu', (ev, elem) => this.onCellContextMenu(ev, elem), {useCapture: true}); } @@ -308,7 +313,7 @@ GridView.gridCommands = { insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); }, insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); }, - renameField: function() { this.currentEditingColumnIndex(this.cursor.fieldIndex()); }, + renameField: function() { this.renameColumn(this.cursor.fieldIndex()); }, hideFields: function() { this.hideFields(this.getSelection()); }, deleteFields: function() { const selection = this.getSelection(); @@ -711,6 +716,10 @@ GridView.prototype.insertColumn = async function(index) { this.currentEditingColumnIndex(index); }; +GridView.prototype.renameColumn = function(index) { + this.currentEditingColumnIndex(index); +}; + GridView.prototype.scrollPaneRight = function() { this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER; }; @@ -1021,15 +1030,27 @@ GridView.prototype.buildDom = function() { kd.style('minWidth', '100%'), kd.style('borderLeftWidth', v.borderWidthPx), kd.foreach(v.viewFields(), field => { - var isEditingLabel = ko.pureComputed({ + const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({ read: () => { const goodIndex = () => editIndex() === field._index(); const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview; const isSummary = () => Boolean(field.column().disableEditData()); return goodIndex() && !isReadonly() && !isSummary(); }, - write: val => editIndex(val ? field._index() : -1) - }).extend({ rateLimit: 0 }); + write: val => { + if (val) { + // Turn on editing. + editIndex(field._index()); + } else { + // Turn off editing only if it wasn't changed to another field (e.g. by tabbing). + const isCurrent = editIndex.peek() === field._index.peek(); + if (isCurrent) { + editIndex(-1); + } + } + } + }).extend({ rateLimit: 0 })).onlyNotifyUnequal(); + let filterTriggerCtl; const isTooltip = ko.pureComputed(() => self.gristDoc.docModel.editingFormula() && @@ -1066,8 +1087,16 @@ GridView.prototype.buildDom = function() { if (btn) { btn.click(); } }), dom('div.g-column-label', - kf.editableLabel(self.isPreview ? field.label : field.displayLabel, isEditingLabel, renameCommands), - dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) + kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null), + dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true), + // We are using editableLabel here, but we don't use it for editing. + kf.editableLabel(self.isPreview ? field.label : field.displayLabel, ko.observable(false)), + kd.scope(field.description, desc => desc ? dom('div.g-column-label-spacer') : null), + buildRenameColumn({ + field, + isEditing: isEditingLabel, + optCommands: renameCommands + }), ), dom.on("mouseenter", () => self.changeHover(field._index())), dom.on("mouseleave", () => self.changeHover(-1)), diff --git a/app/client/lib/koForm.js b/app/client/lib/koForm.js index d8922ea1..37c28c4a 100644 --- a/app/client/lib/koForm.js +++ b/app/client/lib/koForm.js @@ -882,13 +882,12 @@ exports.statusPanel = function(valueObservable, options) { * @param {Observable} optToggleObservable - If another observable is provided, it will be used to * toggle whether or not the field is editable. It will also prevent clicks from affecting whether * the label is editable. - * @param {Observable} optCommands - Optional commands to bind to the input. */ -exports.editableLabel = function(valueObservable, optToggleObservable, optCommands) { +exports.editableLabel = function(valueObservable, optToggleObservable) { var isEditing = optToggleObservable || ko.observable(false); var cancelEdit = false; - var editingCommands = Object.assign({ + var editingCommands = { cancel: function() { cancelEdit = true; isEditing(false); @@ -897,7 +896,7 @@ exports.editableLabel = function(valueObservable, optToggleObservable, optComman cancelEdit = false; isEditing(false); } - }, optCommands || {}); + }; var contentSizer; return dom('div.kf_editable_label', diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 30b5b0c0..b4671ef5 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -20,7 +20,7 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R origCol: ko.Computed; colId: ko.Computed; label: ko.Computed; - description: ko.Computed; + description: modelUtil.KoSaveableObservable; // displayLabel displays label by default but switches to the more helpful colId whenever a // formula field in the view is being edited. @@ -109,7 +109,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void this.origCol = ko.pureComputed(() => this.column().origCol()); this.colId = ko.pureComputed(() => this.column().colId()); this.label = ko.pureComputed(() => this.column().label()); - this.description = ko.pureComputed(() => this.column().description()); + this.description = modelUtil.savingComputed({ + read: () => this.column().description(), + write: (setter, val) => setter(this.column().description, val) + }); // displayLabel displays label by default but switches to the more helpful colId whenever a // formula field in the view is being edited. diff --git a/app/client/ui/ColumnTitle.ts b/app/client/ui/ColumnTitle.ts new file mode 100644 index 00000000..1128c17f --- /dev/null +++ b/app/client/ui/ColumnTitle.ts @@ -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; + 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, + 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; + } +`); diff --git a/app/client/ui/DescriptionConfig.ts b/app/client/ui/DescriptionConfig.ts index 4da48788..242a2352 100644 --- a/app/client/ui/DescriptionConfig.ts +++ b/app/client/ui/DescriptionConfig.ts @@ -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}; diff --git a/app/client/ui/forms.ts b/app/client/ui/forms.ts index 18982d2f..3a006fe8 100644 --- a/app/client/ui/forms.ts +++ b/app/client/ui/forms.ts @@ -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) { + 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; diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 9055e0fa..1dddda4a 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -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 { diff --git a/app/client/widgets/DiscussionEditor.ts b/app/client/widgets/DiscussionEditor.ts index 07d40b45..491ea8bb 100644 --- a/app/client/widgets/DiscussionEditor.ts +++ b/app/client/widgets/DiscussionEditor.ts @@ -34,6 +34,7 @@ import * as ko from 'knockout'; import moment from 'moment'; import maxSize from 'popper-max-size-modifier'; import flatMap = require('lodash/flatMap'); +import {autoGrow} from 'app/client/ui/forms'; const testId = makeTestId('test-discussion-'); const t = makeT('DiscussionEditor'); @@ -922,26 +923,6 @@ function autoFocus() { return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); } -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})`; -} - -function autoGrow(text: Observable) { - 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. - } - })); - }; -} - function buildPopup( owner: Disposable, cell: Element, diff --git a/test/nbrowser/DescriptionColumn.ts b/test/nbrowser/DescriptionColumn.ts index 6922ba2e..96614549 100644 --- a/test/nbrowser/DescriptionColumn.ts +++ b/test/nbrowser/DescriptionColumn.ts @@ -1,25 +1,259 @@ -import { UserAPIImpl } from 'app/common/UserAPI'; -import { assert, driver } from 'mocha-webdriver'; +import {UserAPIImpl} from 'app/common/UserAPI'; +import {assert, driver, Key} from 'mocha-webdriver'; import * as gu from 'test/nbrowser/gristUtils'; -import { setupTestSuite } from 'test/nbrowser/testUtils'; - -async function addColumnDescription(api: UserAPIImpl, docId: string, columnName: string) { - await api.applyUserActions(docId, [ - [ 'ModifyColumn', 'Table1', columnName, { - description: 'This is the column description\nIt is in two lines' - } ], - ]); -} - -function getDescriptionInput() { - return driver.find('.test-right-panel .test-column-description'); -} +import {setupTestSuite} from 'test/nbrowser/testUtils'; describe('DescriptionColumn', function() { this.timeout(20000); const cleanup = setupTestSuite(); - it('should support basic edition', async () => { + it('should show info tooltip in a Grid View', async () => { + const session = await gu.session().teamSite.login(); + await session.tempDoc(cleanup, 'Hello.grist'); + await gu.dismissWelcomeTourIfNeeded(); + + // Start renaming col A. + await doubleClickHeader('A'); + await gu.sendKeys('ColumnA'); + // Check that description is not visible. + await descriptionIsVisible(false); + await addDescriptionIsVisible(true); + // Press add description. + await clickAddDescription(); + // Check that description is visible. + await descriptionIsVisible(true); + await addDescriptionIsVisible(false); + // Wait for focus in the description input + await waitForFocus('description'); + + // Measure the height of the description input + const rBefore = await driver.find(`.test-column-title-description`).getRect(); + + // Send some multiline text (with more than three lines to test if it auto grows). + await gu.sendKeys('Line1'); + await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL); + await gu.sendKeys('Line2'); + await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL); + await gu.sendKeys('Line3'); + await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL); + await gu.sendKeys('Line4'); + await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL); + + // Measure the height of the description input again + const rAfter = await driver.find(`.test-column-title-description`).getRect(); + // Make sure it is at least 13 pixel taller (default font height). + assert.isTrue(rAfter.height >= rBefore.height + 13); + + // Press save + await pressSave(); + + // Make sure column is renamed. + let header = await gu.getColumnHeader({col: 'ColumnA'}); + + // Make sure it has a tooltip. + assert.isTrue(await header.find(".test-column-info-tooltip").isDisplayed()); + + // Click the tooltip. + await header.find(".test-column-info-tooltip").click(); + + // Make sure we see the popup. + await waitForTooltip(); + + // With a proper text. + assert.equal(await driver.find(".test-column-info-tooltip-popup").getText(), 'Line1\nLine2\nLine3\nLine4'); + + // Undo one (those renames should be bundled). + await gu.undo(); + + // Make sure column is renamed back. + header = await gu.getColumnHeader({col: 'A'}); + + // And there is no tooltip. + assert.isFalse(await header.find(".test-column-info-tooltip").isPresent()); + }); + + const saveTest = async (save: () => Promise) => { + const revert = await gu.begin(); + // Start renaming col A. + await doubleClickHeader('B'); + await gu.sendKeys('ColumnB'); + // Press enter. + await save(); + await gu.waitForServer(); + // Make sure it is renamed. + await gu.getColumnHeader({col: 'ColumnB'}); + + // Change description by clicking save. + await doubleClickHeader('ColumnB'); + await clickAddDescription(); + await waitForFocus('description'); + + await gu.sendKeys('ColumnB description'); + await save(); + await gu.waitForServer(); + // Make sure tooltip is shown. + await clickTooltip('ColumnB'); + await gu.waitToPass(async () => { + assert.equal(await driver.findWait(".test-column-info-tooltip-popup", 300).getText(), 'ColumnB description'); + }); + await gu.sendKeys(Key.ESCAPE); + await revert(); + }; + + it('should support saving by clicking save', async () => { + await saveTest(pressSave); + }); + + it('should support saving by clicking away', async () => { + await saveTest(() => gu.getCell('E', 5).click()); + }); + + it('should support saving by clicking Ctrl+Enter', async () => { + await saveTest(async () => await gu.sendKeys(Key.chord(await gu.modKey(), Key.ENTER))); + }); + + it('should support saving by enter', async () => { + const revert = await gu.begin(); + // Start renaming col A. + await doubleClickHeader('B'); + await gu.sendKeys('ColumnB'); + + // Make description. + await clickAddDescription(); + await gu.sendKeys('ColumnB description'); + + // Go to label. + await gu.sendKeys(Key.ARROW_UP); + await gu.sendKeys(Key.ARROW_UP); + await waitForFocus('label'); + + // Save by pressing enter. + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + // Make sure tooltip is shown. + await clickTooltip('ColumnB'); + await gu.waitToPass(async () => { + assert.equal(await driver.findWait(".test-column-info-tooltip-popup", 300).getText(), 'ColumnB description'); + }); + await gu.sendKeys(Key.ESCAPE); + await revert(); + }); + + it('should support saving by tab', async () => { + await saveTest(() => gu.sendKeys(Key.TAB)); + await saveTest(() => gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL)); + }); + + const cancelTest = async (makeCancel: () => Promise) => { + // Rename column A. + await doubleClickHeader('A'); + await gu.sendKeys('ColumnA'); + await makeCancel(); + await gu.waitForServer(); + // Make sure we see column A. + await gu.getColumnHeader({col: 'A'}); + + // Check the same for description. + await doubleClickHeader('A'); + await clickAddDescription(); + await gu.sendKeys('ColumnA description'); + await makeCancel(); + await gu.waitForServer(); + // Make sure that there is no tooltip. + assert.isFalse(await gu.getColumnHeader({col: 'A'}).find(".test-column-info-tooltip").isPresent()); + }; + + it('should support canceling by cancel', async () => { + await cancelTest(pressCancel); + }); + + it('should support canceling by Escape', async () => { + await cancelTest(() => gu.sendKeys(Key.ESCAPE)); + }); + + it('should add description by pressing arrow down', async () => { + await doubleClickHeader('A'); + await addDescriptionIsVisible(true); + await descriptionIsVisible(false); + await gu.sendKeys(Key.ARROW_DOWN); + await waitForFocus('description'); + await addDescriptionIsVisible(false); + await descriptionIsVisible(true); + // Type something. + await gu.sendKeys('ColumnA description', Key.ENTER); + await gu.sendKeys('ColumnA description'); + // Now press 2 times the up key. + await gu.sendKeys(Key.ARROW_UP); + await gu.sendKeys(Key.ARROW_UP); + // We should still be in the description field. + await waitForFocus('description'); + // Now press down key and test if that works. + await gu.sendKeys(Key.ARROW_DOWN); + await driver.wait(() => driver.executeScript(() => ((document as any).activeElement.selectionEnd === 39)), 500); + + // Now press it 3 times, we should be back in the label field. + await gu.sendKeys(Key.ARROW_UP); + await gu.sendKeys(Key.ARROW_UP); + await gu.sendKeys(Key.ARROW_UP); + + // We should be focused back in the label field. + await waitForFocus('label'); + await pressCancel(); + }); + + it('should tab to other columns and save', async () => { + const revert = await gu.begin(); + // Start renaming col A. + await doubleClickHeader('B'); + await gu.sendKeys('ColumnB'); + // Press tab. + await gu.sendKeys(Key.TAB); + await gu.waitForServer(); + + // Make sure it is renamed. + await gu.getColumnHeader({col: 'ColumnB'}); + // Make sure we are now at column C. + await popupIsAt('C'); + + // Rename column C. + await gu.sendKeys('ColumnC'); + + // Add description. + await driver.find(".test-column-title-add-description").click(); + await waitForFocus('description'); + + // Rename description. + await gu.sendKeys('ColumnC description'); + + // Go back to column B from description by pressing shift tab + await gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL); + await gu.waitForServer(); + // Make sure we are now at column B. + await popupIsAt('ColumnB'); + // Make sure the label has focus. + await waitForFocus('label'); + // Go to column C and from the label. + await gu.sendKeys(Key.TAB); + // Make sure we are now at column C. + await popupIsAt('ColumnC'); + // Just quick test that shift tab will work. + await gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL); + // Make sure we are now at column B. + await popupIsAt('ColumnB'); + // Go to column C and test if the description was saved. + await gu.sendKeys(Key.TAB); + // Make sure we are now at column C. + await popupIsAt('ColumnC'); + // And it has proper description. + assert.equal(await driver.find(".test-column-title-description").getAttribute('value'), 'ColumnC description'); + // Close by pressing escape. + await gu.sendKeys(Key.ESCAPE); + await gu.waitForServer(); + + await revert(); + }); + + it('should support basic edition on CardList', async () => { const mainSession = await gu.session().teamSite.login(); const api = mainSession.createHomeApi(); const doc = await mainSession.tempDoc(cleanup, "CardView.grist", { load: true }); @@ -75,15 +309,88 @@ describe('DescriptionColumn', function() { // Open the tooltip await toggle.click(); - assert.isTrue(await driver.findWait('.test-column-info-tooltip-popup', 1000).isDisplayed()); + await waitForTooltip(); // Check the content of the tooltip const descriptionTooltip = await driver - .find('.test-column-info-tooltip-popup .test-column-info-tooltip-popup-body'); + .find('.test-column-info-tooltip-popup'); assert.equal(await descriptionTooltip.getText(), 'This is the column description\nIt is in two lines'); - - // Close the tooltip - await toggle.click(); - assert.lengthOf(await driver.findAll('.test-column-info-tooltip-popup'), 0); }); + }); + +async function clickTooltip(col: string) { + await gu.getColumnHeader({col}).find(".test-column-info-tooltip").click(); +} + +async function addDescriptionIsVisible(visible = true) { + if (visible) { + assert.isTrue(await driver.find(".test-column-title-add-description").isDisplayed()); + } else { + assert.isFalse(await driver.find(".test-column-title-add-description").isPresent()); + } +} + +async function descriptionIsVisible(visible = true) { + if (visible) { + assert.isTrue(await driver.find(".test-column-title-description").isDisplayed()); + } else { + assert.isFalse(await driver.find(".test-column-title-description").isPresent()); + } +} + +async function addColumnDescription(api: UserAPIImpl, docId: string, columnName: string) { + await api.applyUserActions(docId, [ + [ 'ModifyColumn', 'Table1', columnName, { + description: 'This is the column description\nIt is in two lines' + } ], + ]); +} + +function getDescriptionInput() { + return driver.find('.test-right-panel .test-column-description'); +} + +async function popupIsAt(col: string) { + // Make sure we are now at column. + assert.equal(await driver.find(".test-column-title-label").getAttribute('value'), col); + // Make sure that popup is near the column. + const headerCRect = await gu.getColumnHeader({col}).getRect(); + const popup = await driver.find(".test-column-title-popup").getRect(); + assert.isAtLeast(popup.x, headerCRect.x - 2); + assert.isBelow(popup.x, headerCRect.x + 2); + assert.isAtLeast(popup.y, headerCRect.y + headerCRect.height - 2); + assert.isBelow(popup.y, headerCRect.y + headerCRect.height + 2); +} + +async function doubleClickHeader(col: string) { + const header = await gu.getColumnHeader({col}); + await header.click(); + await header.click(); + await waitForFocus('label'); +} + +async function waitForFocus(field: 'label'|'description') { + await gu.waitToPass(async () => assert.isTrue(await driver.find(`.test-column-title-${field}`).hasFocus()), 200); +} + +async function waitForTooltip() { + await gu.waitToPass(async () => { + assert.isTrue(await driver.find(".test-column-info-tooltip-popup").isDisplayed()); + }); +} + +async function pressSave() { + await driver.find(".test-column-title-save").click(); + await gu.waitForServer(); +} + +async function pressCancel() { + await driver.find(".test-column-title-cancel").click(); + await gu.waitForServer(); +} + +async function clickAddDescription() { + await driver.find(".test-column-title-add-description").click(); + await waitForFocus('description'); +} diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index e64886aa..784e0444 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -918,7 +918,7 @@ export async function waitAppFocus(yesNo: boolean = true): Promise { } export async function waitForLabelInput(): Promise { - await driver.wait(async () => (await driver.findWait('.kf_elabel_input', 100).hasFocus()), 300); + await driver.wait(async () => (await driver.findWait('.test-column-title-label', 100).hasFocus()), 300); } /** @@ -1267,7 +1267,7 @@ export async function renameColumn(col: IColHeader, newName: string) { const header = await getColumnHeader(col); await header.click(); await header.click(); // Second click opens the label for editing. - await header.find('.kf_elabel_input').sendKeys(newName, Key.ENTER); + await driver.findWait('.test-column-title-label', 100).sendKeys(newName, Key.ENTER); await waitForServer(); }