diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c3df5e7..0538be7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,10 +98,31 @@ jobs: - name: Run main tests without minio and redis if: contains(matrix.tests, ':nbrowser-') run: | + mkdir -p $MOCHA_WEBDRIVER_LOGDIR export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/") MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3 env: TESTS: ${{ matrix.tests }} + MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/test-logs/webdriver + TESTDIR: ${{ runner.temp }}/test-logs + + - name: Prepare for saving artifact + if: failure() + run: | + ARTIFACT_NAME=logs-$(echo $TESTS | sed 's/[^-a-zA-Z0-9]/_/g') + echo "Artifact name is '$ARTIFACT_NAME'" + echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV + find $TESTDIR -iname "*.socket" -exec rm {} \; + env: + TESTS: ${{ matrix.tests }} + TESTDIR: ${{ runner.temp }}/test-logs + + - name: Save artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ runner.temp }}/test-logs # only exists for webdriver tests services: # https://github.com/bitnami/bitnami-docker-minio/issues/16 diff --git a/app/client/accountMain.ts b/app/client/accountMain.ts deleted file mode 100644 index ed4b2da1..00000000 --- a/app/client/accountMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {AccountPage} from 'app/client/ui/AccountPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(AccountPage, appModel)); diff --git a/app/client/activationMain.ts b/app/client/activationMain.ts deleted file mode 100644 index 33a56d32..00000000 --- a/app/client/activationMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ActivationPage} from 'app/client/ui/ActivationPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(ActivationPage, appModel)); diff --git a/app/client/components/ActionLog.ts b/app/client/components/ActionLog.ts index 46553a53..3c68fb72 100644 --- a/app/client/components/ActionLog.ts +++ b/app/client/components/ActionLog.ts @@ -6,7 +6,6 @@ import * as dispose from 'app/client/lib/dispose'; import dom from 'app/client/lib/dom'; import {timeFormat} from 'app/common/timeFormat'; import * as ko from 'knockout'; -import map = require('lodash/map'); import koArray from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray'; @@ -17,8 +16,8 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {ActionGroup} from 'app/common/ActionGroup'; import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables, LabelDelta} from 'app/common/ActionSummary'; -import {CellDelta} from 'app/common/TabularDiff'; -import {IDomComponent} from 'grainjs'; +import {CellDelta, TabularDiff} from 'app/common/TabularDiff'; +import {DomContents, IDomComponent} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; /** @@ -79,12 +78,13 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { this._displayStack = koArray(); // Computed for the tableId of the table currently being viewed. - if (!this._gristDoc) { - this._selectedTableId = this.autoDispose(ko.computed(() => "")); - } else { - this._selectedTableId = this.autoDispose(ko.computed( - () => this._gristDoc!.viewModel.activeSection().table().tableId())); - } + this._selectedTableId = this.autoDispose(ko.computed(() => { + if (!this._gristDoc || this._gristDoc.viewModel.isDisposed()) { return ""; } + const section = this._gristDoc.viewModel.activeSection(); + if (!section || section.isDisposed()) { return ""; } + const table = section.table(); + return table && !table.isDisposed() ? table.tableId() : ""; + })); } public buildDom() { @@ -141,12 +141,12 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { * @param {string} txt - a textual description of the action * @param {ActionGroupWithState} ag - the full action information we have */ - public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState) { + public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState): HTMLElement { const act = asTabularDiffs(sum); const editDom = dom('div', this._renderTableSchemaChanges(sum, ag), this._renderColumnSchemaChanges(sum, ag), - map(act, (tdiff, table) => { + Object.entries(act).map(([table, tdiff]: [string, TabularDiff]) => { if (tdiff.cells.length === 0) { return dom('div'); } return dom('table.action_log_table', koDom.show(() => this._showForTable(table, ag)), @@ -227,8 +227,9 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { } private _buildLogDom() { - this._loadActionSummaries().catch((error) => gristNotify(t("Action Log failed to load"))); + this._loadActionSummaries().catch(() => gristNotify(t("Action Log failed to load"))); return dom('div.action_log', + {tabIndex: '-1'}, dom('div.preference_item', koForm.checkbox(this._showAllTables, dom.testId('ActionLog_allTables'), @@ -238,7 +239,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { 'Loading...'), koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => { const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : ""; - let desc = ag.desc || ""; + let desc: DomContents = ag.desc || ""; if (ag.actionSummary) { desc = this.renderTabularDiffs(ag.actionSummary, desc, ag); } diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 391eb3ed..43406dc0 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -3,7 +3,6 @@ const _ = require('underscore'); const ko = require('knockout'); const moment = require('moment-timezone'); -const {getSelectionDesc} = require('app/common/DocActions'); const {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil'); const gutil = require('app/common/gutil'); const MANUALSORT = require('app/common/gristTypes').MANUALSORT; @@ -646,20 +645,7 @@ BaseView.prototype.sendPasteActions = function(cutCallback, actions) { // If the cut occurs on an edit restricted cell, there may be no cut action. if (cutAction) { actions.unshift(cutAction); } } - return this.gristDoc.docData.sendActions(actions, - this._getPasteDesc(actions[actions.length - 1], cutAction)); -}; - -/** - * Returns a string which describes a cut/copy action. - */ -BaseView.prototype._getPasteDesc = function(pasteAction, optCutAction) { - if (optCutAction) { - return `Moved ${getSelectionDesc(optCutAction, true)} to ` + - `${getSelectionDesc(pasteAction, true)}.`; - } else { - return `Pasted data to ${getSelectionDesc(pasteAction, true)}.`; - } + return this.gristDoc.docData.sendActions(actions); }; BaseView.prototype.buildDom = function() { diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index c086f7ac..3cfa95b7 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable { } private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) { - if ( - this._isDisabled || - // Don't show tips if surveying is disabled. - // TODO: Move this into a dedicated variable - this is only a short-term fix for hiding - // tips in grist-core. - (!getGristConfig().survey && prompt !== 'rickRow') || - // Or if this tip shouldn't be shown on mobile. - (isNarrowScreen() && !options.showOnMobile) || - // Or if "Don't show tips" was checked in the past. - (this._prefs.get().dontShowTips && !options.forceShow) || - // Or if this tip has been shown and dismissed in the past. - this.hasSeenTip(prompt) - ) { - return; - } + if (!this._shouldQueueTip(prompt, options)) { return; } this._queuedTips.push({prompt, refElement, options}); if (this._queuedTips.length > 1) { @@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable { this._prefs.set({...this._prefs.get(), dontShowTips: true}); this._queuedTips = []; } + + private _shouldQueueTip(prompt: BehavioralPrompt, options: AttachOptions) { + if ( + this._isDisabled || + (isNarrowScreen() && !options.showOnMobile) || + (this._prefs.get().dontShowTips && !options.forceShow) || + this.hasSeenTip(prompt) + ) { + return false; + } + + const {deploymentType} = getGristConfig(); + const {deploymentTypes} = GristBehavioralPrompts[prompt]; + if ( + deploymentTypes !== '*' && + (!deploymentType || !deploymentTypes.includes(deploymentType)) + ) { + return false; + } + + return true; + } } diff --git a/app/client/components/CopySelection.ts b/app/client/components/CopySelection.ts index 87b5692f..d2bce441 100644 --- a/app/client/components/CopySelection.ts +++ b/app/client/components/CopySelection.ts @@ -1,7 +1,6 @@ import type {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import type {CellValue} from 'app/common/DocActions'; -import type {TableData} from 'app/common/TableData'; -import type {UIRowId} from 'app/common/UIRowId'; +import type {TableData, UIRowId} from 'app/common/TableData'; /** * The CopySelection class is an abstraction for a subset of currently selected cells. diff --git a/app/client/components/Cursor.ts b/app/client/components/Cursor.ts index 4b486eaf..1f9f1f03 100644 --- a/app/client/components/Cursor.ts +++ b/app/client/components/Cursor.ts @@ -8,12 +8,12 @@ import BaseView from 'app/client/components/BaseView'; import * as commands from 'app/client/components/commands'; import BaseRowModel from 'app/client/models/BaseRowModel'; import {LazyArrayModel} from 'app/client/models/DataTableModel'; -import type {RowId} from 'app/client/models/rowset'; +import type {UIRowId} from 'app/common/TableData'; import {Disposable} from 'grainjs'; import * as ko from 'knockout'; export interface CursorPos { - rowId?: RowId; + rowId?: UIRowId; rowIndex?: number; fieldIndex?: number; sectionId?: number; @@ -60,7 +60,7 @@ export class Cursor extends Disposable { public rowIndex: ko.Computed; // May be null when there are no rows. public fieldIndex: ko.Observable; - private _rowId: ko.Observable; // May be null when there are no rows. + private _rowId: ko.Observable; // May be null when there are no rows. // The cursor's _rowId property is always fixed across data changes. When isLive is true, // the rowIndex of the cursor is recalculated to match _rowId. When false, they will @@ -68,13 +68,15 @@ export class Cursor extends Disposable { private _isLive: ko.Observable = ko.observable(true); private _sectionId: ko.Computed; + private _properRowId: ko.Computed; + constructor(baseView: BaseView, optCursorPos?: CursorPos) { super(); optCursorPos = optCursorPos || {}; this.viewData = baseView.viewData; this._sectionId = this.autoDispose(ko.computed(() => baseView.viewSection.id())); - this._rowId = ko.observable(optCursorPos.rowId || 0); + this._rowId = ko.observable(optCursorPos.rowId || 0); this.rowIndex = this.autoDispose(ko.computed({ read: () => { if (!this._isLive()) { return this.rowIndex.peek(); } @@ -82,7 +84,7 @@ export class Cursor extends Disposable { return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId)); }, write: (index) => { - const rowIndex = this.viewData.clampIndex(index!); + const rowIndex = index === null ? null : this.viewData.clampIndex(index); this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex)); }, })); @@ -90,8 +92,16 @@ export class Cursor extends Disposable { this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0); this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus)); - // Update the section's activeRowId when the cursor's rowId changes. - this.autoDispose(this._rowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId))); + // RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here + // we will calculate rowId based on rowIndex (so in reverse order), to have a proper value. + this._properRowId = this.autoDispose(ko.computed(() => { + const rowIndex = this.rowIndex(); + const rowId = rowIndex === null ? null : this.viewData.getRowId(rowIndex); + return rowId; + })); + + // Update the section's activeRowId when the cursor's rowIndex is changed. + this.autoDispose(this._properRowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId))); // On dispose, save the current cursor position to the section model. this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); }); @@ -103,7 +113,7 @@ export class Cursor extends Disposable { // Returns the cursor position with rowId, rowIndex, and fieldIndex. public getCursorPos(): CursorPos { return { - rowId: nullAsUndefined(this._rowId()), + rowId: nullAsUndefined(this._properRowId()), rowIndex: nullAsUndefined(this.rowIndex()), fieldIndex: this.fieldIndex(), sectionId: this._sectionId() @@ -117,7 +127,7 @@ export class Cursor extends Disposable { */ public setCursorPos(cursorPos: CursorPos): void { if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) { - this._rowId(cursorPos.rowId); + this.rowIndex(this.viewData.getRowIndex(cursorPos.rowId) ); } else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) { this.rowIndex(cursorPos.rowIndex); } else { diff --git a/app/client/components/DocComm.ts b/app/client/components/DocComm.ts index 1a0dfe37..fc211c24 100644 --- a/app/client/components/DocComm.ts +++ b/app/client/components/DocComm.ts @@ -32,7 +32,6 @@ export class DocComm extends Disposable implements ActiveDocAPI { public addAttachments = this._wrapMethod("addAttachments"); public findColFromValues = this._wrapMethod("findColFromValues"); public getFormulaError = this._wrapMethod("getFormulaError"); - public getAssistance = this._wrapMethod("getAssistance"); public fetchURL = this._wrapMethod("fetchURL"); public autocomplete = this._wrapMethod("autocomplete"); public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc"); diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 0ad54d9c..45dd5d56 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -1233,9 +1233,11 @@ GridView.prototype.buildDom = function() { kd.style("width", ROW_NUMBER_WIDTH + 'px'), dom('div.gridview_data_row_info', kd.toggleClass('linked_dst', () => { + const myRowId = row.id(); + const linkedRowId = self.linkedRowId(); // Must ensure that linkedRowId is not null to avoid drawing on rows whose // row ids are null. - return self.linkedRowId() && self.linkedRowId() === row.getRowId(); + return linkedRowId && linkedRowId === myRowId; }) ), kd.text(function() { return row._index() + 1; }), diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 3f43ffbc..6ef365bd 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -185,6 +185,10 @@ export class GristDoc extends DisposableWithEvents { public readonly currentTheme = this.docPageModel.appModel.currentTheme; + public get docApi() { + return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!); + } + private _actionLog: ActionLog; private _undoStack: UndoStack; private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; @@ -476,6 +480,7 @@ export class GristDoc extends DisposableWithEvents { const viewId = toKo(ko, this.activeViewId)(); if (!isViewDocPage(viewId)) { return null; } const section = this.viewModel.activeSection(); + if (section?.isDisposed()) { return null; } const view = section.viewInstance(); return view; }))); @@ -620,6 +625,11 @@ export class GristDoc extends DisposableWithEvents { public async setCursorPos(cursorPos: CursorPos) { if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) { const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId); + // If the section id is 0, the section doesn't exist (can happen during undo/redo), and should + // be fixed there. For now ignore it, to not create empty sections or views (peeking a view will create it). + if (!desiredSection.id.peek()) { + return; + } // If this is completely unknown section (without a parent), it is probably an import preview. if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) { const view = desiredSection.viewInstance.peek(); @@ -1578,13 +1588,14 @@ async function finalizeAnchor() { } const cssViewContentPane = styled('div', ` + --view-content-page-margin: 12px; flex: auto; display: flex; flex-direction: column; overflow: visible; position: relative; min-width: 240px; - margin: 12px; + margin: var(--view-content-page-margin, 12px); @media ${mediaSmall} { & { margin: 4px; diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts index e6f49367..8b0bcdae 100644 --- a/app/client/components/LayoutTray.ts +++ b/app/client/components/LayoutTray.ts @@ -1164,7 +1164,7 @@ const cssFloaterWrapper = styled('div', ` max-width: 140px; background: ${theme.tableBodyBg}; border: 1px solid ${theme.widgetBorder}; - border-radius: 3px; + border-radius: 4px; -webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px); transform: rotate(5deg) scale(0.8) translate(-10px, 0px); & .mini_section_container { @@ -1174,16 +1174,17 @@ const cssFloaterWrapper = styled('div', ` `); const cssCollapsedTray = styled('div.collapsed_layout', ` - border-radius: 3px; display: flex; flex-direction: column; - border-radius: 3px; - display: flex; overflow: hidden; transition: height 0.2s; position: relative; - margin-bottom: 10px; + margin: calc(-1 * var(--view-content-page-margin, 12px)); + margin-bottom: 0; user-select: none; + background-color: ${theme.pageBg}; + border-bottom: 1px solid ${theme.pagePanelsBorder}; + outline-offset: -1px; &-is-active { outline: 2px dashed ${theme.widgetBorder}; @@ -1197,8 +1198,9 @@ const cssCollapsedTray = styled('div.collapsed_layout', ` const cssRow = styled('div', `display: flex`); const cssLayout = styled(cssRow, ` - padding: 12px; - gap: 10px; + padding: 8px 24px; + column-gap: 16px; + row-gap: 8px; flex-wrap: wrap; position: relative; `); diff --git a/app/client/components/LinkingState.ts b/app/client/components/LinkingState.ts index 1d92280e..d978f6f3 100644 --- a/app/client/components/LinkingState.ts +++ b/app/client/components/LinkingState.ts @@ -4,7 +4,7 @@ import {DocModel} from 'app/client/models/DocModel'; import {ColumnRec} from "app/client/models/entities/ColumnRec"; import {TableRec} from "app/client/models/entities/TableRec"; import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; -import {RowId} from "app/client/models/rowset"; +import {UIRowId} from "app/common/TableData"; import {LinkConfig} from "app/client/ui/selectBy"; import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI"; import {isList, isListType, isRefListType} from "app/common/gristTypes"; @@ -62,7 +62,7 @@ export type FilterColValues = Pick; */ export class LinkingState extends Disposable { // If linking affects target section's cursor, this will be a computed for the cursor rowId. - public readonly cursorPos?: ko.Computed; + public readonly cursorPos?: ko.Computed; // If linking affects filtering, this is a computed for the current filtering state, as a // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId() @@ -136,7 +136,7 @@ export class LinkingState extends Disposable { const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity; if (srcValueFunc) { this.cursorPos = this.autoDispose(ko.computed(() => - srcValueFunc(srcSection.activeRowId()) as RowId + srcValueFunc(srcSection.activeRowId()) as UIRowId )); } @@ -172,7 +172,7 @@ export class LinkingState extends Disposable { // Value for this.filterColValues filtering based on a single column private _simpleFilter( - colId: string, operation: QueryOperation, valuesFunc: (rowId: RowId|null) => any[] + colId: string, operation: QueryOperation, valuesFunc: (rowId: UIRowId|null) => any[] ): ko.Computed { return this.autoDispose(ko.computed(() => { const srcRowId = this._srcSection.activeRowId(); @@ -226,7 +226,7 @@ export class LinkingState extends Disposable { if (!srcCellObs) { return null; } - return (rowId: RowId | null) => { + return (rowId: UIRowId | null) => { srcRowModel.assign(rowId); if (rowId === 'new') { return 'new'; diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts index 77bbb9bb..fc57b8bd 100644 --- a/app/client/components/buildViewSectionDom.ts +++ b/app/client/components/buildViewSectionDom.ts @@ -1,6 +1,7 @@ import BaseView from 'app/client/components/BaseView'; import {GristDoc} from 'app/client/components/GristDoc'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {makeT} from 'app/client/lib/localization'; import {filterBar} from 'app/client/ui/FilterBar'; import {cssIcon} from 'app/client/ui/RightPanelStyles'; import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; @@ -13,6 +14,7 @@ import {menu} from 'app/client/ui2018/menus'; import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs'; import {defaultMenuOptions} from 'popweasel'; +const t = makeT('ViewSection'); export function buildCollapsedSectionDom(options: { gristDoc: GristDoc, @@ -69,8 +71,13 @@ export function buildViewSectionDom(options: { // Creating normal section dom const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); + const selectedBySectionTitle = Computed.create(null, (use) => { + if (!use(vs.linkSrcSectionRef)) { return null; } + return use(use(vs.linkSrcSection).titleDef); + }); return dom('div.view_leaf.viewsection_content.flexvbox.flexauto', testId(`viewlayout-section-${sectionRowId}`), + dom.autoDispose(selectedBySectionTitle), !options.isResizing ? dom.autoDispose(isResizing) : null, cssViewLeaf.cls(''), cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)), @@ -96,10 +103,14 @@ export function buildViewSectionDom(options: { dom('div.view_data_pane_container.flexvbox', cssResizing.cls('', isResizing), dom.maybe(viewInstance.disableEditing, () => - dom('div.disable_viewpane.flexvbox', 'No data') + dom('div.disable_viewpane.flexvbox', + dom.domComputed(selectedBySectionTitle, (title) => title + ? t(`No row selected in {{title}}`, {title}) + : t('No data')), + ) ), dom.maybe(viewInstance.isTruncated, () => - dom('div.viewsection_truncated', 'Not all data is shown') + dom('div.viewsection_truncated', t('Not all data is shown')) ), dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), viewInstance.viewPane @@ -195,7 +206,6 @@ const cssResizing = styled('div', ` const cssMiniSection = styled('div.mini_section_container', ` --icon-color: ${colors.lightGreen}; display: flex; - background: ${theme.mainPanelBg}; align-items: center; padding-right: 8px; `); diff --git a/app/client/lib/imports.d.ts b/app/client/lib/imports.d.ts index 39e3438a..4691bef6 100644 --- a/app/client/lib/imports.d.ts +++ b/app/client/lib/imports.d.ts @@ -1,4 +1,7 @@ +import * as AccountPageModule from 'app/client/ui/AccountPage'; +import * as ActivationPageModule from 'app/client/ui/ActivationPage'; import * as BillingPageModule from 'app/client/ui/BillingPage'; +import * as SupportGristPageModule from 'app/client/ui/SupportGristPage'; import * as GristDocModule from 'app/client/components/GristDoc'; import * as ViewPane from 'app/client/components/ViewPane'; import * as UserManagerModule from 'app/client/ui/UserManager'; @@ -9,7 +12,10 @@ import * as plotly from 'plotly.js'; export type PlotlyType = typeof plotly; export type MomentTimezone = typeof momentTimezone; +export function loadAccountPage(): Promise; +export function loadActivationPage(): Promise; export function loadBillingPage(): Promise; +export function loadSupportGristPage(): Promise; export function loadGristDoc(): Promise; export function loadMomentTimezone(): Promise; export function loadPlotly(): Promise; diff --git a/app/client/lib/imports.js b/app/client/lib/imports.js index a42a4662..b5d6f428 100644 --- a/app/client/lib/imports.js +++ b/app/client/lib/imports.js @@ -6,7 +6,10 @@ * */ +exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */); +exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */); exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */); +exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */); exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */); // When importing this way, the module is under the "default" member, not sure why (maybe // esbuild-loader's doing). diff --git a/app/client/lib/sortUtil.ts b/app/client/lib/sortUtil.ts index 693c29dc..5618332c 100644 --- a/app/client/lib/sortUtil.ts +++ b/app/client/lib/sortUtil.ts @@ -5,6 +5,7 @@ import * as rowset from 'app/client/models/rowset'; import { MANUALSORT } from 'app/common/gristTypes'; import { SortFunc } from 'app/common/SortFunc'; import { Sort } from 'app/common/SortSpec'; +import { UIRowId } from 'app/common/TableData'; import * as ko from 'knockout'; import range = require('lodash/range'); @@ -44,7 +45,7 @@ export async function updatePositions(gristDoc: GristDoc, section: ViewSectionRe sortFunc.updateSpec(section.activeDisplaySortSpec.peek()); const sortedRows = rowset.SortedRowSet.create( null, - (a: rowset.RowId, b: rowset.RowId) => sortFunc.compare(a as number, b as number), + (a: UIRowId, b: UIRowId) => sortFunc.compare(a as number, b as number), tableModel.tableData ); sortedRows.subscribeTo(tableModel); diff --git a/app/client/lib/tableUtil.ts b/app/client/lib/tableUtil.ts index 64d15395..5db8153a 100644 --- a/app/client/lib/tableUtil.ts +++ b/app/client/lib/tableUtil.ts @@ -8,7 +8,6 @@ import {safeJsonParse} from 'app/common/gutil'; import type {TableData} from 'app/common/TableData'; import {tsvEncode} from 'app/common/tsvFormat'; import {dom} from 'grainjs'; -import map = require('lodash/map'); import zipObject = require('lodash/zipObject'); const G = getBrowserGlobals('document', 'DOMParser'); @@ -134,8 +133,11 @@ export function parsePasteHtml(data: string): RichPasteObject[][] { } // Helper function to add css style properties to an html tag -function _styleAttr(style: object) { - return map(style, (value, prop) => `${prop}: ${value};`).join(' '); +function _styleAttr(style: object|undefined) { + if (typeof style !== 'object') { + return ''; + } + return Object.entries(style).map(([prop, value]) => `${prop}: ${value};`).join(' '); } /** diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 0b5d24d2..bd4c20f3 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState'; import {Notifier} from 'app/client/models/NotifyModel'; import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes'; import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; +import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {OrgUsageSummary} from 'app/common/DocUsage'; import {Features, isLegacyPlan, Product} from 'app/common/Features'; @@ -31,7 +32,15 @@ const t = makeT('AppModel'); // Reexported for convenience. export {reportError} from 'app/client/models/errors'; -export type PageType = "doc" | "home" | "billing" | "welcome"; +export type PageType = + | "doc" + | "home" + | "billing" + | "welcome" + | "account" + | "support-grist" + | "activation"; + const G = getBrowserGlobals('document', 'window'); // TopAppModel is the part of the app model that persists across org and user switches. @@ -107,6 +116,8 @@ export interface AppModel { behavioralPromptsManager: BehavioralPromptsManager; + supportGristNudge: SupportGristNudge; + refreshOrgUsage(): Promise; showUpgradeModal(): void; showNewSiteModal(): void; @@ -253,11 +264,33 @@ export class AppModelImpl extends Disposable implements AppModel { // Get the current PageType from the URL. public readonly pageType: Observable = Computed.create(this, urlState().state, - (use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home")))); + (_use, state) => { + if (state.doc) { + return 'doc'; + } else if (state.billing) { + return 'billing'; + } else if (state.welcome) { + return 'welcome'; + } else if (state.account) { + return 'account'; + } else if (state.supportGrist) { + return 'support-grist'; + } else if (state.activation) { + return 'activation'; + } else { + return 'home'; + } + }); public readonly needsOrg: Observable = Computed.create( this, urlState().state, (use, state) => { - return !(Boolean(state.welcome) || state.billing === 'scheduled'); + return !( + Boolean(state.welcome) || + state.billing === 'scheduled' || + Boolean(state.account) || + Boolean(state.activation) || + Boolean(state.supportGrist) + ); }); public readonly notifier = this.topAppModel.notifier; @@ -265,6 +298,8 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly behavioralPromptsManager: BehavioralPromptsManager = BehavioralPromptsManager.create(this, this); + public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this); + constructor( public readonly topAppModel: TopAppModel, public readonly currentUser: FullUser|null, diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index 9efd1d18..e3e30510 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -29,8 +29,7 @@ import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable'; import {canEdit} from 'app/common/roles'; import {RowFilterFunc} from 'app/common/RowFilterFunc'; import {schema, SchemaTypes} from 'app/common/schema'; -import {UIRowId} from 'app/common/UIRowId'; - +import {UIRowId} from 'app/common/TableData'; import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec'; import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec'; import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec'; @@ -318,7 +317,7 @@ export class DocModel { */ function createTablesArray( tablesModel: MetaTableModel, - filterFunc: RowFilterFunc = (_row) => true + filterFunc: RowFilterFunc = (_row) => true ) { const rowSource = new rowset.FilteredRowSource(filterFunc); rowSource.subscribeTo(tablesModel); diff --git a/app/client/models/NotifyModel.ts b/app/client/models/NotifyModel.ts index 81f8b56b..40bcfb66 100644 --- a/app/client/models/NotifyModel.ts +++ b/app/client/models/NotifyModel.ts @@ -43,8 +43,8 @@ export interface CustomAction { label: string, action: () => void } */ export type MessageType = string | (() => DomElementArg); // Identifies supported actions. These are implemented in NotifyUI. -export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem' | 'ask-for-help' | CustomAction; - +export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem' + | 'ask-for-help' | 'manage' | CustomAction; export interface INotifyOptions { message: MessageType; // A string, or a function that builds dom. timestamp?: number; diff --git a/app/client/models/QuerySet.ts b/app/client/models/QuerySet.ts index 7f5ed5ac..ee1b04f4 100644 --- a/app/client/models/QuerySet.ts +++ b/app/client/models/QuerySet.ts @@ -28,7 +28,7 @@ */ import DataTableModel from 'app/client/models/DataTableModel'; import {DocModel} from 'app/client/models/DocModel'; -import {BaseFilteredRowSource, RowId, RowList, RowSource} from 'app/client/models/rowset'; +import {BaseFilteredRowSource, RowList, RowSource} from 'app/client/models/rowset'; import {TableData} from 'app/client/models/TableData'; import {ActiveDocAPI, ClientQuery, QueryOperation} from 'app/common/ActiveDocAPI'; import {CellValue, TableDataAction} from 'app/common/DocActions'; @@ -37,7 +37,7 @@ import {isList} from "app/common/gristTypes"; import {nativeCompare} from 'app/common/gutil'; import {IRefCountSub, RefCountMap} from 'app/common/RefCountMap'; import {RowFilterFunc} from 'app/common/RowFilterFunc'; -import {TableData as BaseTableData} from 'app/common/TableData'; +import {TableData as BaseTableData, UIRowId} from 'app/common/TableData'; import {tbind} from 'app/common/tbind'; import {decodeObject} from "app/plugin/objtypes"; import {Disposable, Holder, IDisposableOwnerT} from 'grainjs'; @@ -303,7 +303,7 @@ export class TableQuerySets { /** * Returns a filtering function which tells whether a row matches the given query. */ -export function getFilterFunc(docData: DocData, query: ClientQuery): RowFilterFunc { +export function getFilterFunc(docData: DocData, query: ClientQuery): RowFilterFunc { // NOTE we rely without checking on tableId and colIds being valid. const tableData: BaseTableData = docData.getTable(query.tableId)!; const colFuncs = Object.keys(query.filters).sort().map( @@ -312,22 +312,22 @@ export function getFilterFunc(docData: DocData, query: ClientQuery): RowFilterFu const values = new Set(query.filters[colId]); switch (query.operations[colId]) { case "intersects": - return (rowId: RowId) => { + return (rowId: UIRowId) => { const value = getter(rowId) as CellValue; return isList(value) && (decodeObject(value) as unknown[]).some(v => values.has(v)); }; case "empty": - return (rowId: RowId) => { + return (rowId: UIRowId) => { const value = getter(rowId); // `isList(value) && value.length === 1` means `value == ['L']` i.e. an empty list return !value || isList(value) && value.length === 1; }; case "in": - return (rowId: RowId) => values.has(getter(rowId)); + return (rowId: UIRowId) => values.has(getter(rowId)); } }); - return (rowId: RowId) => colFuncs.every(f => f(rowId)); + return (rowId: UIRowId) => colFuncs.every(f => f(rowId)); } /** diff --git a/app/client/models/SectionFilter.ts b/app/client/models/SectionFilter.ts index 713fdf00..a858c319 100644 --- a/app/client/models/SectionFilter.ts +++ b/app/client/models/SectionFilter.ts @@ -1,9 +1,9 @@ import {ColumnFilter} from 'app/client/models/ColumnFilter'; import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; -import {RowId} from 'app/client/models/rowset'; import {TableData} from 'app/client/models/TableData'; import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc'; import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc'; +import {UIRowId} from 'app/common/TableData'; import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from 'grainjs'; export type {ColumnFilterFunc}; @@ -26,10 +26,10 @@ type ColFilterCB = (fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilt * results in their being displayed (obviating the need to maintain their rowId explicitly). */ export class SectionFilter extends Disposable { - public readonly sectionFilterFunc: Observable>; + public readonly sectionFilterFunc: Observable>; private _openFilterOverride: Observable = Observable.create(this, null); - private _tempRows: MutableObsArray = obsArray(); + private _tempRows: MutableObsArray = obsArray(); constructor(public viewSection: ViewSectionRec, private _tableData: TableData) { super(); @@ -89,8 +89,8 @@ export class SectionFilter extends Disposable { return this._addRowsToFilter(this._buildPlainFilterFunc(getFilterFunc, use), this._tempRows.get()); } - private _addRowsToFilter(filterFunc: RowFilterFunc, rows: RowId[]) { - return (rowId: RowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId); + private _addRowsToFilter(filterFunc: RowFilterFunc, rows: UIRowId[]) { + return (rowId: UIRowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId); } /** @@ -98,9 +98,9 @@ export class SectionFilter extends Disposable { * columns. You can use `getFilterFunc(column, colFilter)` to customize the filter func for each * column. It calls `getFilterFunc` right away. */ - private _buildPlainFilterFunc(getFilterFunc: ColFilterCB, use: UseCB): RowFilterFunc { + private _buildPlainFilterFunc(getFilterFunc: ColFilterCB, use: UseCB): RowFilterFunc { const filters = use(this.viewSection.filters); - const funcs: Array | null> = filters.map(({filter, fieldOrColumn}) => { + const funcs: Array | null> = filters.map(({filter, fieldOrColumn}) => { const colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type)); const filterFunc = getFilterFunc(fieldOrColumn, colFilter); @@ -108,9 +108,9 @@ export class SectionFilter extends Disposable { if (!filterFunc || !getter) { return null; } - return buildRowFilter(getter as RowValueFunc, filterFunc); + return buildRowFilter(getter as RowValueFunc, filterFunc); }).filter(f => f !== null); // Filter out columns that don't have a filter - return (rowId: RowId) => funcs.every(f => Boolean(f && f(rowId))); + return (rowId: UIRowId) => funcs.every(f => Boolean(f && f(rowId))); } } diff --git a/app/client/models/TelemetryModel.ts b/app/client/models/TelemetryModel.ts new file mode 100644 index 00000000..5d7af638 --- /dev/null +++ b/app/client/models/TelemetryModel.ts @@ -0,0 +1,32 @@ +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {TelemetryPrefs} from 'app/common/Install'; +import {InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources} from 'app/common/InstallAPI'; +import {bundleChanges, Disposable, Observable} from 'grainjs'; + +export interface TelemetryModel { + /** Telemetry preferences (e.g. the current telemetry level). */ + readonly prefs: Observable; + fetchTelemetryPrefs(): Promise; + updateTelemetryPrefs(prefs: Partial): Promise; +} + +export class TelemetryModelImpl extends Disposable implements TelemetryModel { + public readonly prefs: Observable = Observable.create(this, null); + private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); + + constructor(_appModel: AppModel) { + super(); + } + + public async fetchTelemetryPrefs(): Promise { + const prefs = await this._installAPI.getInstallPrefs(); + bundleChanges(() => { + this.prefs.set(prefs.telemetry); + }); + } + + public async updateTelemetryPrefs(prefs: Partial): Promise { + await this._installAPI.updateInstallPrefs({telemetry: prefs}); + await this.fetchTelemetryPrefs(); + } +} diff --git a/app/client/models/entities/ViewRec.ts b/app/client/models/entities/ViewRec.ts index 29f5cff4..a1fcf808 100644 --- a/app/client/models/entities/ViewRec.ts +++ b/app/client/models/entities/ViewRec.ts @@ -62,8 +62,9 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { // Default to the first leaf from layoutSpec (which corresponds to the top-left section), or // fall back to the first item in the list if anything goes wrong (previous behavior). const firstLeaf = getFirstLeaf(this.layoutSpecObj.peek()); - return visible.find(s => s.getRowId() === firstLeaf) ? firstLeaf as number : - (visible[0]?.getRowId() || 0); + const result = visible.find(s => s.id() === firstLeaf) ? firstLeaf as number : + (visible[0]?.id() || 0); + return result; }); this.activeSection = refRecord(docModel.viewSections, this.activeSectionId); diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index cfe0bca4..47a2bf84 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -15,13 +15,13 @@ import { ViewRec } from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; -import {RowId} from 'app/client/models/rowset'; import {LinkConfig} from 'app/client/ui/selectBy'; import {getWidgetTypes} from 'app/client/ui/widgetTypes'; import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import {UserAction} from 'app/common/DocActions'; import {arrayRepeat} from 'app/common/gutil'; import {Sort} from 'app/common/SortSpec'; +import {UIRowId} from 'app/common/TableData'; import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {BEHAVIOR} from 'app/client/models/entities/ColumnRec'; @@ -120,19 +120,30 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO hasFocus: ko.Computed; - activeLinkSrcSectionRef: modelUtil.CustomComputed; - activeLinkSrcColRef: modelUtil.CustomComputed; - activeLinkTargetColRef: modelUtil.CustomComputed; - - // Whether current linking state is as saved. It may be different during editing. - isActiveLinkSaved: ko.Computed; - // Section-linking affects table if linkSrcSection is set. The controller value of the // link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when // srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to // the controller value. Otherwise, the controller value determines the rowId of the cursor. + + /** + * Section selected in the `Select By` dropdown. Used for filtering this section. + */ linkSrcSection: ko.Computed; + /** + * Column selected in the `Select By` dropdown in the remote section. It points to a column in remote section + * that contains a reference to this table (or common table - because we can be linked by having the same reference + * to some other section). + * Used for filtering this section. Can be empty as user can just link by section. + * Watch out, it is not cleared, so it is only valid when we have linkSrcSection. + * In UI it is shown as Target Section (dot) Target Column. + */ linkSrcCol: ko.Computed; + /** + * In case we have multiple reference columns, that are shown as + * Target Section -> My Column or + * Target Section . Target Column -> My Column + * store the reference to the column (my column) to use. + */ linkTargetCol: ko.Computed; // Linking state maintains .filterFunc and .cursorPos observables which we use for @@ -142,7 +153,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO linkingFilter: ko.Computed; - activeRowId: ko.Observable; // May be null when there are no rows. + activeRowId: ko.Observable; // May be null when there are no rows. // If the view instance for section is instantiated, it will be accessible here. viewInstance: ko.Observable; @@ -594,30 +605,20 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): write: (val) => { this.view().activeSectionId(val ? this.id() : 0); } }); - this.activeLinkSrcSectionRef = modelUtil.customValue(this.linkSrcSectionRef); - this.activeLinkSrcColRef = modelUtil.customValue(this.linkSrcColRef); - this.activeLinkTargetColRef = modelUtil.customValue(this.linkTargetColRef); - - // Whether current linking state is as saved. It may be different during editing. - this.isActiveLinkSaved = this.autoDispose(ko.pureComputed(() => - this.activeLinkSrcSectionRef.isSaved() && - this.activeLinkSrcColRef.isSaved() && - this.activeLinkTargetColRef.isSaved())); - // Section-linking affects this table if linkSrcSection is set. The controller value of the // link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when // srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to // the controller value. Otherwise, the controller value determines the rowId of the cursor. - this.linkSrcSection = refRecord(docModel.viewSections, this.activeLinkSrcSectionRef); - this.linkSrcCol = refRecord(docModel.columns, this.activeLinkSrcColRef); - this.linkTargetCol = refRecord(docModel.columns, this.activeLinkTargetColRef); + this.linkSrcSection = refRecord(docModel.viewSections, this.linkSrcSectionRef); + this.linkSrcCol = refRecord(docModel.columns, this.linkSrcColRef); + this.linkTargetCol = refRecord(docModel.columns, this.linkTargetColRef); - this.activeRowId = ko.observable(null); + this.activeRowId = ko.observable(null); this._linkingState = Holder.create(this); this.linkingState = this.autoDispose(ko.pureComputed(() => { - if (!this.activeLinkSrcSectionRef()) { - // This view section isn't selecting by anything. + if (!this.linkSrcSectionRef()) { + // This view section isn't selected by anything. return null; } try { diff --git a/app/client/models/errors.ts b/app/client/models/errors.ts index 219f047a..ad39f368 100644 --- a/app/client/models/errors.ts +++ b/app/client/models/errors.ts @@ -121,7 +121,7 @@ export function reportError(err: Error|string, ev?: ErrorEvent): void { const options: Partial = { title: "Reached plan limit", key: `limit:${details.limit.quantity || message}`, - actions: ['upgrade'], + actions: details.tips?.some(t => t.action === 'manage') ? ['manage'] : ['upgrade'], }; if (details.tips && details.tips.some(tip => tip.action === 'add-members')) { // When adding members would fix a problem, give more specific advice. diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 0295b5f8..4e1523fe 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -156,7 +156,8 @@ export class UrlStateImpl { */ public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || - newState.account || newState.billing || newState.activation || newState.welcome) ? + newState.account || newState.billing || newState.activation || newState.welcome || + newState.supportGrist) ? (prevState.org ? {org: prevState.org} : {}) : prevState; return {...keepState, ...newState}; @@ -186,8 +187,11 @@ export class UrlStateImpl { // Reload when moving to/from the Grist sign-up page. const signupReload = [prevState.login, newState.login].includes('signup') && prevState.login !== newState.login; - return Boolean(orgReload || accountReload || billingReload || activationReload - || gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload); + // Reload when moving to/from the support Grist page. + const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist); + return Boolean(orgReload || accountReload || billingReload || activationReload || + gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload || + supportGristReload); } /** diff --git a/app/client/models/rowset.ts b/app/client/models/rowset.ts index 0f4ab32c..bbae95ab 100644 --- a/app/client/models/rowset.ts +++ b/app/client/models/rowset.ts @@ -24,7 +24,7 @@ import koArray, {KoArray} from 'app/client/lib/koArray'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {CompareFunc, sortedIndex} from 'app/common/gutil'; -import {SkippableRows} from 'app/common/TableData'; +import {SkippableRows, UIRowId} from 'app/common/TableData'; import {RowFilterFunc} from "app/common/RowFilterFunc"; import {Observable} from 'grainjs'; @@ -36,8 +36,7 @@ export const ALL: unique symbol = Symbol("ALL"); export type ChangeType = 'add' | 'remove' | 'update'; export type ChangeMethod = 'onAddRows' | 'onRemoveRows' | 'onUpdateRows'; -export type RowId = number | 'new'; -export type RowList = Iterable; +export type RowList = Iterable; export type RowsChanged = RowList | typeof ALL; // ---------------------------------------------------------------------- @@ -132,7 +131,7 @@ export class RowListener extends DisposableWithEvents { * A trivial RowSource returning a fixed list of rows. */ export abstract class ArrayRowSource extends RowSource { - constructor(private _rows: RowId[]) { super(); } + constructor(private _rows: UIRowId[]) { super(); } public getAllRows(): RowList { return this._rows; } public getNumRows(): number { return this._rows.length; } } @@ -146,11 +145,11 @@ export abstract class ArrayRowSource extends RowSource { * TODO: This class is not used anywhere at the moment, and is a candidate for removal. */ export class MappedRowSource extends RowSource { - private _mapperFunc: (row: RowId) => RowId; + private _mapperFunc: (row: UIRowId) => UIRowId; constructor( public parentRowSource: RowSource, - mapperFunc: (row: RowId) => RowId, + mapperFunc: (row: UIRowId) => UIRowId, ) { super(); @@ -182,7 +181,7 @@ export class ExtendedRowSource extends RowSource { constructor( public parentRowSource: RowSource, - public extras: RowId[] + public extras: UIRowId[] ) { super(); @@ -209,9 +208,9 @@ export class ExtendedRowSource extends RowSource { // ---------------------------------------------------------------------- interface FilterRowChanges { - adds?: RowId[]; - updates?: RowId[]; - removes?: RowId[]; + adds?: UIRowId[]; + updates?: UIRowId[]; + removes?: UIRowId[]; } /** @@ -219,9 +218,9 @@ interface FilterRowChanges { * does not maintain excluded rows, and does not allow changes to filterFunc. */ export class BaseFilteredRowSource extends RowListener implements RowSource { - protected _matchingRows: Set = new Set(); // Set of rows matching the filter. + protected _matchingRows: Set = new Set(); // Set of rows matching the filter. - constructor(protected _filterFunc: RowFilterFunc) { + constructor(protected _filterFunc: RowFilterFunc) { super(); } @@ -309,8 +308,8 @@ export class BaseFilteredRowSource extends RowListener implements RowSource { } // These are implemented by FilteredRowSource, but the base class doesn't need to do anything. - protected _addExcludedRow(row: RowId): void { /* no-op */ } - protected _deleteExcludedRow(row: RowId): boolean { return true; } + protected _addExcludedRow(row: UIRowId): void { /* no-op */ } + protected _deleteExcludedRow(row: UIRowId): boolean { return true; } } /** @@ -321,13 +320,13 @@ export class BaseFilteredRowSource extends RowListener implements RowSource { * FilteredRowSource is also a RowListener, so to subscribe to a rowSource, use `subscribeTo()`. */ export class FilteredRowSource extends BaseFilteredRowSource { - private _excludedRows: Set = new Set(); // Set of rows NOT matching the filter. + private _excludedRows: Set = new Set(); // Set of rows NOT matching the filter. /** * Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate * that rows stopped or started matching the new filter. */ - public updateFilter(filterFunc: RowFilterFunc) { + public updateFilter(filterFunc: RowFilterFunc) { this._filterFunc = filterFunc; const changes: FilterRowChanges = {}; // After the first call, _excludedRows may have additional rows, but there is no harm in it, @@ -356,8 +355,8 @@ export class FilteredRowSource extends BaseFilteredRowSource { return this._excludedRows.values(); } - protected _addExcludedRow(row: RowId): void { this._excludedRows.add(row); } - protected _deleteExcludedRow(row: RowId): boolean { return this._excludedRows.delete(row); } + protected _addExcludedRow(row: UIRowId): void { this._excludedRows.add(row); } + protected _deleteExcludedRow(row: UIRowId): boolean { return this._excludedRows.delete(row); } } // ---------------------------------------------------------------------- @@ -368,7 +367,7 @@ export class FilteredRowSource extends BaseFilteredRowSource { * Private helper object that maintains a set of rows for a particular group. */ class RowGroupHelper extends RowSource { - private _rows: Set = new Set(); + private _rows: Set = new Set(); constructor(public readonly groupValue: Value) { super(); } @@ -411,12 +410,12 @@ function _addToMapOfArrays(map: Map, key: K, r: V): void { */ export class RowGrouping extends RowListener { // Maps row identifiers to groupValues. - private _rowsToValues: Map = new Map(); + private _rowsToValues: Map = new Map(); // Maps group values to RowGroupHelpers private _valuesToGroups: Map> = new Map(); - constructor(private _groupFunc: (row: RowId) => Value) { + constructor(private _groupFunc: (row: UIRowId) => Value) { super(); // On disposal, dispose all RowGroupHelpers that we maintain. @@ -538,15 +537,15 @@ export class RowGrouping extends RowListener { * SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to. */ export class SortedRowSet extends RowListener { - private _allRows: Set = new Set(); + private _allRows: Set = new Set(); private _isPaused: boolean = false; - private _koArray: KoArray; + private _koArray: KoArray; private _keepFunc?: (rowId: number|'new') => boolean; - constructor(private _compareFunc: CompareFunc, + constructor(private _compareFunc: CompareFunc, private _skippableRows?: SkippableRows) { super(); - this._koArray = this.autoDispose(koArray()); + this._koArray = this.autoDispose(koArray()); this._keepFunc = _skippableRows?.getKeepFunc(); } @@ -572,7 +571,7 @@ export class SortedRowSet extends RowListener { /** * Re-sorts the array according to the new compareFunc. */ - public updateSort(compareFunc: CompareFunc): void { + public updateSort(compareFunc: CompareFunc): void { this._compareFunc = compareFunc; if (!this._isPaused) { this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc)); @@ -650,7 +649,7 @@ export class SortedRowSet extends RowListener { // Filter out any rows that should be skipped. This is a no-op if no _keepFunc was found. // All rows that sort within nContext rows of something meant to be kept are also kept. - private _keep(rows: RowId[], nContext: number = 2) { + private _keep(rows: UIRowId[], nContext: number = 2) { // Nothing to be done if there's no _keepFunc. if (!this._keepFunc) { return rows; } @@ -706,7 +705,7 @@ export class SortedRowSet extends RowListener { } } -type RowTester = (rowId: RowId) => boolean; +type RowTester = (rowId: UIRowId) => boolean; /** * RowWatcher is a RowListener that maintains an observable function that checks whether a row * is in the connected RowSource. @@ -718,7 +717,7 @@ export class RowWatcher extends RowListener { public rowFilter: Observable = Observable.create(this, () => false); // We count the number of times the row is added or removed from the source. // In most cases row is added and removed only once. - private _rowCounter: Map = new Map(); + private _rowCounter: Map = new Map(); public clear() { this._rowCounter.clear(); diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index ef45f649..70889a8e 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -65,7 +65,7 @@ export class AccountWidget extends Disposable { t("Toggle Mobile Mode"), cssCheckmark('Tick', dom.show(viewport.viewportEnabled)), testId('usermenu-toggle-mobile'), - ); + ); if (!user) { return [ @@ -100,6 +100,7 @@ export class AccountWidget extends Disposable { this._maybeBuildBillingPageMenuItem(), this._maybeBuildActivationPageMenuItem(), + this._maybeBuildSupportGristPageMenuItem(), mobileModeToggle, @@ -155,10 +156,10 @@ export class AccountWidget extends Disposable { // For links, disabling with just a class is hard; easier to just not make it a link. // TODO weasel menus should support disabling menuItemLink. (isBillingManager ? - menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : - menuItem(() => null, 'Billing Account', dom.cls('disabled', true)) + menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) : + menuItem(() => null, t('Billing Account'), dom.cls('disabled', true)) ) : - menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'); + menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan')); } private _maybeBuildActivationPageMenuItem() { @@ -167,7 +168,21 @@ export class AccountWidget extends Disposable { return null; } - return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); + return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'})); + } + + private _maybeBuildSupportGristPageMenuItem() { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'core') { + return null; + } + + return menuItemLink( + t('Support Grist'), + cssHeartIcon('πŸ’›'), + urlState().setLinkUrl({supportGrist: 'support-grist'}), + testId('usermenu-support-grist'), + ); } } @@ -183,6 +198,10 @@ export const cssUserIcon = styled('div', ` cursor: pointer; `); +const cssHeartIcon = styled('span', ` + margin-left: 8px; +`); + const cssUserInfo = styled('div', ` padding: 12px 24px 12px 16px; min-width: 200px; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 71485ca4..a6f30e0c 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,7 +1,7 @@ import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; import {ViewAsBanner} from 'app/client/components/ViewAsBanner'; import {domAsync} from 'app/client/lib/domAsync'; -import {loadBillingPage} from 'app/client/lib/imports'; +import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports'; import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs'; import {AppModel, TopAppModel} from 'app/client/models/AppModel'; import {DocPageModelImpl} from 'app/client/models/DocPageModel'; @@ -75,6 +75,12 @@ function createMainPage(appModel: AppModel, appObj: App) { return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel))); } else if (pageType === 'welcome') { return dom.create(WelcomePage, appModel); + } else if (pageType === 'account') { + return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel))); + } else if (pageType === 'support-grist') { + return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel))); + } else if (pageType === 'activation') { + return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel))); } else { return dom.create(pagePanelsDoc, appModel, appObj); } diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index c9fa0965..d1f00b1f 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -10,7 +10,7 @@ import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter'; import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel'; import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; -import {RowId, RowSource} from 'app/client/models/rowset'; +import {RowSource} from 'app/client/models/rowset'; import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter'; import {TableData} from 'app/client/models/TableData'; import {cssInput} from 'app/client/ui/cssInput'; @@ -46,6 +46,7 @@ import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils'; import {FocusLayer} from 'app/client/lib/FocusLayer'; import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions'; import {createFormatter} from 'app/common/ValueFormatter'; +import {UIRowId} from 'app/common/TableData'; const t = makeT('ColumnFilterMenu'); @@ -470,9 +471,13 @@ function numericInput(obs: Observable, editMode = false; inputEl.value = formatValue(obs.get()); - // Make sure focus is trapped on input during calendar view, so that uses can still use keyboard - // to navigate relative date options just after picking a date on the calendar. - setTimeout(() => opts.isSelected.get() && inputEl.focus()); + setTimeout(() => { + // Make sure focus is trapped on input during calendar view, so that uses can still use keyboard + // to navigate relative date options just after picking a date on the calendar. + if (opts.viewTypeObs.get() === 'calendarView' && opts.isSelected.get()) { + inputEl.focus(); + } + }); }; const onInput = debounce(() => { if (isRelativeBound(obs.get())) { return; } @@ -829,7 +834,7 @@ interface ICountOptions { * the possible choices as keys). * Note that this logic is replicated in BaseView.prototype.filterByThisCellValue. */ -function addCountsToMap(valueMap: Map, rowIds: RowId[], +function addCountsToMap(valueMap: Map, rowIds: UIRowId[], { keyMapFunc = identity, labelMapFunc = identity, columnType, areHiddenRows = false, valueMapFunc }: ICountOptions) { diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index c4a3eabc..6a716765 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { testId('doclist') ), dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)), - () => upgradeButton.showUpgradeCard(css.upgradeCard.cls(''))), + () => { + // TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to + // manage card popups will be needed if more are added later. + return [ + upgradeButton.showUpgradeCard(css.upgradeCard.cls('')), + home.app.supportGristNudge.showCard(), + ]; + }), )); } diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 97f68a4f..67905417 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -1,4 +1,5 @@ import {GristDoc} from 'app/client/components/GristDoc'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; @@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip'; export class DocTutorial extends FloatingPopup { private _appModel = this._gristDoc.docPageModel.appModel; private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); + private _currentFork = this._currentDoc?.forks?.[0]; private _docComm = this._gristDoc.docComm; private _docData = this._gristDoc.docData; private _docId = this._gristDoc.docId(); private _slides: Observable = Observable.create(this, null); - private _currentSlideIndex = Observable.create(this, - this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); + private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0); private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, { @@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup { private async _saveCurrentSlidePosition() { const currentOptions = this._currentDoc?.options ?? {}; + const currentSlideIndex = this._currentSlideIndex.get(); + const numSlides = this._slides.get()?.length; await this._appModel.api.updateDoc(this._docId, { options: { ...currentOptions, tutorial: { - lastSlideIndex: this._currentSlideIndex.get(), + lastSlideIndex: currentSlideIndex, } } }); + + let percentComplete: number | undefined = undefined; + if (numSlides !== undefined && numSlides > 0) { + percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100); + } + logTelemetryEvent('tutorialProgressChanged', { + full: { + tutorialForkIdDigest: this._currentFork?.id, + tutorialTrunkIdDigest: this._currentFork?.trunkId, + lastSlideIndex: currentSlideIndex, + numSlides, + percentComplete, + }, + }); } private async _changeSlide(slideIndex: number) { diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 62d172c0..04264845 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; -import {commonUrls} from 'app/common/gristUrls'; +import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; import {BehavioralPrompt} from 'app/common/Prefs'; import {dom, DomContents, DomElementArg, styled} from 'grainjs'; @@ -104,6 +104,7 @@ export const GristTooltips: Record = { export interface BehavioralPromptContent { title: () => string; content: (...domArgs: DomElementArg[]) => DomContents; + deploymentTypes: GristDeploymentType[] | '*'; } export const GristBehavioralPrompts: Record = { @@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record t('Reference Columns'), @@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record t('Raw Data page'), @@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record t('Access Rules'), @@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record t('Pinning Filters'), @@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record t('Nested Filtering'), @@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record t('Selecting Data'), @@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record t('Linking Widgets'), @@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record t('Editing Card Layout'), @@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record t('Add New'), @@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record t('Anchor Links'), @@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record t('Custom Widgets'), @@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record