mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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;
 | 
			
		||||
  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 {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
 | 
			
		||||
const {menuToggle} = require('app/client/ui/MenuToggle');
 | 
			
		||||
const {showTooltip} = require('app/client/ui/tooltips');
 | 
			
		||||
const {columnInfoTooltip, showTooltip} = require('app/client/ui/tooltips');
 | 
			
		||||
const {parsePasteForView} = require("./BaseView2");
 | 
			
		||||
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
 | 
			
		||||
const {CombinedStyle} = require("app/client/models/Styles");
 | 
			
		||||
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
 | 
			
		||||
 | 
			
		||||
// A threshold for interpreting a motionless click as a click rather than a drag.
 | 
			
		||||
// Anything longer than this time (in milliseconds) should be interpreted as a drag
 | 
			
		||||
@ -130,15 +131,19 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  this.autoDispose(this.cursor.fieldIndex.subscribe(idx => {
 | 
			
		||||
    // If there are some frozen columns.
 | 
			
		||||
    if (this.numFrozen.peek() && idx < this.numFrozen.peek()) { return; }
 | 
			
		||||
 | 
			
		||||
    const offset = this.colRightOffsets.peek().getSumTo(idx);
 | 
			
		||||
 | 
			
		||||
    const rowNumsWidth = this._cornerDom.clientWidth;
 | 
			
		||||
    const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;
 | 
			
		||||
    const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border
 | 
			
		||||
 | 
			
		||||
    // Left and right pixel edge of 'viewport', starting from edge of row nums
 | 
			
		||||
    const leftEdge = this.scrollPane.scrollLeft;
 | 
			
		||||
    const rightEdge = leftEdge + viewWidth;
 | 
			
		||||
    // Left and right pixel edge of 'viewport', starting from edge of row nums.
 | 
			
		||||
    const frozenWidth = this.frozenWidth.peek();
 | 
			
		||||
    const leftEdge = this.scrollPane.scrollLeft + frozenWidth;
 | 
			
		||||
    const rightEdge = leftEdge + (viewWidth - frozenWidth);
 | 
			
		||||
 | 
			
		||||
    //If cell doesn't fit onscreen, scroll to fit
 | 
			
		||||
    const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
 | 
			
		||||
@ -243,7 +248,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
 | 
			
		||||
 | 
			
		||||
  //--------------------------------------------------
 | 
			
		||||
  // Set up DOM event handling.
 | 
			
		||||
  onDblClickMatchElem(this.scrollPane, '.field', () => this.activateEditorAtCursor());
 | 
			
		||||
  onDblClickMatchElem(this.scrollPane, '.field:not(.column_name)', () => this.activateEditorAtCursor());
 | 
			
		||||
  if (!this.isPreview) {
 | 
			
		||||
    grainjsDom.onMatchElem(this.scrollPane, '.field:not(.column_name)', 'contextmenu', (ev, elem) => this.onCellContextMenu(ev, elem), {useCapture: true});
 | 
			
		||||
  }
 | 
			
		||||
@ -308,7 +313,7 @@ GridView.gridCommands = {
 | 
			
		||||
 | 
			
		||||
  insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); },
 | 
			
		||||
  insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); },
 | 
			
		||||
  renameField: function() { this.currentEditingColumnIndex(this.cursor.fieldIndex()); },
 | 
			
		||||
  renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
 | 
			
		||||
  hideFields: function() { this.hideFields(this.getSelection()); },
 | 
			
		||||
  deleteFields: function() {
 | 
			
		||||
    const selection = this.getSelection();
 | 
			
		||||
@ -711,6 +716,10 @@ GridView.prototype.insertColumn = async function(index) {
 | 
			
		||||
  this.currentEditingColumnIndex(index);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
GridView.prototype.renameColumn = function(index) {
 | 
			
		||||
  this.currentEditingColumnIndex(index);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
GridView.prototype.scrollPaneRight = function() {
 | 
			
		||||
  this.scrollPane.scrollLeft = Number.MAX_SAFE_INTEGER;
 | 
			
		||||
};
 | 
			
		||||
@ -1021,15 +1030,27 @@ GridView.prototype.buildDom = function() {
 | 
			
		||||
            kd.style('minWidth', '100%'),
 | 
			
		||||
            kd.style('borderLeftWidth', v.borderWidthPx),
 | 
			
		||||
            kd.foreach(v.viewFields(), field => {
 | 
			
		||||
              var isEditingLabel = ko.pureComputed({
 | 
			
		||||
              const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({
 | 
			
		||||
                read: () => {
 | 
			
		||||
                  const goodIndex = () => editIndex() === field._index();
 | 
			
		||||
                  const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview;
 | 
			
		||||
                  const isSummary = () => Boolean(field.column().disableEditData());
 | 
			
		||||
                  return goodIndex() && !isReadonly() && !isSummary();
 | 
			
		||||
                },
 | 
			
		||||
                write: val => editIndex(val ? field._index() : -1)
 | 
			
		||||
              }).extend({ rateLimit: 0 });
 | 
			
		||||
                write: val => {
 | 
			
		||||
                  if (val) {
 | 
			
		||||
                    // Turn on editing.
 | 
			
		||||
                    editIndex(field._index());
 | 
			
		||||
                  } else {
 | 
			
		||||
                    // Turn off editing only if it wasn't changed to another field (e.g. by tabbing).
 | 
			
		||||
                    const isCurrent = editIndex.peek() === field._index.peek();
 | 
			
		||||
                    if (isCurrent) {
 | 
			
		||||
                      editIndex(-1);
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }).extend({ rateLimit: 0 })).onlyNotifyUnequal();
 | 
			
		||||
 | 
			
		||||
              let filterTriggerCtl;
 | 
			
		||||
              const isTooltip = ko.pureComputed(() =>
 | 
			
		||||
                  self.gristDoc.docModel.editingFormula() &&
 | 
			
		||||
@ -1066,8 +1087,16 @@ GridView.prototype.buildDom = function() {
 | 
			
		||||
                  if (btn) { btn.click(); }
 | 
			
		||||
                }),
 | 
			
		||||
                dom('div.g-column-label',
 | 
			
		||||
                  kf.editableLabel(self.isPreview ? field.label : field.displayLabel, isEditingLabel, renameCommands),
 | 
			
		||||
                  dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true)
 | 
			
		||||
                  kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null),
 | 
			
		||||
                  dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true),
 | 
			
		||||
                  // We are using editableLabel here, but we don't use it for editing.
 | 
			
		||||
                  kf.editableLabel(self.isPreview ? field.label : field.displayLabel, ko.observable(false)),
 | 
			
		||||
                  kd.scope(field.description, desc => desc ? dom('div.g-column-label-spacer') : null),
 | 
			
		||||
                  buildRenameColumn({
 | 
			
		||||
                    field,
 | 
			
		||||
                    isEditing: isEditingLabel,
 | 
			
		||||
                    optCommands: renameCommands
 | 
			
		||||
                  }),
 | 
			
		||||
                ),
 | 
			
		||||
                dom.on("mouseenter", () => self.changeHover(field._index())),
 | 
			
		||||
                dom.on("mouseleave", () => self.changeHover(-1)),
 | 
			
		||||
 | 
			
		||||
@ -882,13 +882,12 @@ exports.statusPanel = function(valueObservable, options) {
 | 
			
		||||
 * @param {Observable} optToggleObservable - If another observable is provided, it will be used to
 | 
			
		||||
 *   toggle whether or not the field is editable. It will also prevent clicks from affecting whether
 | 
			
		||||
 *   the label is editable.
 | 
			
		||||
 * @param {Observable} optCommands - Optional commands to bind to the input.
 | 
			
		||||
 */
 | 
			
		||||
exports.editableLabel = function(valueObservable, optToggleObservable, optCommands) {
 | 
			
		||||
exports.editableLabel = function(valueObservable, optToggleObservable) {
 | 
			
		||||
  var isEditing = optToggleObservable || ko.observable(false);
 | 
			
		||||
  var cancelEdit = false;
 | 
			
		||||
 | 
			
		||||
  var editingCommands = Object.assign({
 | 
			
		||||
  var editingCommands = {
 | 
			
		||||
    cancel: function() {
 | 
			
		||||
      cancelEdit = true;
 | 
			
		||||
      isEditing(false);
 | 
			
		||||
@ -897,7 +896,7 @@ exports.editableLabel = function(valueObservable, optToggleObservable, optComman
 | 
			
		||||
      cancelEdit = false;
 | 
			
		||||
      isEditing(false);
 | 
			
		||||
    }
 | 
			
		||||
  }, optCommands || {});
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  var contentSizer;
 | 
			
		||||
  return dom('div.kf_editable_label',
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
 | 
			
		||||
  origCol: ko.Computed<ColumnRec>;
 | 
			
		||||
  colId: 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
 | 
			
		||||
  // formula field in the view is being edited.
 | 
			
		||||
@ -109,7 +109,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
 | 
			
		||||
  this.origCol = ko.pureComputed(() => this.column().origCol());
 | 
			
		||||
  this.colId = ko.pureComputed(() => this.column().colId());
 | 
			
		||||
  this.label = ko.pureComputed(() => this.column().label());
 | 
			
		||||
  this.description = ko.pureComputed(() => this.column().description());
 | 
			
		||||
  this.description = modelUtil.savingComputed({
 | 
			
		||||
    read: () => this.column().description(),
 | 
			
		||||
    write: (setter, val) => setter(this.column().description, val)
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // displayLabel displays label by default but switches to the more helpful colId whenever a
 | 
			
		||||
  // formula field in the view is being edited.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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 {makeT} from 'app/client/lib/localization';
 | 
			
		||||
import {ColumnRec} from 'app/client/models/DocModel';
 | 
			
		||||
import {autoGrow} from 'app/client/ui/forms';
 | 
			
		||||
import {textarea} from 'app/client/ui/inputs';
 | 
			
		||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
 | 
			
		||||
import {testId, theme} from 'app/client/ui2018/cssVars';
 | 
			
		||||
@ -36,6 +37,7 @@ export function buildDescriptionConfig(
 | 
			
		||||
            await origColumn.description.saveOnly(elem.value);
 | 
			
		||||
          }),
 | 
			
		||||
          testId('column-description'),
 | 
			
		||||
          autoGrow(fromKo(origColumn.description))
 | 
			
		||||
        )
 | 
			
		||||
      ),
 | 
			
		||||
    ];
 | 
			
		||||
@ -49,6 +51,7 @@ const cssTextArea = styled(textarea, `
 | 
			
		||||
  outline: none;
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
  padding: 3px 7px;
 | 
			
		||||
  min-height: calc(3em * 1.5);
 | 
			
		||||
 | 
			
		||||
  &::placeholder {
 | 
			
		||||
    color: ${theme.inputPlaceholderFg};
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@
 | 
			
		||||
 *   );
 | 
			
		||||
 */
 | 
			
		||||
import {cssCheckboxSquare, cssLabel} from 'app/client/ui2018/checkbox';
 | 
			
		||||
import {dom, DomArg, DomElementArg, styled} from 'grainjs';
 | 
			
		||||
import {dom, DomArg, DomElementArg, Observable, styled} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  form,
 | 
			
		||||
@ -77,6 +77,26 @@ export function hasValue(formData: FormData, nameOrPrefix: string): boolean {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resize(el: HTMLTextAreaElement) {
 | 
			
		||||
  el.style.height = '5px'; // hack for triggering style update.
 | 
			
		||||
  const border = getComputedStyle(el, null).borderTopWidth || "0";
 | 
			
		||||
  el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function autoGrow(text: Observable<string>) {
 | 
			
		||||
  return (el: HTMLTextAreaElement) => {
 | 
			
		||||
    el.addEventListener('input', () => resize(el));
 | 
			
		||||
    setTimeout(() => resize(el), 10);
 | 
			
		||||
    dom.autoDisposeElem(el, text.addListener(val => {
 | 
			
		||||
      // Changes to the text are not reflected by the input event (witch is used by the autoGrow)
 | 
			
		||||
      // So we need to manually update the textarea when the text is cleared.
 | 
			
		||||
      if (!val) {
 | 
			
		||||
        el.style.height = '5px'; // there is a min-height css attribute, so this is only to trigger a style update.
 | 
			
		||||
      }
 | 
			
		||||
    }));
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssForm = styled('form', `
 | 
			
		||||
  margin-bottom: 32px;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
 | 
			
		||||
@ -352,45 +352,33 @@ export function withInfoTooltip(
 | 
			
		||||
 export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) {
 | 
			
		||||
  return cssColumnInfoTooltipButton(
 | 
			
		||||
    icon('Info', dom.cls("info_toggle_icon")),
 | 
			
		||||
    (elem) => {
 | 
			
		||||
      setPopupToCreateDom(
 | 
			
		||||
        elem,
 | 
			
		||||
        (ctl) => {
 | 
			
		||||
          return cssInfoTooltipPopup(
 | 
			
		||||
            cssInfoTooltipPopupCloseButton(
 | 
			
		||||
              icon('CrossSmall'),
 | 
			
		||||
              dom.on('click', () => ctl.close()),
 | 
			
		||||
              testId('column-info-tooltip-close'),
 | 
			
		||||
            ),
 | 
			
		||||
            cssInfoTooltipPopupBody(
 | 
			
		||||
              content,
 | 
			
		||||
              { style: 'white-space: pre-wrap;' },
 | 
			
		||||
              testId('column-info-tooltip-popup-body'),
 | 
			
		||||
            ),
 | 
			
		||||
            dom.cls(menuCssClass),
 | 
			
		||||
            dom.cls(cssMenu.className),
 | 
			
		||||
            dom.onKeyDown({
 | 
			
		||||
              Enter: () => ctl.close(),
 | 
			
		||||
              Escape: () => ctl.close(),
 | 
			
		||||
            }),
 | 
			
		||||
            (popup) => { setTimeout(() => popup.focus(), 0); },
 | 
			
		||||
            testId('column-info-tooltip-popup'),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
        { ...defaultMenuOptions, ...{ placement: 'bottom-end' }, ...menuOptions },
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    testId('column-info-tooltip'),
 | 
			
		||||
    dom.on('mousedown', (e) => e.stopPropagation()),
 | 
			
		||||
    dom.on('click', (e) => e.stopPropagation()),
 | 
			
		||||
    hoverTooltip(() => cssColumnInfoTooltip(content, testId('column-info-tooltip-popup')), {
 | 
			
		||||
      closeDelay: 200,
 | 
			
		||||
      key: 'columnDescription',
 | 
			
		||||
      openOnClick: true,
 | 
			
		||||
    }),
 | 
			
		||||
    dom.cls("info_toggle_icon_wrapper"),
 | 
			
		||||
    ...domArgs,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssColumnInfoTooltip = styled('div', `
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  max-width: min(500px, calc(100vw - 80px)); /* can't use 100%, 500px and 80px are picked by hand */
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssColumnInfoTooltipButton = styled('div', `
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  --icon-color: ${theme.infoButtonFg};
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  margin-left: 5px;
 | 
			
		||||
  padding-left: 5px;
 | 
			
		||||
  line-height: 0px;
 | 
			
		||||
 | 
			
		||||
  &:hover  {
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,7 @@ import * as ko from 'knockout';
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
import maxSize from 'popper-max-size-modifier';
 | 
			
		||||
import flatMap = require('lodash/flatMap');
 | 
			
		||||
import {autoGrow} from 'app/client/ui/forms';
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-discussion-');
 | 
			
		||||
const t = makeT('DiscussionEditor');
 | 
			
		||||
@ -922,26 +923,6 @@ function autoFocus() {
 | 
			
		||||
  return (el: HTMLElement) => void setTimeout(() => el.focus(), 10);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resize(el: HTMLTextAreaElement) {
 | 
			
		||||
  el.style.height = '5px'; // hack for triggering style update.
 | 
			
		||||
  const border = getComputedStyle(el, null).borderTopWidth || "0";
 | 
			
		||||
  el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function autoGrow(text: Observable<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(
 | 
			
		||||
  owner: Disposable,
 | 
			
		||||
  cell: Element,
 | 
			
		||||
 | 
			
		||||
@ -1,25 +1,259 @@
 | 
			
		||||
import { UserAPIImpl } from 'app/common/UserAPI';
 | 
			
		||||
import { assert, driver } from 'mocha-webdriver';
 | 
			
		||||
import {UserAPIImpl} from 'app/common/UserAPI';
 | 
			
		||||
import {assert, driver, Key} from 'mocha-webdriver';
 | 
			
		||||
import * as gu from 'test/nbrowser/gristUtils';
 | 
			
		||||
import { setupTestSuite } from 'test/nbrowser/testUtils';
 | 
			
		||||
 | 
			
		||||
async function addColumnDescription(api: UserAPIImpl, docId: string, columnName: string) {
 | 
			
		||||
  await api.applyUserActions(docId, [
 | 
			
		||||
    [ 'ModifyColumn', 'Table1', columnName, {
 | 
			
		||||
      description: 'This is the column description\nIt is in two lines'
 | 
			
		||||
    } ],
 | 
			
		||||
  ]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getDescriptionInput() {
 | 
			
		||||
  return driver.find('.test-right-panel .test-column-description');
 | 
			
		||||
}
 | 
			
		||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
 | 
			
		||||
 | 
			
		||||
describe('DescriptionColumn', function() {
 | 
			
		||||
  this.timeout(20000);
 | 
			
		||||
  const cleanup = setupTestSuite();
 | 
			
		||||
 | 
			
		||||
  it('should support basic edition', async () => {
 | 
			
		||||
  it('should show info tooltip in a Grid View', async () => {
 | 
			
		||||
    const session = await gu.session().teamSite.login();
 | 
			
		||||
    await session.tempDoc(cleanup, 'Hello.grist');
 | 
			
		||||
    await gu.dismissWelcomeTourIfNeeded();
 | 
			
		||||
 | 
			
		||||
    // Start renaming col A.
 | 
			
		||||
    await doubleClickHeader('A');
 | 
			
		||||
    await gu.sendKeys('ColumnA');
 | 
			
		||||
    // Check that description is not visible.
 | 
			
		||||
    await descriptionIsVisible(false);
 | 
			
		||||
    await addDescriptionIsVisible(true);
 | 
			
		||||
    // Press add description.
 | 
			
		||||
    await clickAddDescription();
 | 
			
		||||
    // Check that description is visible.
 | 
			
		||||
    await descriptionIsVisible(true);
 | 
			
		||||
    await addDescriptionIsVisible(false);
 | 
			
		||||
    // Wait for focus in the description input
 | 
			
		||||
    await waitForFocus('description');
 | 
			
		||||
 | 
			
		||||
    // Measure the height of the description input
 | 
			
		||||
    const rBefore = await driver.find(`.test-column-title-description`).getRect();
 | 
			
		||||
 | 
			
		||||
    // Send some multiline text (with more than three lines to test if it auto grows).
 | 
			
		||||
    await gu.sendKeys('Line1');
 | 
			
		||||
    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);
 | 
			
		||||
    await gu.sendKeys('Line2');
 | 
			
		||||
    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);
 | 
			
		||||
    await gu.sendKeys('Line3');
 | 
			
		||||
    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);
 | 
			
		||||
    await gu.sendKeys('Line4');
 | 
			
		||||
    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);
 | 
			
		||||
 | 
			
		||||
    // Measure the height of the description input again
 | 
			
		||||
    const rAfter = await driver.find(`.test-column-title-description`).getRect();
 | 
			
		||||
    // Make sure it is at least 13 pixel taller (default font height).
 | 
			
		||||
    assert.isTrue(rAfter.height >= rBefore.height + 13);
 | 
			
		||||
 | 
			
		||||
    // Press save
 | 
			
		||||
    await pressSave();
 | 
			
		||||
 | 
			
		||||
    // Make sure column is renamed.
 | 
			
		||||
    let header = await gu.getColumnHeader({col: 'ColumnA'});
 | 
			
		||||
 | 
			
		||||
    // Make sure it has a tooltip.
 | 
			
		||||
    assert.isTrue(await header.find(".test-column-info-tooltip").isDisplayed());
 | 
			
		||||
 | 
			
		||||
    // Click the tooltip.
 | 
			
		||||
    await header.find(".test-column-info-tooltip").click();
 | 
			
		||||
 | 
			
		||||
    // Make sure we see the popup.
 | 
			
		||||
    await waitForTooltip();
 | 
			
		||||
 | 
			
		||||
    // With a proper text.
 | 
			
		||||
    assert.equal(await driver.find(".test-column-info-tooltip-popup").getText(), 'Line1\nLine2\nLine3\nLine4');
 | 
			
		||||
 | 
			
		||||
    // Undo one (those renames should be bundled).
 | 
			
		||||
    await gu.undo();
 | 
			
		||||
 | 
			
		||||
    // Make sure column is renamed back.
 | 
			
		||||
    header = await gu.getColumnHeader({col: 'A'});
 | 
			
		||||
 | 
			
		||||
    // And there is no tooltip.
 | 
			
		||||
    assert.isFalse(await header.find(".test-column-info-tooltip").isPresent());
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const saveTest = async (save: () => Promise<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 api = mainSession.createHomeApi();
 | 
			
		||||
    const doc = await mainSession.tempDoc(cleanup, "CardView.grist", { load: true });
 | 
			
		||||
@ -75,15 +309,88 @@ describe('DescriptionColumn', function() {
 | 
			
		||||
 | 
			
		||||
    // Open the tooltip
 | 
			
		||||
    await toggle.click();
 | 
			
		||||
    assert.isTrue(await driver.findWait('.test-column-info-tooltip-popup', 1000).isDisplayed());
 | 
			
		||||
    await waitForTooltip();
 | 
			
		||||
 | 
			
		||||
    // Check the content of the tooltip
 | 
			
		||||
    const descriptionTooltip = await driver
 | 
			
		||||
      .find('.test-column-info-tooltip-popup .test-column-info-tooltip-popup-body');
 | 
			
		||||
      .find('.test-column-info-tooltip-popup');
 | 
			
		||||
    assert.equal(await descriptionTooltip.getText(), 'This is the column description\nIt is in two lines');
 | 
			
		||||
 | 
			
		||||
    // Close the tooltip
 | 
			
		||||
    await toggle.click();
 | 
			
		||||
    assert.lengthOf(await driver.findAll('.test-column-info-tooltip-popup'), 0);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function clickTooltip(col: string) {
 | 
			
		||||
  await gu.getColumnHeader({col}).find(".test-column-info-tooltip").click();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function addDescriptionIsVisible(visible = true) {
 | 
			
		||||
  if (visible) {
 | 
			
		||||
    assert.isTrue(await driver.find(".test-column-title-add-description").isDisplayed());
 | 
			
		||||
  } else {
 | 
			
		||||
    assert.isFalse(await driver.find(".test-column-title-add-description").isPresent());
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function descriptionIsVisible(visible = true) {
 | 
			
		||||
  if (visible) {
 | 
			
		||||
    assert.isTrue(await driver.find(".test-column-title-description").isDisplayed());
 | 
			
		||||
  } else {
 | 
			
		||||
    assert.isFalse(await driver.find(".test-column-title-description").isPresent());
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function addColumnDescription(api: UserAPIImpl, docId: string, columnName: string) {
 | 
			
		||||
  await api.applyUserActions(docId, [
 | 
			
		||||
    [ 'ModifyColumn', 'Table1', columnName, {
 | 
			
		||||
      description: 'This is the column description\nIt is in two lines'
 | 
			
		||||
    } ],
 | 
			
		||||
  ]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getDescriptionInput() {
 | 
			
		||||
  return driver.find('.test-right-panel .test-column-description');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function popupIsAt(col: string) {
 | 
			
		||||
  // Make sure we are now at column.
 | 
			
		||||
  assert.equal(await driver.find(".test-column-title-label").getAttribute('value'), col);
 | 
			
		||||
  // Make sure that popup is near the column.
 | 
			
		||||
  const headerCRect = await gu.getColumnHeader({col}).getRect();
 | 
			
		||||
  const popup = await driver.find(".test-column-title-popup").getRect();
 | 
			
		||||
  assert.isAtLeast(popup.x, headerCRect.x - 2);
 | 
			
		||||
  assert.isBelow(popup.x, headerCRect.x + 2);
 | 
			
		||||
  assert.isAtLeast(popup.y, headerCRect.y + headerCRect.height - 2);
 | 
			
		||||
  assert.isBelow(popup.y, headerCRect.y + headerCRect.height + 2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function doubleClickHeader(col: string) {
 | 
			
		||||
  const header = await gu.getColumnHeader({col});
 | 
			
		||||
  await header.click();
 | 
			
		||||
  await header.click();
 | 
			
		||||
  await waitForFocus('label');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function waitForFocus(field: 'label'|'description') {
 | 
			
		||||
  await gu.waitToPass(async () => assert.isTrue(await driver.find(`.test-column-title-${field}`).hasFocus()), 200);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function waitForTooltip() {
 | 
			
		||||
  await gu.waitToPass(async () => {
 | 
			
		||||
    assert.isTrue(await driver.find(".test-column-info-tooltip-popup").isDisplayed());
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function pressSave() {
 | 
			
		||||
  await driver.find(".test-column-title-save").click();
 | 
			
		||||
  await gu.waitForServer();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function pressCancel() {
 | 
			
		||||
  await driver.find(".test-column-title-cancel").click();
 | 
			
		||||
  await gu.waitForServer();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function clickAddDescription() {
 | 
			
		||||
  await driver.find(".test-column-title-add-description").click();
 | 
			
		||||
  await waitForFocus('description');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -918,7 +918,7 @@ export async function waitAppFocus(yesNo: boolean = true): 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);
 | 
			
		||||
  await header.click();
 | 
			
		||||
  await header.click();   // Second click opens the label for editing.
 | 
			
		||||
  await header.find('.kf_elabel_input').sendKeys(newName, Key.ENTER);
 | 
			
		||||
  await driver.findWait('.test-column-title-label', 100).sendKeys(newName, Key.ENTER);
 | 
			
		||||
  await waitForServer();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user