diff --git a/README.md b/README.md index 60c49f98..67adb5a9 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,8 @@ COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port. PORT | port number to listen on for Grist server REDIS_URL | optional redis server for browser sessions and db query caching +GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000} +GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made Sandbox related variables: diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index 75f07567..785193ab 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -19,7 +19,7 @@ const tableUtil = require('../lib/tableUtil'); const {FieldContextMenu} = require('../ui/FieldContextMenu'); const {RowContextMenu} = require('../ui/RowContextMenu'); const {parsePasteForView} = require("./BaseView2"); -const {columnInfoTooltip} = require("../ui/tooltips"); +const {descriptionInfoTooltip} = require("../ui/tooltips"); /** @@ -266,7 +266,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'), ); @@ -299,7 +299,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 993981dc..ef1b0bae 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"); @@ -1088,7 +1088,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 0e3a80df..6c0956b4 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; @@ -364,6 +366,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 28205460..5ce862aa 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/server/lib/DocSnapshots.ts b/app/server/lib/DocSnapshots.ts index a353502f..270d129a 100644 --- a/app/server/lib/DocSnapshots.ts +++ b/app/server/lib/DocSnapshots.ts @@ -1,3 +1,4 @@ +import {integerParam} from 'app/server/lib/requestUtils'; import {ObjSnapshotWithMetadata} from 'app/common/DocSnapshot'; import {SnapshotWindow} from 'app/common/Features'; import {KeyedMutex} from 'app/common/KeyedMutex'; @@ -350,16 +351,28 @@ export function shouldKeepSnapshots(snapshots: ObjSnapshotWithMetadata[], snapsh // Get time of current version const start = moment.tz(current.lastModified, tz); + const capObjectString = process.env.GRIST_SNAPSHOT_TIME_CAP + || '{"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000}'; + // Parse the stringified JSON object into an actual object + const caps = JSON.parse(capObjectString); + + // Extract the cap values for each bucket range and convert them to integers + const capHour = integerParam(caps.hour, "GRIST_SNAPSHOT_TIMEBUCKET_CAP.hour"); + const capDay = integerParam(caps.day, "GRIST_SNAPSHOT_TIMEBUCKET_CAP.day"); + const capIsoWeek = integerParam(caps.isoWeek, "GRIST_SNAPSHOT_TIMEBUCKET_CAP.isoWeek"); + const capMonth = integerParam(caps.month, "GRIST_SNAPSHOT_TIMEBUCKET_CAP.month"); + const capYear = integerParam(caps.year, "GRIST_SNAPSHOT_TIMEBUCKET_CAP.year"); // Track saved version per hour, day, week, month, year, and number of times a version // has been saved based on a corresponding rule. const buckets: TimeBucket[] = [ - {range: 'hour', prev: start, usage: 0, cap: 25}, - {range: 'day', prev: start, usage: 0, cap: 32}, - {range: 'isoWeek', prev: start, usage: 0, cap: 12}, - {range: 'month', prev: start, usage: 0, cap: 96}, - {range: 'year', prev: start, usage: 0, cap: 1000} + {range: 'hour', prev: start, usage: 0, cap: capHour}, + {range: 'day', prev: start, usage: 0, cap: capDay}, + {range: 'isoWeek', prev: start, usage: 0, cap: capIsoWeek}, + {range: 'month', prev: start, usage: 0, cap: capMonth}, + {range: 'year', prev: start, usage: 0, cap: capYear} ]; + // For each snapshot starting with newest, check if it is worth saving by comparing // it with the last saved snapshot based on hour, day, week, month, year return snapshots.map((snapshot, index) => { @@ -375,7 +388,9 @@ export function shouldKeepSnapshots(snapshots: ObjSnapshotWithMetadata[], snapsh return false; } - let keep = index < 5; // Keep 5 most recent versions + // Keep 5 most recent versions if NUM_SNAPSHOT_KEEP not exist + let keep = index < integerParam(process.env.GRIST_SNAPSHOT_KEEP || 5, "GRIST_SNAPSHOT_KEEP"); + for (const bucket of buckets) { if (updateAndCheckRange(date, bucket)) { keep = true; } } diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 43cdfc81..6a4cd98d 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -589,7 +589,8 @@ "Columns_one": "Spalte", "Columns_other": "Spalten", "Fields_one": "Feld", - "Fields_other": "Felder" + "Fields_other": "Felder", + "Add referenced columns": "Referenzspalten hinzufügen" }, "RowContextMenu": { "Copy anchor link": "Ankerlink kopieren", @@ -779,7 +780,8 @@ "Override widget title": "Widget-Titel überschreiben", "Provide a table name": "Geben Sie einen Tabellennamen an", "Save": "Speichern", - "WIDGET TITLE": "WIDGET TITEL" + "WIDGET TITLE": "WIDGET TITEL", + "WIDGET DESCRIPTION": "WIDGET-BESCHREIBUNG" }, "breadcrumbs": { "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Sie können Änderungen vornehmen, die jedoch eine neue Kopie erstellen und\ndas Originaldokument nicht beeinflussen.", @@ -1047,6 +1049,7 @@ "Provide a column label": "Geben Sie eine Spaltenbeschriftung an", "Save": "Speichern", "Column label": "Spaltenbeschriftung", - "Column ID copied to clipboard": "Spalten-ID in die Zwischenablage kopiert" + "Column ID copied to clipboard": "Spalten-ID in die Zwischenablage kopiert", + "Close": "Schließen" } } 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/static/locales/es.client.json b/static/locales/es.client.json index 3351488a..c14ea705 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -486,7 +486,8 @@ "Columns_one": "Columna", "Columns_other": "Columnas", "Fields_one": "Campo", - "Fields_other": "Campos" + "Fields_other": "Campos", + "Add referenced columns": "Añadir columnas referenciadas" }, "RowContextMenu": { "Copy anchor link": "Copiar enlace de anclaje", @@ -636,7 +637,8 @@ "Override widget title": "Sobrescribir título del Widget", "Provide a table name": "Proporcionar un nombre de tabla", "Save": "Guardar", - "WIDGET TITLE": "TÍTULO DEL WIDGET" + "WIDGET TITLE": "TÍTULO DEL WIDGET", + "WIDGET DESCRIPTION": "DESCRIPCIÓN DEL WIDGET" }, "errorPages": { "Access denied{{suffix}}": "Acceso negado{{suffix}}", @@ -1037,6 +1039,7 @@ "Add description": "Agregar una descripción", "Column ID copied to clipboard": "ID de la columna copiada al portapapeles", "Column description": "Descripción de la Columna", - "Provide a column label": "Proporciona una etiqueta a la columna" + "Provide a column label": "Proporciona una etiqueta a la columna", + "Close": "Cerrar" } } diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index f645f0fc..70adb462 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -133,7 +133,7 @@ "Each Y series is followed by two series, for top and bottom error bars.": "Each Y series is followed by two series, for top and bottom error bars.", "Create separate series for each value of the selected column.": "Créer une série séparée pour chaque valeur de la colonne sélectionnée.", "Pick a column": "Choisir une colonne", - "selected new group data columns": "selected new group data columns", + "selected new group data columns": "nouveau groupe de colonnes sélectionné", "Toggle chart aggregation": "Activer/désactiver l'agrégation des graphiques" }, "CodeEditorPanel": { @@ -263,12 +263,12 @@ "Locale:": "Langue :", "Currency:": "Devise :", "Local currency ({{currency}})": "Devise locale ({{currency}})", - "Engine (experimental {{span}} change at own risk):": "Moteur (expérimental {{span}} changez à vos risques et périls):", + "Engine (experimental {{span}} change at own risk):": "Moteur (expérimental {{span}} changez à vos risques et périls) :", "Save": "Enregistrer", "Save and Reload": "Enregistrer et recharger", "Document ID copied to clipboard": "Identifiant de document copié", "API": "API", - "Ok": "Ok" + "Ok": "OK" }, "DocumentUsage": { "Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.", @@ -316,19 +316,20 @@ "Mixed Behavior": "Comportement mixte", "Clear and make into formula": "Effacer et transformer en formule", "Convert column to data": "Convertir la colonne en données", - "Convert to trigger formula": "Convert to trigger formula", + "Convert to trigger formula": "Convertir en formule", "Clear and reset": "Effacer et réinitialiser", "Enter formula": "Saisir la formule", "COLUMN BEHAVIOR": "NATURE DE COLONNE", "Set formula": "Définir la formule", "Set trigger formula": "Définir une formule d’initialisation", "Make into data column": "Transformer en colonne de données", - "TRIGGER FORMULA": "TRIGGER FORMULA" + "TRIGGER FORMULA": "TRIGGER FORMULA", + "DESCRIPTION": "DESCRIPTION" }, "FieldMenus": { - "Using common settings": "Using common settings", - "Using separate settings": "Using separate settings", - "Use separate settings": "Use separate settings", + "Using common settings": "Utilisation des paramètres communs", + "Using separate settings": "Utilisation de paramètres distincts", + "Use separate settings": "Utiliser des paramètres distincts", "Save as common settings": "Save common settings", "Revert to common settings": "Revert common settings" }, @@ -374,12 +375,14 @@ "Unfreeze all columns": "Libérer toutes les colonnes", "Add to sort": "Ajouter au tri", "Sorted (#{{count}})_one": "Trié (#{{count}})", - "Sorted (#{{count}})_other": "Triés (#{{count}})" + "Sorted (#{{count}})_other": "Triés (#{{count}})", + "Insert column to the right": "Insérer une colonne à droite", + "Insert column to the left": "Insérer une colonne à gauche" }, "GristDoc": { "Import from file": "Importer depuis un fichier", - "Added new linked section to view {{viewName}}": "Added new linked section to view {{viewName}}", - "Saved linked section {{title}} in view {{name}}": "Saved linked section {{title}} in view {{name}}" + "Added new linked section to view {{viewName}}": "Ajout d'une nouvelle section à la page {{viewName}}", + "Saved linked section {{title}} in view {{name}}": "Sauvegarder la section {{title}} dans la page {{name}}" }, "HomeIntro": { "Sign up": "S'inscrire", @@ -393,7 +396,7 @@ "Welcome to Grist, {{name}}!": "Bienvenue sur Grist, {{name}} !", "Get started by inviting your team and creating your first Grist document.": "Pour commencer, inviter votre équipe et créer votre premier document Grist.", "Get started by creating your first Grist document.": "Commencez en créant votre premier document Grist.", - "Get started by exploring templates, or creating your first Grist document.": "Get started by exploring templates, or creating your first Grist document.", + "Get started by exploring templates, or creating your first Grist document.": "Commencez par explorer des modèles ou créez votre premier document Grist.", "Welcome to Grist!": "Bienvenue sur Grist !", "Help Center": "Centre d'aide", "Invite Team Members": "Inviter un nouveau membre", @@ -401,11 +404,13 @@ "Create Empty Document": "Créer un document vide", "Import Document": "Importer un Fichier", "Visit our {{link}} to learn more.": "Consulter le {{link}} pour en savoir plus.", - "{{signUp}} to save your work. ": "{{signUp}} pour enregistrer votre travail. " + "{{signUp}} to save your work. ": "{{signUp}} pour enregistrer votre travail. ", + "Welcome to Grist, {{- name}}!": "Bienvenue sur Grist, {{- name}} !", + "Welcome to {{- orgName}}": "Bienvenue sur {{- orgName}}" }, "HomeLeftPane": { "All Documents": "Tous les documents", - "Examples & Templates": "Exemples & Templates", + "Examples & Templates": "Modèles", "Create Empty Document": "Créer un document vide", "Import Document": "Importer un Fichier", "Create Workspace": "Créer un nouveau dossier", @@ -416,18 +421,19 @@ "Delete {{workspace}} and all included documents?": "Supprimer le dossier {{workspace}} et tous les documents qu'il contient ?", "Workspace will be moved to Trash.": "Le dossier va être mis à la corbeille.", "Manage Users": "Gérer les utilisateurs", - "Access Details": "Access Details" + "Access Details": "Détails d'accès", + "Tutorial": "Tutoriel" }, "Importer": { - "Update existing records": "Update existing records", - "Merge rows that match these fields:": "Fusionner les lignes si ces champs correspondent:", + "Update existing records": "Mettre à jour les enregistrements existants", + "Merge rows that match these fields:": "Fusionner les lignes si ces champs correspondent :", "Select fields to match on": "Sélectionner les champs pour l'appairage" }, "LeftPanelCommon": { "Help Center": "Centre d'aide" }, "MakeCopyMenu": { - "Replacing the original requires editing rights on the original document.": "Replacing the original requires editing rights on the original document.", + "Replacing the original requires editing rights on the original document.": "Le remplacement de l'original nécessite des droits d'édition sur le document d'origine.", "Cancel": "Annuler", "Update Original": "Mettre à jour l'original", "Update": "Mettre à jour", @@ -435,10 +441,10 @@ "Original Has Modifications": "L'original a été modifié", "Overwrite": "Remplacer", "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Attention, l'original a des modifications qui ne sont pas dans ce document. Ces modifications seront écrasées.", - "Original Looks Unrelated": "Original Looks Unrelated", - "It will be overwritten, losing any content not in this document.": "It will be overwritten, losing any content not in this document.", - "Original Looks Identical": "Original Looks Identical", - "However, it appears to be already identical.": "However, it appears to be already identical.", + "Original Looks Unrelated": "L'original ne semble pas relié", + "It will be overwritten, losing any content not in this document.": "Il sera écrasé, perdant tout contenu ne figurant pas dans ce document.", + "Original Looks Identical": "L'original semble identique", + "However, it appears to be already identical.": "Cependant, il semble être déjà identique.", "Sign up": "Inscription", "To save your changes, please sign up, then reload this page.": "Pour enregistrer vos modifications, veuillez vous inscrire, puis recharger cette page.", "No destination workspace": "Aucun dossier destination", @@ -452,7 +458,7 @@ "You do not have write access to the selected workspace": "Vous n’avez pas accès en écriture à ce dossier" }, "NotifyUI": { - "Upgrade Plan": "Upgrade Plan", + "Upgrade Plan": "Améliorer votre abonnement", "Renew": "Renouveler", "Go to your free personal site": "Accéder à votre espace personnel", "Cannot find personal site, sorry!": "Espace personnel introuvable, désolé !", @@ -490,10 +496,10 @@ "Read Only": "Lecture seule" }, "PluginScreen": { - "Import failed: ": "Échec de l'importation: " + "Import failed: ": "Échec de l'importation : " }, "RecordLayout": { - "Updating record layout.": "Updating record layout." + "Updating record layout.": "Mise à jour de la disposition." }, "RecordLayoutEditor": { "Add Field": "Ajouter un champ", @@ -530,7 +536,7 @@ "SOURCE DATA": "DONNÉES SOURCE", "GROUPED BY": "GROUPER PAR", "Edit Data Selection": "Données source", - "Detach": "Detach", + "Detach": "Détacher", "SELECT BY": "SÉLECTIONNER PAR", "Select Widget": "Choisir la vue", "SELECTOR FOR": "SÉLECTEUR", @@ -578,7 +584,7 @@ "Add Column": "Ajouter une colonne", "Update Data": "Mettre à jour les données", "Use choice position": "Use choice position", - "Natural sort": "Natural sort", + "Natural sort": "Tri naturel", "Empty values last": "Valeurs vides en dernier", "Search Columns": "Rechercher" }, @@ -650,8 +656,8 @@ "Compact": "Compact", "Blocks": "Blocs", "Edit Card Layout": "Disposition de la carte", - "Plugin: ": "Plugin: ", - "Section: ": "Section: " + "Plugin: ": "Plugin : ", + "Section: ": "Section : " }, "ViewLayoutMenu": { "Delete record": "Supprimer la ligne", @@ -665,7 +671,9 @@ "Advanced Sort & Filter": "Tri et filtre avancés", "Data selection": "Sélection des données", "Open configuration": "Ouvrir la configuration", - "Delete widget": "Supprimer la vue" + "Delete widget": "Supprimer la vue", + "Collapse widget": "Réduire la vue", + "Add to page": "Ajouter à la page" }, "ViewSectionMenu": { "Update Sort&Filter settings": "Mettre à jour le tri et le filtre", @@ -682,19 +690,23 @@ "Hidden Fields cannot be reordered": "Les champs masqués ne peuvent pas être réordonnés", "Cannot drop items into Hidden Fields": "Impossible de mettre des éléments dans les champs cachés", "Select All": "Sélectionner tout", - "Clear": "Effacer" + "Clear": "Effacer", + "Visible {{label}}": "{{label}} visible", + "Hide {{label}}": "Cacher {{label}}", + "Show {{label}}": "Montrer {{label}}", + "Hidden {{label}}": "{{label}} caché" }, "WelcomeQuestions": { "Welcome to Grist!": "Bienvenue sur Grist !", "Product Development": "Développement de produit", - "Finance & Accounting": "Finance & comptabilité", + "Finance & Accounting": "Finance et comptabilité", "Media Production": "Production de média", "IT & Technology": "Technologie informatique", "Marketing": "Marketing", "Research": "Recherche", "Sales": "Ventes", "Education": "Éducation", - "HR & Management": "RH & Gestion", + "HR & Management": "RH et Gestion", "Other": "Autres", "What brings you to Grist? Please help us serve you better.": "Pourquoi utilisez-vous Grist ? Aidez-nous à l’améliorer.", "Type here": "Écrire ici" @@ -708,7 +720,7 @@ "Cancel": "Annuler" }, "breadcrumbs": { - "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Vous pouvez faire des modifications, mais une nouvelle copie sera créée et ces modifications n’affecteront pas le document original.", + "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Vous pouvez faire des modifications, mais une nouvelle copie\n sera créée et ces modifications n’affecteront pas le document original.", "snapshot": "instantané", "unsaved": "non enregistré", "recovery mode": "mode récupération", @@ -716,7 +728,7 @@ "fiddle": "bac à sable" }, "duplicatePage": { - "Note that this does not copy data, but creates another view of the same data.": "Note that this does not copy data, but creates another view of the same data.", + "Note that this does not copy data, but creates another view of the same data.": "Notez que cette opération ne duplique pas les données, mais crée une autre page avec les mêmes données.", "Duplicate page {{pageName}}": "Dupliquer la page {{pageName}}" }, "errorPages": { @@ -741,12 +753,23 @@ "menus": { "Select fields": "Sélectionner les champs", "* Workspaces are available on team plans. ": "* Les dossiers sont disponibles avec une offre équipe. ", - "Upgrade now": "Mettre à jour maintenant" + "Upgrade now": "Mettre à jour maintenant", + "Numeric": "Numérique", + "Reference List": "Référence multiple", + "Attachment": "Pièce jointe", + "Text": "Texte", + "Date": "Date", + "DateTime": "Date et Heure", + "Choice": "Choix unique", + "Integer": "Entier", + "Choice List": "Choix multiple", + "Toggle": "Booléen", + "Reference": "Référence" }, "modals": { "Save": "Enregistrer", "Cancel": "Annuler", - "Ok": "Ok" + "Ok": "OK" }, "pages": { "Rename": "Renommer", @@ -820,7 +843,7 @@ "Configuring your document": "Configuration de votre document", "Double-click or hit {{enter}} on a cell to edit it. ": "Double-cliquer ou appuyer sur {{enter}} sur une cellule pour la modifier ", "Editing Data": "Modification des données", - "Welcome to Grist!": "Bienvenue sur Grist!", + "Welcome to Grist!": "Bienvenue sur Grist !", "Start with {{equal}} to enter a formula.": "Commencer par {{equal}} pour ajouter une formule.", "Sharing": "Partager", "Reference": "Référence", @@ -868,7 +891,7 @@ }, "ColumnInfo": { "COLUMN DESCRIPTION": "DESCRIPTION", - "COLUMN ID: ": "ID: ", + "COLUMN ID: ": "Identifiant de la colonne : ", "COLUMN LABEL": "LIBELLÉ", "Cancel": "Annuler", "Save": "Enregistrer" @@ -902,7 +925,7 @@ "SHOW COLUMN": "MONTRER LA COLONNE" }, "HyperLinkEditor": { - "[link label] url": "[label du lien] url" + "[link label] url": "[label du lien] URL" }, "GristTooltips": { "Apply conditional formatting to cells in this column when formula conditions are met.": "Appliquez un formatage conditionnel aux cellules de cette colonne lorsque les conditions de la formule sont remplies.", @@ -942,6 +965,25 @@ "Add New": "Nouveau", "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Les règles d'accès vous donnent le pouvoir de créer des règles nuancées pour déterminer qui peut voir ou modifier quelles parties de votre document.", "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Utilisez l'icône 𝚺 pour créer des tables récapitulatives (ou tables croisées dynamiques), pour les totaux ou les sous-totaux.", - "Unpin to hide the the button while keeping the filter.": "Détachez pour cacher le bouton tout en conservant le filtre." + "Unpin to hide the the button while keeping the filter.": "Détachez pour cacher le bouton tout en conservant le filtre.", + "Anchor Links": "Ancres", + "Custom Widgets": "Vues personnalisées" + }, + "ColumnTitle": { + "Add description": "Ajouter une description", + "Cancel": "Annuler", + "Column ID copied to clipboard": "Identifiant de la column copié", + "COLUMN ID: ": "Identifiant de la column : ", + "Column description": "Description de la colonne", + "Column label": "Libellé de la colonne", + "Provide a column label": "Fournir un libellé pour la colonne", + "Save": "Sauvegarder" + }, + "DescriptionConfig": { + "DESCRIPTION": "DESCRIPTION" + }, + "PagePanels": { + "Open Creator Panel": "Ouvrir le menu latéral", + "Close Creator Panel": "Fermer le menu latéral" } } diff --git a/static/locales/it.client.json b/static/locales/it.client.json index a1cd1280..910d8294 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -122,7 +122,8 @@ "SELECTOR FOR": "SELETTORE PER", "Series_one": "Serie", "Series_other": "Serie", - "Sort & Filter": "Ordina e filtra" + "Sort & Filter": "Ordina e filtra", + "Add referenced columns": "Aggiungi colonne referenziate" }, "RowContextMenu": { "Copy anchor link": "Copia link", @@ -853,7 +854,8 @@ "Override widget title": "Sovrascrivi titolo widget", "Provide a table name": "Inserisci un nome per la tabella", "Save": "Salva", - "WIDGET TITLE": "TITOLO WIDGET" + "WIDGET TITLE": "TITOLO WIDGET", + "WIDGET DESCRIPTION": "DESCRIZIONE WIDGET" }, "breadcrumbs": { "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Puoi fare delle modifiche, ma queste generano una nuova copia\ne l'originale resta immutato.", @@ -983,6 +985,7 @@ "COLUMN ID: ": "ID COLONNA: ", "Column label": "Etichetta colonna", "Provide a column label": "Dare un'etichetta alla colonna", - "Cancel": "Annulla" + "Cancel": "Annulla", + "Close": "Chiudi" } } diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index d254f093..bed4a1eb 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -589,7 +589,8 @@ "Columns_one": "Coluna", "Columns_other": "Colunas", "Fields_one": "Campo", - "Fields_other": "Campos" + "Fields_other": "Campos", + "Add referenced columns": "Adicionar colunas referenciadas" }, "RowContextMenu": { "Copy anchor link": "Copiar o link de ancoragem", @@ -779,7 +780,8 @@ "Override widget title": "Substituir o título do Widget", "Provide a table name": "Forneça um nome de tabela", "Save": "Salvar", - "WIDGET TITLE": "TÍTULO DO WIDGET" + "WIDGET TITLE": "TÍTULO DO WIDGET", + "WIDGET DESCRIPTION": "DESCRIÇÃO DO WIDGET" }, "breadcrumbs": { "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Você pode fazer edições, mas elas criarão uma nova cópia e\nnão afetarão o documento original.", @@ -1047,6 +1049,7 @@ "Add description": "Adicionar descrição", "Column ID copied to clipboard": "ID da coluna copiada para a área de transferência", "COLUMN ID: ": "ID DA COLUNA: ", - "Provide a column label": "Forneça um rótulo de coluna" + "Provide a column label": "Forneça um rótulo de coluna", + "Close": "Fechar" } } 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); +}