Merge branch 'main' of github.com:gristlabs/grist-core into alex/_importParsedFileAsNewTable

This commit is contained in:
Alex Hall
2023-07-11 13:11:05 +02:00
131 changed files with 4296 additions and 983 deletions

View File

@@ -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));

View File

@@ -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));

View File

@@ -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<ActionGroupWithState>();
// 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);
}

View File

@@ -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() {

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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<number|null>; // May be null when there are no rows.
public fieldIndex: ko.Observable<number>;
private _rowId: ko.Observable<RowId|null>; // May be null when there are no rows.
private _rowId: ko.Observable<UIRowId|null>; // 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<boolean> = ko.observable(true);
private _sectionId: ko.Computed<number>;
private _properRowId: ko.Computed<UIRowId|null>;
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<RowId|null>(optCursorPos.rowId || 0);
this._rowId = ko.observable<UIRowId|null>(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 {

View File

@@ -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");

View File

@@ -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; }),

View File

@@ -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;

View File

@@ -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;
`);

View File

@@ -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<ClientQuery, "filters" | "operations">;
*/
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<RowId>;
public readonly cursorPos?: ko.Computed<UIRowId>;
// 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<FilterColValues> {
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';

View File

@@ -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;
`);

View File

@@ -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<typeof AccountPageModule>;
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadSupportGristPage(): Promise<typeof SupportGristPageModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;

View File

@@ -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).

View File

@@ -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);

View File

@@ -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(' ');
}
/**

View File

@@ -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<void>;
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<PageType> = 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<boolean> = 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,

View File

@@ -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<TableRec>,
filterFunc: RowFilterFunc<rowset.RowId> = (_row) => true
filterFunc: RowFilterFunc<UIRowId> = (_row) => true
) {
const rowSource = new rowset.FilteredRowSource(filterFunc);
rowSource.subscribeTo(tablesModel);

View File

@@ -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;

View File

@@ -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<RowId> {
export function getFilterFunc(docData: DocData, query: ClientQuery): RowFilterFunc<UIRowId> {
// 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));
}
/**

View File

@@ -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<RowFilterFunc<RowId>>;
public readonly sectionFilterFunc: Observable<RowFilterFunc<UIRowId>>;
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
private _tempRows: MutableObsArray<RowId> = obsArray();
private _tempRows: MutableObsArray<UIRowId> = 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<RowId>, rows: RowId[]) {
return (rowId: RowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
private _addRowsToFilter(filterFunc: RowFilterFunc<UIRowId>, 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<RowId> {
private _buildPlainFilterFunc(getFilterFunc: ColFilterCB, use: UseCB): RowFilterFunc<UIRowId> {
const filters = use(this.viewSection.filters);
const funcs: Array<RowFilterFunc<RowId> | null> = filters.map(({filter, fieldOrColumn}) => {
const funcs: Array<RowFilterFunc<UIRowId> | 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<RowId>, filterFunc);
return buildRowFilter(getter as RowValueFunc<UIRowId>, 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)));
}
}

View File

@@ -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<TelemetryPrefsWithSources | null>;
fetchTelemetryPrefs(): Promise<void>;
updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void>;
}
export class TelemetryModelImpl extends Disposable implements TelemetryModel {
public readonly prefs: Observable<TelemetryPrefsWithSources | null> = Observable.create(this, null);
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
constructor(_appModel: AppModel) {
super();
}
public async fetchTelemetryPrefs(): Promise<void> {
const prefs = await this._installAPI.getInstallPrefs();
bundleChanges(() => {
this.prefs.set(prefs.telemetry);
});
}
public async updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void> {
await this._installAPI.updateInstallPrefs({telemetry: prefs});
await this.fetchTelemetryPrefs();
}
}

View File

@@ -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);

View File

@@ -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<boolean>;
activeLinkSrcSectionRef: modelUtil.CustomComputed<number>;
activeLinkSrcColRef: modelUtil.CustomComputed<number>;
activeLinkTargetColRef: modelUtil.CustomComputed<number>;
// Whether current linking state is as saved. It may be different during editing.
isActiveLinkSaved: ko.Computed<boolean>;
// 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<ViewSectionRec>;
/**
* 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<ColumnRec>;
/**
* 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<ColumnRec>;
// 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<FilterColValues>;
activeRowId: ko.Observable<RowId | null>; // May be null when there are no rows.
activeRowId: ko.Observable<UIRowId | null>; // May be null when there are no rows.
// If the view instance for section is instantiated, it will be accessible here.
viewInstance: ko.Observable<BaseView | null>;
@@ -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<RowId|null>(null);
this.activeRowId = ko.observable<UIRowId|null>(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 {

View File

@@ -121,7 +121,7 @@ export function reportError(err: Error|string, ev?: ErrorEvent): void {
const options: Partial<INotifyOptions> = {
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.

View File

@@ -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);
}
/**

View File

@@ -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<RowId>;
export type RowList = Iterable<UIRowId>;
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<RowId> = new Set(); // Set of rows matching the filter.
protected _matchingRows: Set<UIRowId> = new Set(); // Set of rows matching the filter.
constructor(protected _filterFunc: RowFilterFunc<RowId>) {
constructor(protected _filterFunc: RowFilterFunc<UIRowId>) {
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<RowId> = new Set(); // Set of rows NOT matching the filter.
private _excludedRows: Set<UIRowId> = 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<RowId>) {
public updateFilter(filterFunc: RowFilterFunc<UIRowId>) {
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<Value> extends RowSource {
private _rows: Set<RowId> = new Set();
private _rows: Set<UIRowId> = new Set();
constructor(public readonly groupValue: Value) {
super();
}
@@ -411,12 +410,12 @@ function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
*/
export class RowGrouping<Value> extends RowListener {
// Maps row identifiers to groupValues.
private _rowsToValues: Map<RowId, Value> = new Map();
private _rowsToValues: Map<UIRowId, Value> = new Map();
// Maps group values to RowGroupHelpers
private _valuesToGroups: Map<Value, RowGroupHelper<Value>> = 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<Value> extends RowListener {
* SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to.
*/
export class SortedRowSet extends RowListener {
private _allRows: Set<RowId> = new Set();
private _allRows: Set<UIRowId> = new Set();
private _isPaused: boolean = false;
private _koArray: KoArray<RowId>;
private _koArray: KoArray<UIRowId>;
private _keepFunc?: (rowId: number|'new') => boolean;
constructor(private _compareFunc: CompareFunc<RowId>,
constructor(private _compareFunc: CompareFunc<UIRowId>,
private _skippableRows?: SkippableRows) {
super();
this._koArray = this.autoDispose(koArray<RowId>());
this._koArray = this.autoDispose(koArray<UIRowId>());
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<RowId>): void {
public updateSort(compareFunc: CompareFunc<UIRowId>): 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<RowTester> = 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<RowId, number> = new Map();
private _rowCounter: Map<UIRowId, number> = new Map();
public clear() {
this._rowCounter.clear();

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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<number|undefined|IRelativeDateSpec>,
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<CellValue, IFilterCount>, rowIds: RowId[],
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: UIRowId[],
{ keyMapFunc = identity, labelMapFunc = identity, columnType,
areHiddenRows = false, valueMapFunc }: ICountOptions) {

View File

@@ -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(),
];
}),
));
}

View File

@@ -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<DocTutorialSlide[] | null> = 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) {

View File

@@ -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<Tooltip, TooltipContentFunc> = {
export interface BehavioralPromptContent {
title: () => string;
content: (...domArgs: DomElementArg[]) => DomContents;
deploymentTypes: GristDeploymentType[] | '*';
}
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
@@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: ['saas'],
},
referenceColumnsConfig: {
title: () => t('Reference Columns'),
@@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: ['saas'],
},
rawDataPage: {
title: () => t('Raw Data page'),
@@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
accessRules: {
title: () => t('Access Rules'),
@@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
filterButtons: {
title: () => t('Pinning Filters'),
@@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
nestedFiltering: {
title: () => t('Nested Filtering'),
@@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', t('Only those rows will appear which match all of the filters.')),
...args,
),
deploymentTypes: ['saas'],
},
pageWidgetPicker: {
title: () => t('Selecting Data'),
@@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
...args,
),
deploymentTypes: ['saas'],
},
pageWidgetPickerSelectBy: {
title: () => t('Linking Widgets'),
@@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
editCardLayout: {
title: () => t('Editing Card Layout'),
@@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
})),
...args,
),
deploymentTypes: ['saas'],
},
addNew: {
title: () => t('Add New'),
@@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
+ 'or import data.')),
...args,
),
deploymentTypes: ['saas'],
},
rickRow: {
title: () => t('Anchor Links'),
@@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
deploymentTypes: '*',
},
customURL: {
title: () => t('Custom Widgets'),
@@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas'],
},
};

View File

@@ -30,6 +30,10 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
return dom('a', cssToastAction.cls(''), t("Upgrade Plan"), {target: '_blank'},
{href: commonUrls.plans});
}
case 'manage':
if (urlState().state.get().billing === 'billing') { return null; }
return dom('a', cssToastAction.cls(''), t("Manage billing"), {target: '_blank'},
{href: urlState().makeUrl({billing: 'billing'})});
case 'renew':
// If already on the billing page, nothing to return.
if (urlState().state.get().billing === 'billing') { return null; }

View File

@@ -0,0 +1,326 @@
import {makeT} from 'app/client/lib/localization';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {getStorage} from 'app/client/lib/storage';
import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-nudge-');
const t = makeT('SupportGristNudge');
type ButtonState =
| 'collapsed'
| 'expanded';
type CardPage =
| 'support-grist'
| 'opted-in';
/**
* Nudges users to support Grist by opting in to telemetry.
*
* This currently includes a button that opens a card with the nudge.
* The button is hidden when the card is visible, and vice versa.
*/
export class SupportGristNudge extends Disposable {
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _buttonState: Observable<ButtonState>;
private readonly _currentPage: Observable<CardPage>;
private readonly _isClosed: Observable<boolean>;
constructor(private _appModel: AppModel) {
super();
if (!this._shouldShowCardOrButton()) { return; }
this._buttonState = localStorageObs(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded'
) as Observable<ButtonState>;
this._currentPage = Observable.create(null, 'support-grist');
this._isClosed = Observable.create(this, false);
}
public showButton() {
if (!this._shouldShowCardOrButton()) { return null; }
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)),
() => this._buildButton()
);
}
public showCard() {
if (!this._shouldShowCardOrButton()) { return null; }
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)),
() => this._buildCard()
);
}
private _markAsDismissed() {
this._appModel.dismissedPopup('supportGrist').set(true);
getStorage().removeItem(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`);
}
private _close() {
this._isClosed.set(true);
}
private _dismissAndClose() {
this._markAsDismissed();
this._close();
}
private _shouldShowCardOrButton() {
if (this._appModel.dismissedPopups.get().includes('supportGrist')) {
return false;
}
const {activation, deploymentType, telemetry} = getGristConfig();
if (deploymentType !== 'core' || !activation?.isManager) {
return false;
}
if (telemetry && telemetry.telemetryLevel !== 'off') {
return false;
}
return true;
}
private _buildButton() {
return cssContributeButton(
cssButtonIconAndText(
icon('Fireworks'),
t('Contribute'),
),
cssContributeButtonCloseButton(
icon('CrossSmall'),
dom.on('click', (ev) => {
ev.stopPropagation();
this._dismissAndClose();
}),
testId('contribute-button-close'),
),
dom.on('click', () => { this._buttonState.set('expanded'); }),
testId('contribute-button'),
);
}
private _buildCard() {
return cssCard(
dom.domComputed(this._currentPage, page => {
if (page === 'support-grist') {
return this._buildSupportGristCardContent();
} else {
return this._buildOptedInCardContent();
}
}),
testId('card'),
);
}
private _buildSupportGristCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')),
testId('card-close'),
),
cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t(
'Opt in to telemetry to help us understand how the product ' +
'is used, so that we can prioritize future improvements.'
)),
cssParagraph(
t(
'We only collect usage statistics, as detailed in our {{helpCenterLink}}, never ' +
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
{
helpCenterLink: helpCenterLink(),
supportGristLink: supportGristLink(),
},
),
),
cssFullWidthButton(
t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()),
testId('card-opt-in'),
),
];
}
private _buildOptedInCardContent() {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._close()),
testId('card-close-icon-button'),
),
cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')),
cssParagraph(
t(
'Thank you! Your trust and support is greatly appreciated. ' +
'Opt out any time from the {{link}} in the user menu.',
{link: supportGristLink()},
),
),
cssCenteredFlex(
cssPrimaryButton(
t('Close'),
dom.on('click', () => this._close()),
testId('card-close-button'),
),
),
];
}
private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._currentPage.set('opted-in');
this._markAsDismissed();
}
}
function helpCenterLink() {
return cssLink(
t('Help Center'),
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
);
}
function supportGristLink() {
return cssLink(
t('Support Grist page'),
{href: urlState().makeUrl({supportGrist: 'support-grist'}), target: '_blank'},
);
}
const cssCenteredFlex = styled('div', `
display: flex;
justify-content: center;
align-items: center;
`);
const cssContributeButton = styled('div', `
position: relative;
background: ${theme.controlPrimaryBg};
color: ${theme.controlPrimaryFg};
border-radius: 25px;
padding: 4px 12px 4px 8px;
font-style: normal;
font-weight: medium;
font-size: 13px;
line-height: 16px;
cursor: pointer;
--icon-color: ${theme.controlPrimaryFg};
&:hover {
background: ${theme.controlPrimaryHoverBg};
}
`);
const cssButtonIconAndText = styled('div', `
display: flex;
gap: 8px;
`);
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
margin-left: 4px;
vertical-align: bottom;
line-height: 1;
position: absolute;
top: -4px;
right: -8px;
border-radius: 16px;
background-color: ${colors.dark};
width: 18px;
height: 18px;
cursor: pointer;
z-index: 1;
display: none;
align-items: center;
justify-content: center;
.${cssContributeButton.className}:hover & {
display: flex;
}
`);
const cssCard = styled('div', `
width: 297px;
padding: 24px;
background: #DCF4EB;
border-radius: 4px;
align-self: flex-start;
position: sticky;
flex-shrink: 0;
top: 0px;
`);
const cssHeader = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: 600;
margin-bottom: 16px;
`);
const cssLeftAlignedHeader = styled(cssHeader, `
text-align: left;
`);
const cssCenterAlignedHeader = styled(cssHeader, `
text-align: center;
`);
const cssParagraph = styled('div', `
font-size: 13px;
line-height: 18px;
margin-bottom: 12px;
`);
const cssPrimaryButton = styled(bigPrimaryButton, `
display: flex;
justify-content: center;
align-items: center;
margin-top: 32px;
text-align: center;
`);
const cssFullWidthButton = styled(cssPrimaryButton, `
width: 100%;
`);
const cssCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.mediumGreyOpaque};
}
`);
const cssSparks = styled('div', `
height: 48px;
width: 48px;
background-image: var(--icon-Sparks);
display: inline-block;
background-repeat: no-repeat;
`);

View File

@@ -0,0 +1,289 @@
import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {commonUrls} from 'app/common/gristUrls';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-page-');
const t = makeT('SupportGristPage');
export class SupportGristPage extends Disposable {
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
(_use, prefs) => {
if (!prefs) { return null; }
return prefs.telemetryLevel.value !== 'off';
})
.onWrite(async (optIn) => {
const telemetryLevel = optIn ? 'limited' : 'off';
await this._model.updateTelemetryPrefs({telemetryLevel});
});
constructor(private _appModel: AppModel) {
super();
this._model.fetchTelemetryPrefs().catch(reportError);
}
public buildDom() {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._buildMainHeader(),
contentTop: buildHomeBanners(this._appModel),
contentMain: this._buildMainContent(),
});
}
private _buildMainHeader() {
return dom.frag(
cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink(
urlState().setLinkUrl({}),
t('Home'),
),
separator(' / '),
dom('span', t('Support Grist')),
),
createTopBarHome(this._appModel),
);
}
private _buildMainContent() {
return cssPageContainer(
cssPage(
dom('div',
cssPageTitle(t('Support Grist')),
this._buildTelemetrySection(),
this._buildSponsorshipSection(),
),
),
);
}
private _buildTelemetrySection() {
return cssSection(
cssSectionTitle(t('Telemetry')),
dom.domComputed(this._model.prefs, prefs => {
if (prefs === null) {
return cssSpinnerBox(loadingSpinner());
}
const {activation} = getGristConfig();
if (!activation?.isManager) {
if (prefs.telemetryLevel.value === 'limited') {
return [
cssParagraph(t(
'This instance is opted in to telemetry. Only the site administrator has permission to change this.',
))
];
} else {
return [
cssParagraph(t(
'This instance is opted out of telemetry. Only the site administrator has permission to change this.',
))
];
}
} else {
return [
cssParagraph(t(
'Support Grist by opting in to telemetry, which helps us understand how the product ' +
'is used, so that we can prioritize future improvements.'
)),
cssParagraph(
t('We only collect usage statistics, as detailed in our {{link}}, never document contents.', {
link: telemetryHelpCenterLink(),
}),
),
cssParagraph(t('You can opt out of telemetry at any time from this page.')),
this._buildTelemetrySectionButtons(prefs),
];
}
}),
testId('telemetry-section'),
);
}
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
const {telemetryLevel: {value, source}} = prefs;
if (source === 'preferences') {
return dom.domComputed(this._optInToTelemetry, (optedIn) => {
if (optedIn) {
return [
cssOptInOutMessage(
t('You have opted in to telemetry. Thank you!'), ' 🙏',
testId('telemetry-section-message'),
),
cssOptOutButton(t('Opt out of Telemetry'),
dom.on('click', () => this._optInToTelemetry.set(false)),
),
];
} else {
return [
cssOptInButton(t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry.set(true)),
),
];
}
});
} else {
return cssOptInOutMessage(
value !== 'off'
? [t('You have opted in to telemetry. Thank you!'), ' 🙏']
: t('You have opted out of telemetry.'),
testId('telemetry-section-message'),
);
}
}
private _buildSponsorshipSection() {
return cssSection(
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
cssParagraph(
t(
'Grist software is developed by Grist Labs, which offers free and paid ' +
'hosted plans. We also make Grist code available under a standard free ' +
'and open OSS license (Apache 2.0) on {{link}}.',
{link: gristCoreLink()},
),
),
cssParagraph(
t(
'You can support Grist open-source development by sponsoring ' +
'us on our {{link}}.',
{link: sponsorGristLink()},
),
),
cssParagraph(t(
'We are a small and determined team. Your support matters a lot to us. ' +
'It also shows to others that there is a determined community behind this product.'
)),
cssSponsorButton(
cssButtonIconAndText(icon('Heart'), cssButtonText(t('Manage Sponsorship'))),
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
),
testId('sponsorship-section'),
);
}
}
function telemetryHelpCenterLink() {
return cssLink(
t('Help Center'),
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
);
}
function sponsorGristLink() {
return cssLink(
t('GitHub Sponsors page'),
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
);
}
function gristCoreLink() {
return cssLink(
t('GitHub'),
{href: commonUrls.githubGristCore, target: '_blank'},
);
}
const cssPageContainer = styled('div', `
overflow: auto;
padding: 64px 80px;
@media ${mediaSmall} {
& {
padding: 0px;
}
}
`);
const cssPage = styled('div', `
padding: 16px;
max-width: 600px;
width: 100%;
`);
const cssPageTitle = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 24px;
color: ${theme.text};
font-size: 24px;
font-weight: ${vars.headerControlTextWeight};
`);
const cssSectionTitle = styled('div', `
height: 24px;
line-height: 24px;
margin-bottom: 24px;
color: ${theme.text};
font-size: ${vars.xlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const cssSection = styled('div', `
margin-bottom: 60px;
`);
const cssParagraph = styled('div', `
color: ${theme.text};
font-size: 14px;
line-height: 20px;
margin-bottom: 12px;
`);
const cssOptInOutMessage = styled(cssParagraph, `
line-height: 40px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 0px;
`);
const cssOptInButton = styled(bigPrimaryButton, `
margin-top: 24px;
`);
const cssOptOutButton = styled(bigBasicButton, `
margin-top: 24px;
`);
const cssSponsorButton = styled(bigBasicButtonLink, `
margin-top: 24px;
`);
const cssButtonIconAndText = styled('div', `
display: flex;
align-items: center;
`);
const cssButtonText = styled('span', `
margin-left: 8px;
`);
const cssSpinnerBox = styled('div', `
margin-top: 24px;
text-align: center;
`);

View File

@@ -26,7 +26,7 @@ const t = makeT('TopBar');
export function createTopBarHome(appModel: AppModel) {
return [
cssFlexSpace(),
appModel.supportGristNudge.showButton(),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[
basicButton(

View File

@@ -217,10 +217,12 @@ export class VisibleFieldsConfig extends Disposable {
primaryButton(
dom.text((use) => t("Hide {{label}}", {label: use(this._fieldLabel)})),
dom.on('click', () => this._removeSelectedFields()),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, false)),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
@@ -259,10 +261,12 @@ export class VisibleFieldsConfig extends Disposable {
primaryButton(
dom.text((use) => t("Show {{label}}", {label: use(this._fieldLabel)})),
dom.on('click', () => this._addSelectedFields()),
testId('hidden-show')
),
basicButton(
t("Clear"),
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),
testId('hidden-clear')
),
testId('hidden-batch-buttons')
)

View File

@@ -72,6 +72,7 @@ export type IconName = "ChartArea" |
"FunctionResult" |
"GreenArrow" |
"Grow" |
"Heart" |
"Help" |
"Home" |
"Idea" |
@@ -214,6 +215,7 @@ export const IconList: IconName[] = ["ChartArea",
"FunctionResult",
"GreenArrow",
"Grow",
"Heart",
"Help",
"Home",
"Idea",

View File

@@ -142,6 +142,7 @@ export const vars = {
onboardingPopupZIndex: new CustomProp('onboarding-popup-z-index', '1000'),
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'),
notificationZIndex: new CustomProp('notification-z-index', '1100'),
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
tooltipZIndex: new CustomProp('tooltip-z-index', '5000'),
@@ -426,6 +427,9 @@ export const theme = {
undefined, colors.slate),
pageInitialsFg: new CustomProp('theme-left-panel-page-initials-fg', undefined, 'white'),
pageInitialsBg: new CustomProp('theme-left-panel-page-initials-bg', undefined, colors.slate),
pageInitialsEmojiBg: new CustomProp('theme-left-panel-page-emoji-fg', undefined, 'white'),
pageInitialsEmojiOutline: new CustomProp('theme-left-panel-page-emoji-outline', undefined,
colors.darkGrey),
/* Right Panel */
rightPanelTabFg: new CustomProp('theme-right-panel-tab-fg', undefined, colors.dark),

View File

@@ -6,7 +6,7 @@ import { theme } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { hoverTooltip, overflowTooltip } from 'app/client/ui/tooltips';
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
import { Computed, dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
const t = makeT('pages');
@@ -54,17 +54,21 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
}
});
const splitName = Computed.create(null, name, (use, _name) => splitPageInitial(_name));
return pageElem = dom(
'div',
dom.autoDispose(lis),
dom.autoDispose(splitName),
domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
domComputed(isRenaming, (isrenaming) => (
isrenaming ?
cssPageItem(
cssPageInitial(
testId('initial'),
dom.text((use) => Array.from(use(name))[0])
),
dom.text((use) => use(splitName).initial),
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
),
cssEditorInput(
{
initialValue: name.get() || '',
@@ -82,10 +86,11 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
cssPageItem(
cssPageInitial(
testId('initial'),
dom.text((use) => Array.from(use(name))[0]),
dom.text((use) => use(splitName).initial),
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
),
cssPageName(
dom.text(name),
dom.text((use) => use(splitName).displayName),
testId('label'),
dom.on('click', (ev) => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)),
overflowTooltip(),
@@ -122,6 +127,24 @@ export function buildCensoredPage() {
);
}
// This crazy expression matches all "possible emoji" and comes from a very official source:
// https://unicode.org/reports/tr51/#EBNF_and_Regex (linked from
// https://stackoverflow.com/a/68146409/328565). It is processed from the original by replacing \x
// with \u, removing whitespace, and factoring out a long subexpression.
const emojiPart = /(?:\p{RI}\p{RI}|\p{Emoji}(?:\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)/u;
const pageInitialRegex = new RegExp(`^${emojiPart.source}(?:\\u{200D}${emojiPart.source})*`, "u");
// Divide up the page name into an "initial" and "displayName", where an emoji initial, if
// present, is omitted from the displayName, but a regular character used as the initial is kept.
function splitPageInitial(name: string): {initial: string, displayName: string, hasEmoji: boolean} {
const m = name.match(pageInitialRegex);
if (m) {
return {initial: m[0], displayName: name.slice(m[0].length).trim(), hasEmoji: true};
} else {
return {initial: Array.from(name)[0], displayName: name.trim(), hasEmoji: false};
}
}
const cssPageItem = styled('a', `
display: flex;
flex-direction: row;
@@ -129,7 +152,8 @@ const cssPageItem = styled('a', `
align-items: center;
flex-grow: 1;
.${treeViewContainer.className}-close & {
margin-left: 16px;
display: flex;
justify-content: center;
}
&, &:hover, &:focus {
text-decoration: none;
@@ -143,10 +167,25 @@ const cssPageInitial = styled('div', `
color: ${theme.pageInitialsFg};
border-radius: 3px;
background-color: ${theme.pageInitialsBg};
width: 16px;
height: 16px;
text-align: center;
width: 20px;
height: 20px;
margin-right: 8px;
display: flex;
justify-content: center;
align-items: center;
&-emoji {
background-color: ${theme.pageInitialsEmojiBg};
box-shadow: 0 0 0 1px var(--grist-theme-left-panel-page-emoji-outline, var(--grist-color-dark-grey));
font-size: 15px;
overflow: hidden;
}
.${treeViewContainer.className}-close & {
margin-right: 0;
}
.${itemHeader.className}.selected &-emoji {
box-shadow: none;
}
`);
const cssPageName = styled('div', `

View File

@@ -17,7 +17,6 @@ import {autoGrow} from 'app/client/ui/forms';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {DocAction} from 'app/common/DocActions';
import {movable} from 'app/client/lib/popupUtils';
import debounce from 'lodash/debounce';
@@ -61,7 +60,7 @@ export class FormulaAssistant extends Disposable {
/** Is the request pending */
private _waiting = Observable.create(this, false);
/** Is this feature enabled at all */
private _assistantEnabled = GRIST_FORMULA_ASSISTANT();
private _assistantEnabled: Computed<boolean>;
/** Preview column id */
private _transformColId: string;
/** Method to invoke when we are closed, it saves or reverts */
@@ -90,6 +89,12 @@ export class FormulaAssistant extends Disposable {
}) {
super();
this._assistantEnabled = Computed.create(this, use => {
const enabledByFlag = use(GRIST_FORMULA_ASSISTANT());
const notAnonymous = Boolean(this._options.gristDoc.appModel.currentValidUser);
return enabledByFlag && notAnonymous;
});
if (!this._options.field) {
// TODO: field is not passed only for rules (as there is no preview there available to the user yet)
// this should be implemented but it requires creating a helper column to helper column and we don't
@@ -263,6 +268,8 @@ export class FormulaAssistant extends Disposable {
this._buildIntro(),
this._chat.buildDom(),
this._buildChatInput(),
// Stop propagation of mousedown events, as the formula editor will still focus.
dom.on('mousedown', (ev) => ev.stopPropagation()),
);
});
}
@@ -516,7 +523,7 @@ export class FormulaAssistant extends Disposable {
this._options.editor.setFormula(entry.formula!);
}
private async _sendMessage(description: string, regenerate = false) {
private async _sendMessage(description: string, regenerate = false): Promise<ChatMessage> {
// Destruct options.
const {column, gristDoc} = this._options;
// Get the state of the chat from the column.
@@ -539,7 +546,12 @@ export class FormulaAssistant extends Disposable {
// some markdown text back, so we need to parse it.
const prettyMessage = state ? (reply || formula || '') : (formula || reply || '');
// Add it to the chat.
this._chat.addResponse(prettyMessage, formula, suggestedActions[0]);
return {
message: prettyMessage,
formula,
action: suggestedActions[0],
sender: 'ai',
};
}
private _clear() {
@@ -556,9 +568,7 @@ export class FormulaAssistant extends Disposable {
if (!last) {
return;
}
this._chat.thinking();
this._waiting.set(true);
await this._sendMessage(last, true).finally(() => this._waiting.set(false));
await this._doAsk(last);
}
private async _ask() {
@@ -568,10 +578,22 @@ export class FormulaAssistant extends Disposable {
const message= this._userInput.get();
if (!message) { return; }
this._chat.addQuestion(message);
this._chat.thinking();
this._userInput.set('');
await this._doAsk(message);
}
private async _doAsk(message: string) {
this._chat.thinking();
this._waiting.set(true);
await this._sendMessage(message, false).finally(() => this._waiting.set(false));
try {
const response = await this._sendMessage(message, false);
this._chat.addResponse(response);
} catch(err) {
this._chat.thinking(false);
throw err;
} finally {
this._waiting.set(false);
}
}
}
@@ -601,32 +623,36 @@ class ChatHistory extends Disposable {
this.length = Computed.create(this, use => use(this.history).length); // ??
}
public thinking() {
this.history.push({
message: '...',
sender: 'ai',
});
this.scrollDown();
public thinking(on = true) {
if (!on) {
// Find all index of all thinking messages.
const messages = [...this.history.get()].filter(m => m.message === '...');
// Remove all thinking messages.
for (const message of messages) {
this.history.splice(this.history.get().indexOf(message), 1);
}
} else {
this.history.push({
message: '...',
sender: 'ai',
});
this.scrollDown();
}
}
public supportsMarkdown() {
return this._options.column.chatHistory.peek().get().state !== undefined;
}
public addResponse(message: string, formula: string|null, action?: DocAction) {
public addResponse(message: ChatMessage) {
// Clear any thinking from messages.
this.history.set(this.history.get().filter(x => x.message !== '...'));
this.history.push({
message,
sender: 'ai',
formula,
action
});
this.thinking(false);
this.history.push({...message, sender: 'ai'});
this.scrollDown();
}
public addQuestion(message: string) {
this.history.set(this.history.get().filter(x => x.message !== '...'));
this.thinking(false);
this.history.push({
message,
sender: 'user',
@@ -740,18 +766,13 @@ async function askAI(grist: GristDoc, options: {
const {column, description, state, regenerate} = options;
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
try {
const result = await grist.docComm.getAssistance({
context: {type: 'formula', tableId, colId},
text: description,
state,
regenerate,
});
return result;
} catch (error) {
reportError(error);
throw error;
}
const result = await grist.docApi.getAssistance({
context: {type: 'formula', tableId, colId},
text: description,
state,
regenerate,
});
return result;
}
/**

View File

@@ -154,13 +154,14 @@ export class FormulaEditor extends NewBaseEditor {
dom.on('mousedown', (ev) => {
// If we are detached, allow user to click and select error text.
if (this.isDetached.get()) {
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
// some input elements (mainly the AI assistant).
const inInput = document.activeElement instanceof HTMLInputElement
|| document.activeElement instanceof HTMLTextAreaElement;
if (inInput && this._dom.contains(document.activeElement)) {
// If we clicked on input element in our dom, don't do anything. We probably clicked on chat input, in AI
// tools box.
const clickedOnInput = ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement;
if (clickedOnInput && this._dom.contains(ev.target)) {
// By not doing anything special here we assume that the input element will take the focus.
return;
}
// Allow clicking the error message.
if (ev.target instanceof HTMLElement && (
ev.target.classList.contains('error_msg') ||

View File

@@ -1,8 +1,7 @@
import {ActionGroup} from 'app/common/ActionGroup';
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {FormulaProperties} from 'app/common/GranularAccessClause';
import {UIRowId} from 'app/common/UIRowId';
import {UIRowId} from 'app/common/TableData';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
import {ParseOptions} from 'app/plugin/FileParserAPI';
@@ -320,11 +319,6 @@ export interface ActiveDocAPI {
*/
getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue>;
/**
* Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula.
*/
getAssistance(request: AssistanceRequest): Promise<AssistanceResponse>;
/**
* Fetch content at a url.
*/

View File

@@ -2,15 +2,17 @@
* A tip for fixing an error.
*/
export interface ApiTip {
action: 'add-members' | 'upgrade' |'ask-for-help';
action: 'add-members' | 'upgrade' | 'ask-for-help' | 'manage';
message: string;
}
export type LimitType = 'collaborators' | 'docs' | 'workspaces' | 'assistant';
/**
* Documentation of a limit relevant to an API error.
*/
export interface ApiLimit {
quantity: 'collaborators' | 'docs' | 'workspaces'; // what are we counting
quantity: LimitType; // what are we counting
subquantity?: string; // a nuance to what we are counting
maximum: number; // maximum allowed
value: number; // current value of quantity for user

View File

@@ -43,6 +43,16 @@ export interface IBillingPlan {
active: boolean;
}
export interface ILimitTier {
name?: string;
volume: number;
price: number;
flatFee: number;
type: string;
planId: string;
interval: string; // probably 'month'|'year';
}
// Utility type that requires all properties to be non-nullish.
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
@@ -69,6 +79,7 @@ export interface IBillingDiscount {
export interface IBillingSubscription {
// All standard plan options.
plans: IBillingPlan[];
tiers: ILimitTier[];
// Index in the plans array of the plan currently in effect.
planIndex: number;
// Index in the plans array of the plan to be in effect after the current period end.
@@ -111,6 +122,14 @@ export interface IBillingSubscription {
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
limit?: ILimit|null;
}
export interface ILimit {
limitValue: number;
currentUsage: number;
type: string; // Limit type, for now only assistant is supported.
price: number; // If this is 0, it means it is a free plan.
}
export interface IBillingOrgSettings {
@@ -139,6 +158,7 @@ export interface BillingAPI {
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string;
updateAssistantPlan(tier: number): Promise<void>;
}
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
@@ -230,6 +250,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return `${this._url}/api/billing/renew`;
}
public async updateAssistantPlan(tier: number): Promise<void> {
await this.request(`${this._url}/api/billing/upgrade-assistant`, {
method: 'POST',
body: JSON.stringify({ tier })
});
}
/**
* Checks if current org has active subscription for a Stripe plan.
*/

View File

@@ -14,8 +14,6 @@ export interface AllCellVersions {
}
export type CellVersions = Partial<AllCellVersions>;
import map = require('lodash/map');
export type AddRecord = ['AddRecord', string, number, ColValues];
export type BulkAddRecord = ['BulkAddRecord', string, number[], BulkColValues];
export type RemoveRecord = ['RemoveRecord', string, number];
@@ -150,21 +148,6 @@ export type UserAction = Array<string|number|object|boolean|null|undefined>;
// Actions that trigger formula calculations in the data engine
export const CALCULATING_USER_ACTIONS = new Set(['Calculate', 'UpdateCurrentTime', 'RespondToRequests']);
/**
* Gives a description for an action which involves setting values to a selection.
* @param {Array} action - The (Bulk)AddRecord/(Bulk)UpdateRecord action to describe.
* @param {Boolean} optExcludeVals - Indicates whether the values should be excluded from
* the description.
*/
export function getSelectionDesc(action: UserAction, optExcludeVals: boolean): string {
const table = action[1];
const rows = action[2];
const colValues: number[] = action[3] as any; // TODO: better typing - but code may evaporate
const columns = map(colValues, (values, col) => optExcludeVals ? col : `${col}: ${values}`);
const s = typeof rows === 'object' ? 's' : '';
return `table ${table}, row${s} ${rows}; ${columns.join(", ")}`;
}
export function getNumRows(action: DocAction): number {
return !isDataAction(action) ? 0
: Array.isArray(action[2]) ? action[2].length

View File

@@ -58,6 +58,11 @@ export interface Features {
// for attached files in a document
gracePeriodDays?: number; // Duration of the grace period in days, before entering delete-only mode
baseMaxAssistantCalls?: number; // Maximum number of AI assistant calls. Defaults to 0 if not set, use -1 to indicate
// unbound limit. This is total limit, not per month or per day, it is used as a seed
// value for the limits table. To create a per-month limit, there must be a separate
// task that resets the usage in the limits table.
}
// Check whether it is possible to add members at the org level. There's no flag

10
app/common/Install.ts Normal file
View File

@@ -0,0 +1,10 @@
import {TelemetryLevel} from 'app/common/Telemetry';
export interface InstallPrefs {
telemetry?: TelemetryPrefs;
}
export interface TelemetryPrefs {
/** Defaults to "off". */
telemetryLevel?: TelemetryLevel;
}

51
app/common/InstallAPI.ts Normal file
View File

@@ -0,0 +1,51 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {InstallPrefs} from 'app/common/Install';
import {TelemetryLevel} from 'app/common/Telemetry';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
export const installPropertyKeys = ['prefs'];
export interface InstallProperties {
prefs: InstallPrefs;
}
export interface InstallPrefsWithSources {
telemetry: {
telemetryLevel: PrefWithSource<TelemetryLevel>;
},
}
export type TelemetryPrefsWithSources = InstallPrefsWithSources['telemetry'];
export interface PrefWithSource<T> {
value: T;
source: PrefSource;
}
export type PrefSource = 'environment-variable' | 'preferences';
export interface InstallAPI {
getInstallPrefs(): Promise<InstallPrefsWithSources>;
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
}
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
constructor(private _homeUrl: string, options: IOptions = {}) {
super(options);
}
public async getInstallPrefs(): Promise<InstallPrefsWithSources> {
return this.requestJson(`${this._url}/api/install/prefs`, {method: 'GET'});
}
public async updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void> {
await this.request(`${this._url}/api/install/prefs`, {
method: 'PATCH',
body: JSON.stringify({...prefs}),
});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}
}

View File

@@ -106,6 +106,7 @@ export const DismissedPopup = StringUnion(
'tutorialFirstCard', // first card of the tutorial,
'formulaHelpInfo', // formula help info shown in the popup editor,
'formulaAssistantInfo', // formula assistant info shown in the popup editor,
'supportGrist', // nudge to opt in to telemetry,
);
export type DismissedPopup = typeof DismissedPopup.type;

View File

@@ -8,12 +8,16 @@ import {
import {getDefaultForType} from 'app/common/gristTypes';
import {arrayRemove, arraySplice, getDistinctValues} from 'app/common/gutil';
import {SchemaTypes} from "app/common/schema";
import {UIRowId} from 'app/common/UIRowId';
import isEqual = require('lodash/isEqual');
import fromPairs = require('lodash/fromPairs');
export interface ColTypeMap { [colId: string]: string; }
// This is the row ID used in the client, but it's helpful to have available in some common code
// as well, which is why it's declared in app/common. Note that for data actions and stored data,
// 'new' is not used.
export type UIRowId = number | 'new';
type UIRowFunc<T> = (rowId: UIRowId) => T;
interface ColData {

View File

@@ -1,5 +1,4 @@
import {StringUnion} from 'app/common/StringUnion';
import pickBy = require('lodash/pickBy');
/**
* Telemetry levels, in increasing order of data collected.
@@ -720,36 +719,3 @@ export function buildTelemetryEventChecker(telemetryLevel: TelemetryLevel) {
}
export type TelemetryEventChecker = (event: TelemetryEvent, metadata?: TelemetryMetadata) => void;
/**
* Returns a new, filtered metadata object.
*
* Metadata in groups that don't meet `telemetryLevel` are removed from the
* returned object, and the returned object is flattened.
*
* Returns undefined if `metadata` is undefined.
*/
export function filterMetadata(
metadata: TelemetryMetadataByLevel | undefined,
telemetryLevel: TelemetryLevel
): TelemetryMetadata | undefined {
if (!metadata) { return; }
let filteredMetadata = {};
for (const level of ['limited', 'full'] as const) {
if (Level[telemetryLevel] < Level[level]) { break; }
filteredMetadata = {...filteredMetadata, ...metadata[level]};
}
filteredMetadata = removeNullishKeys(filteredMetadata);
return removeNullishKeys(filteredMetadata);
}
/**
* Returns a copy of `object` with all null and undefined keys removed.
*/
export function removeNullishKeys(object: Record<string, any>) {
return pickBy(object, value => value !== null && value !== undefined);
}

View File

@@ -185,6 +185,8 @@ export const ThemeColors = t.iface([], {
"left-panel-page-options-selected-hover-bg": "string",
"left-panel-page-initials-fg": "string",
"left-panel-page-initials-bg": "string",
"left-panel-page-emoji-fg": "string",
"left-panel-page-emoji-outline": "string",
"right-panel-tab-fg": "string",
"right-panel-tab-bg": "string",
"right-panel-tab-icon": "string",

View File

@@ -241,6 +241,8 @@ export interface ThemeColors {
'left-panel-page-options-selected-hover-bg': string;
'left-panel-page-initials-fg': string;
'left-panel-page-initials-bg': string;
'left-panel-page-emoji-fg': string;
'left-panel-page-emoji-outline': string;
/* Right Panel */
'right-panel-tab-fg': string;

View File

@@ -1,4 +0,0 @@
// This is the row ID used in the client, but it's helpful to have available in some common code
// as well, which is why it's declared in app/common. Note that for data actions and stored data,
// 'new' is not used.
export type UIRowId = number | 'new';

View File

@@ -1,5 +1,6 @@
import {ActionSummary} from 'app/common/ActionSummary';
import {ApplyUAResult, ForkResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
import {BrowserSettings} from 'app/common/BrowserSettings';
@@ -462,6 +463,8 @@ export interface DocAPI {
// Update webhook
updateWebhook(webhook: WebhookUpdate): Promise<void>;
flushWebhooks(): Promise<void>;
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
}
// Operations that are supported by a doc worker.
@@ -1012,6 +1015,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return response.data[0];
}
public async getAssistance(params: AssistanceRequest): Promise<AssistanceResponse> {
return await this.requestJson(`${this._url}/assistant`, {
method: 'POST',
body: JSON.stringify(params),
});
}
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
if (options?.filters) {

View File

@@ -5,7 +5,7 @@ import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
import {LocalPlugin} from 'app/common/plugin';
import {StringUnion} from 'app/common/StringUnion';
import {TelemetryLevel} from 'app/common/Telemetry';
import {UIRowId} from 'app/common/UIRowId';
import {UIRowId} from 'app/common/TableData';
import {getGristConfig} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI';
import clone = require('lodash/clone');
@@ -41,6 +41,9 @@ export type ActivationPage = typeof ActivationPage.type;
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
export const SupportGristPage = StringUnion('support-grist');
export type SupportGristPage = typeof SupportGristPage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('light', 'full');
export type InterfaceStyle = typeof InterfaceStyle.type;
@@ -72,6 +75,7 @@ export const commonUrls = {
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
plans: "https://www.getgrist.com/pricing",
sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact",
@@ -83,6 +87,8 @@ export const commonUrls = {
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
githubGristCore: 'https://github.com/gristlabs/grist-core',
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
};
/**
@@ -103,6 +109,7 @@ export interface IGristUrlState {
activation?: ActivationPage;
login?: LoginPage;
welcome?: WelcomePage;
supportGrist?: SupportGristPage;
welcomeTour?: boolean;
docTour?: boolean;
manageUsers?: boolean;
@@ -258,6 +265,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
parts.push(`welcome/${state.welcome}`);
}
if (state.supportGrist) { parts.push(state.supportGrist); }
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
queryParams[`${k}_`] = v;
@@ -320,7 +329,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
// the minimum length of a urlId prefix is longer than the maximum length
// of any of the valid keys in the url.
for (const key of map.keys()) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) {
map.set('doc', key);
map.set('slug', map.get(key)!);
map.delete(key);
@@ -358,6 +367,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
}
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
if (map.has('support-grist')) {
state.supportGrist = SupportGristPage.parse(map.get('support-grist')) || 'support-grist';
}
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
if (sp.has('billingTask')) {

View File

@@ -1,5 +1,4 @@
import {TableData} from 'app/common/TableData';
import {UIRowId} from 'app/common/UIRowId';
import {TableData, UIRowId} from 'app/common/TableData';
/**
* Return whether a table (identified by the rowId of its metadata record) should

View File

@@ -220,6 +220,8 @@ export const GristDark: ThemeColors = {
'left-panel-page-options-selected-hover-bg': '#A4A4A4',
'left-panel-page-initials-fg': 'white',
'left-panel-page-initials-bg': '#929299',
'left-panel-page-emoji-fg': 'black',
'left-panel-page-emoji-outline': '#69697D',
/* Right Panel */
'right-panel-tab-fg': '#EFEFEF',

View File

@@ -220,6 +220,8 @@ export const GristLight: ThemeColors = {
'left-panel-page-options-selected-hover-bg': '#929299',
'left-panel-page-initials-fg': 'white',
'left-panel-page-initials-bg': '#929299',
'left-panel-page-emoji-fg': 'white',
'left-panel-page-emoji-outline': '#BDBDBD',
/* Right Panel */
'right-panel-tab-fg': '#262633',

View File

@@ -1,3 +1,7 @@
import {InstallPrefs} from "app/common/Install";
import {ApiError} from "app/common/ApiError";
import {InstallProperties, installPropertyKeys} from "app/common/InstallAPI";
import {nativeValues} from "app/gen-server/lib/values";
import {BaseEntity, Column, Entity, PrimaryColumn} from "typeorm";
@Entity({name: 'activations'})
@@ -9,9 +13,47 @@ export class Activation extends BaseEntity {
@Column({name: 'key', type: 'text', nullable: true})
public key: string|null;
@Column({type: nativeValues.jsonEntityType, nullable: true})
public prefs: InstallPrefs|null;
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
public createdAt: Date;
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
public updatedAt: Date;
public checkProperties(props: any): props is Partial<InstallProperties> {
for (const key of Object.keys(props)) {
if (!installPropertyKeys.includes(key)) {
throw new ApiError(`Unrecognized property ${key}`, 400);
}
}
return true;
}
public updateFromProperties(props: Partial<InstallProperties>) {
if (props.prefs === undefined) { return; }
if (props.prefs === null) {
this.prefs = null;
} else {
this.prefs = this.prefs || {};
if (props.prefs.telemetry !== undefined) {
this.prefs.telemetry = this.prefs.telemetry || {};
if (props.prefs.telemetry.telemetryLevel !== undefined) {
this.prefs.telemetry.telemetryLevel = props.prefs.telemetry.telemetryLevel;
}
}
for (const key of Object.keys(this.prefs) as Array<keyof InstallPrefs>) {
if (this.prefs[key] === null) {
delete this.prefs[key];
}
}
if (Object.keys(this.prefs).length === 0) {
this.prefs = null;
}
}
}
}

View File

@@ -3,12 +3,13 @@ import {BillingAccountManager} from 'app/gen-server/entity/BillingAccountManager
import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product';
import {nativeValues} from 'app/gen-server/lib/values';
import {Limit} from 'app/gen-server/entity/Limit';
// This type is for billing account status information. Intended for stuff
// like "free trial running out in N days".
interface BillingAccountStatus {
export interface BillingAccountStatus {
stripeStatus?: string;
currentPeriodEnd?: Date;
currentPeriodEnd?: string;
message?: string;
}
@@ -68,6 +69,9 @@ export class BillingAccount extends BaseEntity {
@OneToMany(type => Organization, org => org.billingAccount)
public orgs: Organization[];
@OneToMany(type => Limit, limit => limit.billingAccount)
public limits: Limit[];
// A calculated column that is true if it looks like there is a paid plan.
@Column({name: 'paid', type: 'boolean', insert: false, select: false})
public paid?: boolean;

View File

@@ -1,10 +1,8 @@
import {ApiError} from 'app/common/ApiError';
import {DocumentUsage} from 'app/common/DocUsage';
import {hashId} from 'app/common/hashingUtils';
import {Role} from 'app/common/roles';
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,
NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {nativeValues} from 'app/gen-server/lib/values';
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
import {AclRuleDoc} from "./AclRule";
@@ -92,7 +90,7 @@ export class Document extends Resource {
return super.checkProperties(props, documentPropertyKeys);
}
public updateFromProperties(props: Partial<DocumentProperties>, dbManager?: HomeDBManager) {
public updateFromProperties(props: Partial<DocumentProperties>) {
super.updateFromProperties(props);
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
if (props.urlId !== undefined) {
@@ -135,9 +133,6 @@ export class Document extends Resource {
}
if (props.options.tutorial.lastSlideIndex !== undefined) {
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
if (dbManager && this.options.tutorial.numSlides) {
this._emitTutorialProgressChangeEvent(dbManager, this.options.tutorial);
}
}
}
}
@@ -154,26 +149,6 @@ export class Document extends Resource {
}
}
}
private _emitTutorialProgressChangeEvent(
dbManager: HomeDBManager,
tutorialMetadata: TutorialMetadata
) {
const lastSlideIndex = tutorialMetadata.lastSlideIndex;
const numSlides = tutorialMetadata.numSlides;
const percentComplete = lastSlideIndex !== undefined && numSlides !== undefined
? Math.floor((lastSlideIndex / numSlides) * 100)
: undefined;
dbManager?.emit('tutorialProgressChanged', {
full: {
tutorialForkIdDigest: hashId(this.id),
tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined,
lastSlideIndex,
numSlides,
percentComplete,
},
});
}
}
// Check that icon points to an expected location. This will definitely

View File

@@ -0,0 +1,46 @@
import {BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn} from 'typeorm';
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
import {nativeValues} from 'app/gen-server/lib/values';
@Entity('limits')
export class Limit extends BaseEntity {
@PrimaryGeneratedColumn()
public id: number;
@Column()
public limit: number;
@Column()
public usage: number;
@Column()
public type: string;
@Column({name: 'billing_account_id'})
public billingAccountId: number;
@ManyToOne(type => BillingAccount)
@JoinColumn({name: 'billing_account_id'})
public billingAccount: BillingAccount;
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
public createdAt: Date;
/**
* Last time the Limit.limit value was changed, by an upgrade or downgrade. Null if it has never been changed.
*/
@Column({name: 'changed_at', type: nativeValues.dateTimeType, nullable: true})
public changedAt: Date|null;
/**
* Last time the Limit.usage was used (by sending a request to the model). Null if it has never been used.
*/
@Column({name: 'used_at', type: nativeValues.dateTimeType, nullable: true})
public usedAt: Date|null;
/**
* Last time the Limit.usage was reset, probably by billing cycle change. Null if it has never been reset.
*/
@Column({name: 'reset_at', type: nativeValues.dateTimeType, nullable: true})
public resetAt: Date|null;
}

View File

@@ -13,7 +13,11 @@ export const personalLegacyFeatures: Features = {
// no vanity domain
maxDocsPerOrg: 10,
maxSharesPerDoc: 2,
maxWorkspacesPerOrg: 1
maxWorkspacesPerOrg: 1,
/**
* One time limit of 100 requests.
*/
baseMaxAssistantCalls: 100,
};
/**
@@ -23,7 +27,12 @@ export const teamFeatures: Features = {
workspaces: true,
vanityDomain: true,
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
maxSharesPerDoc: 2
maxSharesPerDoc: 2,
/**
* Limit of 100 requests, but unlike for personal/free orgs the usage for this limit is reset at every billing cycle
* through Stripe webhook. For canceled subscription the usage is not reset, as the billing cycle is not changed.
*/
baseMaxAssistantCalls: 100,
};
/**
@@ -40,6 +49,10 @@ export const teamFreeFeatures: Features = {
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
gracePeriodDays: 14,
/**
* One time limit of 100 requests.
*/
baseMaxAssistantCalls: 100,
};
/**
@@ -55,6 +68,7 @@ export const teamFreeFeatures: Features = {
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
gracePeriodDays: 14,
baseMaxAssistantCalls: 100,
};
export const testDailyApiLimitFeatures = {
@@ -79,6 +93,7 @@ export const suspendedFeatures: Features = {
maxDocsPerOrg: 0,
maxSharesPerDoc: 0,
maxWorkspacesPerOrg: 0,
baseMaxAssistantCalls: 0,
};
/**

View File

@@ -20,6 +20,7 @@ export class Activations {
if (!activation) {
activation = manager.create(Activation);
activation.id = makeId();
activation.prefs = {};
await activation.save();
}
return activation;

View File

@@ -60,6 +60,7 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/assign', withDocWithoutAuth);
app.use('/api/docs/:docId/webhooks/queue', withDoc);
app.use('/api/docs/:docId/webhooks', withDoc);
app.use('/api/docs/:docId/assistant', withDoc);
app.use('^/api/docs$', withoutDoc);
}

View File

@@ -1,4 +1,4 @@
import {ApiError} from 'app/common/ApiError';
import {ApiError, ApiErrorDetails, LimitType} from 'app/common/ApiError';
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
import {getDataLimitStatus} from 'app/common/DocLimits';
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
@@ -38,6 +38,7 @@ import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-ser
import {Secret} from "app/gen-server/entity/Secret";
import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace";
import {Limit} from 'app/gen-server/entity/Limit';
import {Permissions} from 'app/gen-server/lib/Permissions';
import {scrubUserFromOrg} from "app/gen-server/lib/scrubUserFromOrg";
import {applyPatch} from 'app/gen-server/lib/TypeORMPatches';
@@ -89,14 +90,6 @@ export const NotifierEvents = StringUnion(
export type NotifierEvent = typeof NotifierEvents.type;
export const HomeDBTelemetryEvents = StringUnion(
'tutorialProgressChanged',
);
export type HomeDBTelemetryEvent = typeof HomeDBTelemetryEvents.type;
export type Event = NotifierEvent | HomeDBTelemetryEvent;
// Nominal email address of a user who can view anything (for thumbnails).
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
@@ -324,7 +317,7 @@ export class HomeDBManager extends EventEmitter {
orgOnly: true
}];
public emit(event: Event, ...args: any[]): boolean {
public emit(event: NotifierEvent, ...args: any[]): boolean {
return super.emit(event, ...args);
}
@@ -1960,7 +1953,7 @@ export class HomeDBManager extends EventEmitter {
// Update the name and save.
const doc: Document = queryResult.data;
doc.checkProperties(props);
doc.updateFromProperties(props, this);
doc.updateFromProperties(props);
if (forkId) {
await manager.save(doc);
return {status: 200};
@@ -2888,6 +2881,144 @@ export class HomeDBManager extends EventEmitter {
return this._org(scope, scope.includeSupport || false, org, options);
}
public async getLimits(accountId: number): Promise<Limit[]> {
const result = this._connection.transaction(async manager => {
return await manager.createQueryBuilder()
.select('limit')
.from(Limit, 'limit')
.innerJoin('limit.billingAccount', 'account')
.where('account.id = :accountId', {accountId})
.getMany();
});
return result;
}
public async getLimit(accountId: number, limitType: LimitType): Promise<Limit|null> {
return await this._getOrCreateLimit(accountId, limitType, true);
}
public async peekLimit(accountId: number, limitType: LimitType): Promise<Limit|null> {
return await this._getOrCreateLimit(accountId, limitType, false);
}
public async removeLimit(scope: Scope, limitType: LimitType): Promise<void> {
await this._connection.transaction(async manager => {
const org = await this._org(scope, false, scope.org ?? null, {manager, needRealOrg: true})
.innerJoinAndSelect('orgs.billingAccount', 'billing_account')
.innerJoinAndSelect('billing_account.product', 'product')
.leftJoinAndSelect('billing_account.limits', 'limit', 'limit.type = :limitType', {limitType})
.getOne();
const existing = org?.billingAccount?.limits?.[0];
if (existing) {
await manager.remove(existing);
}
});
}
/**
* Increases the usage of a limit for a given org. If the limit doesn't exist, it will be created.
* Pass `dryRun: true` to check if the limit can be increased without actually increasing it.
*/
public async increaseUsage(scope: Scope, limitType: LimitType, options: {
delta: number,
dryRun?: boolean,
}): Promise<void> {
const limitError = await this._connection.transaction(async manager => {
const org = await this._org(scope, false, scope.org ?? null, {manager, needRealOrg: true})
.innerJoinAndSelect('orgs.billingAccount', 'billing_account')
.innerJoinAndSelect('billing_account.product', 'product')
.leftJoinAndSelect('billing_account.limits', 'limit', 'limit.type = :limitType', {limitType})
.getOne();
// If the org doesn't exists, or is a fake one (like for anonymous users), don't do anything.
if (!org || org.id === 0) {
// This API shouldn't be called, it should be checked first if the org is valid.
throw new ApiError(`Can't create a limit for non-existing organization`, 500);
}
let existing = org?.billingAccount?.limits?.[0];
if (!existing) {
const product = org?.billingAccount?.product;
if (!product) {
throw new ApiError(`getLimit: no product found for org`, 500);
}
if (product.features.baseMaxAssistantCalls === undefined) {
// If the product has no assistantLimit, then it is not billable yet, and we don't need to
// track usage as it is basically unlimited.
return;
}
existing = new Limit();
existing.billingAccountId = org.billingAccountId;
existing.type = limitType;
existing.limit = product.features.baseMaxAssistantCalls ?? 0;
existing.usage = 0;
}
const limitLess = existing.limit === -1; // -1 means no limit, it is not possible to do in stripe.
const usageAfter = existing.usage + options.delta;
if (!limitLess && usageAfter > existing.limit) {
const billable = Boolean(org?.billingAccount?.stripeCustomerId);
return {
limit: {
maximum: existing.limit,
projectedValue: existing.usage + options.delta,
quantity: limitType,
value: existing.usage,
},
tips: [{
// For non-billable accounts, suggest getting a plan, otherwise suggest visiting the billing page.
action: billable ? 'manage' : 'upgrade',
message: `Upgrade to a paid plan to increase your ${limitType} limit.`,
}]
} as ApiErrorDetails;
}
existing.usage += options.delta;
existing.usedAt = new Date();
if (!options.dryRun) {
await manager.save(existing);
}
});
if (limitError) {
let message = `Your ${limitType} limit has been reached. Please upgrade your plan to increase your limit.`;
if (limitType === 'assistant') {
message = 'You used all available credits. For a bigger limit upgrade you Assistant plan.';
}
throw new ApiError(message, 429, limitError);
}
}
private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
if (accountId === 0) {
throw new Error(`getLimit: called for not existing account`);
}
const result = this._connection.transaction(async manager => {
let existing = await manager.createQueryBuilder()
.select('limit')
.from(Limit, 'limit')
.innerJoin('limit.billingAccount', 'account')
.where('account.id = :accountId', {accountId})
.andWhere('limit.type = :limitType', {limitType})
.getOne();
if (!force && !existing) { return null; }
if (existing) { return existing; }
const product = await manager.createQueryBuilder()
.select('product')
.from(Product, 'product')
.innerJoinAndSelect('product.accounts', 'account')
.where('account.id = :accountId', {accountId})
.getOne();
if (!product) {
throw new Error(`getLimit: no product for account ${accountId}`);
}
existing = new Limit();
existing.billingAccountId = product.accounts[0].id;
existing.type = limitType;
existing.limit = product.features.baseMaxAssistantCalls ?? 0;
existing.usage = 0;
await manager.save(existing);
return existing;
});
return result;
}
private _org(scope: Scope|null, includeSupport: boolean, org: string|number|null,
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
let query = this._orgs(options.manager);

View File

@@ -0,0 +1,16 @@
import {nativeValues} from 'app/gen-server/lib/values';
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
export class ActivationPrefs1682636695021 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn('activations', new TableColumn({
name: 'prefs',
type: nativeValues.jsonType,
isNullable: true,
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn('activations', 'prefs');
}
}

View File

@@ -0,0 +1,82 @@
import * as sqlUtils from "app/gen-server/sqlUtils";
import {MigrationInterface, QueryRunner, Table, TableIndex} from 'typeorm';
export class AssistantLimit1685343047786 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const dbType = queryRunner.connection.driver.options.type;
const datetime = sqlUtils.datetime(dbType);
const now = sqlUtils.now(dbType);
await queryRunner.createTable(
new Table({
name: 'limits',
columns: [
{
name: 'id',
type: 'integer',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'type',
type: 'varchar',
},
{
name: 'billing_account_id',
type: 'integer',
},
{
name: 'limit',
type: 'integer',
default: 0,
},
{
name: 'usage',
type: 'integer',
default: 0,
},
{
name: "created_at",
type: datetime,
default: now
},
{
name: "changed_at", // When the limit was last changed
type: datetime,
isNullable: true
},
{
name: "used_at", // When the usage was last increased
type: datetime,
isNullable: true
},
{
name: "reset_at", // When the usage was last reset.
type: datetime,
isNullable: true
},
],
foreignKeys: [
{
columnNames: ['billing_account_id'],
referencedTableName: 'billing_accounts',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
})
);
await queryRunner.createIndex(
'limits',
new TableIndex({
name: 'limits_billing_account_id',
columnNames: ['billing_account_id'],
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('limits');
}
}

View File

@@ -6,7 +6,7 @@ import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
import { getMigrations, getOrCreateConnection, getTypeORMSettings,
undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
import { getDatabaseUrl } from 'app/server/lib/serverUtils';
import { getTelemetryLevel } from 'app/server/lib/Telemetry';
import { getTelemetryPrefs } from 'app/server/lib/Telemetry';
import { Gristifier } from 'app/server/utils/gristify';
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
import * as commander from 'commander';
@@ -81,12 +81,14 @@ export function addSettingsCommand(program: commander.Command,
.action(showTelemetry);
}
function showTelemetry(options: {
async function showTelemetry(options: {
json?: boolean,
all?: boolean,
}) {
const contracts = TelemetryContracts;
const levelName = getTelemetryLevel();
const db = await getHomeDBManager();
const prefs = await getTelemetryPrefs(db);
const levelName = prefs.telemetryLevel.value;
const level = Level[levelName];
if (options.json) {
console.log(JSON.stringify({

View File

@@ -16,9 +16,7 @@
import {LocalActionBundle} from 'app/common/ActionBundle';
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
import {createEmptyActionSummary} from 'app/common/ActionSummary';
import {getSelectionDesc, UserAction} from 'app/common/DocActions';
import {DocState} from 'app/common/UserAPI';
import toPairs = require('lodash/toPairs');
import {summarizeAction} from 'app/common/ActionSummarizer';
export interface ActionGroupOptions {
@@ -163,81 +161,6 @@ export abstract class ActionHistory {
}
/**
* Old helper to display the actionGroup in a human-readable way. Being maintained
* to avoid having to change too much at once.
*/
export function humanDescription(actions: UserAction[]): string {
const action = actions[0];
if (!action) { return ""; }
let output = '';
// Common names for various action parameters
const name = action[0];
const table = action[1];
const rows = action[2];
const colId = action[2];
const columns: any = action[3]; // TODO - better typing - but code may evaporate
switch (name) {
case 'UpdateRecord':
case 'BulkUpdateRecord':
case 'AddRecord':
case 'BulkAddRecord':
output = name + ' ' + getSelectionDesc(action, columns);
break;
case 'ApplyUndoActions':
// Currently cannot display information about what action was undone, as the action comes
// with the description of the "undo" message, which might be very different
// Also, cannot currently properly log redos as they are not distinguished from others in any way
// TODO: make an ApplyRedoActions type for redoing actions
output = 'Undo Previous Action';
break;
case 'InitNewDoc':
output = 'Initialized new Document';
break;
case 'AddColumn':
output = 'Added column ' + colId + ' to ' + table;
break;
case 'RemoveColumn':
output = 'Removed column ' + colId + ' from ' + table;
break;
case 'RemoveRecord':
case 'BulkRemoveRecord':
output = 'Removed record(s) ' + rows + ' from ' + table;
break;
case 'EvalCode':
output = 'Evaluated Code ' + action[1];
break;
case 'AddTable':
output = 'Added table ' + table;
break;
case 'RemoveTable':
output = 'Removed table ' + table;
break;
case 'ModifyColumn':
// TODO: The Action Log currently only logs user actions,
// But ModifyColumn/Rename Column are almost always triggered from the client
// through a meta-table UpdateRecord.
// so, this is a case where making use of explicit sandbox engine 'looged' actions
// may be useful
output = 'Modify column ' + colId + ", ";
for (const [col, val] of toPairs(columns)) {
output += col + ": " + val + ", ";
}
output += ' in table ' + table;
break;
case 'RenameColumn': {
const newColId = action[3];
output = 'Renamed Column ' + colId + ' to ' + newColId + ' in ' + table;
break;
}
default:
output = name + ' [No Description]';
}
// A period for good grammar
output += '.';
return output;
}
/**
* Convert an ActionBundle into an ActionGroup. ActionGroups are the representation of
* actions on the client.
@@ -260,7 +183,9 @@ export function asActionGroup(history: ActionHistory,
return {
actionNum: act.actionNum,
actionHash: act.actionHash || "",
desc: info.desc || humanDescription(act.userActions),
// Desc is a human-readable description of the user action set in a few places by client-side
// code, but is mostly (or maybe completely) unused.
desc: info.desc,
actionSummary: summarize ? summarizeAction(act) : createEmptyActionSummary(),
fromSelf,
linkId: info.linkId,

View File

@@ -14,7 +14,6 @@ import {
} from 'app/common/ActionBundle';
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
import {ActionSummary} from "app/common/ActionSummary";
import {AssistanceRequest, AssistanceResponse} from "app/common/AssistancePrompts";
import {
AclResources,
AclTableDescription,
@@ -68,14 +67,12 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
import {isHiddenCol} from 'app/common/gristTypes';
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval';
import * as roles from 'app/common/roles';
import {schema, SCHEMA_VERSION} from 'app/common/schema';
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
import {MetaRowRecord, SingleCell, UIRowId} from 'app/common/TableData';
import {TelemetryEvent, TelemetryMetadataByLevel} from 'app/common/Telemetry';
import {UIRowId} from 'app/common/UIRowId';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {Document as APIDocument, DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
import {convertFromColumn} from 'app/common/ValueConverter';
@@ -86,7 +83,7 @@ import {Document} from 'app/gen-server/entity/Document';
import {ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {AssistanceDoc, AssistanceSchemaPromptV1Context, sendForCompletion} from 'app/server/lib/Assistance';
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
import {Authorizer} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
@@ -186,7 +183,7 @@ interface UpdateUsageOptions {
* either .loadDoc() or .createEmptyDoc() is called.
* @param {String} docName - The document's filename, without the '.grist' extension.
*/
export class ActiveDoc extends EventEmitter implements AssistanceDoc {
export class ActiveDoc extends EventEmitter {
/**
* Decorator for ActiveDoc methods that prevents shutdown while the method is running, i.e.
* until the returned promise is resolved.
@@ -1266,18 +1263,14 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
return this._pyCall('autocomplete', txt, tableId, columnId, rowId, user.toJSON());
}
public async getAssistance(docSession: DocSession, request: AssistanceRequest): Promise<AssistanceResponse> {
return this.getAssistanceWithOptions(docSession, request);
}
public async getAssistanceWithOptions(docSession: DocSession,
request: AssistanceRequest): Promise<AssistanceResponse> {
// Callback to generate a prompt containing schema info for assistance.
public async assistanceSchemaPromptV1(
docSession: OptDocSession, options: AssistanceSchemaPromptV1Context): Promise<string> {
// Making a prompt leaks names of tables and columns etc.
if (!await this._granularAccess.canScanData(docSession)) {
throw new Error("Permission denied");
}
await this.waitForInitialization();
return sendForCompletion(this, request);
return await this._pyCall('get_formula_prompt', options.tableId, options.colId, options.docString);
}
// Callback to make a data-engine formula tweak for assistance.
@@ -1285,11 +1278,6 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
return this._pyCall('convert_formula_completion', txt);
}
// Callback to generate a prompt containing schema info for assistance.
public assistanceSchemaPromptV1(options: AssistanceSchemaPromptV1Context): Promise<string> {
return this._pyCall('get_formula_prompt', options.tableId, options.colId, options.docString);
}
public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise<UploadResult> {
return fetchURL(url, this.makeAccessId(docSession.authorizer.getUserId()), options);
}
@@ -1396,9 +1384,9 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
this.logTelemetryEvent(docSession, 'documentForked', {
limited: {
forkIdDigest: hashId(forkIds.forkId),
forkDocIdDigest: hashId(forkIds.docId),
trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined,
forkIdDigest: forkIds.forkId,
forkDocIdDigest: forkIds.docId,
trunkIdDigest: doc.trunkId,
isTemplate,
lastActivity: doc.updatedAt,
},
@@ -2540,7 +2528,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
altSessionId ? {altSessionId} : {},
{
limited: {
docIdDigest: hashId(this._docName),
docIdDigest: this._docName,
},
full: {
siteId: this._doc?.workspace.org.id,

View File

@@ -9,7 +9,6 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
import {ApiError} from 'app/common/ApiError';
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
import {removeTrailingSlash} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI';
@@ -304,13 +303,13 @@ export function attachAppEndpoint(options: AttachOptions): void {
}
const isPublic = ((doc as unknown) as APIDocument).public ?? false;
const isSnapshot = parseUrlId(urlId).snapshotId;
const isSnapshot = Boolean(parseUrlId(urlId).snapshotId);
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
if (isPublic || isTemplate) {
gristServer.getTelemetry().logEvent('documentOpened', {
limited: {
docIdDigest: hashId(docId),
docIdDigest: docId,
access: doc.access,
isPublic,
isSnapshot,

View File

@@ -5,6 +5,7 @@
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
import {delay} from 'app/common/delay';
import {DocAction} from 'app/common/DocActions';
import {OptDocSession} from 'app/server/lib/DocSession';
import log from 'app/server/lib/log';
import fetch from 'node-fetch';
@@ -15,7 +16,7 @@ export const DEPS = { fetch };
* by interfacing with an external LLM endpoint.
*/
export interface Assistant {
apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse>;
apply(session: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse>;
}
/**
@@ -30,8 +31,7 @@ export interface AssistanceDoc {
* Marked "V1" to suggest that it is a particular prompt and it would
* be great to try variants.
*/
assistanceSchemaPromptV1(options: AssistanceSchemaPromptV1Context): Promise<string>;
assistanceSchemaPromptV1(session: OptDocSession, options: AssistanceSchemaPromptV1Context): Promise<string>;
/**
* Some tweaks to a formula after it has been generated.
*/
@@ -46,7 +46,7 @@ export interface AssistanceSchemaPromptV1Context {
/**
* A flavor of assistant for use with the OpenAI API.
* Tested primarily with text-davinci-002 and gpt-3.5-turbo.
* Tested primarily with gpt-3.5-turbo.
*/
export class OpenAIAssistant implements Assistant {
private _apiKey: string;
@@ -60,37 +60,43 @@ export class OpenAIAssistant implements Assistant {
throw new Error('OPENAI_API_KEY not set');
}
this._apiKey = apiKey;
this._model = process.env.COMPLETION_MODEL || "text-davinci-002";
this._model = process.env.COMPLETION_MODEL || "gpt-3.5-turbo-0613";
this._chatMode = this._model.includes('turbo');
if (!this._chatMode) {
throw new Error('Only turbo models are currently supported');
}
this._endpoint = `https://api.openai.com/v1/${this._chatMode ? 'chat/' : ''}completions`;
}
public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
public async apply(
optSession: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
const messages = request.state?.messages || [];
const chatMode = this._chatMode;
if (chatMode) {
if (messages.length === 0) {
messages.push({
role: 'system',
content: 'The user gives you one or more Python classes, ' +
'with one last method that needs completing. Write the ' +
'method body as a single code block, ' +
'including the docstring the user gave. ' +
'Just give the Python code as a markdown block, ' +
'do not give any introduction, that will just be ' +
'awkward for the user when copying and pasting. ' +
'You are working with Grist, an environment very like ' +
'regular Python except `rec` (like record) is used ' +
'instead of `self`. ' +
'Include at least one `return` statement or the method ' +
'will fail, disappointing the user. ' +
'Your answer should be the body of a single method, ' +
'not a class, and should not include `dataclass` or ' +
'`class` since the user is counting on you to provide ' +
'a single method. Thanks!'
content: 'You are a helpful assistant for a user of software called Grist. ' +
'Below are one or more Python classes. ' +
'The last method needs completing. ' +
"The user will probably give a description of what they want the method (a 'formula') to return. " +
'If so, your response should include the method body as Python code in a markdown block. ' +
'Do not include the class or method signature, just the method body. ' +
'If your code starts with `class`, `@dataclass`, or `def` it will fail. Only give the method body. ' +
'You can import modules inside the method body if needed. ' +
'You cannot define additional functions or methods. ' +
'The method should be a pure function that performs some computation and returns a result. ' +
'It CANNOT perform any side effects such as adding/removing/modifying rows/columns/cells/tables/etc. ' +
'It CANNOT interact with files/databases/networks/etc. ' +
'It CANNOT display images/charts/graphs/maps/etc. ' +
'If the user asks for these things, tell them that you cannot help. ' +
'The method uses `rec` instead of `self` as the first parameter.\n\n' +
'```python\n' +
await makeSchemaPromptV1(optSession, doc, request) +
'\n```',
});
messages.push({
role: 'user', content: await makeSchemaPromptV1(doc, request),
role: 'user', content: request.text,
});
} else {
if (request.regenerate) {
@@ -105,7 +111,7 @@ export class OpenAIAssistant implements Assistant {
} else {
messages.length = 0;
messages.push({
role: 'user', content: await makeSchemaPromptV1(doc, request),
role: 'user', content: await makeSchemaPromptV1(optSession, doc, request),
});
}
@@ -173,11 +179,12 @@ export class HuggingFaceAssistant implements Assistant {
}
public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
public async apply(
optSession: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
if (request.state) {
throw new Error("HuggingFaceAssistant does not support state");
}
const prompt = await makeSchemaPromptV1(doc, request);
const prompt = await makeSchemaPromptV1(optSession, doc, request);
const response = await DEPS.fetch(
this._completionUrl,
{
@@ -215,7 +222,10 @@ export class HuggingFaceAssistant implements Assistant {
* Test assistant that mimics ChatGPT and just returns the input.
*/
export class EchoAssistant implements Assistant {
public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
public async apply(sess: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
if (request.text === "ERROR") {
throw new Error(`ERROR`);
}
const messages = request.state?.messages || [];
if (messages.length === 0) {
messages.push({
@@ -250,25 +260,28 @@ export class EchoAssistant implements Assistant {
/**
* Instantiate an assistant, based on environment variables.
*/
function getAssistant() {
export function getAssistant() {
if (process.env.OPENAI_API_KEY === 'test') {
return new EchoAssistant();
}
if (process.env.OPENAI_API_KEY) {
return new OpenAIAssistant();
}
if (process.env.HUGGINGFACE_API_KEY) {
return new HuggingFaceAssistant();
}
throw new Error('Please set OPENAI_API_KEY or HUGGINGFACE_API_KEY');
// Maintaining this is too much of a burden for now.
// if (process.env.HUGGINGFACE_API_KEY) {
// return new HuggingFaceAssistant();
// }
throw new Error('Please set OPENAI_API_KEY');
}
/**
* Service a request for assistance, with a little retry logic
* since these endpoints can be a bit flakey.
*/
export async function sendForCompletion(doc: AssistanceDoc,
request: AssistanceRequest): Promise<AssistanceResponse> {
export async function sendForCompletion(
optSession: OptDocSession,
doc: AssistanceDoc,
request: AssistanceRequest): Promise<AssistanceResponse> {
const assistant = getAssistant();
let retries: number = 0;
@@ -276,7 +289,7 @@ export async function sendForCompletion(doc: AssistanceDoc,
let response: AssistanceResponse|null = null;
while(retries++ < 3) {
try {
response = await assistant.apply(doc, request);
response = await assistant.apply(optSession, doc, request);
break;
} catch(e) {
log.error(`Completion error: ${e}`);
@@ -289,11 +302,11 @@ export async function sendForCompletion(doc: AssistanceDoc,
return response;
}
async function makeSchemaPromptV1(doc: AssistanceDoc, request: AssistanceRequest) {
async function makeSchemaPromptV1(session: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest) {
if (request.context.type !== 'formula') {
throw new Error('makeSchemaPromptV1 only works for formulas');
}
return doc.assistanceSchemaPromptV1({
return doc.assistanceSchemaPromptV1(session, {
tableId: request.context.tableId,
colId: request.context.colId,
docString: request.text,

View File

@@ -1,5 +1,5 @@
import {createEmptyActionSummary} from "app/common/ActionSummary";
import {ApiError} from 'app/common/ApiError';
import {ApiError, LimitType} from 'app/common/ApiError';
import {BrowserSettings} from "app/common/BrowserSettings";
import {
BulkColValues,
@@ -11,7 +11,6 @@ import {
import {isRaisedException} from "app/common/gristTypes";
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {isAffirmative} from "app/common/gutil";
import {hashId} from "app/common/hashingUtils";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec';
@@ -69,6 +68,7 @@ import {
} from 'app/server/lib/requestUtils';
import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters';
import {localeFromRequest} from "app/server/lib/ServerLocale";
import {sendForCompletion} from 'app/server/lib/Assistance';
import {isUrlAllowed, WebhookAction, WebHookSecret} from "app/server/lib/Triggers";
import {handleOptionalUpload, handleUpload} from "app/server/lib/uploads";
import * as assert from 'assert';
@@ -162,6 +162,8 @@ export class DocWorkerApi {
const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true));
// converts google code to access token and adds it to request object
const decodeGoogleToken = expressWrap(googleAuthTokenMiddleware.bind(null));
// check that limit can be increased by 1
const checkLimit = (type: LimitType) => expressWrap(this._checkLimit.bind(this, type));
// Middleware to limit number of outstanding requests per document. Will also
// handle errors like expressWrap would.
@@ -918,8 +920,8 @@ export class DocWorkerApi {
const {forkId} = parseUrlId(scope.urlId);
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
full: {
tutorialForkIdDigest: forkId ? hashId(forkId) : undefined,
tutorialTrunkIdDigest: hashId(tutorialTrunkId),
tutorialForkIdDigest: forkId,
tutorialTrunkIdDigest: tutorialTrunkId,
},
});
}
@@ -1053,6 +1055,20 @@ export class DocWorkerApi {
this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive));
/**
* Send a request to the formula assistant to get completions for a formula. Increases the
* usage of the formula assistant for the billing account in case of success.
*/
this._app.post('/api/docs/:docId/assistant', canView, checkLimit('assistant'),
withDoc(async (activeDoc, req, res) => {
const docSession = docSessionFromRequest(req);
const request = req.body;
const result = await sendForCompletion(docSession, activeDoc, request);
await this._increaseLimit('assistant', req);
res.json(result);
})
);
// Create a document. When an upload is included, it is imported as the initial
// state of the document. Otherwise a fresh empty document is created.
// A "timezone" option can be supplied.
@@ -1235,6 +1251,21 @@ export class DocWorkerApi {
return false;
}
/**
* Creates a middleware that checks the current usage of a limit and rejects the request if it is exceeded.
*/
private async _checkLimit(limit: LimitType, req: Request, res: Response, next: NextFunction) {
await this._dbManager.increaseUsage(getDocScope(req), limit, {dryRun: true, delta: 1});
next();
}
/**
* Increases the current usage of a limit by 1.
*/
private async _increaseLimit(limit: LimitType, req: Request) {
await this._dbManager.increaseUsage(getDocScope(req), limit, {delta: 1});
}
private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean,
req: Request, res: Response, next: NextFunction) {
const scope = getDocScope(req);

View File

@@ -378,7 +378,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(', ');
const types = newCols.map(c => c.type);
const sqlParams = DocStorage._encodeColumnsToRows(types, newCols.map(c => [PENDING_VALUE]));
await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, sqlParams[0]);
await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, ...sqlParams[0]);
}
},
@@ -1093,7 +1093,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
public _process_RemoveRecord(tableId: string, rowId: string): Promise<RunResult> {
const sql = "DELETE FROM " + quoteIdent(tableId) + " WHERE id=?";
debuglog("RemoveRecord SQL: " + sql, [rowId]);
return this.run(sql, [rowId]);
return this.run(sql, rowId);
}
@@ -1130,7 +1130,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
const stmt = await this.prepare(preSql + chunkParams + postSql);
for (const index of _.range(0, numChunks * chunkSize, chunkSize)) {
debuglog("DocStorage.BulkRemoveRecord: chunk delete " + index + "-" + (index + chunkSize - 1));
await stmt.run(rowIds.slice(index, index + chunkSize));
await stmt.run(...rowIds.slice(index, index + chunkSize));
}
await stmt.finalize();
}
@@ -1139,7 +1139,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
debuglog("DocStorage.BulkRemoveRecord: leftover delete " + (numChunks * chunkSize) + "-" + (rowIds.length - 1));
const leftoverParams = _.range(numLeftovers).map(q).join(',');
await this.run(preSql + leftoverParams + postSql,
rowIds.slice(numChunks * chunkSize, rowIds.length));
...rowIds.slice(numChunks * chunkSize, rowIds.length));
}
}

View File

@@ -110,7 +110,6 @@ export class DocWorker {
applyUserActionsById: activeDocMethod.bind(null, 'editors', 'applyUserActionsById'),
findColFromValues: activeDocMethod.bind(null, 'viewers', 'findColFromValues'),
getFormulaError: activeDocMethod.bind(null, 'viewers', 'getFormulaError'),
getAssistance: activeDocMethod.bind(null, 'editors', 'getAssistance'),
importFiles: activeDocMethod.bind(null, 'editors', 'importFiles'),
finishImportFiles: activeDocMethod.bind(null, 'editors', 'finishImportFiles'),
cancelImportFiles: activeDocMethod.bind(null, 'editors', 'cancelImportFiles'),

View File

@@ -5,6 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
sanitizePathTail} from 'app/common/gristUrls';
import {getOrgUrlInfo} from 'app/common/gristUrls';
import {InstallProperties} from 'app/common/InstallAPI';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {tbind} from 'app/common/tbind';
import * as version from 'app/common/version';
@@ -50,14 +51,15 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, optStringParam,
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, isDefaultUser, optStringParam,
RequestWithGristInfo, sendOkReply, stringParam, TEST_HTTPS_OFFSET,
trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
import * as shutdown from 'app/server/lib/shutdown';
import {TagChecker} from 'app/server/lib/TagChecker';
import {ITelemetry} from 'app/server/lib/Telemetry';
import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry';
import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads';
@@ -706,11 +708,17 @@ export class FlexServer implements GristServer {
});
}
public addTelemetry() {
public async addTelemetry() {
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
this._telemetry = this.create.Telemetry(this._dbManager, this);
this._telemetry.addEndpoints(this.app);
this._telemetry.addPages(this.app, [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
]);
await this._telemetry.start();
// Start up a monitor for memory and cpu usage.
this._processMonitorStop = ProcessMonitor.start(this._telemetry);
@@ -1124,7 +1132,11 @@ export class FlexServer implements GristServer {
await this.loadConfig();
this.addComm();
// Temporary duplication of external storage configuration.
// This may break https://github.com/gristlabs/grist-core/pull/546,
// but will revive other uses of external storage. TODO: reconcile.
await this.create.configure?.();
if (!isSingleUserMode()) {
const externalStorage = appSettings.section('externalStorage');
const haveExternalStorage = Object.values(externalStorage.nested)
@@ -1135,6 +1147,7 @@ export class FlexServer implements GristServer {
this._disableExternalStorage = true;
externalStorage.flag('active').set(false);
}
await this.create.configure?.();
const workers = this._docWorkerMap;
const docWorkerId = await this._addSelfAsWorker(workers);
@@ -1198,7 +1211,7 @@ export class FlexServer implements GristServer {
];
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
return this._sendAppPage(req, resp, {path: 'account.html', status: 200, config: {}});
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
}
@@ -1460,6 +1473,43 @@ export class FlexServer implements GristServer {
addGoogleAuthEndpoint(this.app, messagePage);
}
public addInstallEndpoints() {
if (this._check('install')) { return; }
const isManager = expressWrap(
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
next();
}
);
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => {
const activation = await this._activations.current();
return sendOkReply(null, resp, {
telemetry: await getTelemetryPrefs(this._dbManager, activation),
});
}));
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => {
const props = {prefs: req.body};
const activation = await this._activations.current();
activation.checkProperties(props);
activation.updateFromProperties(props);
await activation.save();
if ((props as Partial<InstallProperties>).prefs?.telemetry) {
// Make sure the Telemetry singleton picks up the changes to telemetry preferences.
// TODO: if there are multiple home server instances, notify them all of changes to
// preferences (via Redis Pub/Sub).
await this._telemetry.fetchTelemetryPrefs();
}
return resp.status(200).send();
}));
}
// Get the HTML template sent for document pages.
public async getDocTemplate(): Promise<DocTemplate> {
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),

View File

@@ -138,8 +138,11 @@ export function createDummyGristServer(): GristServer {
export function createDummyTelemetry(): ITelemetry {
return {
logEvent() { return Promise.resolve(); },
addEndpoints() { /* do nothing */ },
getTelemetryLevel() { return 'off'; },
addPages() { /* do nothing */ },
start() { return Promise.resolve(); },
logEvent() { return Promise.resolve(); },
getTelemetryConfig() { return undefined; },
fetchTelemetryPrefs() { return Promise.resolve(); },
};
}

View File

@@ -50,6 +50,7 @@ export interface ICreateActiveDocOptions {
export interface ICreateStorageOptions {
name: string;
check(): boolean;
checkBackend?(): Promise<void>;
create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined;
}
@@ -119,7 +120,10 @@ export function makeSimpleCreator(opts: {
},
async configure() {
for (const s of storage || []) {
if (s.check()) { break; }
if (s.check()) {
await s.checkBackend?.();
break;
}
}
},
...(opts.shell && {

View File

@@ -107,6 +107,11 @@ export class MinIOExternalStorage implements ExternalStorage {
}
}
public async hasVersioning(): Promise<Boolean> {
const versioning = await this._s3.getBucketVersioning(this.bucket);
return versioning && versioning.Status === 'Enabled';
}
public async versions(key: string, options?: { includeDeleteMarkers?: boolean }) {
const results: minio.BucketItem[] = [];
await new Promise((resolve, reject) => {

View File

@@ -1,8 +1,10 @@
import {ApiError} from 'app/common/ApiError';
import {TelemetryConfig} from 'app/common/gristUrls';
import {assertIsDefined} from 'app/common/gutil';
import {
buildTelemetryEventChecker,
filterMetadata,
removeNullishKeys,
Level,
TelemetryContracts,
TelemetryEvent,
TelemetryEventChecker,
TelemetryEvents,
@@ -11,18 +13,28 @@ import {
TelemetryMetadata,
TelemetryMetadataByLevel,
} from 'app/common/Telemetry';
import {HomeDBManager, HomeDBTelemetryEvents} from 'app/gen-server/lib/HomeDBManager';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {Activation} from 'app/gen-server/entity/Activation';
import {Activations} from 'app/gen-server/lib/Activations';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {RequestWithLogin} from 'app/server/lib/Authorizer';
import {expressWrap} from 'app/server/lib/expressWrap';
import {GristServer} from 'app/server/lib/GristServer';
import {hashId} from 'app/server/lib/hashingUtils';
import {LogMethods} from 'app/server/lib/LogMethods';
import {stringParam} from 'app/server/lib/requestUtils';
import * as express from 'express';
import fetch from 'node-fetch';
import merge = require('lodash/merge');
import pickBy = require('lodash/pickBy');
export interface ITelemetry {
start(): Promise<void>;
logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise<void>;
addEndpoints(app: express.Express): void;
getTelemetryLevel(): TelemetryLevel;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
getTelemetryConfig(): TelemetryConfig | undefined;
fetchTelemetryPrefs(): Promise<void>;
}
const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
@@ -31,26 +43,30 @@ const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
* Manages telemetry for Grist.
*/
export class Telemetry implements ITelemetry {
private _telemetryLevel: TelemetryLevel;
private _deploymentType = this._gristServer.getDeploymentType();
private _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
private _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
'https://telemetry.getgrist.com/api/telemetry';
private _activation: Activation | undefined;
private readonly _deploymentType = this._gristServer.getDeploymentType();
private _telemetryPrefs: TelemetryPrefsWithSources | undefined;
private readonly _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
'https://telemetry.getgrist.com/api/telemetry';
private _numPendingForwardEventRequests = 0;
private _installationId: string | undefined;
private _logger = new LogMethods('Telemetry ', () => ({}));
private _telemetryLogger = new LogMethods('Telemetry ', () => ({
private readonly _logger = new LogMethods('Telemetry ', () => ({}));
private readonly _telemetryLogger = new LogMethods('Telemetry ', () => ({
eventType: 'telemetry',
}));
private _checkEvent: TelemetryEventChecker | undefined;
private _checkTelemetryEvent: TelemetryEventChecker | undefined;
constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) {
this._initialize().catch((e) => {
this._logger.error(undefined, 'failed to initialize', e);
});
}
public async start() {
await this.fetchTelemetryPrefs();
}
/**
@@ -96,19 +112,29 @@ export class Telemetry implements ITelemetry {
event: TelemetryEvent,
metadata?: TelemetryMetadataByLevel
) {
if (this._telemetryLevel === 'off') { return; }
if (!this._checkTelemetryEvent) {
this._logger.error(undefined, 'logEvent called but telemetry event checker is undefined');
return;
}
metadata = filterMetadata(metadata, this._telemetryLevel);
const prefs = this._telemetryPrefs;
if (!prefs) {
this._logger.error(undefined, 'logEvent called but telemetry preferences are undefined');
return;
}
const {telemetryLevel} = prefs;
if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel.value]) {
return;
}
metadata = filterMetadata(metadata, telemetryLevel.value);
this._checkTelemetryEvent(event, metadata);
if (this._shouldForwardTelemetryEvents) {
await this._forwardEvent(event, metadata);
} else {
this._telemetryLogger.rawLog('info', null, event, {
eventName: event,
eventSource: `grist-${this._deploymentType}`,
...metadata,
});
this._logEvent(event, metadata);
}
}
@@ -125,7 +151,7 @@ export class Telemetry implements ITelemetry {
* source. Otherwise, the event will only be logged after passing various
* checks.
*/
app.post('/api/telemetry', async (req, resp) => {
app.post('/api/telemetry', expressWrap(async (req, resp) => {
const mreq = req as RequestWithLogin;
const event = stringParam(req.body.event, 'event', TelemetryEvents.values);
if ('eventSource' in req.body.metadata) {
@@ -135,11 +161,12 @@ export class Telemetry implements ITelemetry {
});
} else {
try {
this._assertTelemetryIsReady();
await this.logEvent(event as TelemetryEvent, merge(
{
limited: {
eventSource: `grist-${this._deploymentType}`,
...(this._deploymentType !== 'saas' ? {installationId: this._installationId} : {}),
...(this._deploymentType !== 'saas' ? {installationId: this._activation!.id} : {}),
},
full: {
userId: mreq.userId,
@@ -154,38 +181,51 @@ export class Telemetry implements ITelemetry {
}
}
return resp.status(200).send();
}));
}
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
if (this._deploymentType === 'core') {
app.get('/support-grist', ...middleware, expressWrap(async (req, resp) => {
return this._gristServer.sendAppPage(req, resp,
{path: 'app.html', status: 200, config: {}});
}));
}
}
public getTelemetryConfig(): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs;
if (!prefs) {
this._logger.error(undefined, 'getTelemetryConfig called but telemetry preferences are undefined');
return undefined;
}
return {
telemetryLevel: prefs.telemetryLevel.value,
};
}
public async fetchTelemetryPrefs() {
this._activation = await this._gristServer.getActivations().current();
await this._fetchTelemetryPrefs();
}
private async _fetchTelemetryPrefs() {
this._telemetryPrefs = await getTelemetryPrefs(this._dbManager, this._activation);
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value);
}
private _logEvent(
event: TelemetryEvent,
metadata?: TelemetryMetadata
) {
this._telemetryLogger.rawLog('info', null, event, {
eventName: event,
eventSource: `grist-${this._deploymentType}`,
...metadata,
});
}
public getTelemetryLevel() {
return this._telemetryLevel;
}
private async _initialize() {
this._telemetryLevel = getTelemetryLevel();
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryLevel);
}
const {id} = await this._gristServer.getActivations().current();
this._installationId = id;
for (const event of HomeDBTelemetryEvents.values) {
this._dbManager.on(event, async (metadata) => {
this.logEvent(event, metadata).catch(e =>
this._logger.error(undefined, `failed to log telemetry event ${event}`, e));
});
}
}
private _checkTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadata) {
if (!this._checkEvent) {
throw new Error('Telemetry._checkEvent is undefined');
}
this._checkEvent(event, metadata);
}
private async _forwardEvent(
event: TelemetryEvent,
metadata?: TelemetryMetadata
@@ -198,7 +238,7 @@ export class Telemetry implements ITelemetry {
try {
this._numPendingForwardEventRequests += 1;
await this._postJsonPayload(JSON.stringify({event, metadata}));
await this._doForwardEvent(JSON.stringify({event, metadata}));
} catch (e) {
this._logger.error(undefined, `failed to forward telemetry event ${event}`, e);
} finally {
@@ -206,7 +246,7 @@ export class Telemetry implements ITelemetry {
}
}
private async _postJsonPayload(payload: string) {
private async _doForwardEvent(payload: string) {
await fetch(this._forwardTelemetryEventsUrl, {
method: 'POST',
headers: {
@@ -215,12 +255,90 @@ export class Telemetry implements ITelemetry {
body: payload,
});
}
}
export function getTelemetryLevel(): TelemetryLevel {
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
return TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL);
} else {
return 'off';
private _assertTelemetryIsReady() {
try {
assertIsDefined('activation', this._activation);
} catch (e) {
this._logger.error(null, 'activation is undefined', e);
throw new ApiError('Telemetry is not ready', 500);
}
}
}
export async function getTelemetryPrefs(
db: HomeDBManager,
activation?: Activation
): Promise<TelemetryPrefsWithSources> {
const GRIST_TELEMETRY_LEVEL = process.env.GRIST_TELEMETRY_LEVEL;
if (GRIST_TELEMETRY_LEVEL !== undefined) {
const value = TelemetryLevels.check(GRIST_TELEMETRY_LEVEL);
return {
telemetryLevel: {
value,
source: 'environment-variable',
},
};
}
const {prefs} = activation ?? await new Activations(db).current();
return {
telemetryLevel: {
value: prefs?.telemetry?.telemetryLevel ?? 'off',
source: 'preferences',
}
};
}
/**
* Returns a new, filtered metadata object, or undefined if `metadata` is undefined.
*
* Filtering currently:
* - removes keys in groups that exceed `telemetryLevel`
* - removes keys with values of null or undefined
* - hashes the values of keys suffixed with "Digest" (e.g. doc ids, fork ids)
* - flattens the entire metadata object (i.e. removes the nesting of keys under
* "limited" or "full")
*/
export function filterMetadata(
metadata: TelemetryMetadataByLevel | undefined,
telemetryLevel: TelemetryLevel
): TelemetryMetadata | undefined {
if (!metadata) { return; }
let filteredMetadata: TelemetryMetadata = {};
for (const level of ['limited', 'full'] as const) {
if (Level[telemetryLevel] < Level[level]) { break; }
filteredMetadata = {...filteredMetadata, ...metadata[level]};
}
filteredMetadata = removeNullishKeys(filteredMetadata);
filteredMetadata = hashDigestKeys(filteredMetadata);
return filteredMetadata;
}
/**
* Returns a copy of `object` with all null and undefined keys removed.
*/
export function removeNullishKeys(object: Record<string, any>) {
return pickBy(object, value => value !== null && value !== undefined);
}
/**
* Returns a copy of `metadata`, replacing the values of all keys suffixed
* with "Digest" with the result of hashing the value. The hash is prefixed with
* the first 4 characters of the original value, to assist with troubleshooting.
*/
export function hashDigestKeys(metadata: TelemetryMetadata): TelemetryMetadata {
const filteredMetadata: TelemetryMetadata = {};
Object.entries(metadata).forEach(([key, value]) => {
if (key.endsWith('Digest') && typeof value === 'string') {
filteredMetadata[key] = hashId(value);
} else {
filteredMetadata[key] = value;
}
});
return filteredMetadata;
}

View File

@@ -60,3 +60,16 @@ export function checkMinIOExternalStorage() {
region
};
}
export async function checkMinIOBucket() {
const options = checkMinIOExternalStorage();
if (!options) {
throw new Error('Configuration check failed for MinIO backend storage.');
}
const externalStorage = new MinIOExternalStorage(options.bucket, options);
if (!await externalStorage.hasVersioning()) {
await externalStorage.close();
throw new Error(`FATAL: the MinIO bucket "${options.bucket}" does not have versioning enabled`);
}
}

View File

@@ -1,11 +1,11 @@
import {createHash} from 'crypto';
/**
* Returns a hash of `id` prefixed with the first 4 characters of `id`.
* Returns a hash of `id` prefixed with the first 4 characters of `id`. The first 4
* characters are included to assist with troubleshooting.
*
* Useful for situations where potentially sensitive identifiers are logged, such as
* doc ids (like those that have public link sharing enabled). The first 4 characters
* are included to assist with troubleshooting.
* doc ids of docs that have public link sharing enabled.
*/
export function hashId(id: string): string {
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;

View File

@@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
@@ -352,3 +352,9 @@ export function addAbortHandler(req: Request, res: Writable, op: () => void) {
}
});
}
export function isDefaultUser(req: Request) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
const {loginEmail} = getUser(req);
return defaultEmail && defaultEmail === loginEmail;
}

View File

@@ -76,7 +76,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
telemetry: server ? getTelemetryConfig(server) : undefined,
telemetry: server?.getTelemetry().getTelemetryConfig(),
deploymentType: server?.getDeploymentType(),
...extra,
};
@@ -163,13 +163,6 @@ function getFeatures(): IFeature[] {
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
}
function getTelemetryConfig(server: GristServer) {
const telemetry = server.getTelemetry();
return {
telemetryLevel: telemetry.getTelemetryLevel(),
};
}
function configuredPageTitleSuffix() {
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
return result === "_blank" ? "" : result;

View File

@@ -106,44 +106,50 @@ export async function main(port: number, serverTypes: ServerType[],
server.addApiMiddleware();
await server.addBillingMiddleware();
await server.start();
try {
await server.start();
if (includeHome) {
server.addUsage();
if (!includeDocs) {
server.addDocApiForwarder();
if (includeHome) {
server.addUsage();
if (!includeDocs) {
server.addDocApiForwarder();
}
server.addJsonSupport();
await server.addLandingPages();
// todo: add support for home api to standalone app
server.addHomeApi();
server.addBillingApi();
server.addNotifier();
await server.addTelemetry();
await server.addHousekeeper();
await server.addLoginRoutes();
server.addAccountPage();
server.addBillingPages();
server.addWelcomePaths();
server.addLogEndpoint();
server.addGoogleAuthEndpoint();
server.addInstallEndpoints();
}
server.addJsonSupport();
await server.addLandingPages();
// todo: add support for home api to standalone app
server.addHomeApi();
server.addBillingApi();
server.addNotifier();
server.addTelemetry();
await server.addHousekeeper();
await server.addLoginRoutes();
server.addAccountPage();
server.addBillingPages();
server.addWelcomePaths();
server.addLogEndpoint();
server.addGoogleAuthEndpoint();
if (includeDocs) {
server.addJsonSupport();
await server.addTelemetry();
await server.addDoc();
}
if (includeHome) {
server.addClientSecrets();
}
server.finalize();
server.checkOptionCombinations();
server.summary();
return server;
} catch(e) {
await server.close();
throw e;
}
if (includeDocs) {
server.addJsonSupport();
server.addTelemetry();
await server.addDoc();
}
if (includeHome) {
server.addClientSecrets();
}
server.finalize();
server.checkOptionCombinations();
server.summary();
return server;
}