mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
3aac027a13
commit
b13fb1d97e
@ -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;
|
||||||
|
}
|
||||||
|
@ -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)),
|
||||||
|
@ -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',
|
||||||
|
@ -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.
|
||||||
|
370
app/client/ui/ColumnTitle.ts
Normal file
370
app/client/ui/ColumnTitle.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`);
|
@ -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};
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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');
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user