(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

@ -397,3 +397,26 @@
min-width: 40px; min-width: 40px;
padding-right: 12px; 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;
}

View File

@ -44,10 +44,11 @@ const {testId, isNarrowScreen} = require('app/client/ui2018/cssVars');
const {contextMenu} = require('app/client/ui/contextMenu'); const {contextMenu} = require('app/client/ui/contextMenu');
const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); const {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
const {menuToggle} = require('app/client/ui/MenuToggle'); 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 {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter'); const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles"); 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. // 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 // 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 => { 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 offset = this.colRightOffsets.peek().getSumTo(idx);
const rowNumsWidth = this._cornerDom.clientWidth; const rowNumsWidth = this._cornerDom.clientWidth;
const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;
const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border
// Left and right pixel edge of 'viewport', starting from edge of row nums // Left and right pixel edge of 'viewport', starting from edge of row nums.
const leftEdge = this.scrollPane.scrollLeft; const frozenWidth = this.frozenWidth.peek();
const rightEdge = leftEdge + viewWidth; const leftEdge = this.scrollPane.scrollLeft + frozenWidth;
const rightEdge = leftEdge + (viewWidth - frozenWidth);
//If cell doesn't fit onscreen, scroll to fit //If cell doesn't fit onscreen, scroll to fit
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
@ -243,7 +248,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
//-------------------------------------------------- //--------------------------------------------------
// Set up DOM event handling. // Set up DOM event handling.
onDblClickMatchElem(this.scrollPane, '.field', () => this.activateEditorAtCursor()); onDblClickMatchElem(this.scrollPane, '.field:not(.column_name)', () => this.activateEditorAtCursor());
if (!this.isPreview) { if (!this.isPreview) {
grainjsDom.onMatchElem(this.scrollPane, '.field:not(.column_name)', 'contextmenu', (ev, elem) => this.onCellContextMenu(ev, elem), {useCapture: true}); 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()); }, insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); },
insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); }, 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()); }, hideFields: function() { this.hideFields(this.getSelection()); },
deleteFields: function() { deleteFields: function() {
const selection = this.getSelection(); const selection = this.getSelection();
@ -711,6 +716,10 @@ GridView.prototype.insertColumn = async function(index) {
this.currentEditingColumnIndex(index); this.currentEditingColumnIndex(index);
}; };
GridView.prototype.renameColumn = function(index) {
this.currentEditingColumnIndex(index);
};
GridView.prototype.scrollPaneRight = function() { GridView.prototype.scrollPaneRight = function() {
this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER; this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER;
}; };
@ -1021,15 +1030,27 @@ GridView.prototype.buildDom = function() {
kd.style('minWidth', '100%'), kd.style('minWidth', '100%'),
kd.style('borderLeftWidth', v.borderWidthPx), kd.style('borderLeftWidth', v.borderWidthPx),
kd.foreach(v.viewFields(), field => { kd.foreach(v.viewFields(), field => {
var isEditingLabel = ko.pureComputed({ const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({
read: () => { read: () => {
const goodIndex = () => editIndex() === field._index(); const goodIndex = () => editIndex() === field._index();
const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview; const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview;
const isSummary = () => Boolean(field.column().disableEditData()); const isSummary = () => Boolean(field.column().disableEditData());
return goodIndex() && !isReadonly() && !isSummary(); return goodIndex() && !isReadonly() && !isSummary();
}, },
write: val => editIndex(val ? field._index() : -1) write: val => {
}).extend({ rateLimit: 0 }); 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; let filterTriggerCtl;
const isTooltip = ko.pureComputed(() => const isTooltip = ko.pureComputed(() =>
self.gristDoc.docModel.editingFormula() && self.gristDoc.docModel.editingFormula() &&
@ -1066,8 +1087,16 @@ GridView.prototype.buildDom = function() {
if (btn) { btn.click(); } if (btn) { btn.click(); }
}), }),
dom('div.g-column-label', dom('div.g-column-label',
kf.editableLabel(self.isPreview ? field.label : field.displayLabel, isEditingLabel, renameCommands), kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null),
dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) 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("mouseenter", () => self.changeHover(field._index())),
dom.on("mouseleave", () => self.changeHover(-1)), dom.on("mouseleave", () => self.changeHover(-1)),

View File

@ -882,13 +882,12 @@ exports.statusPanel = function(valueObservable, options) {
* @param {Observable} optToggleObservable - If another observable is provided, it will be used to * @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 * toggle whether or not the field is editable. It will also prevent clicks from affecting whether
* the label is editable. * 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 isEditing = optToggleObservable || ko.observable(false);
var cancelEdit = false; var cancelEdit = false;
var editingCommands = Object.assign({ var editingCommands = {
cancel: function() { cancel: function() {
cancelEdit = true; cancelEdit = true;
isEditing(false); isEditing(false);
@ -897,7 +896,7 @@ exports.editableLabel = function(valueObservable, optToggleObservable, optComman
cancelEdit = false; cancelEdit = false;
isEditing(false); isEditing(false);
} }
}, optCommands || {}); };
var contentSizer; var contentSizer;
return dom('div.kf_editable_label', return dom('div.kf_editable_label',

View File

@ -20,7 +20,7 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
origCol: ko.Computed<ColumnRec>; origCol: ko.Computed<ColumnRec>;
colId: ko.Computed<string>; colId: ko.Computed<string>;
label: ko.Computed<string>; label: ko.Computed<string>;
description: ko.Computed<string>; description: modelUtil.KoSaveableObservable<string>;
// displayLabel displays label by default but switches to the more helpful colId whenever a // displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited. // 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.origCol = ko.pureComputed(() => this.column().origCol());
this.colId = ko.pureComputed(() => this.column().colId()); this.colId = ko.pureComputed(() => this.column().colId());
this.label = ko.pureComputed(() => this.column().label()); 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 // displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited. // formula field in the view is being edited.

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

View File

@ -15,7 +15,7 @@
* ); * );
*/ */
import {cssCheckboxSquare, cssLabel} from 'app/client/ui2018/checkbox'; import {cssCheckboxSquare, cssLabel} from 'app/client/ui2018/checkbox';
import {dom, DomArg, DomElementArg, styled} from 'grainjs'; import {dom, DomArg, DomElementArg, Observable, styled} from 'grainjs';
export { export {
form, 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', ` const cssForm = styled('form', `
margin-bottom: 32px; margin-bottom: 32px;
font-size: 14px; font-size: 14px;

View File

@ -352,45 +352,33 @@ export function withInfoTooltip(
export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) { export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) {
return cssColumnInfoTooltipButton( return cssColumnInfoTooltipButton(
icon('Info', dom.cls("info_toggle_icon")), 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'), 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, ...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', ` const cssColumnInfoTooltipButton = styled('div', `
cursor: pointer; cursor: pointer;
--icon-color: ${theme.infoButtonFg}; --icon-color: ${theme.infoButtonFg};
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
margin-left: 5px; padding-left: 5px;
line-height: 0px; line-height: 0px;
&:hover { &:hover {

View File

@ -34,6 +34,7 @@ import * as ko from 'knockout';
import moment from 'moment'; import moment from 'moment';
import maxSize from 'popper-max-size-modifier'; import maxSize from 'popper-max-size-modifier';
import flatMap = require('lodash/flatMap'); import flatMap = require('lodash/flatMap');
import {autoGrow} from 'app/client/ui/forms';
const testId = makeTestId('test-discussion-'); const testId = makeTestId('test-discussion-');
const t = makeT('DiscussionEditor'); const t = makeT('DiscussionEditor');
@ -922,26 +923,6 @@ function autoFocus() {
return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); 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<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.
}
}));
};
}
function buildPopup( function buildPopup(
owner: Disposable, owner: Disposable,
cell: Element, cell: Element,

View File

@ -1,25 +1,259 @@
import { UserAPIImpl } from 'app/common/UserAPI'; import {UserAPIImpl} from 'app/common/UserAPI';
import { assert, driver } from 'mocha-webdriver'; import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils'; import * as gu from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils'; 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');
}
describe('DescriptionColumn', function() { describe('DescriptionColumn', function() {
this.timeout(20000); this.timeout(20000);
const cleanup = setupTestSuite(); 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<void>) => {
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<void>) => {
// 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 mainSession = await gu.session().teamSite.login();
const api = mainSession.createHomeApi(); const api = mainSession.createHomeApi();
const doc = await mainSession.tempDoc(cleanup, "CardView.grist", { load: true }); const doc = await mainSession.tempDoc(cleanup, "CardView.grist", { load: true });
@ -75,15 +309,88 @@ describe('DescriptionColumn', function() {
// Open the tooltip // Open the tooltip
await toggle.click(); await toggle.click();
assert.isTrue(await driver.findWait('.test-column-info-tooltip-popup', 1000).isDisplayed()); await waitForTooltip();
// Check the content of the tooltip // Check the content of the tooltip
const descriptionTooltip = await driver 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'); 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');
}

View File

@ -918,7 +918,7 @@ export async function waitAppFocus(yesNo: boolean = true): Promise<void> {
} }
export async function waitForLabelInput(): Promise<void> { export async function waitForLabelInput(): Promise<void> {
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); const header = await getColumnHeader(col);
await header.click(); await header.click();
await header.click(); // Second click opens the label for editing. 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(); await waitForServer();
} }