diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index a869c6bb..2d69c816 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -16,7 +16,7 @@ const RecordLayout = require('./RecordLayout'); const commands = require('./commands'); const {RowContextMenu} = require('../ui/RowContextMenu'); const {parsePasteForView} = require("./BaseView2"); -const {columnInfoTooltip} = require("../ui/tooltips"); +const {descriptionInfoTooltip} = require("../ui/tooltips"); /** @@ -247,7 +247,7 @@ DetailView.prototype.buildFieldDom = function(field, row) { kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }), dom('div.g_record_detail_label_container', dom('div.g_record_detail_label', kd.text(field.label)), - kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null) + kd.scope(field.description, desc => desc ? descriptionInfoTooltip(kd.text(field.description), "colmun") : null) ), dom('div.g_record_detail_value'), ); @@ -280,7 +280,7 @@ DetailView.prototype.buildFieldDom = function(field, row) { kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }), dom('div.g_record_detail_label_container', dom('div.g_record_detail_label', kd.text(field.displayLabel)), - kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null) + kd.scope(field.description, desc => desc ? descriptionInfoTooltip(kd.text(field.description), "column") : null) ), dom('div.g_record_detail_value', kd.toggleClass('scissors', isCopyActive), diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index b35089cf..ecbb3f5b 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -44,7 +44,7 @@ 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 {columnInfoTooltip, showTooltip} = require('app/client/ui/tooltips'); +const {descriptionInfoTooltip, 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"); @@ -1087,7 +1087,7 @@ GridView.prototype.buildDom = function() { if (btn) { btn.click(); } }), dom('div.g-column-label', - kd.scope(field.description, desc => desc ? columnInfoTooltip(kd.text(field.description)) : null), + kd.scope(field.description, desc => desc ? descriptionInfoTooltip(kd.text(field.description), "column") : 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)), diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 168d0c0d..4ad353df 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -53,6 +53,8 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO // Default widget title (the one that is used in titleDef). defaultWidgetTitle: ko.PureComputed; + description: modelUtil.KoSaveableObservable; + // true if this record is its table's rawViewSection, i.e. a 'raw data view' // in which case the UI prevents various things like hiding columns or changing the widget type. isRaw: ko.Computed; @@ -363,6 +365,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): // Widget title. this.titleDef = modelUtil.fieldWithDefault(this.title, this.defaultWidgetTitle); + // Widget description + this.description = modelUtil.fieldWithDefault(this.description, this.description()); + // true if this record is its table's rawViewSection, i.e. a 'raw data view' // in which case the UI prevents various things like hiding columns or changing the widget type. this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId())); diff --git a/app/client/ui/ColumnTitle.ts b/app/client/ui/ColumnTitle.ts index 8626b6d7..7c9c33ab 100644 --- a/app/client/ui/ColumnTitle.ts +++ b/app/client/ui/ColumnTitle.ts @@ -6,17 +6,16 @@ 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, 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 {Computed, dom, makeTestId, Observable, styled} from 'grainjs'; import * as ko from 'knockout'; import {IOpenController, PopupControl, setPopupToCreateDom} from 'popweasel'; +import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles'; const testId = makeTestId('test-column-title-'); @@ -281,16 +280,6 @@ const cssAddDescription = styled('div', ` } `); -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; @@ -298,17 +287,6 @@ const cssColLabelBlock = styled('div', ` 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}; @@ -321,29 +299,6 @@ const cssColId = styled('div', ` 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; @@ -352,29 +307,3 @@ const cssButtons = styled('div', ` min-width: calc(50 / 13 * 1em); /* Min 50px for 13px font size, to make Save and Close buttons equal width */ } `); - -const cssInputWithIcon = styled('div', ` - position: relative; - display: flex; - flex-direction: column; -`); - -const cssInput = styled(( - obs: Observable, - opts: IInputOptions, - ...args) => input(obs, opts, cssTextInput.cls(''), ...args), ` - text-overflow: ellipsis; - color: ${theme.inputFg}; - background-color: transparent; - &:disabled { - color: ${theme.inputDisabledFg}; - background-color: ${theme.inputDisabledBg}; - pointer-events: none; - } - &::placeholder { - color: ${theme.inputPlaceholderFg}; - } - .${cssInputWithIcon.className} > &:disabled { - padding-right: 28px; - } -`); diff --git a/app/client/ui/DescriptionConfig.ts b/app/client/ui/DescriptionConfig.ts index ae67fa1b..65f03042 100644 --- a/app/client/ui/DescriptionConfig.ts +++ b/app/client/ui/DescriptionConfig.ts @@ -1,18 +1,21 @@ import {CursorPos} from 'app/client/components/Cursor'; import {makeT} from 'app/client/lib/localization'; -import {ColumnRec} from 'app/client/models/DocModel'; +import { KoSaveableObservable } from 'app/client/models/modelUtil'; 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'; import {dom, fromKo, MultiHolder, styled} from 'grainjs'; -const t = makeT('FieldConfig'); +const t = makeT('DescriptionConfig'); export function buildDescriptionConfig( owner: MultiHolder, - origColumn: ColumnRec, - cursor: ko.Computed, + description: KoSaveableObservable, + options: { + cursor: ko.Computed, + testPrefix: string, + }, ) { // We will listen to cursor position and force a blur event on @@ -22,7 +25,7 @@ export function buildDescriptionConfig( // update a different column. let editor: HTMLTextAreaElement | undefined; owner.autoDispose( - cursor.subscribe(() => { + options.cursor.subscribe(() => { editor?.blur(); }) ); @@ -30,14 +33,14 @@ export function buildDescriptionConfig( return [ cssLabel(t("DESCRIPTION")), cssRow( - editor = cssTextArea(fromKo(origColumn.description), + editor = cssTextArea(fromKo(description), { onInput: false }, { rows: '3' }, dom.on('blur', async (e, elem) => { - await origColumn.description.setAndSave(elem.value.trim()); + await description.saveOnly(elem.value); }), - testId('column-description'), - autoGrow(fromKo(origColumn.description)) + testId(`${options.testPrefix}-description`), + autoGrow(fromKo(description)) ) ), ]; diff --git a/app/client/ui/RenamePopupStyles.ts b/app/client/ui/RenamePopupStyles.ts new file mode 100644 index 00000000..e658b786 --- /dev/null +++ b/app/client/ui/RenamePopupStyles.ts @@ -0,0 +1,75 @@ +import { theme, vars } from 'app/client/ui2018/cssVars'; +import {textarea} from 'app/client/ui/inputs'; +import {cssTextInput} from 'app/client/ui2018/editableLabel'; +import {IInputOptions, input, Observable, styled} from 'grainjs'; + + +export const cssRenamePopup = styled('div', ` + display: flex; + flex-direction: column; + min-width: 280px; + padding: 16px; + background-color: ${theme.popupBg}; + border-radius: 2px; + outline: none; +`); + +export 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 cssInputWithIcon = styled('div', ` + position: relative; + display: flex; + flex-direction: column; +`); + +export const cssInput = styled(( + obs: Observable, + opts: IInputOptions, + ...args) => input(obs, opts, cssTextInput.cls(''), ...args), ` + text-overflow: ellipsis; + color: ${theme.inputFg}; + background-color: transparent; + &:disabled { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + pointer-events: none; + } + &::placeholder { + color: ${theme.inputPlaceholderFg}; + } + .${cssInputWithIcon.className} > &:disabled { + padding-right: 28px; + } +`); + +export const cssTextArea = styled(textarea, ` + color: ${theme.inputFg}; + background-color: ${theme.mainPanelBg}; + border: 1px solid ${theme.inputBorder}; + width: 100%; + padding: 3px 6px; + 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}; + } +`); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 6c60b0e5..3524ea45 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -251,7 +251,7 @@ export class RightPanel extends Disposable { dom.create(buildNameConfig, origColumn, cursor, isMultiSelect), ), cssSection( - dom.create(buildDescriptionConfig, origColumn, cursor), + dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }), ), cssSeparator(), cssSection( @@ -361,6 +361,13 @@ export class RightPanel extends Disposable { const hasColumnMapping = use(activeSection.columnsToMap); return Boolean(isCustom && hasColumnMapping); }); + + // build cursor position observable + const cursor = owner.autoDispose(ko.computed(() => { + const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); + return vsi?.cursor.currentPosition() ?? {}; + })); + return dom.maybe(viewConfigTab, (vct) => [ this._disableIfReadonly(), cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")), @@ -377,6 +384,10 @@ export class RightPanel extends Disposable { testId('right-widget-title') )), + cssSection( + dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }), + ), + dom.maybe( (use) => !use(activeSection.isRaw), () => cssRow( diff --git a/app/client/ui/WidgetTitle.ts b/app/client/ui/WidgetTitle.ts index a47425ab..fb13bf3f 100644 --- a/app/client/ui/WidgetTitle.ts +++ b/app/client/ui/WidgetTitle.ts @@ -1,13 +1,16 @@ +import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; -import {FocusLayer} from 'app/client/lib/FocusLayer'; +import { FocusLayer } from 'app/client/lib/FocusLayer'; import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec'; import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons'; -import {theme, vars} from 'app/client/ui2018/cssVars'; -import {cssTextInput} from 'app/client/ui2018/editableLabel'; +import { theme } from 'app/client/ui2018/cssVars'; import {menuCssClass} from 'app/client/ui2018/menus'; import {ModalControl} from 'app/client/ui2018/modals'; -import {Computed, dom, DomElementArg, IInputOptions, input, makeTestId, Observable, styled} from 'grainjs'; +import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs'; import {IOpenController, setPopupToCreateDom} from 'popweasel'; +import { descriptionInfoTooltip } from './tooltips'; +import { autoGrow } from './forms'; +import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles'; const testId = makeTestId('test-widget-title-'); const t = makeT('WidgetTitle'); @@ -19,17 +22,20 @@ interface WidgetTitleOptions { export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) { const title = Computed.create(null, use => use(vs.titleDef)); - return buildRenameWidget(vs, title, options, dom.autoDispose(title), ...args); + const description = Computed.create(null, use => use(vs.description)); + return buildRenameWidget(vs, title, description, options, dom.autoDispose(title), ...args); } export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) { const title = Computed.create(null, use => use(use(vs.table).tableNameDef)); - return buildRenameWidget(vs, title, { widgetNameHidden: true }, dom.autoDispose(title), ...args); + const description = Computed.create(null, use => use(vs.description)); + return buildRenameWidget(vs, title, description, { widgetNameHidden: true }, dom.autoDispose(title), ...args); } export function buildRenameWidget( vs: ViewSectionRec, title: Observable, + description: Observable, options: WidgetTitleOptions, ...args: DomElementArg[]) { return cssTitleContainer( @@ -48,6 +54,9 @@ export function buildRenameWidget( }, dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), ), + dom.maybe(description, () => [ + descriptionInfoTooltip(description.get(), "widget") + ]), ...args ); } @@ -69,11 +78,19 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio // - when widget title is set, shows just a text to override it. const inputWidgetPlaceholder = !vs.title.peek() ? t("Override widget title") : vs.defaultWidgetTitle.peek(); + // User input for widget description + const inputWidgetDesc = Observable.create(ctrl, vs.description.peek() ?? ''); + const disableSave = Computed.create(ctrl, (use) => { const newTableName = use(inputTableName)?.trim() ?? ''; const newWidgetTitle = use(inputWidgetTitle)?.trim() ?? ''; + const newWidgetDesc = use(inputWidgetDesc)?.trim() ?? ''; // Can't save when table name is empty or there wasn't any change. - return !newTableName || (newTableName === tableName && newWidgetTitle === use(vs.title)); + return !newTableName || ( + newTableName === tableName + && newWidgetTitle === use(vs.title) + && newWidgetDesc === use(vs.description) + ); }); const modalCtl = ModalControl.create(ctrl, () => ctrl.close()); @@ -99,10 +116,20 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio await vs.title.saveOnly(newTitle); } }; - const doSave = modalCtl.doWork(() => Promise.all([ + + const saveWidgetDesc = async () => { + const newWidgetDesc = inputWidgetDesc.get().trim() ?? ''; + // If value was changed. + if (newWidgetDesc !== vs.description.peek()) { + await vs.description.saveOnly(newWidgetDesc); + } + }; + + const save = () => Promise.all([ saveTableName(), - saveWidgetTitle() - ]), {close: true}); + saveWidgetTitle(), + saveWidgetDesc() + ]); function initialFocus() { const isRawView = !widgetInput; @@ -122,18 +149,72 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio } } - // Build actual dom that looks like: - // DATA TABLE NAME - // [input] - // WIDGET TITLE - // [input] - // [Save] [Cancel] + // 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); + } + }; + + // 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(); + }, + // ArrowUp + cursorUp: () => { + // moves focus to the widget title input if it is already at the top of widget description + if (document.activeElement === descInput && descInput?.selectionStart === 0) { + widgetInput?.focus(); + widgetInput?.select(); + } else if (document.activeElement === widgetInput) { + tableInput?.focus(); + tableInput?.select(); + } else { + return true; + } + }, + // ArrowDown + cursorDown: () => { + if (document.activeElement === tableInput) { + widgetInput?.focus(); + widgetInput?.select(); + } else if (document.activeElement === widgetInput) { + descInput?.focus(); + descInput?.select(); + } else { + return true; + } + } + }; + + // Create this group and attach it to the popup and all inputs. + const commandGroup = commands.createGroup({ ...myCommands }, ctrl, true); + let tableInput: HTMLInputElement|undefined; let widgetInput: HTMLInputElement|undefined; + let descInput: HTMLTextAreaElement | undefined; return cssRenamePopup( // Create a FocusLayer to keep focus in this popup while it's active, and prevent keyboard // shortcuts from being seen by the view underneath. - elem => { FocusLayer.create(ctrl, {defaultFocusElem: elem, pauseMousetrap: true}); }, + elem => { FocusLayer.create(ctrl, { defaultFocusElem: elem, pauseMousetrap: false }); }, + dom.onDispose(onClose), + dom.autoDispose(commandGroup), testId('popup'), dom.cls(menuCssClass), dom.maybe(!options.tableNameHidden, () => [ @@ -144,30 +225,41 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio inputTableName, updateOnKey, {disabled: isSummary, placeholder: t("Provide a table name")}, - testId('table-name-input') + testId('table-name-input'), + commandGroup.attach(), ), ]), dom.maybe(!options.widgetNameHidden, () => [ cssLabel(t("WIDGET TITLE")), widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder}, - testId('section-name-input') + testId('section-name-input'), + commandGroup.attach(), ), ]), + cssLabel(t("WIDGET DESCRIPTION")), + descInput = cssTextArea(inputWidgetDesc, updateOnKey, + testId('section-description-input'), + commandGroup.attach(), + autoGrow(inputWidgetDesc), + ), cssButtons( primaryButton(t("Save"), - dom.on('click', doSave), + dom.on('click', close), dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)), testId('save'), ), basicButton(t("Cancel"), testId('cancel'), - dom.on('click', () => modalCtl.close()) + dom.on('click', cancel) ), ), dom.onKeyDown({ - Escape: () => modalCtl.close(), - // On enter save or cancel - depending on the change. - Enter: () => disableSave.get() ? modalCtl.close() : doSave(), + Enter$: e => { + if (e.ctrlKey || e.metaKey) { + close(); + return false; + } + } }), elem => { setTimeout(initialFocus, 0); }, ); @@ -180,6 +272,10 @@ const cssTitleContainer = styled('div', ` flex: 1 1 0px; min-width: 0px; display: flex; + .info_toggle_icon { + width: 13px; + height: 13px; + } `); const cssTitle = styled('div', ` @@ -199,26 +295,6 @@ const cssTitle = styled('div', ` } `); -const cssRenamePopup = styled('div', ` - display: flex; - flex-direction: column; - min-width: 280px; - padding: 16px; - background-color: ${theme.popupBg}; - border-radius: 2px; - outline: none; -`); - -const cssLabel = styled('label', ` - color: ${theme.text}; - font-size: ${vars.xsmallFontSize}; - font-weight: ${vars.bigControlTextWeight}; - margin: 0 0 8px 0; - &:not(:first-child) { - margin-top: 16px; - } -`); - const cssButtons = styled('div', ` display: flex; margin-top: 16px; @@ -226,29 +302,3 @@ const cssButtons = styled('div', ` margin-left: 8px; } `); - -const cssInputWithIcon = styled('div', ` - position: relative; - display: flex; - flex-direction: column; -`); - -const cssInput = styled(( - obs: Observable, - opts: IInputOptions, - ...args) => input(obs, opts, cssTextInput.cls(''), ...args), ` - text-overflow: ellipsis; - color: ${theme.inputFg}; - background-color: transparent; - &:disabled { - color: ${theme.inputDisabledFg}; - background-color: ${theme.inputDisabledBg}; - pointer-events: none; - } - &::placeholder { - color: ${theme.inputPlaceholderFg}; - } - .${cssInputWithIcon.className} > &:disabled { - padding-right: 28px; - } -`); diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 1dddda4a..2b1ff90d 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -347,15 +347,18 @@ export function withInfoTooltip( } /** - * Renders an column info icon that shows a tooltip with the specified `content` on click. + * Renders an description info icon that shows a tooltip with the specified `content` on click. */ - export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) { - return cssColumnInfoTooltipButton( +export function descriptionInfoTooltip( + content: DomContents, + testPrefix: string, + ...domArgs: DomElementArg[]) { + return cssDescriptionInfoTooltipButton( icon('Info', dom.cls("info_toggle_icon")), - testId('column-info-tooltip'), + testId(`${testPrefix}-info-tooltip`), dom.on('mousedown', (e) => e.stopPropagation()), dom.on('click', (e) => e.stopPropagation()), - hoverTooltip(() => cssColumnInfoTooltip(content, testId('column-info-tooltip-popup')), { + hoverTooltip(() => cssDescriptionInfoTooltip(content, testId(`${testPrefix}-info-tooltip-popup`)), { closeDelay: 200, key: 'columnDescription', openOnClick: true, @@ -365,7 +368,8 @@ export function withInfoTooltip( ); } -const cssColumnInfoTooltip = styled('div', ` + +const cssDescriptionInfoTooltip = styled('div', ` white-space: pre-wrap; text-align: left; text-overflow: ellipsis; @@ -373,7 +377,7 @@ const cssColumnInfoTooltip = styled('div', ` max-width: min(500px, calc(100vw - 80px)); /* can't use 100%, 500px and 80px are picked by hand */ `); -const cssColumnInfoTooltipButton = styled('div', ` +const cssDescriptionInfoTooltipButton = styled('div', ` cursor: pointer; --icon-color: ${theme.infoButtonFg}; border-radius: 50%; diff --git a/app/common/schema.ts b/app/common/schema.ts index 58dd93f4..1c7fd3a6 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 37; +export const SCHEMA_VERSION = 38; export const schema = { @@ -104,6 +104,7 @@ export const schema = { parentId : "Ref:_grist_Views", parentKey : "Text", title : "Text", + description : "Text", defaultWidth : "Int", borderWidth : "Int", theme : "Text", @@ -311,6 +312,7 @@ export interface SchemaTypes { parentId: number; parentKey: string; title: string; + description: string; defaultWidth: number; borderWidth: number; theme: string; diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index f06c7bba..b7271835 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef" CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999); CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999); CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT ''); -CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); +CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); @@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); @@ -62,9 +62,9 @@ CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INT INSERT INTO _grist_Pages VALUES(1,1,0,1); CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT ''); INSERT INTO _grist_Views VALUES(1,'Table1','raw_data',''); -CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); -INSERT INTO _grist_Views_section VALUES(1,1,1,'record','',100,1,'','','','','','[]',0,0,0,'',NULL); -INSERT INTO _grist_Views_section VALUES(2,1,0,'record','',100,1,'','','','','','',0,0,0,'',NULL); +CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); +INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL); +INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL); CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL); diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 24756589..80020f32 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1204,3 +1204,11 @@ def migration37(tdset): Add fileExt column to _grist_Attachments. """ return tdset.apply_doc_actions([add_column('_grist_Attachments', 'fileExt', 'Text')]) + +@migration(schema_version=38) +def migration38(tdset): + """ + Add description to widget + """ + return tdset.apply_doc_actions([add_column('_grist_Views_section', 'description', 'Text')]) + \ No newline at end of file diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 0c516965..650e5090 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 37 +SCHEMA_VERSION = 38 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -181,6 +181,7 @@ def schema_create_actions(): # TODO: rename this (e.g. to "sectionType"). make_column("parentKey", "Text"), make_column("title", "Text"), + make_column("description", "Text"), make_column("defaultWidth", "Int", formula="100"), make_column("borderWidth", "Int", formula="1"), make_column("theme", "Text"), diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 72a5a6a2..4f69f902 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -724,7 +724,8 @@ "Override widget title": "Override widget title", "Provide a table name": "Provide a table name", "Save": "Save", - "WIDGET TITLE": "WIDGET TITLE" + "WIDGET TITLE": "WIDGET TITLE", + "WIDGET DESCRIPTION": "WIDGET DESCRIPTION" }, "breadcrumbs": { "You may make edits, but they will create a new copy and will\nnot affect the original document.": "You may make edits, but they will create a new copy and will\nnot affect the original document.", diff --git a/test/nbrowser/DescriptionWidget.ts b/test/nbrowser/DescriptionWidget.ts new file mode 100644 index 00000000..09fc11bb --- /dev/null +++ b/test/nbrowser/DescriptionWidget.ts @@ -0,0 +1,96 @@ +import { assert, driver, Key } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; + + +describe('DescriptionWidget', function() { + this.timeout(20000); + const cleanup = setupTestSuite(); + + it('should support basic edition in right panel', async () => { + const mainSession = await gu.session().teamSite.login(); + await mainSession.tempDoc(cleanup, "CardView.grist", { load: true }); + + const newWidgetDesc = "This is the widget description\nIt is in two lines"; + await driver.find('.test-right-opener').click(); + // Sleep 100ms to let open the right panel and make the description input clickable + await driver.sleep(100); + const rightPanelDescriptionInput = await driver.find('.test-right-panel .test-right-widget-description'); + await rightPanelDescriptionInput.click(); + await rightPanelDescriptionInput.sendKeys(newWidgetDesc); + // Click on other input to unselect descriptionInput + await driver.find('.test-right-panel .test-right-widget-title').click(); + await checkDescValueInWidgetTooltip("Table", newWidgetDesc); + }); + + it('should support basic edition in widget popup', async () => { + const mainSession = await gu.session().teamSite.login(); + await mainSession.tempDoc(cleanup, "CardView.grist", { load: true }); + + const widgetName = "Table"; + const newWidgetDescFirstLine = "First line of the description"; + const newWidgetDescSecondLine = "Second line of the description"; + + await addWidgetDescription(widgetName, newWidgetDescFirstLine, newWidgetDescSecondLine); + await checkDescValueInWidgetTooltip(widgetName, `${newWidgetDescFirstLine}\n${newWidgetDescSecondLine}`); + }); + + it('should show info tooltip only if there is a description', async () => { + const mainSession = await gu.session().teamSite.login(); + await mainSession.tempDoc(cleanup, "CardView.grist", { load: true }); + + const newWidgetDesc = "New description for widget Table"; + + await addWidgetDescription("Table", newWidgetDesc); + + assert.isFalse(await getWidgetTooltip("Single card").isPresent()); + assert.isTrue(await getWidgetTooltip("Table").isPresent()); + + await checkDescValueInWidgetTooltip("Table", newWidgetDesc); + }); +}); + +async function waitForEditPopup() { + await gu.waitToPass(async () => { + assert.isTrue(await driver.find(".test-widget-title-popup").isDisplayed()); + }); +} + +async function waitForTooltip() { + await gu.waitToPass(async () => { + assert.isTrue(await driver.find(".test-widget-info-tooltip-popup").isDisplayed()); + }); +} + +function getWidgetTitle(widgetName: string) { + return driver.findContent('.test-widget-title-text', `${widgetName}`); +} + +function getWidgetTooltip(widgetName: string) { + return getWidgetTitle(widgetName).findClosest(".test-viewsection-title").find(".test-widget-info-tooltip"); +} + +async function addWidgetDescription(widgetName: string, desc: string, descSecondLine: string = "") { + // Click on the title and open the edition popup + await getWidgetTitle(widgetName).click(); + await waitForEditPopup(); + const widgetEditPopup = await driver.find('.test-widget-title-popup'); + const widgetDescInput = await widgetEditPopup.find('.test-widget-title-section-description-input'); + + // Edit the description of the widget inside the popup + await widgetDescInput.click(); + await widgetDescInput.sendKeys(desc); + if (descSecondLine !== "") { + await widgetDescInput.sendKeys(Key.ENTER, descSecondLine); + } + await widgetDescInput.sendKeys(Key.CONTROL, Key.ENTER); + await gu.waitForServer(); +} + +async function checkDescValueInWidgetTooltip(widgetName: string, desc: string) { + await getWidgetTooltip(widgetName).click(); + await waitForTooltip(); + const descriptionTooltip = await driver + .find('.test-widget-info-tooltip-popup'); + assert.equal(await descriptionTooltip.getText(), desc); +}