mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Merge branch 'main' of github.com:gristlabs/grist-core into alex/_importParsedFileAsNewTable
This commit is contained in:
commit
6fbe3db677
21
.github/workflows/main.yml
vendored
21
.github/workflows/main.yml
vendored
@ -98,10 +98,31 @@ jobs:
|
|||||||
- name: Run main tests without minio and redis
|
- name: Run main tests without minio and redis
|
||||||
if: contains(matrix.tests, ':nbrowser-')
|
if: contains(matrix.tests, ':nbrowser-')
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p $MOCHA_WEBDRIVER_LOGDIR
|
||||||
export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/")
|
export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/")
|
||||||
MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3
|
MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3
|
||||||
env:
|
env:
|
||||||
TESTS: ${{ matrix.tests }}
|
TESTS: ${{ matrix.tests }}
|
||||||
|
MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/test-logs/webdriver
|
||||||
|
TESTDIR: ${{ runner.temp }}/test-logs
|
||||||
|
|
||||||
|
- name: Prepare for saving artifact
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
ARTIFACT_NAME=logs-$(echo $TESTS | sed 's/[^-a-zA-Z0-9]/_/g')
|
||||||
|
echo "Artifact name is '$ARTIFACT_NAME'"
|
||||||
|
echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV
|
||||||
|
find $TESTDIR -iname "*.socket" -exec rm {} \;
|
||||||
|
env:
|
||||||
|
TESTS: ${{ matrix.tests }}
|
||||||
|
TESTDIR: ${{ runner.temp }}/test-logs
|
||||||
|
|
||||||
|
- name: Save artifacts on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ env.ARTIFACT_NAME }}
|
||||||
|
path: ${{ runner.temp }}/test-logs # only exists for webdriver tests
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# https://github.com/bitnami/bitnami-docker-minio/issues/16
|
# https://github.com/bitnami/bitnami-docker-minio/issues/16
|
||||||
|
@ -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));
|
|
@ -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));
|
|
@ -6,7 +6,6 @@ import * as dispose from 'app/client/lib/dispose';
|
|||||||
import dom from 'app/client/lib/dom';
|
import dom from 'app/client/lib/dom';
|
||||||
import {timeFormat} from 'app/common/timeFormat';
|
import {timeFormat} from 'app/common/timeFormat';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import map = require('lodash/map');
|
|
||||||
|
|
||||||
import koArray from 'app/client/lib/koArray';
|
import koArray from 'app/client/lib/koArray';
|
||||||
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 {ActionGroup} from 'app/common/ActionGroup';
|
||||||
import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables,
|
import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables,
|
||||||
LabelDelta} from 'app/common/ActionSummary';
|
LabelDelta} from 'app/common/ActionSummary';
|
||||||
import {CellDelta} from 'app/common/TabularDiff';
|
import {CellDelta, TabularDiff} from 'app/common/TabularDiff';
|
||||||
import {IDomComponent} from 'grainjs';
|
import {DomContents, IDomComponent} from 'grainjs';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,12 +78,13 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
|
|||||||
this._displayStack = koArray<ActionGroupWithState>();
|
this._displayStack = koArray<ActionGroupWithState>();
|
||||||
|
|
||||||
// Computed for the tableId of the table currently being viewed.
|
// Computed for the tableId of the table currently being viewed.
|
||||||
if (!this._gristDoc) {
|
this._selectedTableId = this.autoDispose(ko.computed(() => {
|
||||||
this._selectedTableId = this.autoDispose(ko.computed(() => ""));
|
if (!this._gristDoc || this._gristDoc.viewModel.isDisposed()) { return ""; }
|
||||||
} else {
|
const section = this._gristDoc.viewModel.activeSection();
|
||||||
this._selectedTableId = this.autoDispose(ko.computed(
|
if (!section || section.isDisposed()) { return ""; }
|
||||||
() => this._gristDoc!.viewModel.activeSection().table().tableId()));
|
const table = section.table();
|
||||||
}
|
return table && !table.isDisposed() ? table.tableId() : "";
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
@ -141,12 +141,12 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
|
|||||||
* @param {string} txt - a textual description of the action
|
* @param {string} txt - a textual description of the action
|
||||||
* @param {ActionGroupWithState} ag - the full action information we have
|
* @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 act = asTabularDiffs(sum);
|
||||||
const editDom = dom('div',
|
const editDom = dom('div',
|
||||||
this._renderTableSchemaChanges(sum, ag),
|
this._renderTableSchemaChanges(sum, ag),
|
||||||
this._renderColumnSchemaChanges(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'); }
|
if (tdiff.cells.length === 0) { return dom('div'); }
|
||||||
return dom('table.action_log_table',
|
return dom('table.action_log_table',
|
||||||
koDom.show(() => this._showForTable(table, ag)),
|
koDom.show(() => this._showForTable(table, ag)),
|
||||||
@ -227,8 +227,9 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _buildLogDom() {
|
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',
|
return dom('div.action_log',
|
||||||
|
{tabIndex: '-1'},
|
||||||
dom('div.preference_item',
|
dom('div.preference_item',
|
||||||
koForm.checkbox(this._showAllTables,
|
koForm.checkbox(this._showAllTables,
|
||||||
dom.testId('ActionLog_allTables'),
|
dom.testId('ActionLog_allTables'),
|
||||||
@ -238,7 +239,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
|
|||||||
'Loading...'),
|
'Loading...'),
|
||||||
koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => {
|
koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => {
|
||||||
const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : "";
|
const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : "";
|
||||||
let desc = ag.desc || "";
|
let desc: DomContents = ag.desc || "";
|
||||||
if (ag.actionSummary) {
|
if (ag.actionSummary) {
|
||||||
desc = this.renderTabularDiffs(ag.actionSummary, desc, ag);
|
desc = this.renderTabularDiffs(ag.actionSummary, desc, ag);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
const _ = require('underscore');
|
const _ = require('underscore');
|
||||||
const ko = require('knockout');
|
const ko = require('knockout');
|
||||||
const moment = require('moment-timezone');
|
const moment = require('moment-timezone');
|
||||||
const {getSelectionDesc} = require('app/common/DocActions');
|
|
||||||
const {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil');
|
const {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil');
|
||||||
const gutil = require('app/common/gutil');
|
const gutil = require('app/common/gutil');
|
||||||
const MANUALSORT = require('app/common/gristTypes').MANUALSORT;
|
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 the cut occurs on an edit restricted cell, there may be no cut action.
|
||||||
if (cutAction) { actions.unshift(cutAction); }
|
if (cutAction) { actions.unshift(cutAction); }
|
||||||
}
|
}
|
||||||
return this.gristDoc.docData.sendActions(actions,
|
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)}.`;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
BaseView.prototype.buildDom = function() {
|
BaseView.prototype.buildDom = function() {
|
||||||
|
@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
|
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
|
||||||
if (
|
if (!this._shouldQueueTip(prompt, options)) { return; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._queuedTips.push({prompt, refElement, options});
|
this._queuedTips.push({prompt, refElement, options});
|
||||||
if (this._queuedTips.length > 1) {
|
if (this._queuedTips.length > 1) {
|
||||||
@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable {
|
|||||||
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
||||||
this._queuedTips = [];
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import type {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import type {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
import type {CellValue} from 'app/common/DocActions';
|
import type {CellValue} from 'app/common/DocActions';
|
||||||
import type {TableData} from 'app/common/TableData';
|
import type {TableData, UIRowId} from 'app/common/TableData';
|
||||||
import type {UIRowId} from 'app/common/UIRowId';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The CopySelection class is an abstraction for a subset of currently selected cells.
|
* The CopySelection class is an abstraction for a subset of currently selected cells.
|
||||||
|
@ -8,12 +8,12 @@ import BaseView from 'app/client/components/BaseView';
|
|||||||
import * as commands from 'app/client/components/commands';
|
import * as commands from 'app/client/components/commands';
|
||||||
import BaseRowModel from 'app/client/models/BaseRowModel';
|
import BaseRowModel from 'app/client/models/BaseRowModel';
|
||||||
import {LazyArrayModel} from 'app/client/models/DataTableModel';
|
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 {Disposable} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
export interface CursorPos {
|
export interface CursorPos {
|
||||||
rowId?: RowId;
|
rowId?: UIRowId;
|
||||||
rowIndex?: number;
|
rowIndex?: number;
|
||||||
fieldIndex?: number;
|
fieldIndex?: number;
|
||||||
sectionId?: 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 rowIndex: ko.Computed<number|null>; // May be null when there are no rows.
|
||||||
public fieldIndex: ko.Observable<number>;
|
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 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
|
// 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 _isLive: ko.Observable<boolean> = ko.observable(true);
|
||||||
private _sectionId: ko.Computed<number>;
|
private _sectionId: ko.Computed<number>;
|
||||||
|
|
||||||
|
private _properRowId: ko.Computed<UIRowId|null>;
|
||||||
|
|
||||||
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
|
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
|
||||||
super();
|
super();
|
||||||
optCursorPos = optCursorPos || {};
|
optCursorPos = optCursorPos || {};
|
||||||
this.viewData = baseView.viewData;
|
this.viewData = baseView.viewData;
|
||||||
|
|
||||||
this._sectionId = this.autoDispose(ko.computed(() => baseView.viewSection.id()));
|
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({
|
this.rowIndex = this.autoDispose(ko.computed({
|
||||||
read: () => {
|
read: () => {
|
||||||
if (!this._isLive()) { return this.rowIndex.peek(); }
|
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));
|
return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId));
|
||||||
},
|
},
|
||||||
write: (index) => {
|
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));
|
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.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
|
||||||
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
|
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
|
||||||
|
|
||||||
// Update the section's activeRowId when the cursor's rowId changes.
|
// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
|
||||||
this.autoDispose(this._rowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId)));
|
// 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.
|
// On dispose, save the current cursor position to the section model.
|
||||||
this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });
|
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.
|
// Returns the cursor position with rowId, rowIndex, and fieldIndex.
|
||||||
public getCursorPos(): CursorPos {
|
public getCursorPos(): CursorPos {
|
||||||
return {
|
return {
|
||||||
rowId: nullAsUndefined(this._rowId()),
|
rowId: nullAsUndefined(this._properRowId()),
|
||||||
rowIndex: nullAsUndefined(this.rowIndex()),
|
rowIndex: nullAsUndefined(this.rowIndex()),
|
||||||
fieldIndex: this.fieldIndex(),
|
fieldIndex: this.fieldIndex(),
|
||||||
sectionId: this._sectionId()
|
sectionId: this._sectionId()
|
||||||
@ -117,7 +127,7 @@ export class Cursor extends Disposable {
|
|||||||
*/
|
*/
|
||||||
public setCursorPos(cursorPos: CursorPos): void {
|
public setCursorPos(cursorPos: CursorPos): void {
|
||||||
if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) {
|
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) {
|
} else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) {
|
||||||
this.rowIndex(cursorPos.rowIndex);
|
this.rowIndex(cursorPos.rowIndex);
|
||||||
} else {
|
} else {
|
||||||
|
@ -32,7 +32,6 @@ export class DocComm extends Disposable implements ActiveDocAPI {
|
|||||||
public addAttachments = this._wrapMethod("addAttachments");
|
public addAttachments = this._wrapMethod("addAttachments");
|
||||||
public findColFromValues = this._wrapMethod("findColFromValues");
|
public findColFromValues = this._wrapMethod("findColFromValues");
|
||||||
public getFormulaError = this._wrapMethod("getFormulaError");
|
public getFormulaError = this._wrapMethod("getFormulaError");
|
||||||
public getAssistance = this._wrapMethod("getAssistance");
|
|
||||||
public fetchURL = this._wrapMethod("fetchURL");
|
public fetchURL = this._wrapMethod("fetchURL");
|
||||||
public autocomplete = this._wrapMethod("autocomplete");
|
public autocomplete = this._wrapMethod("autocomplete");
|
||||||
public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc");
|
public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc");
|
||||||
|
@ -1233,9 +1233,11 @@ GridView.prototype.buildDom = function() {
|
|||||||
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
|
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
|
||||||
dom('div.gridview_data_row_info',
|
dom('div.gridview_data_row_info',
|
||||||
kd.toggleClass('linked_dst', () => {
|
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
|
// Must ensure that linkedRowId is not null to avoid drawing on rows whose
|
||||||
// row ids are null.
|
// row ids are null.
|
||||||
return self.linkedRowId() && self.linkedRowId() === row.getRowId();
|
return linkedRowId && linkedRowId === myRowId;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
kd.text(function() { return row._index() + 1; }),
|
kd.text(function() { return row._index() + 1; }),
|
||||||
|
@ -185,6 +185,10 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
|
|
||||||
public readonly currentTheme = this.docPageModel.appModel.currentTheme;
|
public readonly currentTheme = this.docPageModel.appModel.currentTheme;
|
||||||
|
|
||||||
|
public get docApi() {
|
||||||
|
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
|
||||||
|
}
|
||||||
|
|
||||||
private _actionLog: ActionLog;
|
private _actionLog: ActionLog;
|
||||||
private _undoStack: UndoStack;
|
private _undoStack: UndoStack;
|
||||||
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
||||||
@ -476,6 +480,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
const viewId = toKo(ko, this.activeViewId)();
|
const viewId = toKo(ko, this.activeViewId)();
|
||||||
if (!isViewDocPage(viewId)) { return null; }
|
if (!isViewDocPage(viewId)) { return null; }
|
||||||
const section = this.viewModel.activeSection();
|
const section = this.viewModel.activeSection();
|
||||||
|
if (section?.isDisposed()) { return null; }
|
||||||
const view = section.viewInstance();
|
const view = section.viewInstance();
|
||||||
return view;
|
return view;
|
||||||
})));
|
})));
|
||||||
@ -620,6 +625,11 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public async setCursorPos(cursorPos: CursorPos) {
|
public async setCursorPos(cursorPos: CursorPos) {
|
||||||
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
|
||||||
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
|
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 this is completely unknown section (without a parent), it is probably an import preview.
|
||||||
if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) {
|
if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) {
|
||||||
const view = desiredSection.viewInstance.peek();
|
const view = desiredSection.viewInstance.peek();
|
||||||
@ -1578,13 +1588,14 @@ async function finalizeAnchor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cssViewContentPane = styled('div', `
|
const cssViewContentPane = styled('div', `
|
||||||
|
--view-content-page-margin: 12px;
|
||||||
flex: auto;
|
flex: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
margin: 12px;
|
margin: var(--view-content-page-margin, 12px);
|
||||||
@media ${mediaSmall} {
|
@media ${mediaSmall} {
|
||||||
& {
|
& {
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
|
@ -1164,7 +1164,7 @@ const cssFloaterWrapper = styled('div', `
|
|||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
background: ${theme.tableBodyBg};
|
background: ${theme.tableBodyBg};
|
||||||
border: 1px solid ${theme.widgetBorder};
|
border: 1px solid ${theme.widgetBorder};
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
-webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px);
|
-webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px);
|
||||||
transform: rotate(5deg) scale(0.8) translate(-10px, 0px);
|
transform: rotate(5deg) scale(0.8) translate(-10px, 0px);
|
||||||
& .mini_section_container {
|
& .mini_section_container {
|
||||||
@ -1174,16 +1174,17 @@ const cssFloaterWrapper = styled('div', `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssCollapsedTray = styled('div.collapsed_layout', `
|
const cssCollapsedTray = styled('div.collapsed_layout', `
|
||||||
border-radius: 3px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 3px;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: height 0.2s;
|
transition: height 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 10px;
|
margin: calc(-1 * var(--view-content-page-margin, 12px));
|
||||||
|
margin-bottom: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
background-color: ${theme.pageBg};
|
||||||
|
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
||||||
|
outline-offset: -1px;
|
||||||
|
|
||||||
&-is-active {
|
&-is-active {
|
||||||
outline: 2px dashed ${theme.widgetBorder};
|
outline: 2px dashed ${theme.widgetBorder};
|
||||||
@ -1197,8 +1198,9 @@ const cssCollapsedTray = styled('div.collapsed_layout', `
|
|||||||
|
|
||||||
const cssRow = styled('div', `display: flex`);
|
const cssRow = styled('div', `display: flex`);
|
||||||
const cssLayout = styled(cssRow, `
|
const cssLayout = styled(cssRow, `
|
||||||
padding: 12px;
|
padding: 8px 24px;
|
||||||
gap: 10px;
|
column-gap: 16px;
|
||||||
|
row-gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
`);
|
`);
|
||||||
|
@ -4,7 +4,7 @@ import {DocModel} from 'app/client/models/DocModel';
|
|||||||
import {ColumnRec} from "app/client/models/entities/ColumnRec";
|
import {ColumnRec} from "app/client/models/entities/ColumnRec";
|
||||||
import {TableRec} from "app/client/models/entities/TableRec";
|
import {TableRec} from "app/client/models/entities/TableRec";
|
||||||
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
|
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 {LinkConfig} from "app/client/ui/selectBy";
|
||||||
import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI";
|
import {ClientQuery, QueryOperation} from "app/common/ActiveDocAPI";
|
||||||
import {isList, isListType, isRefListType} from "app/common/gristTypes";
|
import {isList, isListType, isRefListType} from "app/common/gristTypes";
|
||||||
@ -62,7 +62,7 @@ export type FilterColValues = Pick<ClientQuery, "filters" | "operations">;
|
|||||||
*/
|
*/
|
||||||
export class LinkingState extends Disposable {
|
export class LinkingState extends Disposable {
|
||||||
// If linking affects target section's cursor, this will be a computed for the cursor rowId.
|
// 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
|
// If linking affects filtering, this is a computed for the current filtering state, as a
|
||||||
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
|
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
|
||||||
@ -136,7 +136,7 @@ export class LinkingState extends Disposable {
|
|||||||
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
|
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
|
||||||
if (srcValueFunc) {
|
if (srcValueFunc) {
|
||||||
this.cursorPos = this.autoDispose(ko.computed(() =>
|
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
|
// Value for this.filterColValues filtering based on a single column
|
||||||
private _simpleFilter(
|
private _simpleFilter(
|
||||||
colId: string, operation: QueryOperation, valuesFunc: (rowId: RowId|null) => any[]
|
colId: string, operation: QueryOperation, valuesFunc: (rowId: UIRowId|null) => any[]
|
||||||
): ko.Computed<FilterColValues> {
|
): ko.Computed<FilterColValues> {
|
||||||
return this.autoDispose(ko.computed(() => {
|
return this.autoDispose(ko.computed(() => {
|
||||||
const srcRowId = this._srcSection.activeRowId();
|
const srcRowId = this._srcSection.activeRowId();
|
||||||
@ -226,7 +226,7 @@ export class LinkingState extends Disposable {
|
|||||||
if (!srcCellObs) {
|
if (!srcCellObs) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (rowId: RowId | null) => {
|
return (rowId: UIRowId | null) => {
|
||||||
srcRowModel.assign(rowId);
|
srcRowModel.assign(rowId);
|
||||||
if (rowId === 'new') {
|
if (rowId === 'new') {
|
||||||
return 'new';
|
return 'new';
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import BaseView from 'app/client/components/BaseView';
|
import BaseView from 'app/client/components/BaseView';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {filterBar} from 'app/client/ui/FilterBar';
|
import {filterBar} from 'app/client/ui/FilterBar';
|
||||||
import {cssIcon} from 'app/client/ui/RightPanelStyles';
|
import {cssIcon} from 'app/client/ui/RightPanelStyles';
|
||||||
import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
|
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 {Computed, dom, DomElementArg, Observable, styled} from 'grainjs';
|
||||||
import {defaultMenuOptions} from 'popweasel';
|
import {defaultMenuOptions} from 'popweasel';
|
||||||
|
|
||||||
|
const t = makeT('ViewSection');
|
||||||
|
|
||||||
export function buildCollapsedSectionDom(options: {
|
export function buildCollapsedSectionDom(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
@ -69,8 +71,13 @@ export function buildViewSectionDom(options: {
|
|||||||
|
|
||||||
// Creating normal section dom
|
// Creating normal section dom
|
||||||
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
|
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',
|
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
|
||||||
testId(`viewlayout-section-${sectionRowId}`),
|
testId(`viewlayout-section-${sectionRowId}`),
|
||||||
|
dom.autoDispose(selectedBySectionTitle),
|
||||||
!options.isResizing ? dom.autoDispose(isResizing) : null,
|
!options.isResizing ? dom.autoDispose(isResizing) : null,
|
||||||
cssViewLeaf.cls(''),
|
cssViewLeaf.cls(''),
|
||||||
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
|
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
|
||||||
@ -96,10 +103,14 @@ export function buildViewSectionDom(options: {
|
|||||||
dom('div.view_data_pane_container.flexvbox',
|
dom('div.view_data_pane_container.flexvbox',
|
||||||
cssResizing.cls('', isResizing),
|
cssResizing.cls('', isResizing),
|
||||||
dom.maybe(viewInstance.disableEditing, () =>
|
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.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)),
|
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
|
||||||
viewInstance.viewPane
|
viewInstance.viewPane
|
||||||
@ -195,7 +206,6 @@ const cssResizing = styled('div', `
|
|||||||
const cssMiniSection = styled('div.mini_section_container', `
|
const cssMiniSection = styled('div.mini_section_container', `
|
||||||
--icon-color: ${colors.lightGreen};
|
--icon-color: ${colors.lightGreen};
|
||||||
display: flex;
|
display: flex;
|
||||||
background: ${theme.mainPanelBg};
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
`);
|
`);
|
||||||
|
6
app/client/lib/imports.d.ts
vendored
6
app/client/lib/imports.d.ts
vendored
@ -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 BillingPageModule from 'app/client/ui/BillingPage';
|
||||||
|
import * as SupportGristPageModule from 'app/client/ui/SupportGristPage';
|
||||||
import * as GristDocModule from 'app/client/components/GristDoc';
|
import * as GristDocModule from 'app/client/components/GristDoc';
|
||||||
import * as ViewPane from 'app/client/components/ViewPane';
|
import * as ViewPane from 'app/client/components/ViewPane';
|
||||||
import * as UserManagerModule from 'app/client/ui/UserManager';
|
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 PlotlyType = typeof plotly;
|
||||||
export type MomentTimezone = typeof momentTimezone;
|
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 loadBillingPage(): Promise<typeof BillingPageModule>;
|
||||||
|
export function loadSupportGristPage(): Promise<typeof SupportGristPageModule>;
|
||||||
export function loadGristDoc(): Promise<typeof GristDocModule>;
|
export function loadGristDoc(): Promise<typeof GristDocModule>;
|
||||||
export function loadMomentTimezone(): Promise<MomentTimezone>;
|
export function loadMomentTimezone(): Promise<MomentTimezone>;
|
||||||
export function loadPlotly(): Promise<PlotlyType>;
|
export function loadPlotly(): Promise<PlotlyType>;
|
||||||
|
@ -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.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" */);
|
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
|
||||||
// When importing this way, the module is under the "default" member, not sure why (maybe
|
// When importing this way, the module is under the "default" member, not sure why (maybe
|
||||||
// esbuild-loader's doing).
|
// esbuild-loader's doing).
|
||||||
|
@ -5,6 +5,7 @@ import * as rowset from 'app/client/models/rowset';
|
|||||||
import { MANUALSORT } from 'app/common/gristTypes';
|
import { MANUALSORT } from 'app/common/gristTypes';
|
||||||
import { SortFunc } from 'app/common/SortFunc';
|
import { SortFunc } from 'app/common/SortFunc';
|
||||||
import { Sort } from 'app/common/SortSpec';
|
import { Sort } from 'app/common/SortSpec';
|
||||||
|
import { UIRowId } from 'app/common/TableData';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import range = require('lodash/range');
|
import range = require('lodash/range');
|
||||||
|
|
||||||
@ -44,7 +45,7 @@ export async function updatePositions(gristDoc: GristDoc, section: ViewSectionRe
|
|||||||
sortFunc.updateSpec(section.activeDisplaySortSpec.peek());
|
sortFunc.updateSpec(section.activeDisplaySortSpec.peek());
|
||||||
const sortedRows = rowset.SortedRowSet.create(
|
const sortedRows = rowset.SortedRowSet.create(
|
||||||
null,
|
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
|
tableModel.tableData
|
||||||
);
|
);
|
||||||
sortedRows.subscribeTo(tableModel);
|
sortedRows.subscribeTo(tableModel);
|
||||||
|
@ -8,7 +8,6 @@ import {safeJsonParse} from 'app/common/gutil';
|
|||||||
import type {TableData} from 'app/common/TableData';
|
import type {TableData} from 'app/common/TableData';
|
||||||
import {tsvEncode} from 'app/common/tsvFormat';
|
import {tsvEncode} from 'app/common/tsvFormat';
|
||||||
import {dom} from 'grainjs';
|
import {dom} from 'grainjs';
|
||||||
import map = require('lodash/map');
|
|
||||||
import zipObject = require('lodash/zipObject');
|
import zipObject = require('lodash/zipObject');
|
||||||
|
|
||||||
const G = getBrowserGlobals('document', 'DOMParser');
|
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
|
// Helper function to add css style properties to an html tag
|
||||||
function _styleAttr(style: object) {
|
function _styleAttr(style: object|undefined) {
|
||||||
return map(style, (value, prop) => `${prop}: ${value};`).join(' ');
|
if (typeof style !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return Object.entries(style).map(([prop, value]) => `${prop}: ${value};`).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState';
|
|||||||
import {Notifier} from 'app/client/models/NotifyModel';
|
import {Notifier} from 'app/client/models/NotifyModel';
|
||||||
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
||||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
||||||
|
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||||
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
|
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
|
||||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||||
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||||
@ -31,7 +32,15 @@ const t = makeT('AppModel');
|
|||||||
// Reexported for convenience.
|
// Reexported for convenience.
|
||||||
export {reportError} from 'app/client/models/errors';
|
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');
|
const G = getBrowserGlobals('document', 'window');
|
||||||
|
|
||||||
// TopAppModel is the part of the app model that persists across org and user switches.
|
// TopAppModel is the part of the app model that persists across org and user switches.
|
||||||
@ -107,6 +116,8 @@ export interface AppModel {
|
|||||||
|
|
||||||
behavioralPromptsManager: BehavioralPromptsManager;
|
behavioralPromptsManager: BehavioralPromptsManager;
|
||||||
|
|
||||||
|
supportGristNudge: SupportGristNudge;
|
||||||
|
|
||||||
refreshOrgUsage(): Promise<void>;
|
refreshOrgUsage(): Promise<void>;
|
||||||
showUpgradeModal(): void;
|
showUpgradeModal(): void;
|
||||||
showNewSiteModal(): void;
|
showNewSiteModal(): void;
|
||||||
@ -253,11 +264,33 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
|
|
||||||
// Get the current PageType from the URL.
|
// Get the current PageType from the URL.
|
||||||
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
|
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(
|
public readonly needsOrg: Observable<boolean> = Computed.create(
|
||||||
this, urlState().state, (use, state) => {
|
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;
|
public readonly notifier = this.topAppModel.notifier;
|
||||||
@ -265,6 +298,8 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
public readonly behavioralPromptsManager: BehavioralPromptsManager =
|
public readonly behavioralPromptsManager: BehavioralPromptsManager =
|
||||||
BehavioralPromptsManager.create(this, this);
|
BehavioralPromptsManager.create(this, this);
|
||||||
|
|
||||||
|
public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly topAppModel: TopAppModel,
|
public readonly topAppModel: TopAppModel,
|
||||||
public readonly currentUser: FullUser|null,
|
public readonly currentUser: FullUser|null,
|
||||||
|
@ -29,8 +29,7 @@ import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable';
|
|||||||
import {canEdit} from 'app/common/roles';
|
import {canEdit} from 'app/common/roles';
|
||||||
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
||||||
import {schema, SchemaTypes} from 'app/common/schema';
|
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 {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
|
||||||
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
|
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
|
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
|
||||||
@ -318,7 +317,7 @@ export class DocModel {
|
|||||||
*/
|
*/
|
||||||
function createTablesArray(
|
function createTablesArray(
|
||||||
tablesModel: MetaTableModel<TableRec>,
|
tablesModel: MetaTableModel<TableRec>,
|
||||||
filterFunc: RowFilterFunc<rowset.RowId> = (_row) => true
|
filterFunc: RowFilterFunc<UIRowId> = (_row) => true
|
||||||
) {
|
) {
|
||||||
const rowSource = new rowset.FilteredRowSource(filterFunc);
|
const rowSource = new rowset.FilteredRowSource(filterFunc);
|
||||||
rowSource.subscribeTo(tablesModel);
|
rowSource.subscribeTo(tablesModel);
|
||||||
|
@ -43,8 +43,8 @@ export interface CustomAction { label: string, action: () => void }
|
|||||||
*/
|
*/
|
||||||
export type MessageType = string | (() => DomElementArg);
|
export type MessageType = string | (() => DomElementArg);
|
||||||
// Identifies supported actions. These are implemented in NotifyUI.
|
// 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 {
|
export interface INotifyOptions {
|
||||||
message: MessageType; // A string, or a function that builds dom.
|
message: MessageType; // A string, or a function that builds dom.
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
*/
|
*/
|
||||||
import DataTableModel from 'app/client/models/DataTableModel';
|
import DataTableModel from 'app/client/models/DataTableModel';
|
||||||
import {DocModel} from 'app/client/models/DocModel';
|
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 {TableData} from 'app/client/models/TableData';
|
||||||
import {ActiveDocAPI, ClientQuery, QueryOperation} from 'app/common/ActiveDocAPI';
|
import {ActiveDocAPI, ClientQuery, QueryOperation} from 'app/common/ActiveDocAPI';
|
||||||
import {CellValue, TableDataAction} from 'app/common/DocActions';
|
import {CellValue, TableDataAction} from 'app/common/DocActions';
|
||||||
@ -37,7 +37,7 @@ import {isList} from "app/common/gristTypes";
|
|||||||
import {nativeCompare} from 'app/common/gutil';
|
import {nativeCompare} from 'app/common/gutil';
|
||||||
import {IRefCountSub, RefCountMap} from 'app/common/RefCountMap';
|
import {IRefCountSub, RefCountMap} from 'app/common/RefCountMap';
|
||||||
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
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 {tbind} from 'app/common/tbind';
|
||||||
import {decodeObject} from "app/plugin/objtypes";
|
import {decodeObject} from "app/plugin/objtypes";
|
||||||
import {Disposable, Holder, IDisposableOwnerT} from 'grainjs';
|
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.
|
* 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.
|
// NOTE we rely without checking on tableId and colIds being valid.
|
||||||
const tableData: BaseTableData = docData.getTable(query.tableId)!;
|
const tableData: BaseTableData = docData.getTable(query.tableId)!;
|
||||||
const colFuncs = Object.keys(query.filters).sort().map(
|
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]);
|
const values = new Set(query.filters[colId]);
|
||||||
switch (query.operations[colId]) {
|
switch (query.operations[colId]) {
|
||||||
case "intersects":
|
case "intersects":
|
||||||
return (rowId: RowId) => {
|
return (rowId: UIRowId) => {
|
||||||
const value = getter(rowId) as CellValue;
|
const value = getter(rowId) as CellValue;
|
||||||
return isList(value) &&
|
return isList(value) &&
|
||||||
(decodeObject(value) as unknown[]).some(v => values.has(v));
|
(decodeObject(value) as unknown[]).some(v => values.has(v));
|
||||||
};
|
};
|
||||||
case "empty":
|
case "empty":
|
||||||
return (rowId: RowId) => {
|
return (rowId: UIRowId) => {
|
||||||
const value = getter(rowId);
|
const value = getter(rowId);
|
||||||
// `isList(value) && value.length === 1` means `value == ['L']` i.e. an empty list
|
// `isList(value) && value.length === 1` means `value == ['L']` i.e. an empty list
|
||||||
return !value || isList(value) && value.length === 1;
|
return !value || isList(value) && value.length === 1;
|
||||||
};
|
};
|
||||||
case "in":
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import {ColumnFilter} from 'app/client/models/ColumnFilter';
|
import {ColumnFilter} from 'app/client/models/ColumnFilter';
|
||||||
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {RowId} from 'app/client/models/rowset';
|
|
||||||
import {TableData} from 'app/client/models/TableData';
|
import {TableData} from 'app/client/models/TableData';
|
||||||
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
||||||
import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc';
|
import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc';
|
||||||
|
import {UIRowId} from 'app/common/TableData';
|
||||||
import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from 'grainjs';
|
import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from 'grainjs';
|
||||||
|
|
||||||
export type {ColumnFilterFunc};
|
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).
|
* results in their being displayed (obviating the need to maintain their rowId explicitly).
|
||||||
*/
|
*/
|
||||||
export class SectionFilter extends Disposable {
|
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 _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) {
|
constructor(public viewSection: ViewSectionRec, private _tableData: TableData) {
|
||||||
super();
|
super();
|
||||||
@ -89,8 +89,8 @@ export class SectionFilter extends Disposable {
|
|||||||
return this._addRowsToFilter(this._buildPlainFilterFunc(getFilterFunc, use), this._tempRows.get());
|
return this._addRowsToFilter(this._buildPlainFilterFunc(getFilterFunc, use), this._tempRows.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _addRowsToFilter(filterFunc: RowFilterFunc<RowId>, rows: RowId[]) {
|
private _addRowsToFilter(filterFunc: RowFilterFunc<UIRowId>, rows: UIRowId[]) {
|
||||||
return (rowId: RowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
|
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
|
* columns. You can use `getFilterFunc(column, colFilter)` to customize the filter func for each
|
||||||
* column. It calls `getFilterFunc` right away.
|
* 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 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 colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type));
|
||||||
const filterFunc = getFilterFunc(fieldOrColumn, colFilter);
|
const filterFunc = getFilterFunc(fieldOrColumn, colFilter);
|
||||||
|
|
||||||
@ -108,9 +108,9 @@ export class SectionFilter extends Disposable {
|
|||||||
|
|
||||||
if (!filterFunc || !getter) { return null; }
|
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
|
}).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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
32
app/client/models/TelemetryModel.ts
Normal file
32
app/client/models/TelemetryModel.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
// 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).
|
// fall back to the first item in the list if anything goes wrong (previous behavior).
|
||||||
const firstLeaf = getFirstLeaf(this.layoutSpecObj.peek());
|
const firstLeaf = getFirstLeaf(this.layoutSpecObj.peek());
|
||||||
return visible.find(s => s.getRowId() === firstLeaf) ? firstLeaf as number :
|
const result = visible.find(s => s.id() === firstLeaf) ? firstLeaf as number :
|
||||||
(visible[0]?.getRowId() || 0);
|
(visible[0]?.id() || 0);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);
|
this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);
|
||||||
|
@ -15,13 +15,13 @@ import {
|
|||||||
ViewRec
|
ViewRec
|
||||||
} from 'app/client/models/DocModel';
|
} from 'app/client/models/DocModel';
|
||||||
import * as modelUtil from 'app/client/models/modelUtil';
|
import * as modelUtil from 'app/client/models/modelUtil';
|
||||||
import {RowId} from 'app/client/models/rowset';
|
|
||||||
import {LinkConfig} from 'app/client/ui/selectBy';
|
import {LinkConfig} from 'app/client/ui/selectBy';
|
||||||
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
|
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
|
||||||
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
|
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
|
||||||
import {UserAction} from 'app/common/DocActions';
|
import {UserAction} from 'app/common/DocActions';
|
||||||
import {arrayRepeat} from 'app/common/gutil';
|
import {arrayRepeat} from 'app/common/gutil';
|
||||||
import {Sort} from 'app/common/SortSpec';
|
import {Sort} from 'app/common/SortSpec';
|
||||||
|
import {UIRowId} from 'app/common/TableData';
|
||||||
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
|
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
|
||||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||||
import {BEHAVIOR} from 'app/client/models/entities/ColumnRec';
|
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>;
|
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
|
// 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
|
// 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
|
// 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.
|
// 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>;
|
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>;
|
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>;
|
linkTargetCol: ko.Computed<ColumnRec>;
|
||||||
|
|
||||||
// Linking state maintains .filterFunc and .cursorPos observables which we use for
|
// 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>;
|
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.
|
// If the view instance for section is instantiated, it will be accessible here.
|
||||||
viewInstance: ko.Observable<BaseView | null>;
|
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); }
|
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
|
// 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
|
// 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
|
// 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.
|
// the controller value. Otherwise, the controller value determines the rowId of the cursor.
|
||||||
this.linkSrcSection = refRecord(docModel.viewSections, this.activeLinkSrcSectionRef);
|
this.linkSrcSection = refRecord(docModel.viewSections, this.linkSrcSectionRef);
|
||||||
this.linkSrcCol = refRecord(docModel.columns, this.activeLinkSrcColRef);
|
this.linkSrcCol = refRecord(docModel.columns, this.linkSrcColRef);
|
||||||
this.linkTargetCol = refRecord(docModel.columns, this.activeLinkTargetColRef);
|
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 = Holder.create(this);
|
||||||
this.linkingState = this.autoDispose(ko.pureComputed(() => {
|
this.linkingState = this.autoDispose(ko.pureComputed(() => {
|
||||||
if (!this.activeLinkSrcSectionRef()) {
|
if (!this.linkSrcSectionRef()) {
|
||||||
// This view section isn't selecting by anything.
|
// This view section isn't selected by anything.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
@ -121,7 +121,7 @@ export function reportError(err: Error|string, ev?: ErrorEvent): void {
|
|||||||
const options: Partial<INotifyOptions> = {
|
const options: Partial<INotifyOptions> = {
|
||||||
title: "Reached plan limit",
|
title: "Reached plan limit",
|
||||||
key: `limit:${details.limit.quantity || message}`,
|
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')) {
|
if (details.tips && details.tips.some(tip => tip.action === 'add-members')) {
|
||||||
// When adding members would fix a problem, give more specific advice.
|
// When adding members would fix a problem, give more specific advice.
|
||||||
|
@ -156,7 +156,8 @@ export class UrlStateImpl {
|
|||||||
*/
|
*/
|
||||||
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
|
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
|
||||||
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
|
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.org ? {org: prevState.org} : {}) :
|
||||||
prevState;
|
prevState;
|
||||||
return {...keepState, ...newState};
|
return {...keepState, ...newState};
|
||||||
@ -186,8 +187,11 @@ export class UrlStateImpl {
|
|||||||
// Reload when moving to/from the Grist sign-up page.
|
// Reload when moving to/from the Grist sign-up page.
|
||||||
const signupReload = [prevState.login, newState.login].includes('signup')
|
const signupReload = [prevState.login, newState.login].includes('signup')
|
||||||
&& prevState.login !== newState.login;
|
&& prevState.login !== newState.login;
|
||||||
return Boolean(orgReload || accountReload || billingReload || activationReload
|
// Reload when moving to/from the support Grist page.
|
||||||
|| gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload);
|
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist);
|
||||||
|
return Boolean(orgReload || accountReload || billingReload || activationReload ||
|
||||||
|
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
|
||||||
|
supportGristReload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
import koArray, {KoArray} from 'app/client/lib/koArray';
|
import koArray, {KoArray} from 'app/client/lib/koArray';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {CompareFunc, sortedIndex} from 'app/common/gutil';
|
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 {RowFilterFunc} from "app/common/RowFilterFunc";
|
||||||
import {Observable} from 'grainjs';
|
import {Observable} from 'grainjs';
|
||||||
|
|
||||||
@ -36,8 +36,7 @@ export const ALL: unique symbol = Symbol("ALL");
|
|||||||
|
|
||||||
export type ChangeType = 'add' | 'remove' | 'update';
|
export type ChangeType = 'add' | 'remove' | 'update';
|
||||||
export type ChangeMethod = 'onAddRows' | 'onRemoveRows' | 'onUpdateRows';
|
export type ChangeMethod = 'onAddRows' | 'onRemoveRows' | 'onUpdateRows';
|
||||||
export type RowId = number | 'new';
|
export type RowList = Iterable<UIRowId>;
|
||||||
export type RowList = Iterable<RowId>;
|
|
||||||
export type RowsChanged = RowList | typeof ALL;
|
export type RowsChanged = RowList | typeof ALL;
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@ -132,7 +131,7 @@ export class RowListener extends DisposableWithEvents {
|
|||||||
* A trivial RowSource returning a fixed list of rows.
|
* A trivial RowSource returning a fixed list of rows.
|
||||||
*/
|
*/
|
||||||
export abstract class ArrayRowSource extends RowSource {
|
export abstract class ArrayRowSource extends RowSource {
|
||||||
constructor(private _rows: RowId[]) { super(); }
|
constructor(private _rows: UIRowId[]) { super(); }
|
||||||
public getAllRows(): RowList { return this._rows; }
|
public getAllRows(): RowList { return this._rows; }
|
||||||
public getNumRows(): number { return this._rows.length; }
|
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.
|
* TODO: This class is not used anywhere at the moment, and is a candidate for removal.
|
||||||
*/
|
*/
|
||||||
export class MappedRowSource extends RowSource {
|
export class MappedRowSource extends RowSource {
|
||||||
private _mapperFunc: (row: RowId) => RowId;
|
private _mapperFunc: (row: UIRowId) => UIRowId;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public parentRowSource: RowSource,
|
public parentRowSource: RowSource,
|
||||||
mapperFunc: (row: RowId) => RowId,
|
mapperFunc: (row: UIRowId) => UIRowId,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -182,7 +181,7 @@ export class ExtendedRowSource extends RowSource {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public parentRowSource: RowSource,
|
public parentRowSource: RowSource,
|
||||||
public extras: RowId[]
|
public extras: UIRowId[]
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -209,9 +208,9 @@ export class ExtendedRowSource extends RowSource {
|
|||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
interface FilterRowChanges {
|
interface FilterRowChanges {
|
||||||
adds?: RowId[];
|
adds?: UIRowId[];
|
||||||
updates?: RowId[];
|
updates?: UIRowId[];
|
||||||
removes?: RowId[];
|
removes?: UIRowId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -219,9 +218,9 @@ interface FilterRowChanges {
|
|||||||
* does not maintain excluded rows, and does not allow changes to filterFunc.
|
* does not maintain excluded rows, and does not allow changes to filterFunc.
|
||||||
*/
|
*/
|
||||||
export class BaseFilteredRowSource extends RowListener implements RowSource {
|
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();
|
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.
|
// These are implemented by FilteredRowSource, but the base class doesn't need to do anything.
|
||||||
protected _addExcludedRow(row: RowId): void { /* no-op */ }
|
protected _addExcludedRow(row: UIRowId): void { /* no-op */ }
|
||||||
protected _deleteExcludedRow(row: RowId): boolean { return true; }
|
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()`.
|
* FilteredRowSource is also a RowListener, so to subscribe to a rowSource, use `subscribeTo()`.
|
||||||
*/
|
*/
|
||||||
export class FilteredRowSource extends BaseFilteredRowSource {
|
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
|
* Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate
|
||||||
* that rows stopped or started matching the new filter.
|
* that rows stopped or started matching the new filter.
|
||||||
*/
|
*/
|
||||||
public updateFilter(filterFunc: RowFilterFunc<RowId>) {
|
public updateFilter(filterFunc: RowFilterFunc<UIRowId>) {
|
||||||
this._filterFunc = filterFunc;
|
this._filterFunc = filterFunc;
|
||||||
const changes: FilterRowChanges = {};
|
const changes: FilterRowChanges = {};
|
||||||
// After the first call, _excludedRows may have additional rows, but there is no harm in it,
|
// 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();
|
return this._excludedRows.values();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _addExcludedRow(row: RowId): void { this._excludedRows.add(row); }
|
protected _addExcludedRow(row: UIRowId): void { this._excludedRows.add(row); }
|
||||||
protected _deleteExcludedRow(row: RowId): boolean { return this._excludedRows.delete(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.
|
* Private helper object that maintains a set of rows for a particular group.
|
||||||
*/
|
*/
|
||||||
class RowGroupHelper<Value> extends RowSource {
|
class RowGroupHelper<Value> extends RowSource {
|
||||||
private _rows: Set<RowId> = new Set();
|
private _rows: Set<UIRowId> = new Set();
|
||||||
constructor(public readonly groupValue: Value) {
|
constructor(public readonly groupValue: Value) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -411,12 +410,12 @@ function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
|
|||||||
*/
|
*/
|
||||||
export class RowGrouping<Value> extends RowListener {
|
export class RowGrouping<Value> extends RowListener {
|
||||||
// Maps row identifiers to groupValues.
|
// Maps row identifiers to groupValues.
|
||||||
private _rowsToValues: Map<RowId, Value> = new Map();
|
private _rowsToValues: Map<UIRowId, Value> = new Map();
|
||||||
|
|
||||||
// Maps group values to RowGroupHelpers
|
// Maps group values to RowGroupHelpers
|
||||||
private _valuesToGroups: Map<Value, RowGroupHelper<Value>> = new Map();
|
private _valuesToGroups: Map<Value, RowGroupHelper<Value>> = new Map();
|
||||||
|
|
||||||
constructor(private _groupFunc: (row: RowId) => Value) {
|
constructor(private _groupFunc: (row: UIRowId) => Value) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// On disposal, dispose all RowGroupHelpers that we maintain.
|
// 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.
|
* SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to.
|
||||||
*/
|
*/
|
||||||
export class SortedRowSet extends RowListener {
|
export class SortedRowSet extends RowListener {
|
||||||
private _allRows: Set<RowId> = new Set();
|
private _allRows: Set<UIRowId> = new Set();
|
||||||
private _isPaused: boolean = false;
|
private _isPaused: boolean = false;
|
||||||
private _koArray: KoArray<RowId>;
|
private _koArray: KoArray<UIRowId>;
|
||||||
private _keepFunc?: (rowId: number|'new') => boolean;
|
private _keepFunc?: (rowId: number|'new') => boolean;
|
||||||
|
|
||||||
constructor(private _compareFunc: CompareFunc<RowId>,
|
constructor(private _compareFunc: CompareFunc<UIRowId>,
|
||||||
private _skippableRows?: SkippableRows) {
|
private _skippableRows?: SkippableRows) {
|
||||||
super();
|
super();
|
||||||
this._koArray = this.autoDispose(koArray<RowId>());
|
this._koArray = this.autoDispose(koArray<UIRowId>());
|
||||||
this._keepFunc = _skippableRows?.getKeepFunc();
|
this._keepFunc = _skippableRows?.getKeepFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,7 +571,7 @@ export class SortedRowSet extends RowListener {
|
|||||||
/**
|
/**
|
||||||
* Re-sorts the array according to the new compareFunc.
|
* Re-sorts the array according to the new compareFunc.
|
||||||
*/
|
*/
|
||||||
public updateSort(compareFunc: CompareFunc<RowId>): void {
|
public updateSort(compareFunc: CompareFunc<UIRowId>): void {
|
||||||
this._compareFunc = compareFunc;
|
this._compareFunc = compareFunc;
|
||||||
if (!this._isPaused) {
|
if (!this._isPaused) {
|
||||||
this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc));
|
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.
|
// 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.
|
// 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.
|
// Nothing to be done if there's no _keepFunc.
|
||||||
if (!this._keepFunc) { return rows; }
|
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
|
* RowWatcher is a RowListener that maintains an observable function that checks whether a row
|
||||||
* is in the connected RowSource.
|
* is in the connected RowSource.
|
||||||
@ -718,7 +717,7 @@ export class RowWatcher extends RowListener {
|
|||||||
public rowFilter: Observable<RowTester> = Observable.create(this, () => false);
|
public rowFilter: Observable<RowTester> = Observable.create(this, () => false);
|
||||||
// We count the number of times the row is added or removed from the source.
|
// 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.
|
// 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() {
|
public clear() {
|
||||||
this._rowCounter.clear();
|
this._rowCounter.clear();
|
||||||
|
@ -100,6 +100,7 @@ export class AccountWidget extends Disposable {
|
|||||||
|
|
||||||
this._maybeBuildBillingPageMenuItem(),
|
this._maybeBuildBillingPageMenuItem(),
|
||||||
this._maybeBuildActivationPageMenuItem(),
|
this._maybeBuildActivationPageMenuItem(),
|
||||||
|
this._maybeBuildSupportGristPageMenuItem(),
|
||||||
|
|
||||||
mobileModeToggle,
|
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.
|
// For links, disabling with just a class is hard; easier to just not make it a link.
|
||||||
// TODO weasel menus should support disabling menuItemLink.
|
// TODO weasel menus should support disabling menuItemLink.
|
||||||
(isBillingManager ?
|
(isBillingManager ?
|
||||||
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
|
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) :
|
||||||
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
|
menuItem(() => null, t('Billing Account'), dom.cls('disabled', true))
|
||||||
) :
|
) :
|
||||||
menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan');
|
menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _maybeBuildActivationPageMenuItem() {
|
private _maybeBuildActivationPageMenuItem() {
|
||||||
@ -167,7 +168,21 @@ export class AccountWidget extends Disposable {
|
|||||||
return null;
|
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;
|
cursor: pointer;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssHeartIcon = styled('span', `
|
||||||
|
margin-left: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
const cssUserInfo = styled('div', `
|
const cssUserInfo = styled('div', `
|
||||||
padding: 12px 24px 12px 16px;
|
padding: 12px 24px 12px 16px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
|
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
|
||||||
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
|
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
|
||||||
import {domAsync} from 'app/client/lib/domAsync';
|
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 {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||||
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
|
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
|
||||||
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
|
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)));
|
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
|
||||||
} else if (pageType === 'welcome') {
|
} else if (pageType === 'welcome') {
|
||||||
return dom.create(WelcomePage, appModel);
|
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 {
|
} else {
|
||||||
return dom.create(pagePanelsDoc, appModel, appObj);
|
return dom.create(pagePanelsDoc, appModel, appObj);
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
|
|||||||
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
||||||
import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||||
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
|
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 {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
|
||||||
import {TableData} from 'app/client/models/TableData';
|
import {TableData} from 'app/client/models/TableData';
|
||||||
import {cssInput} from 'app/client/ui/cssInput';
|
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 {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
|
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
|
||||||
import {createFormatter} from 'app/common/ValueFormatter';
|
import {createFormatter} from 'app/common/ValueFormatter';
|
||||||
|
import {UIRowId} from 'app/common/TableData';
|
||||||
|
|
||||||
const t = makeT('ColumnFilterMenu');
|
const t = makeT('ColumnFilterMenu');
|
||||||
|
|
||||||
@ -470,9 +471,13 @@ function numericInput(obs: Observable<number|undefined|IRelativeDateSpec>,
|
|||||||
editMode = false;
|
editMode = false;
|
||||||
inputEl.value = formatValue(obs.get());
|
inputEl.value = formatValue(obs.get());
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
// Make sure focus is trapped on input during calendar view, so that uses can still use keyboard
|
// 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.
|
// to navigate relative date options just after picking a date on the calendar.
|
||||||
setTimeout(() => opts.isSelected.get() && inputEl.focus());
|
if (opts.viewTypeObs.get() === 'calendarView' && opts.isSelected.get()) {
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onInput = debounce(() => {
|
const onInput = debounce(() => {
|
||||||
if (isRelativeBound(obs.get())) { return; }
|
if (isRelativeBound(obs.get())) { return; }
|
||||||
@ -829,7 +834,7 @@ interface ICountOptions {
|
|||||||
* the possible choices as keys).
|
* the possible choices as keys).
|
||||||
* Note that this logic is replicated in BaseView.prototype.filterByThisCellValue.
|
* 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,
|
{ keyMapFunc = identity, labelMapFunc = identity, columnType,
|
||||||
areHiddenRows = false, valueMapFunc }: ICountOptions) {
|
areHiddenRows = false, valueMapFunc }: ICountOptions) {
|
||||||
|
|
||||||
|
@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
|||||||
testId('doclist')
|
testId('doclist')
|
||||||
),
|
),
|
||||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
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(),
|
||||||
|
];
|
||||||
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||||
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||||
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||||
@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip';
|
|||||||
export class DocTutorial extends FloatingPopup {
|
export class DocTutorial extends FloatingPopup {
|
||||||
private _appModel = this._gristDoc.docPageModel.appModel;
|
private _appModel = this._gristDoc.docPageModel.appModel;
|
||||||
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
||||||
|
private _currentFork = this._currentDoc?.forks?.[0];
|
||||||
private _docComm = this._gristDoc.docComm;
|
private _docComm = this._gristDoc.docComm;
|
||||||
private _docData = this._gristDoc.docData;
|
private _docData = this._gristDoc.docData;
|
||||||
private _docId = this._gristDoc.docId();
|
private _docId = this._gristDoc.docId();
|
||||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||||
private _currentSlideIndex = Observable.create(this,
|
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||||
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
|
|
||||||
|
|
||||||
|
|
||||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
||||||
@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
|
|
||||||
private async _saveCurrentSlidePosition() {
|
private async _saveCurrentSlidePosition() {
|
||||||
const currentOptions = this._currentDoc?.options ?? {};
|
const currentOptions = this._currentDoc?.options ?? {};
|
||||||
|
const currentSlideIndex = this._currentSlideIndex.get();
|
||||||
|
const numSlides = this._slides.get()?.length;
|
||||||
await this._appModel.api.updateDoc(this._docId, {
|
await this._appModel.api.updateDoc(this._docId, {
|
||||||
options: {
|
options: {
|
||||||
...currentOptions,
|
...currentOptions,
|
||||||
tutorial: {
|
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) {
|
private async _changeSlide(slideIndex: number) {
|
||||||
|
@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization';
|
|||||||
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
|
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
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 {BehavioralPrompt} from 'app/common/Prefs';
|
||||||
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
||||||
|
|
||||||
@ -104,6 +104,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
|
|||||||
export interface BehavioralPromptContent {
|
export interface BehavioralPromptContent {
|
||||||
title: () => string;
|
title: () => string;
|
||||||
content: (...domArgs: DomElementArg[]) => DomContents;
|
content: (...domArgs: DomElementArg[]) => DomContents;
|
||||||
|
deploymentTypes: GristDeploymentType[] | '*';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
|
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
|
||||||
@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
|||||||
),
|
),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
referenceColumnsConfig: {
|
referenceColumnsConfig: {
|
||||||
title: () => t('Reference Columns'),
|
title: () => t('Reference Columns'),
|
||||||
@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
|||||||
),
|
),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
rawDataPage: {
|
rawDataPage: {
|
||||||
title: () => t('Raw Data page'),
|
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.'))),
|
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
accessRules: {
|
accessRules: {
|
||||||
title: () => t('Access Rules'),
|
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.'))),
|
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
filterButtons: {
|
filterButtons: {
|
||||||
title: () => t('Pinning Filters'),
|
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.'))),
|
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
nestedFiltering: {
|
nestedFiltering: {
|
||||||
title: () => t('Nested Filtering'),
|
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.')),
|
dom('div', t('Only those rows will appear which match all of the filters.')),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
pageWidgetPicker: {
|
pageWidgetPicker: {
|
||||||
title: () => t('Selecting Data'),
|
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.')),
|
dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
pageWidgetPickerSelectBy: {
|
pageWidgetPickerSelectBy: {
|
||||||
title: () => t('Linking Widgets'),
|
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.'))),
|
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
editCardLayout: {
|
editCardLayout: {
|
||||||
title: () => t('Editing Card Layout'),
|
title: () => t('Editing Card Layout'),
|
||||||
@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
|||||||
})),
|
})),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
addNew: {
|
addNew: {
|
||||||
title: () => t('Add New'),
|
title: () => t('Add New'),
|
||||||
@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
|||||||
+ 'or import data.')),
|
+ 'or import data.')),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
rickRow: {
|
rickRow: {
|
||||||
title: () => t('Anchor Links'),
|
title: () => t('Anchor Links'),
|
||||||
@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
|||||||
),
|
),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: '*',
|
||||||
},
|
},
|
||||||
customURL: {
|
customURL: {
|
||||||
title: () => t('Custom Widgets'),
|
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.'))),
|
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
deploymentTypes: ['saas'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -30,6 +30,10 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
|
|||||||
return dom('a', cssToastAction.cls(''), t("Upgrade Plan"), {target: '_blank'},
|
return dom('a', cssToastAction.cls(''), t("Upgrade Plan"), {target: '_blank'},
|
||||||
{href: commonUrls.plans});
|
{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':
|
case 'renew':
|
||||||
// If already on the billing page, nothing to return.
|
// If already on the billing page, nothing to return.
|
||||||
if (urlState().state.get().billing === 'billing') { return null; }
|
if (urlState().state.get().billing === 'billing') { return null; }
|
||||||
|
326
app/client/ui/SupportGristNudge.ts
Normal file
326
app/client/ui/SupportGristNudge.ts
Normal 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;
|
||||||
|
`);
|
289
app/client/ui/SupportGristPage.ts
Normal file
289
app/client/ui/SupportGristPage.ts
Normal 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;
|
||||||
|
`);
|
@ -26,7 +26,7 @@ const t = makeT('TopBar');
|
|||||||
export function createTopBarHome(appModel: AppModel) {
|
export function createTopBarHome(appModel: AppModel) {
|
||||||
return [
|
return [
|
||||||
cssFlexSpace(),
|
cssFlexSpace(),
|
||||||
|
appModel.supportGristNudge.showButton(),
|
||||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||||
[
|
[
|
||||||
basicButton(
|
basicButton(
|
||||||
|
@ -217,10 +217,12 @@ export class VisibleFieldsConfig extends Disposable {
|
|||||||
primaryButton(
|
primaryButton(
|
||||||
dom.text((use) => t("Hide {{label}}", {label: use(this._fieldLabel)})),
|
dom.text((use) => t("Hide {{label}}", {label: use(this._fieldLabel)})),
|
||||||
dom.on('click', () => this._removeSelectedFields()),
|
dom.on('click', () => this._removeSelectedFields()),
|
||||||
|
testId('visible-hide')
|
||||||
),
|
),
|
||||||
basicButton(
|
basicButton(
|
||||||
t("Clear"),
|
t("Clear"),
|
||||||
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, false)),
|
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, false)),
|
||||||
|
testId('visible-clear')
|
||||||
),
|
),
|
||||||
testId('visible-batch-buttons')
|
testId('visible-batch-buttons')
|
||||||
),
|
),
|
||||||
@ -259,10 +261,12 @@ export class VisibleFieldsConfig extends Disposable {
|
|||||||
primaryButton(
|
primaryButton(
|
||||||
dom.text((use) => t("Show {{label}}", {label: use(this._fieldLabel)})),
|
dom.text((use) => t("Show {{label}}", {label: use(this._fieldLabel)})),
|
||||||
dom.on('click', () => this._addSelectedFields()),
|
dom.on('click', () => this._addSelectedFields()),
|
||||||
|
testId('hidden-show')
|
||||||
),
|
),
|
||||||
basicButton(
|
basicButton(
|
||||||
t("Clear"),
|
t("Clear"),
|
||||||
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),
|
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),
|
||||||
|
testId('hidden-clear')
|
||||||
),
|
),
|
||||||
testId('hidden-batch-buttons')
|
testId('hidden-batch-buttons')
|
||||||
)
|
)
|
||||||
|
@ -72,6 +72,7 @@ export type IconName = "ChartArea" |
|
|||||||
"FunctionResult" |
|
"FunctionResult" |
|
||||||
"GreenArrow" |
|
"GreenArrow" |
|
||||||
"Grow" |
|
"Grow" |
|
||||||
|
"Heart" |
|
||||||
"Help" |
|
"Help" |
|
||||||
"Home" |
|
"Home" |
|
||||||
"Idea" |
|
"Idea" |
|
||||||
@ -214,6 +215,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"FunctionResult",
|
"FunctionResult",
|
||||||
"GreenArrow",
|
"GreenArrow",
|
||||||
"Grow",
|
"Grow",
|
||||||
|
"Heart",
|
||||||
"Help",
|
"Help",
|
||||||
"Home",
|
"Home",
|
||||||
"Idea",
|
"Idea",
|
||||||
|
@ -142,6 +142,7 @@ export const vars = {
|
|||||||
onboardingPopupZIndex: new CustomProp('onboarding-popup-z-index', '1000'),
|
onboardingPopupZIndex: new CustomProp('onboarding-popup-z-index', '1000'),
|
||||||
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
|
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
|
||||||
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
|
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
|
||||||
|
pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'),
|
||||||
notificationZIndex: new CustomProp('notification-z-index', '1100'),
|
notificationZIndex: new CustomProp('notification-z-index', '1100'),
|
||||||
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
|
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
|
||||||
tooltipZIndex: new CustomProp('tooltip-z-index', '5000'),
|
tooltipZIndex: new CustomProp('tooltip-z-index', '5000'),
|
||||||
@ -426,6 +427,9 @@ export const theme = {
|
|||||||
undefined, colors.slate),
|
undefined, colors.slate),
|
||||||
pageInitialsFg: new CustomProp('theme-left-panel-page-initials-fg', undefined, 'white'),
|
pageInitialsFg: new CustomProp('theme-left-panel-page-initials-fg', undefined, 'white'),
|
||||||
pageInitialsBg: new CustomProp('theme-left-panel-page-initials-bg', undefined, colors.slate),
|
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 */
|
/* Right Panel */
|
||||||
rightPanelTabFg: new CustomProp('theme-right-panel-tab-fg', undefined, colors.dark),
|
rightPanelTabFg: new CustomProp('theme-right-panel-tab-fg', undefined, colors.dark),
|
||||||
|
@ -6,7 +6,7 @@ import { theme } from "app/client/ui2018/cssVars";
|
|||||||
import { icon } from "app/client/ui2018/icons";
|
import { icon } from "app/client/ui2018/icons";
|
||||||
import { hoverTooltip, overflowTooltip } from 'app/client/ui/tooltips';
|
import { hoverTooltip, overflowTooltip } from 'app/client/ui/tooltips';
|
||||||
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
|
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');
|
const t = makeT('pages');
|
||||||
|
|
||||||
@ -54,16 +54,20 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const splitName = Computed.create(null, name, (use, _name) => splitPageInitial(_name));
|
||||||
|
|
||||||
return pageElem = dom(
|
return pageElem = dom(
|
||||||
'div',
|
'div',
|
||||||
dom.autoDispose(lis),
|
dom.autoDispose(lis),
|
||||||
|
dom.autoDispose(splitName),
|
||||||
domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
|
domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
|
||||||
domComputed(isRenaming, (isrenaming) => (
|
domComputed(isRenaming, (isrenaming) => (
|
||||||
isrenaming ?
|
isrenaming ?
|
||||||
cssPageItem(
|
cssPageItem(
|
||||||
cssPageInitial(
|
cssPageInitial(
|
||||||
testId('initial'),
|
testId('initial'),
|
||||||
dom.text((use) => Array.from(use(name))[0])
|
dom.text((use) => use(splitName).initial),
|
||||||
|
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
|
||||||
),
|
),
|
||||||
cssEditorInput(
|
cssEditorInput(
|
||||||
{
|
{
|
||||||
@ -82,10 +86,11 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
|
|||||||
cssPageItem(
|
cssPageItem(
|
||||||
cssPageInitial(
|
cssPageInitial(
|
||||||
testId('initial'),
|
testId('initial'),
|
||||||
dom.text((use) => Array.from(use(name))[0]),
|
dom.text((use) => use(splitName).initial),
|
||||||
|
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
|
||||||
),
|
),
|
||||||
cssPageName(
|
cssPageName(
|
||||||
dom.text(name),
|
dom.text((use) => use(splitName).displayName),
|
||||||
testId('label'),
|
testId('label'),
|
||||||
dom.on('click', (ev) => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)),
|
dom.on('click', (ev) => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)),
|
||||||
overflowTooltip(),
|
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', `
|
const cssPageItem = styled('a', `
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -129,7 +152,8 @@ const cssPageItem = styled('a', `
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
.${treeViewContainer.className}-close & {
|
.${treeViewContainer.className}-close & {
|
||||||
margin-left: 16px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
&, &:hover, &:focus {
|
&, &:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -143,10 +167,25 @@ const cssPageInitial = styled('div', `
|
|||||||
color: ${theme.pageInitialsFg};
|
color: ${theme.pageInitialsFg};
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: ${theme.pageInitialsBg};
|
background-color: ${theme.pageInitialsBg};
|
||||||
width: 16px;
|
width: 20px;
|
||||||
height: 16px;
|
height: 20px;
|
||||||
text-align: center;
|
|
||||||
margin-right: 8px;
|
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', `
|
const cssPageName = styled('div', `
|
||||||
|
@ -17,7 +17,6 @@ import {autoGrow} from 'app/client/ui/forms';
|
|||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {DocAction} from 'app/common/DocActions';
|
|
||||||
import {movable} from 'app/client/lib/popupUtils';
|
import {movable} from 'app/client/lib/popupUtils';
|
||||||
|
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
@ -61,7 +60,7 @@ export class FormulaAssistant extends Disposable {
|
|||||||
/** Is the request pending */
|
/** Is the request pending */
|
||||||
private _waiting = Observable.create(this, false);
|
private _waiting = Observable.create(this, false);
|
||||||
/** Is this feature enabled at all */
|
/** Is this feature enabled at all */
|
||||||
private _assistantEnabled = GRIST_FORMULA_ASSISTANT();
|
private _assistantEnabled: Computed<boolean>;
|
||||||
/** Preview column id */
|
/** Preview column id */
|
||||||
private _transformColId: string;
|
private _transformColId: string;
|
||||||
/** Method to invoke when we are closed, it saves or reverts */
|
/** Method to invoke when we are closed, it saves or reverts */
|
||||||
@ -90,6 +89,12 @@ export class FormulaAssistant extends Disposable {
|
|||||||
}) {
|
}) {
|
||||||
super();
|
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) {
|
if (!this._options.field) {
|
||||||
// TODO: field is not passed only for rules (as there is no preview there available to the user yet)
|
// 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
|
// 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._buildIntro(),
|
||||||
this._chat.buildDom(),
|
this._chat.buildDom(),
|
||||||
this._buildChatInput(),
|
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!);
|
this._options.editor.setFormula(entry.formula!);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _sendMessage(description: string, regenerate = false) {
|
private async _sendMessage(description: string, regenerate = false): Promise<ChatMessage> {
|
||||||
// Destruct options.
|
// Destruct options.
|
||||||
const {column, gristDoc} = this._options;
|
const {column, gristDoc} = this._options;
|
||||||
// Get the state of the chat from the column.
|
// 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.
|
// some markdown text back, so we need to parse it.
|
||||||
const prettyMessage = state ? (reply || formula || '') : (formula || reply || '');
|
const prettyMessage = state ? (reply || formula || '') : (formula || reply || '');
|
||||||
// Add it to the chat.
|
// Add it to the chat.
|
||||||
this._chat.addResponse(prettyMessage, formula, suggestedActions[0]);
|
return {
|
||||||
|
message: prettyMessage,
|
||||||
|
formula,
|
||||||
|
action: suggestedActions[0],
|
||||||
|
sender: 'ai',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clear() {
|
private _clear() {
|
||||||
@ -556,9 +568,7 @@ export class FormulaAssistant extends Disposable {
|
|||||||
if (!last) {
|
if (!last) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._chat.thinking();
|
await this._doAsk(last);
|
||||||
this._waiting.set(true);
|
|
||||||
await this._sendMessage(last, true).finally(() => this._waiting.set(false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ask() {
|
private async _ask() {
|
||||||
@ -568,10 +578,22 @@ export class FormulaAssistant extends Disposable {
|
|||||||
const message= this._userInput.get();
|
const message= this._userInput.get();
|
||||||
if (!message) { return; }
|
if (!message) { return; }
|
||||||
this._chat.addQuestion(message);
|
this._chat.addQuestion(message);
|
||||||
this._chat.thinking();
|
|
||||||
this._userInput.set('');
|
this._userInput.set('');
|
||||||
|
await this._doAsk(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doAsk(message: string) {
|
||||||
|
this._chat.thinking();
|
||||||
this._waiting.set(true);
|
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); // ??
|
this.length = Computed.create(this, use => use(this.history).length); // ??
|
||||||
}
|
}
|
||||||
|
|
||||||
public thinking() {
|
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({
|
this.history.push({
|
||||||
message: '...',
|
message: '...',
|
||||||
sender: 'ai',
|
sender: 'ai',
|
||||||
});
|
});
|
||||||
this.scrollDown();
|
this.scrollDown();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public supportsMarkdown() {
|
public supportsMarkdown() {
|
||||||
return this._options.column.chatHistory.peek().get().state !== undefined;
|
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.
|
// Clear any thinking from messages.
|
||||||
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
this.thinking(false);
|
||||||
this.history.push({
|
this.history.push({...message, sender: 'ai'});
|
||||||
message,
|
|
||||||
sender: 'ai',
|
|
||||||
formula,
|
|
||||||
action
|
|
||||||
});
|
|
||||||
this.scrollDown();
|
this.scrollDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
public addQuestion(message: string) {
|
public addQuestion(message: string) {
|
||||||
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
this.thinking(false);
|
||||||
this.history.push({
|
this.history.push({
|
||||||
message,
|
message,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
@ -740,18 +766,13 @@ async function askAI(grist: GristDoc, options: {
|
|||||||
const {column, description, state, regenerate} = options;
|
const {column, description, state, regenerate} = options;
|
||||||
const tableId = column.table.peek().tableId.peek();
|
const tableId = column.table.peek().tableId.peek();
|
||||||
const colId = column.colId.peek();
|
const colId = column.colId.peek();
|
||||||
try {
|
const result = await grist.docApi.getAssistance({
|
||||||
const result = await grist.docComm.getAssistance({
|
|
||||||
context: {type: 'formula', tableId, colId},
|
context: {type: 'formula', tableId, colId},
|
||||||
text: description,
|
text: description,
|
||||||
state,
|
state,
|
||||||
regenerate,
|
regenerate,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
|
||||||
reportError(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -154,13 +154,14 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
dom.on('mousedown', (ev) => {
|
dom.on('mousedown', (ev) => {
|
||||||
// If we are detached, allow user to click and select error text.
|
// If we are detached, allow user to click and select error text.
|
||||||
if (this.isDetached.get()) {
|
if (this.isDetached.get()) {
|
||||||
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
|
// If we clicked on input element in our dom, don't do anything. We probably clicked on chat input, in AI
|
||||||
// some input elements (mainly the AI assistant).
|
// tools box.
|
||||||
const inInput = document.activeElement instanceof HTMLInputElement
|
const clickedOnInput = ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement;
|
||||||
|| document.activeElement instanceof HTMLTextAreaElement;
|
if (clickedOnInput && this._dom.contains(ev.target)) {
|
||||||
if (inInput && this._dom.contains(document.activeElement)) {
|
// By not doing anything special here we assume that the input element will take the focus.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow clicking the error message.
|
// Allow clicking the error message.
|
||||||
if (ev.target instanceof HTMLElement && (
|
if (ev.target instanceof HTMLElement && (
|
||||||
ev.target.classList.contains('error_msg') ||
|
ev.target.classList.contains('error_msg') ||
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import {ActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup} from 'app/common/ActionGroup';
|
||||||
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
|
||||||
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
||||||
import {FormulaProperties} from 'app/common/GranularAccessClause';
|
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 {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
||||||
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
@ -320,11 +319,6 @@ export interface ActiveDocAPI {
|
|||||||
*/
|
*/
|
||||||
getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue>;
|
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.
|
* Fetch content at a url.
|
||||||
*/
|
*/
|
||||||
|
@ -2,15 +2,17 @@
|
|||||||
* A tip for fixing an error.
|
* A tip for fixing an error.
|
||||||
*/
|
*/
|
||||||
export interface ApiTip {
|
export interface ApiTip {
|
||||||
action: 'add-members' | 'upgrade' |'ask-for-help';
|
action: 'add-members' | 'upgrade' | 'ask-for-help' | 'manage';
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LimitType = 'collaborators' | 'docs' | 'workspaces' | 'assistant';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Documentation of a limit relevant to an API error.
|
* Documentation of a limit relevant to an API error.
|
||||||
*/
|
*/
|
||||||
export interface ApiLimit {
|
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
|
subquantity?: string; // a nuance to what we are counting
|
||||||
maximum: number; // maximum allowed
|
maximum: number; // maximum allowed
|
||||||
value: number; // current value of quantity for user
|
value: number; // current value of quantity for user
|
||||||
|
@ -43,6 +43,16 @@ export interface IBillingPlan {
|
|||||||
active: boolean;
|
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.
|
// Utility type that requires all properties to be non-nullish.
|
||||||
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
|
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
|
||||||
|
|
||||||
@ -69,6 +79,7 @@ export interface IBillingDiscount {
|
|||||||
export interface IBillingSubscription {
|
export interface IBillingSubscription {
|
||||||
// All standard plan options.
|
// All standard plan options.
|
||||||
plans: IBillingPlan[];
|
plans: IBillingPlan[];
|
||||||
|
tiers: ILimitTier[];
|
||||||
// Index in the plans array of the plan currently in effect.
|
// Index in the plans array of the plan currently in effect.
|
||||||
planIndex: number;
|
planIndex: number;
|
||||||
// Index in the plans array of the plan to be in effect after the current period end.
|
// 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.
|
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.
|
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.
|
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 {
|
export interface IBillingOrgSettings {
|
||||||
@ -139,6 +158,7 @@ export interface BillingAPI {
|
|||||||
downgradePlan(planName: string): Promise<void>;
|
downgradePlan(planName: string): Promise<void>;
|
||||||
renewPlan(): string;
|
renewPlan(): string;
|
||||||
customerPortal(): string;
|
customerPortal(): string;
|
||||||
|
updateAssistantPlan(tier: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||||
@ -230,6 +250,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
|||||||
return `${this._url}/api/billing/renew`;
|
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.
|
* Checks if current org has active subscription for a Stripe plan.
|
||||||
*/
|
*/
|
||||||
|
@ -14,8 +14,6 @@ export interface AllCellVersions {
|
|||||||
}
|
}
|
||||||
export type CellVersions = Partial<AllCellVersions>;
|
export type CellVersions = Partial<AllCellVersions>;
|
||||||
|
|
||||||
import map = require('lodash/map');
|
|
||||||
|
|
||||||
export type AddRecord = ['AddRecord', string, number, ColValues];
|
export type AddRecord = ['AddRecord', string, number, ColValues];
|
||||||
export type BulkAddRecord = ['BulkAddRecord', string, number[], BulkColValues];
|
export type BulkAddRecord = ['BulkAddRecord', string, number[], BulkColValues];
|
||||||
export type RemoveRecord = ['RemoveRecord', string, number];
|
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
|
// Actions that trigger formula calculations in the data engine
|
||||||
export const CALCULATING_USER_ACTIONS = new Set(['Calculate', 'UpdateCurrentTime', 'RespondToRequests']);
|
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 {
|
export function getNumRows(action: DocAction): number {
|
||||||
return !isDataAction(action) ? 0
|
return !isDataAction(action) ? 0
|
||||||
: Array.isArray(action[2]) ? action[2].length
|
: Array.isArray(action[2]) ? action[2].length
|
||||||
|
@ -58,6 +58,11 @@ export interface Features {
|
|||||||
// for attached files in a document
|
// for attached files in a document
|
||||||
|
|
||||||
gracePeriodDays?: number; // Duration of the grace period in days, before entering delete-only mode
|
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
|
// Check whether it is possible to add members at the org level. There's no flag
|
||||||
|
10
app/common/Install.ts
Normal file
10
app/common/Install.ts
Normal 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
51
app/common/InstallAPI.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -106,6 +106,7 @@ export const DismissedPopup = StringUnion(
|
|||||||
'tutorialFirstCard', // first card of the tutorial,
|
'tutorialFirstCard', // first card of the tutorial,
|
||||||
'formulaHelpInfo', // formula help info shown in the popup editor,
|
'formulaHelpInfo', // formula help info shown in the popup editor,
|
||||||
'formulaAssistantInfo', // formula assistant 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;
|
export type DismissedPopup = typeof DismissedPopup.type;
|
||||||
|
|
||||||
|
@ -8,12 +8,16 @@ import {
|
|||||||
import {getDefaultForType} from 'app/common/gristTypes';
|
import {getDefaultForType} from 'app/common/gristTypes';
|
||||||
import {arrayRemove, arraySplice, getDistinctValues} from 'app/common/gutil';
|
import {arrayRemove, arraySplice, getDistinctValues} from 'app/common/gutil';
|
||||||
import {SchemaTypes} from "app/common/schema";
|
import {SchemaTypes} from "app/common/schema";
|
||||||
import {UIRowId} from 'app/common/UIRowId';
|
|
||||||
import isEqual = require('lodash/isEqual');
|
import isEqual = require('lodash/isEqual');
|
||||||
import fromPairs = require('lodash/fromPairs');
|
import fromPairs = require('lodash/fromPairs');
|
||||||
|
|
||||||
export interface ColTypeMap { [colId: string]: string; }
|
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;
|
type UIRowFunc<T> = (rowId: UIRowId) => T;
|
||||||
|
|
||||||
interface ColData {
|
interface ColData {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import pickBy = require('lodash/pickBy');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telemetry levels, in increasing order of data collected.
|
* 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;
|
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);
|
|
||||||
}
|
|
||||||
|
@ -185,6 +185,8 @@ export const ThemeColors = t.iface([], {
|
|||||||
"left-panel-page-options-selected-hover-bg": "string",
|
"left-panel-page-options-selected-hover-bg": "string",
|
||||||
"left-panel-page-initials-fg": "string",
|
"left-panel-page-initials-fg": "string",
|
||||||
"left-panel-page-initials-bg": "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-fg": "string",
|
||||||
"right-panel-tab-bg": "string",
|
"right-panel-tab-bg": "string",
|
||||||
"right-panel-tab-icon": "string",
|
"right-panel-tab-icon": "string",
|
||||||
|
@ -241,6 +241,8 @@ export interface ThemeColors {
|
|||||||
'left-panel-page-options-selected-hover-bg': string;
|
'left-panel-page-options-selected-hover-bg': string;
|
||||||
'left-panel-page-initials-fg': string;
|
'left-panel-page-initials-fg': string;
|
||||||
'left-panel-page-initials-bg': string;
|
'left-panel-page-initials-bg': string;
|
||||||
|
'left-panel-page-emoji-fg': string;
|
||||||
|
'left-panel-page-emoji-outline': string;
|
||||||
|
|
||||||
/* Right Panel */
|
/* Right Panel */
|
||||||
'right-panel-tab-fg': string;
|
'right-panel-tab-fg': string;
|
||||||
|
@ -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';
|
|
@ -1,5 +1,6 @@
|
|||||||
import {ActionSummary} from 'app/common/ActionSummary';
|
import {ActionSummary} from 'app/common/ActionSummary';
|
||||||
import {ApplyUAResult, ForkResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
|
import {ApplyUAResult, ForkResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
|
||||||
|
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
||||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||||
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
|
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
|
||||||
import {BrowserSettings} from 'app/common/BrowserSettings';
|
import {BrowserSettings} from 'app/common/BrowserSettings';
|
||||||
@ -462,6 +463,8 @@ export interface DocAPI {
|
|||||||
// Update webhook
|
// Update webhook
|
||||||
updateWebhook(webhook: WebhookUpdate): Promise<void>;
|
updateWebhook(webhook: WebhookUpdate): Promise<void>;
|
||||||
flushWebhooks(): Promise<void>;
|
flushWebhooks(): Promise<void>;
|
||||||
|
|
||||||
|
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operations that are supported by a doc worker.
|
// Operations that are supported by a doc worker.
|
||||||
@ -1012,6 +1015,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
|||||||
return response.data[0];
|
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> {
|
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
|
||||||
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
|
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
|
||||||
if (options?.filters) {
|
if (options?.filters) {
|
||||||
|
@ -5,7 +5,7 @@ import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
|
|||||||
import {LocalPlugin} from 'app/common/plugin';
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
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 {getGristConfig} from 'app/common/urlUtils';
|
||||||
import {Document} from 'app/common/UserAPI';
|
import {Document} from 'app/common/UserAPI';
|
||||||
import clone = require('lodash/clone');
|
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 const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
|
||||||
export type LoginPage = typeof LoginPage.type;
|
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.
|
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
||||||
export const InterfaceStyle = StringUnion('light', 'full');
|
export const InterfaceStyle = StringUnion('light', 'full');
|
||||||
export type InterfaceStyle = typeof InterfaceStyle.type;
|
export type InterfaceStyle = typeof InterfaceStyle.type;
|
||||||
@ -72,6 +75,7 @@ export const commonUrls = {
|
|||||||
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
||||||
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
||||||
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
||||||
|
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||||
contact: "https://www.getgrist.com/contact",
|
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',
|
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
||||||
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
||||||
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
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;
|
activation?: ActivationPage;
|
||||||
login?: LoginPage;
|
login?: LoginPage;
|
||||||
welcome?: WelcomePage;
|
welcome?: WelcomePage;
|
||||||
|
supportGrist?: SupportGristPage;
|
||||||
welcomeTour?: boolean;
|
welcomeTour?: boolean;
|
||||||
docTour?: boolean;
|
docTour?: boolean;
|
||||||
manageUsers?: boolean;
|
manageUsers?: boolean;
|
||||||
@ -258,6 +265,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
|||||||
parts.push(`welcome/${state.welcome}`);
|
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};
|
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
|
||||||
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
|
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
|
||||||
queryParams[`${k}_`] = v;
|
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
|
// the minimum length of a urlId prefix is longer than the maximum length
|
||||||
// of any of the valid keys in the url.
|
// of any of the valid keys in the url.
|
||||||
for (const key of map.keys()) {
|
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('doc', key);
|
||||||
map.set('slug', map.get(key)!);
|
map.set('slug', map.get(key)!);
|
||||||
map.delete(key);
|
map.delete(key);
|
||||||
@ -358,6 +367,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
|
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
|
||||||
}
|
}
|
||||||
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
|
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('planType')) { state.params!.planType = sp.get('planType')!; }
|
||||||
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
|
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
|
||||||
if (sp.has('billingTask')) {
|
if (sp.has('billingTask')) {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import {TableData} from 'app/common/TableData';
|
import {TableData, UIRowId} from 'app/common/TableData';
|
||||||
import {UIRowId} from 'app/common/UIRowId';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return whether a table (identified by the rowId of its metadata record) should
|
* Return whether a table (identified by the rowId of its metadata record) should
|
||||||
|
@ -220,6 +220,8 @@ export const GristDark: ThemeColors = {
|
|||||||
'left-panel-page-options-selected-hover-bg': '#A4A4A4',
|
'left-panel-page-options-selected-hover-bg': '#A4A4A4',
|
||||||
'left-panel-page-initials-fg': 'white',
|
'left-panel-page-initials-fg': 'white',
|
||||||
'left-panel-page-initials-bg': '#929299',
|
'left-panel-page-initials-bg': '#929299',
|
||||||
|
'left-panel-page-emoji-fg': 'black',
|
||||||
|
'left-panel-page-emoji-outline': '#69697D',
|
||||||
|
|
||||||
/* Right Panel */
|
/* Right Panel */
|
||||||
'right-panel-tab-fg': '#EFEFEF',
|
'right-panel-tab-fg': '#EFEFEF',
|
||||||
|
@ -220,6 +220,8 @@ export const GristLight: ThemeColors = {
|
|||||||
'left-panel-page-options-selected-hover-bg': '#929299',
|
'left-panel-page-options-selected-hover-bg': '#929299',
|
||||||
'left-panel-page-initials-fg': 'white',
|
'left-panel-page-initials-fg': 'white',
|
||||||
'left-panel-page-initials-bg': '#929299',
|
'left-panel-page-initials-bg': '#929299',
|
||||||
|
'left-panel-page-emoji-fg': 'white',
|
||||||
|
'left-panel-page-emoji-outline': '#BDBDBD',
|
||||||
|
|
||||||
/* Right Panel */
|
/* Right Panel */
|
||||||
'right-panel-tab-fg': '#262633',
|
'right-panel-tab-fg': '#262633',
|
||||||
|
@ -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";
|
import {BaseEntity, Column, Entity, PrimaryColumn} from "typeorm";
|
||||||
|
|
||||||
@Entity({name: 'activations'})
|
@Entity({name: 'activations'})
|
||||||
@ -9,9 +13,47 @@ export class Activation extends BaseEntity {
|
|||||||
@Column({name: 'key', type: 'text', nullable: true})
|
@Column({name: 'key', type: 'text', nullable: true})
|
||||||
public key: string|null;
|
public key: string|null;
|
||||||
|
|
||||||
|
@Column({type: nativeValues.jsonEntityType, nullable: true})
|
||||||
|
public prefs: InstallPrefs|null;
|
||||||
|
|
||||||
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
|
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
|
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
|
||||||
public updatedAt: Date;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,13 @@ import {BillingAccountManager} from 'app/gen-server/entity/BillingAccountManager
|
|||||||
import {Organization} from 'app/gen-server/entity/Organization';
|
import {Organization} from 'app/gen-server/entity/Organization';
|
||||||
import {Product} from 'app/gen-server/entity/Product';
|
import {Product} from 'app/gen-server/entity/Product';
|
||||||
import {nativeValues} from 'app/gen-server/lib/values';
|
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
|
// This type is for billing account status information. Intended for stuff
|
||||||
// like "free trial running out in N days".
|
// like "free trial running out in N days".
|
||||||
interface BillingAccountStatus {
|
export interface BillingAccountStatus {
|
||||||
stripeStatus?: string;
|
stripeStatus?: string;
|
||||||
currentPeriodEnd?: Date;
|
currentPeriodEnd?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +69,9 @@ export class BillingAccount extends BaseEntity {
|
|||||||
@OneToMany(type => Organization, org => org.billingAccount)
|
@OneToMany(type => Organization, org => org.billingAccount)
|
||||||
public orgs: Organization[];
|
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.
|
// A calculated column that is true if it looks like there is a paid plan.
|
||||||
@Column({name: 'paid', type: 'boolean', insert: false, select: false})
|
@Column({name: 'paid', type: 'boolean', insert: false, select: false})
|
||||||
public paid?: boolean;
|
public paid?: boolean;
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {DocumentUsage} from 'app/common/DocUsage';
|
import {DocumentUsage} from 'app/common/DocUsage';
|
||||||
import {hashId} from 'app/common/hashingUtils';
|
|
||||||
import {Role} from 'app/common/roles';
|
import {Role} from 'app/common/roles';
|
||||||
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
|
import {DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,
|
||||||
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
|
NEW_DOCUMENT_CODE} from "app/common/UserAPI";
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
|
||||||
import {nativeValues} from 'app/gen-server/lib/values';
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
|
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
|
||||||
import {AclRuleDoc} from "./AclRule";
|
import {AclRuleDoc} from "./AclRule";
|
||||||
@ -92,7 +90,7 @@ export class Document extends Resource {
|
|||||||
return super.checkProperties(props, documentPropertyKeys);
|
return super.checkProperties(props, documentPropertyKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateFromProperties(props: Partial<DocumentProperties>, dbManager?: HomeDBManager) {
|
public updateFromProperties(props: Partial<DocumentProperties>) {
|
||||||
super.updateFromProperties(props);
|
super.updateFromProperties(props);
|
||||||
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
|
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
|
||||||
if (props.urlId !== undefined) {
|
if (props.urlId !== undefined) {
|
||||||
@ -135,9 +133,6 @@ export class Document extends Resource {
|
|||||||
}
|
}
|
||||||
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
||||||
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
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
|
// Check that icon points to an expected location. This will definitely
|
||||||
|
46
app/gen-server/entity/Limit.ts
Normal file
46
app/gen-server/entity/Limit.ts
Normal 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;
|
||||||
|
}
|
@ -13,7 +13,11 @@ export const personalLegacyFeatures: Features = {
|
|||||||
// no vanity domain
|
// no vanity domain
|
||||||
maxDocsPerOrg: 10,
|
maxDocsPerOrg: 10,
|
||||||
maxSharesPerDoc: 2,
|
maxSharesPerDoc: 2,
|
||||||
maxWorkspacesPerOrg: 1
|
maxWorkspacesPerOrg: 1,
|
||||||
|
/**
|
||||||
|
* One time limit of 100 requests.
|
||||||
|
*/
|
||||||
|
baseMaxAssistantCalls: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,7 +27,12 @@ export const teamFeatures: Features = {
|
|||||||
workspaces: true,
|
workspaces: true,
|
||||||
vanityDomain: true,
|
vanityDomain: true,
|
||||||
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
|
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
|
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
|
||||||
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
||||||
gracePeriodDays: 14,
|
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
|
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
|
||||||
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
||||||
gracePeriodDays: 14,
|
gracePeriodDays: 14,
|
||||||
|
baseMaxAssistantCalls: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testDailyApiLimitFeatures = {
|
export const testDailyApiLimitFeatures = {
|
||||||
@ -79,6 +93,7 @@ export const suspendedFeatures: Features = {
|
|||||||
maxDocsPerOrg: 0,
|
maxDocsPerOrg: 0,
|
||||||
maxSharesPerDoc: 0,
|
maxSharesPerDoc: 0,
|
||||||
maxWorkspacesPerOrg: 0,
|
maxWorkspacesPerOrg: 0,
|
||||||
|
baseMaxAssistantCalls: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -20,6 +20,7 @@ export class Activations {
|
|||||||
if (!activation) {
|
if (!activation) {
|
||||||
activation = manager.create(Activation);
|
activation = manager.create(Activation);
|
||||||
activation.id = makeId();
|
activation.id = makeId();
|
||||||
|
activation.prefs = {};
|
||||||
await activation.save();
|
await activation.save();
|
||||||
}
|
}
|
||||||
return activation;
|
return activation;
|
||||||
|
@ -60,6 +60,7 @@ export class DocApiForwarder {
|
|||||||
app.use('/api/docs/:docId/assign', withDocWithoutAuth);
|
app.use('/api/docs/:docId/assign', withDocWithoutAuth);
|
||||||
app.use('/api/docs/:docId/webhooks/queue', withDoc);
|
app.use('/api/docs/:docId/webhooks/queue', withDoc);
|
||||||
app.use('/api/docs/:docId/webhooks', withDoc);
|
app.use('/api/docs/:docId/webhooks', withDoc);
|
||||||
|
app.use('/api/docs/:docId/assistant', withDoc);
|
||||||
app.use('^/api/docs$', withoutDoc);
|
app.use('^/api/docs$', withoutDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||||
import {getDataLimitStatus} from 'app/common/DocLimits';
|
import {getDataLimitStatus} from 'app/common/DocLimits';
|
||||||
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
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 {Secret} from "app/gen-server/entity/Secret";
|
||||||
import {User} from "app/gen-server/entity/User";
|
import {User} from "app/gen-server/entity/User";
|
||||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
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 {Permissions} from 'app/gen-server/lib/Permissions';
|
||||||
import {scrubUserFromOrg} from "app/gen-server/lib/scrubUserFromOrg";
|
import {scrubUserFromOrg} from "app/gen-server/lib/scrubUserFromOrg";
|
||||||
import {applyPatch} from 'app/gen-server/lib/TypeORMPatches';
|
import {applyPatch} from 'app/gen-server/lib/TypeORMPatches';
|
||||||
@ -89,14 +90,6 @@ export const NotifierEvents = StringUnion(
|
|||||||
|
|
||||||
export type NotifierEvent = typeof NotifierEvents.type;
|
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).
|
// Nominal email address of a user who can view anything (for thumbnails).
|
||||||
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
|
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
|
||||||
|
|
||||||
@ -324,7 +317,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
orgOnly: true
|
orgOnly: true
|
||||||
}];
|
}];
|
||||||
|
|
||||||
public emit(event: Event, ...args: any[]): boolean {
|
public emit(event: NotifierEvent, ...args: any[]): boolean {
|
||||||
return super.emit(event, ...args);
|
return super.emit(event, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1960,7 +1953,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// Update the name and save.
|
// Update the name and save.
|
||||||
const doc: Document = queryResult.data;
|
const doc: Document = queryResult.data;
|
||||||
doc.checkProperties(props);
|
doc.checkProperties(props);
|
||||||
doc.updateFromProperties(props, this);
|
doc.updateFromProperties(props);
|
||||||
if (forkId) {
|
if (forkId) {
|
||||||
await manager.save(doc);
|
await manager.save(doc);
|
||||||
return {status: 200};
|
return {status: 200};
|
||||||
@ -2888,6 +2881,144 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
return this._org(scope, scope.includeSupport || false, org, options);
|
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,
|
private _org(scope: Scope|null, includeSupport: boolean, org: string|number|null,
|
||||||
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
|
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
|
||||||
let query = this._orgs(options.manager);
|
let query = this._orgs(options.manager);
|
||||||
|
16
app/gen-server/migration/1682636695021-ActivationPrefs.ts
Normal file
16
app/gen-server/migration/1682636695021-ActivationPrefs.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
82
app/gen-server/migration/1685343047786-AssistantLimit.ts
Normal file
82
app/gen-server/migration/1685343047786-AssistantLimit.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
|
|||||||
import { getMigrations, getOrCreateConnection, getTypeORMSettings,
|
import { getMigrations, getOrCreateConnection, getTypeORMSettings,
|
||||||
undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
|
undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
|
||||||
import { getDatabaseUrl } from 'app/server/lib/serverUtils';
|
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 { Gristifier } from 'app/server/utils/gristify';
|
||||||
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
|
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
|
||||||
import * as commander from 'commander';
|
import * as commander from 'commander';
|
||||||
@ -81,12 +81,14 @@ export function addSettingsCommand(program: commander.Command,
|
|||||||
.action(showTelemetry);
|
.action(showTelemetry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTelemetry(options: {
|
async function showTelemetry(options: {
|
||||||
json?: boolean,
|
json?: boolean,
|
||||||
all?: boolean,
|
all?: boolean,
|
||||||
}) {
|
}) {
|
||||||
const contracts = TelemetryContracts;
|
const contracts = TelemetryContracts;
|
||||||
const levelName = getTelemetryLevel();
|
const db = await getHomeDBManager();
|
||||||
|
const prefs = await getTelemetryPrefs(db);
|
||||||
|
const levelName = prefs.telemetryLevel.value;
|
||||||
const level = Level[levelName];
|
const level = Level[levelName];
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
|
@ -16,9 +16,7 @@
|
|||||||
import {LocalActionBundle} from 'app/common/ActionBundle';
|
import {LocalActionBundle} from 'app/common/ActionBundle';
|
||||||
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
|
||||||
import {createEmptyActionSummary} from 'app/common/ActionSummary';
|
import {createEmptyActionSummary} from 'app/common/ActionSummary';
|
||||||
import {getSelectionDesc, UserAction} from 'app/common/DocActions';
|
|
||||||
import {DocState} from 'app/common/UserAPI';
|
import {DocState} from 'app/common/UserAPI';
|
||||||
import toPairs = require('lodash/toPairs');
|
|
||||||
import {summarizeAction} from 'app/common/ActionSummarizer';
|
import {summarizeAction} from 'app/common/ActionSummarizer';
|
||||||
|
|
||||||
export interface ActionGroupOptions {
|
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
|
* Convert an ActionBundle into an ActionGroup. ActionGroups are the representation of
|
||||||
* actions on the client.
|
* actions on the client.
|
||||||
@ -260,7 +183,9 @@ export function asActionGroup(history: ActionHistory,
|
|||||||
return {
|
return {
|
||||||
actionNum: act.actionNum,
|
actionNum: act.actionNum,
|
||||||
actionHash: act.actionHash || "",
|
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(),
|
actionSummary: summarize ? summarizeAction(act) : createEmptyActionSummary(),
|
||||||
fromSelf,
|
fromSelf,
|
||||||
linkId: info.linkId,
|
linkId: info.linkId,
|
||||||
|
@ -14,7 +14,6 @@ import {
|
|||||||
} from 'app/common/ActionBundle';
|
} from 'app/common/ActionBundle';
|
||||||
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
|
||||||
import {ActionSummary} from "app/common/ActionSummary";
|
import {ActionSummary} from "app/common/ActionSummary";
|
||||||
import {AssistanceRequest, AssistanceResponse} from "app/common/AssistancePrompts";
|
|
||||||
import {
|
import {
|
||||||
AclResources,
|
AclResources,
|
||||||
AclTableDescription,
|
AclTableDescription,
|
||||||
@ -68,14 +67,12 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
|
|||||||
import {isHiddenCol} from 'app/common/gristTypes';
|
import {isHiddenCol} from 'app/common/gristTypes';
|
||||||
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
|
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
|
||||||
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
||||||
import {hashId} from 'app/common/hashingUtils';
|
|
||||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||||
import {Interval} from 'app/common/Interval';
|
import {Interval} from 'app/common/Interval';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
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 {TelemetryEvent, TelemetryMetadataByLevel} from 'app/common/Telemetry';
|
||||||
import {UIRowId} from 'app/common/UIRowId';
|
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {Document as APIDocument, DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
import {Document as APIDocument, DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
||||||
import {convertFromColumn} from 'app/common/ValueConverter';
|
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 {ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
|
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
|
||||||
import {compileAclFormula} from 'app/server/lib/ACLFormula';
|
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 {Authorizer} from 'app/server/lib/Authorizer';
|
||||||
import {checksumFile} from 'app/server/lib/checksumFile';
|
import {checksumFile} from 'app/server/lib/checksumFile';
|
||||||
import {Client} from 'app/server/lib/Client';
|
import {Client} from 'app/server/lib/Client';
|
||||||
@ -186,7 +183,7 @@ interface UpdateUsageOptions {
|
|||||||
* either .loadDoc() or .createEmptyDoc() is called.
|
* either .loadDoc() or .createEmptyDoc() is called.
|
||||||
* @param {String} docName - The document's filename, without the '.grist' extension.
|
* @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.
|
* Decorator for ActiveDoc methods that prevents shutdown while the method is running, i.e.
|
||||||
* until the returned promise is resolved.
|
* 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());
|
return this._pyCall('autocomplete', txt, tableId, columnId, rowId, user.toJSON());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssistance(docSession: DocSession, request: AssistanceRequest): Promise<AssistanceResponse> {
|
// Callback to generate a prompt containing schema info for assistance.
|
||||||
return this.getAssistanceWithOptions(docSession, request);
|
public async assistanceSchemaPromptV1(
|
||||||
}
|
docSession: OptDocSession, options: AssistanceSchemaPromptV1Context): Promise<string> {
|
||||||
|
|
||||||
public async getAssistanceWithOptions(docSession: DocSession,
|
|
||||||
request: AssistanceRequest): Promise<AssistanceResponse> {
|
|
||||||
// Making a prompt leaks names of tables and columns etc.
|
// Making a prompt leaks names of tables and columns etc.
|
||||||
if (!await this._granularAccess.canScanData(docSession)) {
|
if (!await this._granularAccess.canScanData(docSession)) {
|
||||||
throw new Error("Permission denied");
|
throw new Error("Permission denied");
|
||||||
}
|
}
|
||||||
await this.waitForInitialization();
|
return await this._pyCall('get_formula_prompt', options.tableId, options.colId, options.docString);
|
||||||
return sendForCompletion(this, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback to make a data-engine formula tweak for assistance.
|
// 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);
|
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> {
|
public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise<UploadResult> {
|
||||||
return fetchURL(url, this.makeAccessId(docSession.authorizer.getUserId()), options);
|
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';
|
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
|
||||||
this.logTelemetryEvent(docSession, 'documentForked', {
|
this.logTelemetryEvent(docSession, 'documentForked', {
|
||||||
limited: {
|
limited: {
|
||||||
forkIdDigest: hashId(forkIds.forkId),
|
forkIdDigest: forkIds.forkId,
|
||||||
forkDocIdDigest: hashId(forkIds.docId),
|
forkDocIdDigest: forkIds.docId,
|
||||||
trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined,
|
trunkIdDigest: doc.trunkId,
|
||||||
isTemplate,
|
isTemplate,
|
||||||
lastActivity: doc.updatedAt,
|
lastActivity: doc.updatedAt,
|
||||||
},
|
},
|
||||||
@ -2540,7 +2528,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
|
|||||||
altSessionId ? {altSessionId} : {},
|
altSessionId ? {altSessionId} : {},
|
||||||
{
|
{
|
||||||
limited: {
|
limited: {
|
||||||
docIdDigest: hashId(this._docName),
|
docIdDigest: this._docName,
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
siteId: this._doc?.workspace.org.id,
|
siteId: this._doc?.workspace.org.id,
|
||||||
|
@ -9,7 +9,6 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
|
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
|
||||||
import {removeTrailingSlash} from 'app/common/gutil';
|
import {removeTrailingSlash} from 'app/common/gutil';
|
||||||
import {hashId} from 'app/common/hashingUtils';
|
|
||||||
import {LocalPlugin} from "app/common/plugin";
|
import {LocalPlugin} from "app/common/plugin";
|
||||||
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
|
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
|
||||||
import {Document as APIDocument} from 'app/common/UserAPI';
|
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 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.)
|
// 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';
|
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
|
||||||
if (isPublic || isTemplate) {
|
if (isPublic || isTemplate) {
|
||||||
gristServer.getTelemetry().logEvent('documentOpened', {
|
gristServer.getTelemetry().logEvent('documentOpened', {
|
||||||
limited: {
|
limited: {
|
||||||
docIdDigest: hashId(docId),
|
docIdDigest: docId,
|
||||||
access: doc.access,
|
access: doc.access,
|
||||||
isPublic,
|
isPublic,
|
||||||
isSnapshot,
|
isSnapshot,
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
||||||
import {delay} from 'app/common/delay';
|
import {delay} from 'app/common/delay';
|
||||||
import {DocAction} from 'app/common/DocActions';
|
import {DocAction} from 'app/common/DocActions';
|
||||||
|
import {OptDocSession} from 'app/server/lib/DocSession';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ export const DEPS = { fetch };
|
|||||||
* by interfacing with an external LLM endpoint.
|
* by interfacing with an external LLM endpoint.
|
||||||
*/
|
*/
|
||||||
export interface Assistant {
|
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
|
* Marked "V1" to suggest that it is a particular prompt and it would
|
||||||
* be great to try variants.
|
* 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.
|
* 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.
|
* 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 {
|
export class OpenAIAssistant implements Assistant {
|
||||||
private _apiKey: string;
|
private _apiKey: string;
|
||||||
@ -60,37 +60,43 @@ export class OpenAIAssistant implements Assistant {
|
|||||||
throw new Error('OPENAI_API_KEY not set');
|
throw new Error('OPENAI_API_KEY not set');
|
||||||
}
|
}
|
||||||
this._apiKey = apiKey;
|
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');
|
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`;
|
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 messages = request.state?.messages || [];
|
||||||
const chatMode = this._chatMode;
|
const chatMode = this._chatMode;
|
||||||
if (chatMode) {
|
if (chatMode) {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: 'The user gives you one or more Python classes, ' +
|
content: 'You are a helpful assistant for a user of software called Grist. ' +
|
||||||
'with one last method that needs completing. Write the ' +
|
'Below are one or more Python classes. ' +
|
||||||
'method body as a single code block, ' +
|
'The last method needs completing. ' +
|
||||||
'including the docstring the user gave. ' +
|
"The user will probably give a description of what they want the method (a 'formula') to return. " +
|
||||||
'Just give the Python code as a markdown block, ' +
|
'If so, your response should include the method body as Python code in a markdown block. ' +
|
||||||
'do not give any introduction, that will just be ' +
|
'Do not include the class or method signature, just the method body. ' +
|
||||||
'awkward for the user when copying and pasting. ' +
|
'If your code starts with `class`, `@dataclass`, or `def` it will fail. Only give the method body. ' +
|
||||||
'You are working with Grist, an environment very like ' +
|
'You can import modules inside the method body if needed. ' +
|
||||||
'regular Python except `rec` (like record) is used ' +
|
'You cannot define additional functions or methods. ' +
|
||||||
'instead of `self`. ' +
|
'The method should be a pure function that performs some computation and returns a result. ' +
|
||||||
'Include at least one `return` statement or the method ' +
|
'It CANNOT perform any side effects such as adding/removing/modifying rows/columns/cells/tables/etc. ' +
|
||||||
'will fail, disappointing the user. ' +
|
'It CANNOT interact with files/databases/networks/etc. ' +
|
||||||
'Your answer should be the body of a single method, ' +
|
'It CANNOT display images/charts/graphs/maps/etc. ' +
|
||||||
'not a class, and should not include `dataclass` or ' +
|
'If the user asks for these things, tell them that you cannot help. ' +
|
||||||
'`class` since the user is counting on you to provide ' +
|
'The method uses `rec` instead of `self` as the first parameter.\n\n' +
|
||||||
'a single method. Thanks!'
|
'```python\n' +
|
||||||
|
await makeSchemaPromptV1(optSession, doc, request) +
|
||||||
|
'\n```',
|
||||||
});
|
});
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user', content: await makeSchemaPromptV1(doc, request),
|
role: 'user', content: request.text,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (request.regenerate) {
|
if (request.regenerate) {
|
||||||
@ -105,7 +111,7 @@ export class OpenAIAssistant implements Assistant {
|
|||||||
} else {
|
} else {
|
||||||
messages.length = 0;
|
messages.length = 0;
|
||||||
messages.push({
|
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) {
|
if (request.state) {
|
||||||
throw new Error("HuggingFaceAssistant does not support 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(
|
const response = await DEPS.fetch(
|
||||||
this._completionUrl,
|
this._completionUrl,
|
||||||
{
|
{
|
||||||
@ -215,7 +222,10 @@ export class HuggingFaceAssistant implements Assistant {
|
|||||||
* Test assistant that mimics ChatGPT and just returns the input.
|
* Test assistant that mimics ChatGPT and just returns the input.
|
||||||
*/
|
*/
|
||||||
export class EchoAssistant implements Assistant {
|
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 || [];
|
const messages = request.state?.messages || [];
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
messages.push({
|
messages.push({
|
||||||
@ -250,24 +260,27 @@ export class EchoAssistant implements Assistant {
|
|||||||
/**
|
/**
|
||||||
* Instantiate an assistant, based on environment variables.
|
* Instantiate an assistant, based on environment variables.
|
||||||
*/
|
*/
|
||||||
function getAssistant() {
|
export function getAssistant() {
|
||||||
if (process.env.OPENAI_API_KEY === 'test') {
|
if (process.env.OPENAI_API_KEY === 'test') {
|
||||||
return new EchoAssistant();
|
return new EchoAssistant();
|
||||||
}
|
}
|
||||||
if (process.env.OPENAI_API_KEY) {
|
if (process.env.OPENAI_API_KEY) {
|
||||||
return new OpenAIAssistant();
|
return new OpenAIAssistant();
|
||||||
}
|
}
|
||||||
if (process.env.HUGGINGFACE_API_KEY) {
|
// Maintaining this is too much of a burden for now.
|
||||||
return new HuggingFaceAssistant();
|
// if (process.env.HUGGINGFACE_API_KEY) {
|
||||||
}
|
// return new HuggingFaceAssistant();
|
||||||
throw new Error('Please set OPENAI_API_KEY or HUGGINGFACE_API_KEY');
|
// }
|
||||||
|
throw new Error('Please set OPENAI_API_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service a request for assistance, with a little retry logic
|
* Service a request for assistance, with a little retry logic
|
||||||
* since these endpoints can be a bit flakey.
|
* since these endpoints can be a bit flakey.
|
||||||
*/
|
*/
|
||||||
export async function sendForCompletion(doc: AssistanceDoc,
|
export async function sendForCompletion(
|
||||||
|
optSession: OptDocSession,
|
||||||
|
doc: AssistanceDoc,
|
||||||
request: AssistanceRequest): Promise<AssistanceResponse> {
|
request: AssistanceRequest): Promise<AssistanceResponse> {
|
||||||
const assistant = getAssistant();
|
const assistant = getAssistant();
|
||||||
|
|
||||||
@ -276,7 +289,7 @@ export async function sendForCompletion(doc: AssistanceDoc,
|
|||||||
let response: AssistanceResponse|null = null;
|
let response: AssistanceResponse|null = null;
|
||||||
while(retries++ < 3) {
|
while(retries++ < 3) {
|
||||||
try {
|
try {
|
||||||
response = await assistant.apply(doc, request);
|
response = await assistant.apply(optSession, doc, request);
|
||||||
break;
|
break;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
log.error(`Completion error: ${e}`);
|
log.error(`Completion error: ${e}`);
|
||||||
@ -289,11 +302,11 @@ export async function sendForCompletion(doc: AssistanceDoc,
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeSchemaPromptV1(doc: AssistanceDoc, request: AssistanceRequest) {
|
async function makeSchemaPromptV1(session: OptDocSession, doc: AssistanceDoc, request: AssistanceRequest) {
|
||||||
if (request.context.type !== 'formula') {
|
if (request.context.type !== 'formula') {
|
||||||
throw new Error('makeSchemaPromptV1 only works for formulas');
|
throw new Error('makeSchemaPromptV1 only works for formulas');
|
||||||
}
|
}
|
||||||
return doc.assistanceSchemaPromptV1({
|
return doc.assistanceSchemaPromptV1(session, {
|
||||||
tableId: request.context.tableId,
|
tableId: request.context.tableId,
|
||||||
colId: request.context.colId,
|
colId: request.context.colId,
|
||||||
docString: request.text,
|
docString: request.text,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {createEmptyActionSummary} from "app/common/ActionSummary";
|
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 {BrowserSettings} from "app/common/BrowserSettings";
|
||||||
import {
|
import {
|
||||||
BulkColValues,
|
BulkColValues,
|
||||||
@ -11,7 +11,6 @@ import {
|
|||||||
import {isRaisedException} from "app/common/gristTypes";
|
import {isRaisedException} from "app/common/gristTypes";
|
||||||
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
|
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
|
||||||
import {isAffirmative} from "app/common/gutil";
|
import {isAffirmative} from "app/common/gutil";
|
||||||
import {hashId} from "app/common/hashingUtils";
|
|
||||||
import {SchemaTypes} from "app/common/schema";
|
import {SchemaTypes} from "app/common/schema";
|
||||||
import {SortFunc} from 'app/common/SortFunc';
|
import {SortFunc} from 'app/common/SortFunc';
|
||||||
import {Sort} from 'app/common/SortSpec';
|
import {Sort} from 'app/common/SortSpec';
|
||||||
@ -69,6 +68,7 @@ import {
|
|||||||
} from 'app/server/lib/requestUtils';
|
} from 'app/server/lib/requestUtils';
|
||||||
import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters';
|
import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters';
|
||||||
import {localeFromRequest} from "app/server/lib/ServerLocale";
|
import {localeFromRequest} from "app/server/lib/ServerLocale";
|
||||||
|
import {sendForCompletion} from 'app/server/lib/Assistance';
|
||||||
import {isUrlAllowed, WebhookAction, WebHookSecret} from "app/server/lib/Triggers";
|
import {isUrlAllowed, WebhookAction, WebHookSecret} from "app/server/lib/Triggers";
|
||||||
import {handleOptionalUpload, handleUpload} from "app/server/lib/uploads";
|
import {handleOptionalUpload, handleUpload} from "app/server/lib/uploads";
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
@ -162,6 +162,8 @@ export class DocWorkerApi {
|
|||||||
const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true));
|
const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true));
|
||||||
// converts google code to access token and adds it to request object
|
// converts google code to access token and adds it to request object
|
||||||
const decodeGoogleToken = expressWrap(googleAuthTokenMiddleware.bind(null));
|
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
|
// Middleware to limit number of outstanding requests per document. Will also
|
||||||
// handle errors like expressWrap would.
|
// handle errors like expressWrap would.
|
||||||
@ -918,8 +920,8 @@ export class DocWorkerApi {
|
|||||||
const {forkId} = parseUrlId(scope.urlId);
|
const {forkId} = parseUrlId(scope.urlId);
|
||||||
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
|
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
|
||||||
full: {
|
full: {
|
||||||
tutorialForkIdDigest: forkId ? hashId(forkId) : undefined,
|
tutorialForkIdDigest: forkId,
|
||||||
tutorialTrunkIdDigest: hashId(tutorialTrunkId),
|
tutorialTrunkIdDigest: tutorialTrunkId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1053,6 +1055,20 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
this._app.get('/api/docs/:docId/send-to-drive', canView, decodeGoogleToken, withDoc(exportToDrive));
|
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
|
// 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.
|
// state of the document. Otherwise a fresh empty document is created.
|
||||||
// A "timezone" option can be supplied.
|
// A "timezone" option can be supplied.
|
||||||
@ -1235,6 +1251,21 @@ export class DocWorkerApi {
|
|||||||
return false;
|
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,
|
private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean,
|
||||||
req: Request, res: Response, next: NextFunction) {
|
req: Request, res: Response, next: NextFunction) {
|
||||||
const scope = getDocScope(req);
|
const scope = getDocScope(req);
|
||||||
|
@ -378,7 +378,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
|||||||
const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(', ');
|
const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(', ');
|
||||||
const types = newCols.map(c => c.type);
|
const types = newCols.map(c => c.type);
|
||||||
const sqlParams = DocStorage._encodeColumnsToRows(types, newCols.map(c => [PENDING_VALUE]));
|
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> {
|
public _process_RemoveRecord(tableId: string, rowId: string): Promise<RunResult> {
|
||||||
const sql = "DELETE FROM " + quoteIdent(tableId) + " WHERE id=?";
|
const sql = "DELETE FROM " + quoteIdent(tableId) + " WHERE id=?";
|
||||||
debuglog("RemoveRecord SQL: " + sql, [rowId]);
|
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);
|
const stmt = await this.prepare(preSql + chunkParams + postSql);
|
||||||
for (const index of _.range(0, numChunks * chunkSize, chunkSize)) {
|
for (const index of _.range(0, numChunks * chunkSize, chunkSize)) {
|
||||||
debuglog("DocStorage.BulkRemoveRecord: chunk delete " + index + "-" + (index + chunkSize - 1));
|
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();
|
await stmt.finalize();
|
||||||
}
|
}
|
||||||
@ -1139,7 +1139,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
|||||||
debuglog("DocStorage.BulkRemoveRecord: leftover delete " + (numChunks * chunkSize) + "-" + (rowIds.length - 1));
|
debuglog("DocStorage.BulkRemoveRecord: leftover delete " + (numChunks * chunkSize) + "-" + (rowIds.length - 1));
|
||||||
const leftoverParams = _.range(numLeftovers).map(q).join(',');
|
const leftoverParams = _.range(numLeftovers).map(q).join(',');
|
||||||
await this.run(preSql + leftoverParams + postSql,
|
await this.run(preSql + leftoverParams + postSql,
|
||||||
rowIds.slice(numChunks * chunkSize, rowIds.length));
|
...rowIds.slice(numChunks * chunkSize, rowIds.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,7 +110,6 @@ export class DocWorker {
|
|||||||
applyUserActionsById: activeDocMethod.bind(null, 'editors', 'applyUserActionsById'),
|
applyUserActionsById: activeDocMethod.bind(null, 'editors', 'applyUserActionsById'),
|
||||||
findColFromValues: activeDocMethod.bind(null, 'viewers', 'findColFromValues'),
|
findColFromValues: activeDocMethod.bind(null, 'viewers', 'findColFromValues'),
|
||||||
getFormulaError: activeDocMethod.bind(null, 'viewers', 'getFormulaError'),
|
getFormulaError: activeDocMethod.bind(null, 'viewers', 'getFormulaError'),
|
||||||
getAssistance: activeDocMethod.bind(null, 'editors', 'getAssistance'),
|
|
||||||
importFiles: activeDocMethod.bind(null, 'editors', 'importFiles'),
|
importFiles: activeDocMethod.bind(null, 'editors', 'importFiles'),
|
||||||
finishImportFiles: activeDocMethod.bind(null, 'editors', 'finishImportFiles'),
|
finishImportFiles: activeDocMethod.bind(null, 'editors', 'finishImportFiles'),
|
||||||
cancelImportFiles: activeDocMethod.bind(null, 'editors', 'cancelImportFiles'),
|
cancelImportFiles: activeDocMethod.bind(null, 'editors', 'cancelImportFiles'),
|
||||||
|
@ -5,6 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
|
|||||||
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
||||||
sanitizePathTail} from 'app/common/gristUrls';
|
sanitizePathTail} from 'app/common/gristUrls';
|
||||||
import {getOrgUrlInfo} from 'app/common/gristUrls';
|
import {getOrgUrlInfo} from 'app/common/gristUrls';
|
||||||
|
import {InstallProperties} from 'app/common/InstallAPI';
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {tbind} from 'app/common/tbind';
|
import {tbind} from 'app/common/tbind';
|
||||||
import * as version from 'app/common/version';
|
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 {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
|
||||||
import {PluginManager} from 'app/server/lib/PluginManager';
|
import {PluginManager} from 'app/server/lib/PluginManager';
|
||||||
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
|
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
|
||||||
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, optStringParam,
|
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, isDefaultUser, optStringParam,
|
||||||
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
|
RequestWithGristInfo, sendOkReply, stringParam, TEST_HTTPS_OFFSET,
|
||||||
|
trustOrigin} from 'app/server/lib/requestUtils';
|
||||||
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
||||||
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
|
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
|
||||||
import {Sessions} from 'app/server/lib/Sessions';
|
import {Sessions} from 'app/server/lib/Sessions';
|
||||||
import * as shutdown from 'app/server/lib/shutdown';
|
import * as shutdown from 'app/server/lib/shutdown';
|
||||||
import {TagChecker} from 'app/server/lib/TagChecker';
|
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 {startTestingHooks} from 'app/server/lib/TestingHooks';
|
||||||
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
|
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
|
||||||
import {addUploadRoute} from 'app/server/lib/uploads';
|
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; }
|
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
|
||||||
|
|
||||||
this._telemetry = this.create.Telemetry(this._dbManager, this);
|
this._telemetry = this.create.Telemetry(this._dbManager, this);
|
||||||
this._telemetry.addEndpoints(this.app);
|
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.
|
// Start up a monitor for memory and cpu usage.
|
||||||
this._processMonitorStop = ProcessMonitor.start(this._telemetry);
|
this._processMonitorStop = ProcessMonitor.start(this._telemetry);
|
||||||
@ -1124,7 +1132,11 @@ export class FlexServer implements GristServer {
|
|||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
this.addComm();
|
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?.();
|
await this.create.configure?.();
|
||||||
|
|
||||||
if (!isSingleUserMode()) {
|
if (!isSingleUserMode()) {
|
||||||
const externalStorage = appSettings.section('externalStorage');
|
const externalStorage = appSettings.section('externalStorage');
|
||||||
const haveExternalStorage = Object.values(externalStorage.nested)
|
const haveExternalStorage = Object.values(externalStorage.nested)
|
||||||
@ -1135,6 +1147,7 @@ export class FlexServer implements GristServer {
|
|||||||
this._disableExternalStorage = true;
|
this._disableExternalStorage = true;
|
||||||
externalStorage.flag('active').set(false);
|
externalStorage.flag('active').set(false);
|
||||||
}
|
}
|
||||||
|
await this.create.configure?.();
|
||||||
const workers = this._docWorkerMap;
|
const workers = this._docWorkerMap;
|
||||||
const docWorkerId = await this._addSelfAsWorker(workers);
|
const docWorkerId = await this._addSelfAsWorker(workers);
|
||||||
|
|
||||||
@ -1198,7 +1211,7 @@ export class FlexServer implements GristServer {
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
|
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);
|
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.
|
// Get the HTML template sent for document pages.
|
||||||
public async getDocTemplate(): Promise<DocTemplate> {
|
public async getDocTemplate(): Promise<DocTemplate> {
|
||||||
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),
|
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),
|
||||||
|
@ -138,8 +138,11 @@ export function createDummyGristServer(): GristServer {
|
|||||||
|
|
||||||
export function createDummyTelemetry(): ITelemetry {
|
export function createDummyTelemetry(): ITelemetry {
|
||||||
return {
|
return {
|
||||||
logEvent() { return Promise.resolve(); },
|
|
||||||
addEndpoints() { /* do nothing */ },
|
addEndpoints() { /* do nothing */ },
|
||||||
getTelemetryLevel() { return 'off'; },
|
addPages() { /* do nothing */ },
|
||||||
|
start() { return Promise.resolve(); },
|
||||||
|
logEvent() { return Promise.resolve(); },
|
||||||
|
getTelemetryConfig() { return undefined; },
|
||||||
|
fetchTelemetryPrefs() { return Promise.resolve(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -50,6 +50,7 @@ export interface ICreateActiveDocOptions {
|
|||||||
export interface ICreateStorageOptions {
|
export interface ICreateStorageOptions {
|
||||||
name: string;
|
name: string;
|
||||||
check(): boolean;
|
check(): boolean;
|
||||||
|
checkBackend?(): Promise<void>;
|
||||||
create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined;
|
create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,7 +120,10 @@ export function makeSimpleCreator(opts: {
|
|||||||
},
|
},
|
||||||
async configure() {
|
async configure() {
|
||||||
for (const s of storage || []) {
|
for (const s of storage || []) {
|
||||||
if (s.check()) { break; }
|
if (s.check()) {
|
||||||
|
await s.checkBackend?.();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...(opts.shell && {
|
...(opts.shell && {
|
||||||
|
@ -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 }) {
|
public async versions(key: string, options?: { includeDeleteMarkers?: boolean }) {
|
||||||
const results: minio.BucketItem[] = [];
|
const results: minio.BucketItem[] = [];
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
|
import {TelemetryConfig} from 'app/common/gristUrls';
|
||||||
|
import {assertIsDefined} from 'app/common/gutil';
|
||||||
import {
|
import {
|
||||||
buildTelemetryEventChecker,
|
buildTelemetryEventChecker,
|
||||||
filterMetadata,
|
Level,
|
||||||
removeNullishKeys,
|
TelemetryContracts,
|
||||||
TelemetryEvent,
|
TelemetryEvent,
|
||||||
TelemetryEventChecker,
|
TelemetryEventChecker,
|
||||||
TelemetryEvents,
|
TelemetryEvents,
|
||||||
@ -11,18 +13,28 @@ import {
|
|||||||
TelemetryMetadata,
|
TelemetryMetadata,
|
||||||
TelemetryMetadataByLevel,
|
TelemetryMetadataByLevel,
|
||||||
} from 'app/common/Telemetry';
|
} 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 {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
import {GristServer} from 'app/server/lib/GristServer';
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
|
import {hashId} from 'app/server/lib/hashingUtils';
|
||||||
import {LogMethods} from 'app/server/lib/LogMethods';
|
import {LogMethods} from 'app/server/lib/LogMethods';
|
||||||
import {stringParam} from 'app/server/lib/requestUtils';
|
import {stringParam} from 'app/server/lib/requestUtils';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
import merge = require('lodash/merge');
|
import merge = require('lodash/merge');
|
||||||
|
import pickBy = require('lodash/pickBy');
|
||||||
|
|
||||||
export interface ITelemetry {
|
export interface ITelemetry {
|
||||||
|
start(): Promise<void>;
|
||||||
logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise<void>;
|
logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise<void>;
|
||||||
addEndpoints(app: express.Express): 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;
|
const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
|
||||||
@ -31,26 +43,30 @@ const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
|
|||||||
* Manages telemetry for Grist.
|
* Manages telemetry for Grist.
|
||||||
*/
|
*/
|
||||||
export class Telemetry implements ITelemetry {
|
export class Telemetry implements ITelemetry {
|
||||||
private _telemetryLevel: TelemetryLevel;
|
private _activation: Activation | undefined;
|
||||||
private _deploymentType = this._gristServer.getDeploymentType();
|
private readonly _deploymentType = this._gristServer.getDeploymentType();
|
||||||
private _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
|
|
||||||
private _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
|
private _telemetryPrefs: TelemetryPrefsWithSources | undefined;
|
||||||
|
|
||||||
|
private readonly _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
|
||||||
|
private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
|
||||||
'https://telemetry.getgrist.com/api/telemetry';
|
'https://telemetry.getgrist.com/api/telemetry';
|
||||||
private _numPendingForwardEventRequests = 0;
|
private _numPendingForwardEventRequests = 0;
|
||||||
|
|
||||||
private _installationId: string | undefined;
|
|
||||||
|
|
||||||
private _logger = new LogMethods('Telemetry ', () => ({}));
|
private readonly _logger = new LogMethods('Telemetry ', () => ({}));
|
||||||
private _telemetryLogger = new LogMethods('Telemetry ', () => ({
|
private readonly _telemetryLogger = new LogMethods('Telemetry ', () => ({
|
||||||
eventType: 'telemetry',
|
eventType: 'telemetry',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
private _checkEvent: TelemetryEventChecker | undefined;
|
private _checkTelemetryEvent: TelemetryEventChecker | undefined;
|
||||||
|
|
||||||
constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) {
|
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,
|
event: TelemetryEvent,
|
||||||
metadata?: TelemetryMetadataByLevel
|
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);
|
this._checkTelemetryEvent(event, metadata);
|
||||||
|
|
||||||
if (this._shouldForwardTelemetryEvents) {
|
if (this._shouldForwardTelemetryEvents) {
|
||||||
await this._forwardEvent(event, metadata);
|
await this._forwardEvent(event, metadata);
|
||||||
} else {
|
} else {
|
||||||
this._telemetryLogger.rawLog('info', null, event, {
|
this._logEvent(event, metadata);
|
||||||
eventName: event,
|
|
||||||
eventSource: `grist-${this._deploymentType}`,
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +151,7 @@ export class Telemetry implements ITelemetry {
|
|||||||
* source. Otherwise, the event will only be logged after passing various
|
* source. Otherwise, the event will only be logged after passing various
|
||||||
* checks.
|
* checks.
|
||||||
*/
|
*/
|
||||||
app.post('/api/telemetry', async (req, resp) => {
|
app.post('/api/telemetry', expressWrap(async (req, resp) => {
|
||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
const event = stringParam(req.body.event, 'event', TelemetryEvents.values);
|
const event = stringParam(req.body.event, 'event', TelemetryEvents.values);
|
||||||
if ('eventSource' in req.body.metadata) {
|
if ('eventSource' in req.body.metadata) {
|
||||||
@ -135,11 +161,12 @@ export class Telemetry implements ITelemetry {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
this._assertTelemetryIsReady();
|
||||||
await this.logEvent(event as TelemetryEvent, merge(
|
await this.logEvent(event as TelemetryEvent, merge(
|
||||||
{
|
{
|
||||||
limited: {
|
limited: {
|
||||||
eventSource: `grist-${this._deploymentType}`,
|
eventSource: `grist-${this._deploymentType}`,
|
||||||
...(this._deploymentType !== 'saas' ? {installationId: this._installationId} : {}),
|
...(this._deploymentType !== 'saas' ? {installationId: this._activation!.id} : {}),
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
userId: mreq.userId,
|
userId: mreq.userId,
|
||||||
@ -154,38 +181,51 @@ export class Telemetry implements ITelemetry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp.status(200).send();
|
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(
|
private async _forwardEvent(
|
||||||
event: TelemetryEvent,
|
event: TelemetryEvent,
|
||||||
metadata?: TelemetryMetadata
|
metadata?: TelemetryMetadata
|
||||||
@ -198,7 +238,7 @@ export class Telemetry implements ITelemetry {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this._numPendingForwardEventRequests += 1;
|
this._numPendingForwardEventRequests += 1;
|
||||||
await this._postJsonPayload(JSON.stringify({event, metadata}));
|
await this._doForwardEvent(JSON.stringify({event, metadata}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._logger.error(undefined, `failed to forward telemetry event ${event}`, e);
|
this._logger.error(undefined, `failed to forward telemetry event ${event}`, e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -206,7 +246,7 @@ export class Telemetry implements ITelemetry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _postJsonPayload(payload: string) {
|
private async _doForwardEvent(payload: string) {
|
||||||
await fetch(this._forwardTelemetryEventsUrl, {
|
await fetch(this._forwardTelemetryEventsUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -215,12 +255,90 @@ export class Telemetry implements ITelemetry {
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function getTelemetryLevel(): TelemetryLevel {
|
private _assertTelemetryIsReady() {
|
||||||
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
|
try {
|
||||||
return TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL);
|
assertIsDefined('activation', this._activation);
|
||||||
} else {
|
} catch (e) {
|
||||||
return 'off';
|
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;
|
||||||
|
}
|
||||||
|
@ -60,3 +60,16 @@ export function checkMinIOExternalStorage() {
|
|||||||
region
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import {createHash} from 'crypto';
|
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
|
* 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
|
* doc ids of docs that have public link sharing enabled.
|
||||||
* are included to assist with troubleshooting.
|
|
||||||
*/
|
*/
|
||||||
export function hashId(id: string): string {
|
export function hashId(id: string): string {
|
||||||
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;
|
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;
|
@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
|
|||||||
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
|
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
|
||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
|
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 {RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||||
import {RequestWithGrist} from 'app/server/lib/GristServer';
|
import {RequestWithGrist} from 'app/server/lib/GristServer';
|
||||||
import log from 'app/server/lib/log';
|
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;
|
||||||
|
}
|
||||||
|
@ -76,7 +76,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
|
|||||||
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
|
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
|
||||||
supportEmail: SUPPORT_EMAIL,
|
supportEmail: SUPPORT_EMAIL,
|
||||||
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
||||||
telemetry: server ? getTelemetryConfig(server) : undefined,
|
telemetry: server?.getTelemetry().getTelemetryConfig(),
|
||||||
deploymentType: server?.getDeploymentType(),
|
deploymentType: server?.getDeploymentType(),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@ -163,13 +163,6 @@ function getFeatures(): IFeature[] {
|
|||||||
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
|
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTelemetryConfig(server: GristServer) {
|
|
||||||
const telemetry = server.getTelemetry();
|
|
||||||
return {
|
|
||||||
telemetryLevel: telemetry.getTelemetryLevel(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function configuredPageTitleSuffix() {
|
function configuredPageTitleSuffix() {
|
||||||
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
|
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
|
||||||
return result === "_blank" ? "" : result;
|
return result === "_blank" ? "" : result;
|
||||||
|
@ -106,6 +106,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.addApiMiddleware();
|
server.addApiMiddleware();
|
||||||
await server.addBillingMiddleware();
|
await server.addBillingMiddleware();
|
||||||
|
|
||||||
|
try {
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
||||||
if (includeHome) {
|
if (includeHome) {
|
||||||
@ -119,7 +120,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.addHomeApi();
|
server.addHomeApi();
|
||||||
server.addBillingApi();
|
server.addBillingApi();
|
||||||
server.addNotifier();
|
server.addNotifier();
|
||||||
server.addTelemetry();
|
await server.addTelemetry();
|
||||||
await server.addHousekeeper();
|
await server.addHousekeeper();
|
||||||
await server.addLoginRoutes();
|
await server.addLoginRoutes();
|
||||||
server.addAccountPage();
|
server.addAccountPage();
|
||||||
@ -127,11 +128,12 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.addWelcomePaths();
|
server.addWelcomePaths();
|
||||||
server.addLogEndpoint();
|
server.addLogEndpoint();
|
||||||
server.addGoogleAuthEndpoint();
|
server.addGoogleAuthEndpoint();
|
||||||
|
server.addInstallEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeDocs) {
|
if (includeDocs) {
|
||||||
server.addJsonSupport();
|
server.addJsonSupport();
|
||||||
server.addTelemetry();
|
await server.addTelemetry();
|
||||||
await server.addDoc();
|
await server.addDoc();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +146,10 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.checkOptionCombinations();
|
server.checkOptionCombinations();
|
||||||
server.summary();
|
server.summary();
|
||||||
return server;
|
return server;
|
||||||
|
} catch(e) {
|
||||||
|
await server.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
const fs = require('fs');
|
||||||
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
|
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
|
||||||
const { ProvidePlugin } = require('webpack');
|
const { ProvidePlugin } = require('webpack');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@ -12,10 +13,12 @@ module.exports = {
|
|||||||
entry: {
|
entry: {
|
||||||
main: "app/client/app",
|
main: "app/client/app",
|
||||||
errorPages: "app/client/errorMain",
|
errorPages: "app/client/errorMain",
|
||||||
account: "app/client/accountMain",
|
|
||||||
billing: "app/client/billingMain",
|
billing: "app/client/billingMain",
|
||||||
activation: "app/client/activationMain",
|
// Include client test harness if it is present (it won't be in
|
||||||
|
// docker image).
|
||||||
|
...(fs.existsSync("test/client-harness/client.js") ? {
|
||||||
test: "test/client-harness/client",
|
test: "test/client-harness/client",
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: "[name].bundle.js",
|
filename: "[name].bundle.js",
|
||||||
|
@ -1,35 +1,35 @@
|
|||||||
# Using Large Language Models with Grist
|
# Using Large Language Models with Grist
|
||||||
|
|
||||||
In this experimental Grist feature, originally developed by Alex Hall,
|
In this experimental Grist feature, originally developed by Alex Hall,
|
||||||
you can hook up an AI model such as OpenAI's Codex to write formulas for
|
you can hook up OpenAI's ChatGPT to write formulas for
|
||||||
you. Here's how.
|
you. Here's how.
|
||||||
|
|
||||||
First, you need an API key. You'll have best results currently with an
|
First, you need an API key. Visit https://openai.com/api/ and prepare a key, then
|
||||||
OpenAI model. Visit https://openai.com/api/ and prepare a key, then
|
|
||||||
store it in an environment variable `OPENAI_API_KEY`.
|
store it in an environment variable `OPENAI_API_KEY`.
|
||||||
|
|
||||||
Alternatively, there are many non-proprietary models hosted on Hugging Face.
|
|
||||||
At the time of writing, none can compare with OpenAI for use with Grist.
|
|
||||||
Things can change quickly in the world of AI though. So instead of OpenAI,
|
|
||||||
you can visit https://huggingface.co/ and prepare a key, then
|
|
||||||
store it in an environment variable `HUGGINGFACE_API_KEY`.
|
|
||||||
|
|
||||||
That's all the configuration needed!
|
That's all the configuration needed!
|
||||||
|
|
||||||
Currently it is only a backend feature, we are still working on the UI for it.
|
Currently it is only a backend feature, we are still working on the UI for it.
|
||||||
|
|
||||||
## Trying other models
|
## Hugging Face and other OpenAI models (deactivated)
|
||||||
|
|
||||||
The model used will default to `text-davinci-002` for OpenAI. You can
|
_Not currently available, needs some work to revive. These notes are only preserved as a reminder to ourselves of how this worked._
|
||||||
get better results by setting an environment variable `COMPLETION_MODEL` to
|
|
||||||
`code-davinci-002` if you have access to that model.
|
|
||||||
|
|
||||||
The model used will default to `NovelAI/genji-python-6B` for
|
~~To use a different OpenAI model such as `code-davinci-002` or `text-davinci-003`,
|
||||||
|
set the environment variable `COMPLETION_MODEL` to the name of the model.~~
|
||||||
|
|
||||||
|
~~Alternatively, there are many non-proprietary models hosted on Hugging Face.
|
||||||
|
At the time of writing, none can compare with OpenAI for use with Grist.
|
||||||
|
Things can change quickly in the world of AI though. So instead of OpenAI,
|
||||||
|
you can visit https://huggingface.co/ and prepare a key, then
|
||||||
|
store it in an environment variable `HUGGINGFACE_API_KEY`.~~
|
||||||
|
|
||||||
|
~~The model used will default to `NovelAI/genji-python-6B` for
|
||||||
Hugging Face. There's no particularly great model for this application,
|
Hugging Face. There's no particularly great model for this application,
|
||||||
but you can try other models by setting an environment variable
|
but you can try other models by setting an environment variable
|
||||||
`COMPLETION_MODEL` to `codeparrot/codeparrot` or
|
`COMPLETION_MODEL` to `codeparrot/codeparrot` or
|
||||||
`NinedayWang/PolyCoder-2.7B` or similar.
|
`NinedayWang/PolyCoder-2.7B` or similar.~~
|
||||||
|
|
||||||
If you are hosting a model yourself, host it as Hugging Face does,
|
~~If you are hosting a model yourself, host it as Hugging Face does,
|
||||||
and use `COMPLETION_URL` rather than `COMPLETION_MODEL` to
|
and use `COMPLETION_URL` rather than `COMPLETION_MODEL` to
|
||||||
point to the model on your own server rather than Hugging Face.
|
point to the model on your own server rather than Hugging Face.~~
|
||||||
|
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "grist-core",
|
"name": "grist-core",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Grist is the evolution of spreadsheets",
|
"description": "Grist is the evolution of spreadsheets",
|
||||||
"homepage": "https://github.com/gristlabs/grist-core",
|
"homepage": "https://github.com/gristlabs/grist-core",
|
||||||
@ -13,8 +13,8 @@
|
|||||||
"install:python3": "buildtools/prepare_python3.sh",
|
"install:python3": "buildtools/prepare_python3.sh",
|
||||||
"build:prod": "buildtools/build.sh",
|
"build:prod": "buildtools/build.sh",
|
||||||
"start:prod": "sandbox/run.sh",
|
"start:prod": "sandbox/run.sh",
|
||||||
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} --slow 8000 ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
||||||
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
|
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
|
||||||
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
|
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
|
||||||
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
|
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
|
||||||
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
||||||
@ -190,11 +190,13 @@
|
|||||||
"@gristlabs/sqlite3": "5.1.4-grist.8"
|
"@gristlabs/sqlite3": "5.1.4-grist.8"
|
||||||
},
|
},
|
||||||
"mocha": {
|
"mocha": {
|
||||||
"require": ["test/setupPaths",
|
"require": [
|
||||||
|
"test/setupPaths",
|
||||||
"source-map-support/register",
|
"source-map-support/register",
|
||||||
"test/report-why-tests-hang",
|
"test/report-why-tests-hang",
|
||||||
"test/init-mocha-webdriver",
|
"test/init-mocha-webdriver",
|
||||||
"test/split-tests",
|
"test/split-tests",
|
||||||
"test/chai-as-promised"]
|
"test/chai-as-promised"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -150,7 +150,7 @@ def class_schema(engine, table_id, exclude_col_id=None, lookups=False):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_formula_prompt(engine, table_id, col_id, description,
|
def get_formula_prompt(engine, table_id, col_id, _description,
|
||||||
include_all_tables=True,
|
include_all_tables=True,
|
||||||
lookups=True):
|
lookups=True):
|
||||||
result = ""
|
result = ""
|
||||||
@ -165,9 +165,7 @@ def get_formula_prompt(engine, table_id, col_id, description,
|
|||||||
result += " @property\n"
|
result += " @property\n"
|
||||||
result += " # rec is alias for self\n"
|
result += " # rec is alias for self\n"
|
||||||
result += " def {}(rec) -> {}:\n".format(col_id, return_type)
|
result += " def {}(rec) -> {}:\n".format(col_id, return_type)
|
||||||
result += ' """\n'
|
result += " # Please fill in code only after this line, not the `def`\n"
|
||||||
result += '{}\n'.format(indent(description, " "))
|
|
||||||
result += ' """\n'
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def indent(text, prefix, predicate=None):
|
def indent(text, prefix, predicate=None):
|
||||||
|
@ -151,9 +151,7 @@ class Table2:
|
|||||||
@property
|
@property
|
||||||
# rec is alias for self
|
# rec is alias for self
|
||||||
def new_formula(rec) -> float:
|
def new_formula(rec) -> float:
|
||||||
"""
|
# Please fill in code only after this line, not the `def`
|
||||||
description here
|
|
||||||
"""
|
|
||||||
''')
|
''')
|
||||||
|
|
||||||
def test_get_formula_prompt(self):
|
def test_get_formula_prompt(self):
|
||||||
@ -183,9 +181,7 @@ class Table1:
|
|||||||
@property
|
@property
|
||||||
# rec is alias for self
|
# rec is alias for self
|
||||||
def text(rec) -> str:
|
def text(rec) -> str:
|
||||||
"""
|
# Please fill in code only after this line, not the `def`
|
||||||
description here
|
|
||||||
"""
|
|
||||||
''')
|
''')
|
||||||
|
|
||||||
self.assert_prompt("Table2", "ref", '''\
|
self.assert_prompt("Table2", "ref", '''\
|
||||||
@ -199,9 +195,7 @@ class Table2:
|
|||||||
@property
|
@property
|
||||||
# rec is alias for self
|
# rec is alias for self
|
||||||
def ref(rec) -> Table1:
|
def ref(rec) -> Table1:
|
||||||
"""
|
# Please fill in code only after this line, not the `def`
|
||||||
description here
|
|
||||||
"""
|
|
||||||
''')
|
''')
|
||||||
|
|
||||||
self.assert_prompt("Table3", "reflist", '''\
|
self.assert_prompt("Table3", "reflist", '''\
|
||||||
@ -219,9 +213,7 @@ class Table3:
|
|||||||
@property
|
@property
|
||||||
# rec is alias for self
|
# rec is alias for self
|
||||||
def reflist(rec) -> List[Table2]:
|
def reflist(rec) -> List[Table2]:
|
||||||
"""
|
# Please fill in code only after this line, not the `def`
|
||||||
description here
|
|
||||||
"""
|
|
||||||
''')
|
''')
|
||||||
|
|
||||||
def test_convert_completion(self):
|
def test_convert_completion(self):
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf8">
|
|
||||||
<!-- INSERT BASE -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
|
||||||
<link rel="stylesheet" href="icons/icons.css">
|
|
||||||
<!-- INSERT LOCALE -->
|
|
||||||
<!-- INSERT CONFIG -->
|
|
||||||
<!-- INSERT CUSTOM -->
|
|
||||||
<title>Account<!-- INSERT TITLE SUFFIX --></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script src="account.bundle.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,17 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf8">
|
|
||||||
<!-- INSERT BASE -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
|
||||||
<link rel="stylesheet" href="icons/icons.css">
|
|
||||||
<!-- INSERT LOCALE -->
|
|
||||||
<!-- INSERT CUSTOM -->
|
|
||||||
<title>Activation<!-- INSERT TITLE SUFFIX --></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- INSERT ERROR -->
|
|
||||||
<!-- INSERT CONFIG -->
|
|
||||||
<script src="activation.bundle.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -73,6 +73,7 @@
|
|||||||
--icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
|
--icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
|
||||||
--icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
--icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
||||||
|
--icon-Heart: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMTciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzg0Nl8xNTQxOSkiPjxwYXRoIGQ9Ik03LjY4NTY0IDE1Ljk5OUM2Ljg4NTY0IDE1LjI5OSAwLjI4NTY0NSA5Ljc5OTAxIDAuMjg1NjQ1IDUuNTk5MDFDMC4yODU2NDUgMi44OTkwMSAyLjQ4NTY0IDAuNzk5MDExIDUuMDg1NjQgMC43OTkwMTFDNi4yODU2NCAwLjc5OTAxMSA3LjM4NTY0IDEuMjk5MDEgOC4yODU2NCAxLjk5OTAxQzkuMTg1NjQgMS4xOTkwMSAxMC4yODU2IDAuNzk5MDExIDExLjQ4NTYgMC43OTkwMTFDMTQuMTg1NiAwLjc5OTAxMSAxNi4yODU2IDIuOTk5MDEgMTYuMjg1NiA1LjU5OTAxQzE2LjI4NTYgOS43OTkwMSA5LjY4NTY1IDE1LjI5OSA4Ljg4NTY0IDE1Ljg5OUM4LjU4NTY0IDE2LjI5OSA3Ljk4NTY0IDE2LjI5OSA3LjY4NTY0IDE1Ljk5OVpNNS4wODU2NCAyLjc5OTAxQzMuNTg1NjQgMi43OTkwMSAyLjI4NTY0IDQuMDk5MDEgMi4yODU2NCA1LjU5OTAxQzIuMjg1NjQgNy43OTkwMSA1Ljc4NTY0IDExLjU5OSA4LjI4NTY0IDEzLjc5OUMxMC4zODU2IDExLjg5OSAxNC4yODU2IDcuOTk5MDEgMTQuMjg1NiA1LjU5OTAxQzE0LjI4NTYgMy45OTkwMSAxMi45ODU2IDIuNzk5MDEgMTEuNDg1NiAyLjc5OTAxQzEwLjU4NTYgMi43OTkwMSA5LjY4NTY0IDMuMjk5MDEgOS4xODU2NCA0LjA5OTAxQzguNzg1NjQgNC42OTkwMSA3Ljg4NTY0IDQuNjk5MDEgNy40ODU2NCA0LjA5OTAxQzYuODg1NjQgMy4yOTkwMSA2LjA4NTY0IDIuNzk5MDEgNS4wODU2NCAyLjc5OTAxWiIgZmlsbD0iIzE2QjM3OCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzg0Nl8xNTQxOSI+PHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLjI4NiAuNDk5KSIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
|
||||||
--icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||||
|
@ -1073,5 +1073,31 @@
|
|||||||
"WebhookPage": {
|
"WebhookPage": {
|
||||||
"Clear Queue": "Warteschlange löschen",
|
"Clear Queue": "Warteschlange löschen",
|
||||||
"Webhook Settings": "Webhaken Einstellungen"
|
"Webhook Settings": "Webhaken Einstellungen"
|
||||||
|
},
|
||||||
|
"FormulaAssistant": {
|
||||||
|
"Ask the bot.": "Fragen Sie den Bot.",
|
||||||
|
"Capabilities": "Fähigkeiten",
|
||||||
|
"Community": "Gemeinschaft",
|
||||||
|
"Data": "Daten",
|
||||||
|
"Formula Cheat Sheet": "Formel-Spickzettel",
|
||||||
|
"Formula Help. ": "Formel-Hilfe. ",
|
||||||
|
"Function List": "Funktionsliste",
|
||||||
|
"Grist's AI Assistance": "Grists KI-Unterstützung",
|
||||||
|
"Grist's AI Formula Assistance. ": "Grists KI-Formelunterstützung. ",
|
||||||
|
"Need help? Our AI assistant can help.": "Brauchen Sie Hilfe? Unser KI-Assistent kann helfen.",
|
||||||
|
"New Chat": "Neuer Chat",
|
||||||
|
"Preview": "Vorschau",
|
||||||
|
"Regenerate": "Regenerieren",
|
||||||
|
"Save": "Speichern",
|
||||||
|
"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Weitere Informationen finden Sie unter {{helpFunction}} und {{formulaCheat}} oder besuchen Sie unsere {{community}} für weitere Hilfe.",
|
||||||
|
"Tips": "Tipps"
|
||||||
|
},
|
||||||
|
"GridView": {
|
||||||
|
"Click to insert": "Zum Einfügen klicken"
|
||||||
|
},
|
||||||
|
"WelcomeSitePicker": {
|
||||||
|
"Welcome back": "Willkommen zurück",
|
||||||
|
"You can always switch sites using the account menu.": "Sie können jederzeit über das Kontomenü zwischen den Websites wechseln.",
|
||||||
|
"You have access to the following Grist sites.": "Sie haben Zugriff auf die folgenden Grist-Seiten."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user