From b505d21e79cb85bd56c7ff90a025acbb01b453d6 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 29 Mar 2024 15:20:02 -0700 Subject: [PATCH 01/45] (core) Disable headers shortcut in summary tables Summary: Summary tables have restrictions on which columns can be renamed. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4222 --- app/client/components/GridView.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 73970b2b..184f7375 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -2008,8 +2008,11 @@ GridView.prototype._getCellContextMenuOptions = function() { this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected() ), - disableMakeHeadersFromRow: Boolean ( - this.gristDoc.isReadonly.get() || this.getSelection().rowIds.length !== 1 || this.getSelection().onlyAddRowSelected() + disableMakeHeadersFromRow: Boolean( + this.gristDoc.isReadonly.get() || + this.getSelection().rowIds.length !== 1 || + this.getSelection().onlyAddRowSelected() || + this.viewSection.table().summarySourceTable() !== 0 ), isViewSorted: this.viewSection.activeSortSpec.peek().length > 0, numRows: this.getSelection().rowIds.length, From c87d83553301a41f53ed35a2253a34f4b4af0a6d Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Tue, 2 Apr 2024 22:24:50 -0700 Subject: [PATCH 02/45] (core) Update WS deps after grist-core sync Summary: Some WS-related code was touched in a recent PR to grist-core. This extends those changes to the rest of the codebase so that builds work again. Test Plan: N/A Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4224 --- app/client/components/GristClientSocket.ts | 9 ++++-- app/client/components/GristWSConnection.ts | 32 +++++++++++++++------- app/server/lib/GristSocketServer.ts | 2 +- app/server/lib/requestUtils.ts | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/client/components/GristClientSocket.ts b/app/client/components/GristClientSocket.ts index b7eb27da..ed2d2d6f 100644 --- a/app/client/components/GristClientSocket.ts +++ b/app/client/components/GristClientSocket.ts @@ -56,7 +56,8 @@ export class GristClientSocket { } } - // pause() and resume() are used for tests and assume a WS.WebSocket transport + // pause(), resume(), and isOpen() are only used by tests and assume + // a WS.WebSocket transport. public pause() { (this._wsSocket as WS.WebSocket)?.pause(); } @@ -65,6 +66,10 @@ export class GristClientSocket { (this._wsSocket as WS.WebSocket)?.resume(); } + public isOpen() { + return (this._wsSocket as WS.WebSocket)?.readyState === WS.OPEN; + } + private _createWSSocket() { if (typeof WebSocket !== 'undefined') { this._wsSocket = new WebSocket(this._url); @@ -149,4 +154,4 @@ export class GristClientSocket { private _onEIOClose() { this._closeHandler?.(); } -} \ No newline at end of file +} diff --git a/app/client/components/GristWSConnection.ts b/app/client/components/GristWSConnection.ts index f1d77732..912cd336 100644 --- a/app/client/components/GristWSConnection.ts +++ b/app/client/components/GristWSConnection.ts @@ -330,6 +330,15 @@ export class GristWSConnection extends Disposable { this._reconnectAttempts++; } + let url: string; + try { + url = this._buildWebsocketUrl(isReconnecting, timezone); + } catch (e) { + this._warn('Failed to get the URL for the worker serving the document'); + this._scheduleReconnect(isReconnecting); + return; + } + // Note that if a WebSocket can't establish a connection it will trigger onclose() // As per http://dev.w3.org/html5/websockets/ // "If the establish a WebSocket connection algorithm fails, @@ -337,7 +346,6 @@ export class GristWSConnection extends Disposable { // which then invokes the close the WebSocket connection algorithm, // which then establishes that the WebSocket connection is closed, // which fires the close event." - const url = this._buildWebsocketUrl(isReconnecting, timezone); this._log("GristWSConnection connecting to: " + url); this._ws = this._settings.makeWebSocket(url); @@ -367,18 +375,22 @@ export class GristWSConnection extends Disposable { this.trigger('connectState', false); if (!this._wantReconnect) { return; } - const reconnectTimeout = gutil.getReconnectTimeout(this._reconnectAttempts, reconnectInterval); - this._log("Trying to reconnect in", reconnectTimeout, "ms"); - this.trigger('connectionStatus', 'Trying to reconnect...', 'WARNING'); - this._reconnectTimeout = setTimeout(async () => { - this._reconnectTimeout = null; - // Make sure we've gotten through all lazy-loading. - await this._initialConnection; - await this.connect(true); - }, reconnectTimeout); + this._scheduleReconnect(true); }; } + private _scheduleReconnect(isReconnecting: boolean) { + const reconnectTimeout = gutil.getReconnectTimeout(this._reconnectAttempts, reconnectInterval); + this._log('Trying to reconnect in', reconnectTimeout, 'ms'); + this.trigger('connectionStatus', 'Trying to reconnect...', 'WARNING'); + this._reconnectTimeout = setTimeout(async () => { + this._reconnectTimeout = null; + // Make sure we've gotten through all lazy-loading. + await this._initialConnection; + await this.connect(isReconnecting); + }, reconnectTimeout); + } + private _buildWebsocketUrl(isReconnecting: boolean, timezone: any): string { const url = new URL(this.docWorkerUrl); url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:'; diff --git a/app/server/lib/GristSocketServer.ts b/app/server/lib/GristSocketServer.ts index aab417d6..5a098f06 100644 --- a/app/server/lib/GristSocketServer.ts +++ b/app/server/lib/GristSocketServer.ts @@ -108,4 +108,4 @@ export class GristSocketServer { (socket as any).request = null; // Free initial request as recommended in the Engine.IO documentation this._connectionHandler?.(new GristServerSocketEIO(socket), req); } -} \ No newline at end of file +} diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 0b928f93..e7cd3a10 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -350,7 +350,7 @@ export function getEndUserProtocol(req: IncomingMessage) { } // TODO we shouldn't blindly trust X-Forwarded-Proto. See the Express approach: // https://expressjs.com/en/5x/api.html#trust.proxy.options.table - return req.headers["x-forwarded-proto"] || ((req.socket as TLSSocket).encrypted ? 'https' : 'http'); + return req.headers["x-forwarded-proto"] || ((req.socket as TLSSocket)?.encrypted ? 'https' : 'http'); } /** From 03ead0d1ca0e7a94eb37bf953ec90cb4793543a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 2 Apr 2024 13:22:58 +0200 Subject: [PATCH 03/45] (core) Adding a flag for the UI to check if emails are enabled Summary: Front-end code can now test if emails are enabled and hide some parts of UI based on it. Test Plan: Only secondery text was hidden on add users dialog. Tested manually. Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: georgegevoian Differential Revision: https://phab.getgrist.com/D4221 --- app/client/lib/ACUserManager.ts | 8 ++++---- app/common/gristUrls.ts | 3 +++ app/server/lib/FlexServer.ts | 4 ++-- app/server/lib/GristServer.ts | 2 ++ app/server/lib/ICreate.ts | 8 ++------ app/server/lib/INotifier.ts | 9 +++++++++ app/server/lib/sendAppPage.ts | 1 + 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/client/lib/ACUserManager.ts b/app/client/lib/ACUserManager.ts index fb746a56..ca35d4b7 100644 --- a/app/client/lib/ACUserManager.ts +++ b/app/client/lib/ACUserManager.ts @@ -16,6 +16,7 @@ import { cssMemberText, } from "app/client/ui/UserItem"; import {createUserImage, cssUserImage} from "app/client/ui/UserImage"; +import {getGristConfig} from 'app/common/urlUtils'; import {Computed, computed, dom, DomElementArg, Holder, IDisposableOwner, Observable, styled} from "grainjs"; import {cssMenuItem} from "popweasel"; @@ -111,10 +112,9 @@ export function buildACMemberEmail( )), cssMemberText( cssMemberPrimaryPlus(t("Invite new member")), - cssMemberSecondaryPlus( - // dom.text(use => `We'll email an invite to ${use(emailObs)}`) - dom.text(use => t("We'll email an invite to {{email}}", {email: use(emailObs)})) // TODO i18next - ) + getGristConfig().notifierEnabled ? cssMemberSecondaryPlus( + dom.text(use => t("We'll email an invite to {{email}}", {email: use(emailObs)})) + ) : null, ), testId("um-add-email") ) diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index a7708354..69f2fa2a 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -786,6 +786,9 @@ export interface GristLoadConfig { canCloseAccount?: boolean; experimentalPlugins?: boolean; + + // If backend has an email service for sending notifications. + notifierEnabled?: boolean; } export const Features = StringUnion( diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 728adee8..020e70ae 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -47,7 +47,7 @@ import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions'; import {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {IBilling} from 'app/server/lib/IBilling'; import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; -import {INotifier} from 'app/server/lib/INotifier'; +import {EmptyNotifier, INotifier} from 'app/server/lib/INotifier'; import {InstallAdmin} from 'app/server/lib/InstallAdmin'; import log from 'app/server/lib/log'; import {getLoginSystem} from 'app/server/lib/logins'; @@ -384,7 +384,7 @@ export class FlexServer implements GristServer { } public hasNotifier(): boolean { - return Boolean(this._notifier); + return Boolean(this._notifier) && this._notifier !== EmptyNotifier; } public getNotifier(): INotifier { diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index a1fd8dac..b72b3ed5 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -52,6 +52,7 @@ export interface GristServer { getHomeDBManager(): HomeDBManager; getStorageManager(): IDocStorageManager; getTelemetry(): ITelemetry; + hasNotifier(): boolean; getNotifier(): INotifier; getDocTemplate(): Promise; getTag(): string; @@ -142,6 +143,7 @@ export function createDummyGristServer(): GristServer { getStorageManager() { throw new Error('no storage manager'); }, getTelemetry() { return createDummyTelemetry(); }, getNotifier() { throw new Error('no notifier'); }, + hasNotifier() { return false; }, getDocTemplate() { throw new Error('no doc template'); }, getTag() { return 'tag'; }, sendAppPage() { return Promise.resolve(); }, diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 8f3ab17c..8d76d030 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -5,7 +5,7 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {ExternalStorage} from 'app/server/lib/ExternalStorage'; import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer'; import {IBilling} from 'app/server/lib/IBilling'; -import {INotifier} from 'app/server/lib/INotifier'; +import {EmptyNotifier, INotifier} from 'app/server/lib/INotifier'; import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin'; import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox'; import {IShell} from 'app/server/lib/IShell'; @@ -99,11 +99,7 @@ export function makeSimpleCreator(opts: { }; }, Notifier(dbManager, gristConfig) { - return notifier?.create(dbManager, gristConfig) ?? { - get testPending() { return false; }, - async deleteUser() { /* do nothing */ }, - testSetSendMessageCallback() { return undefined; }, - }; + return notifier?.create(dbManager, gristConfig) ?? EmptyNotifier; }, ExternalStorage(purpose, extraPrefix) { for (const s of storage || []) { diff --git a/app/server/lib/INotifier.ts b/app/server/lib/INotifier.ts index 34740b1a..9c3e0364 100644 --- a/app/server/lib/INotifier.ts +++ b/app/server/lib/INotifier.ts @@ -10,3 +10,12 @@ export interface INotifier { // Return undefined if no notification system is available. testSetSendMessageCallback(op: (body: SendGridMail, description: string) => Promise): SendGridConfig|undefined; } + +/** + * A notifier that does nothing. Used when no email notifications are configured. + */ +export const EmptyNotifier: INotifier = { + get testPending() { return false; }, + async deleteUser() { /* do nothing */ }, + testSetSendMessageCallback() { return undefined; }, +} as const; diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 98aff5fb..8c5ebf92 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -97,6 +97,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi templateOrg: getTemplateOrg(), canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE), experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS), + notifierEnabled: server?.hasNotifier(), ...extra, }; } From ddc28e327bfb924626b45631e1ade068f6e34582 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Mon, 8 Apr 2024 10:20:50 -0700 Subject: [PATCH 04/45] (core) Use login email to match billing profile Summary: The login email was being used for the "email" parameter of a billing endpoint, but the endpoint was checking it against the non-login email when looking for a matching profile. Test Plan: Manual. Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4226 --- test/nbrowser/homeUtil.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts index 31690a8c..f19bfc58 100644 --- a/test/nbrowser/homeUtil.ts +++ b/test/nbrowser/homeUtil.ts @@ -9,6 +9,7 @@ import fetch from 'node-fetch'; import {authenticator} from 'otplib'; import * as path from 'path'; +import {normalizeEmail} from 'app/common/emails'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs'; import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; @@ -105,7 +106,11 @@ export class HomeUtil { const testingHooks = await this.server.getTestingHooks(); const sid = await this.getGristSid(); if (!sid) { throw new Error('no session available'); } - await testingHooks.setLoginSessionProfile(sid, {name, email, loginMethod}, org); + await testingHooks.setLoginSessionProfile( + sid, + {name, email, loginEmail: normalizeEmail(email), loginMethod}, + org + ); } else { if (loginMethod && loginMethod !== 'Email + Password') { throw new Error('only Email + Password logins supported for external server tests'); From bddbcddbef2d2416bf2984e95e7b06ba4b32e1eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Fri, 5 Apr 2024 10:54:53 +0200 Subject: [PATCH 05/45] (core) Endpoint to report on the latest version of stable grist-core image Summary: New endpoint `/api/version` that returns latest version of stable docker image in format: ``` {"latestVersion":"1.1.12"," updatedAt":"2024-03-06T06:28:25.752337Z"," isCritical":false, "updateURL":"https://hub.docker.com/r/gristlabs/grist" } ``` It connects to docker hub API and reads the version from the tag lists endpoint. Stores telemetry passed from the client such us: current version, deployment type, installationId and others. Test Plan: Added new test Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4220 --- app/common/SortFunc.ts | 2 +- app/common/Telemetry.ts | 20 +- app/server/lib/FlexServer.ts | 18 ++ app/server/lib/UpdateManager.ts | 263 ++++++++++++++++++++++++ app/server/mergedServerMain.ts | 1 + test/gen-server/UpdateChecks.ts | 347 ++++++++++++++++++++++++++++++++ test/server/testUtils.ts | 17 +- 7 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 app/server/lib/UpdateManager.ts create mode 100644 test/gen-server/UpdateChecks.ts diff --git a/app/common/SortFunc.ts b/app/common/SortFunc.ts index 36fb7598..4306443e 100644 --- a/app/common/SortFunc.ts +++ b/app/common/SortFunc.ts @@ -28,7 +28,7 @@ type Comparator = (val1: any, val2: any) => number; * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare */ const collator = new Intl.Collator(undefined, {numeric: true}); -function naturalCompare(val1: any, val2: any) { +export function naturalCompare(val1: any, val2: any) { if (typeof val1 === 'string' && typeof val2 === 'string') { return collator.compare(val1, val2); } diff --git a/app/common/Telemetry.ts b/app/common/Telemetry.ts index f2be644e..328a3c7e 100644 --- a/app/common/Telemetry.ts +++ b/app/common/Telemetry.ts @@ -1740,6 +1740,22 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + checkedUpdateAPI: { + category: "SelfHosted", + description: 'Triggered when the app checks for updates.', + minimumTelemetryLevel: Level.limited, + retentionPeriod: 'indefinitely', + metadataContracts: { + installationId: { + description: 'The installation id of the client.', + dataType: 'string', + }, + deploymentType: { + description: 'The deployment type of the client.', + dataType: 'string', + }, + }, + } }; type TelemetryContracts = Record; @@ -1810,6 +1826,7 @@ export const TelemetryEvents = StringUnion( 'visitedForm', 'submittedForm', 'changedAccessRules', + 'checkedUpdateAPI' ); export type TelemetryEvent = typeof TelemetryEvents.type; @@ -1824,7 +1841,8 @@ type TelemetryEventCategory = | 'TeamSite' | 'ProductVisits' | 'AccessRules' - | 'WidgetUsage'; + | 'WidgetUsage' + | 'SelfHosted'; interface TelemetryEventContract { description: string; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 020e70ae..33a10c20 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -67,6 +67,7 @@ import {TagChecker} from 'app/server/lib/TagChecker'; import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; +import {UpdateManager} from 'app/server/lib/UpdateManager'; import {addUploadRoute} from 'app/server/lib/uploads'; import {buildWidgetRepository, getWidgetsInPlugins, IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {setupLocale} from 'app/server/localization'; @@ -179,6 +180,7 @@ export class FlexServer implements GristServer { // Set once ready() is called private _isReady: boolean = false; private _probes: BootProbes; + private _updateManager: UpdateManager; constructor(public port: number, public name: string = 'flexServer', public readonly options: FlexServerOptions = {}) { @@ -405,6 +407,11 @@ export class FlexServer implements GristServer { return this._accessTokens; } + public getUpdateManager() { + if (!this._updateManager) { throw new Error('no UpdateManager available'); } + return this._updateManager; + } + public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise { if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); } return this._sendAppPage(req, resp, options); @@ -880,6 +887,7 @@ export class FlexServer implements GristServer { public async close() { this._processMonitorStop?.(); + await this._updateManager?.clear(); if (this.usage) { await this.usage.close(); } if (this._hosts) { this._hosts.close(); } if (this._dbManager) { @@ -1863,6 +1871,16 @@ export class FlexServer implements GristServer { return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem()); } + public addUpdatesCheck() { + if (this._check('update')) { return; } + + // For now we only are active for sass deployments. + if (this._deploymentType !== 'saas') { return; } + + this._updateManager = new UpdateManager(this.app, this); + this._updateManager.addEndpoints(); + } + // Adds endpoints that support imports and exports. private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) { if (!this._docWorker) { throw new Error("need DocWorker"); } diff --git a/app/server/lib/UpdateManager.ts b/app/server/lib/UpdateManager.ts new file mode 100644 index 00000000..d96b7090 --- /dev/null +++ b/app/server/lib/UpdateManager.ts @@ -0,0 +1,263 @@ +import { ApiError } from "app/common/ApiError"; +import { MapWithTTL } from "app/common/AsyncCreate"; +import { GristDeploymentType } from "app/common/gristUrls"; +import { naturalCompare } from "app/common/SortFunc"; +import { RequestWithLogin } from "app/server/lib/Authorizer"; +import { GristServer } from "app/server/lib/GristServer"; +import { optIntegerParam, optStringParam } from "app/server/lib/requestUtils"; +import { AbortController, AbortSignal } from 'node-abort-controller'; +import type * as express from "express"; +import fetch from "node-fetch"; +import {expressWrap} from 'app/server/lib/expressWrap'; + + +// URL to show to the client where the new version for docker based deployments can be found. +const DOCKER_IMAGE_SITE = "https://hub.docker.com/r/gristlabs/grist"; + +// URL to show to the client where the new version for docker based deployments can be found. +const DOCKER_ENDPOINT = process.env.GRIST_TEST_UPDATE_DOCKER_HUB_URL || + "https://hub.docker.com/v2/namespaces/gristlabs/repositories/grist/tags"; +// Timeout for the request to the external resource. +const REQUEST_TIMEOUT = optIntegerParam(process.env.GRIST_TEST_UPDATE_REQUEST_TIMEOUT, '') ?? 10000; // 10s +// Delay between retries in case of rate limiting. +const RETRY_TIMEOUT = optIntegerParam(process.env.GRIST_TEST_UPDATE_RETRY_TIMEOUT, '') ?? 4000; // 4s +// We cache the good result for an hour. +const GOOD_RESULT_TTL = optIntegerParam(process.env.GRIST_TEST_UPDATE_CHECK_TTL, '') ?? 60 * 60 * 1000; // 1h +// We cache the bad result errors from external resources for a minute. +const BAD_RESULT_TTL = optIntegerParam(process.env.GRIST_TEST_UPDATE_ERROR_TTL, '') ?? 60 * 1000; // 1m + +// A hook for tests to override the default values. +export const Deps = { + DOCKER_IMAGE_SITE, + DOCKER_ENDPOINT, + REQUEST_TIMEOUT, + RETRY_TIMEOUT, + GOOD_RESULT_TTL, + BAD_RESULT_TTL, +}; + +export class UpdateManager { + + // Cache for the latest version of the client. + private _latestVersion: MapWithTTL< + GristDeploymentType, + // We cache the promise, so that we can wait for the first request. + // This promise will always resolves, but can be resolved with an error. + Promise + >; + + private _abortController = new AbortController(); + + public constructor( + private _app: express.Application, + private _server: GristServer + ) { + this._latestVersion = new MapWithTTL>(Deps.GOOD_RESULT_TTL); + } + + public addEndpoints() { + // Make sure that config is ok, so that we are not surprised when client asks as about that. + if (Deps.DOCKER_ENDPOINT) { + try { + new URL(Deps.DOCKER_ENDPOINT); + } catch (err) { + throw new Error( + `Invalid value for GRIST_UPDATE_DOCKER_URL, expected URL: ${Deps.DOCKER_ENDPOINT}` + ); + } + } + + // Support both POST and GET requests. + this._app.use("/api/version", expressWrap(async (req, res) => { + // Get some telemetry from the body request. + const payload = (name: string) => req.body?.[name] ?? req.query[name]; + + // This is the most interesting part for us, to track installation ids and match them + // with the version of the client. Won't be send without telemetry opt in. + const installationId = optStringParam( + payload("installationId"), + "installationId" + ); + + // Current version of grist-core part of the client. Currently not used and not + // passed from the client. + + // Deployment type of the client (we expect this to be 'core' for most of the cases). + const deploymentType = optStringParam( + payload("deploymentType"), + "deploymentType" + ) as GristDeploymentType|undefined; + + this._server + .getTelemetry() + .logEvent(req as RequestWithLogin, "checkedUpdateAPI", { + full: { + installationId, + deploymentType, + }, + }); + + // For now we will just check the latest tag of docker stable image, assuming + // that this is what the client wants. In the future we might have different + // implementation based on the client deployment type. + const deploymentToCheck = 'core'; + const versionChecker: VersionChecker = getLatestStableDockerVersion; + + // To not spam the docker hub with requests, we will cache the good result for an hour. + // We are actually caching the promise, so subsequent requests will wait for the first one. + if (!this._latestVersion.has(deploymentToCheck)) { + const task = versionChecker(this._abortController.signal).catch(err => err); + this._latestVersion.set(deploymentToCheck, task); + } + const resData = await this._latestVersion.get(deploymentToCheck)!; + if (resData instanceof ApiError) { + // If the request has failed for any reason, we will throw the error to the client, + // but shorten the TTL to 1 minute, so that the next client will try after that time. + this._latestVersion.setWithCustomTTL(deploymentToCheck, Promise.resolve(resData), Deps.BAD_RESULT_TTL); + throw resData; + } + res.json(resData); + })); + } + + public async clear() { + this._abortController.abort(); + for (const task of this._latestVersion.values()) { + await task.catch(() => {}); + } + this._latestVersion.clear(); + + // This function just clears cache and state, we should end with a fine state. + this._abortController = new AbortController(); + } +} + +/** + * JSON returned to the client (exported for tests). + */ +export interface LatestVersion { + /** + * Latest version of core component of the client. + */ + latestVersion: string; + /** + * If there were any critical updates after client's version. Undefined if + * we don't know client version or couldn't figure this out for some other reason. + */ + isCritical?: boolean; + /** + * Url where the client can download the latest version (if applicable) + */ + updateURL?: string; + + /** + * When the latest version was updated (in ISO format). + */ + updatedAt?: string; +} + +type VersionChecker = (signal: AbortSignal) => Promise; + +/** + * Get the latest stable version of docker image from the hub. + */ +export async function getLatestStableDockerVersion(signal: AbortSignal): Promise { + try { + // Find stable tag. + const tags = await listRepositoryTags(signal); + const stableTag = tags.find((tag) => tag.name === "stable"); + if (!stableTag) { + throw new ApiError("No stable tag found", 404); + } + + // Now find all tags with the same image. + const up = tags + // Filter by digest. + .filter((tag) => tag.digest === stableTag.digest) + // Name should be a version number in a correct format (should start with a number or v and number). + .filter(tag => /^v?\d+/.test(tag.name)) + // And sort it in natural order (so that 1.1.10 is after 1.1.9). + .sort(compare("name")); + + const last = up[up.length - 1]; + // Panic if we don't have any tags that looks like version numbers. + if (!last) { + throw new ApiError("No stable image found", 404); + } + return { + latestVersion: last.name, + updatedAt: last.tag_last_pushed, + isCritical: false, + updateURL: Deps.DOCKER_IMAGE_SITE + }; + } catch (err) { + // Make sure to throw only ApiErrors (cache depends on that). + if (err instanceof ApiError) { + throw err; + } + throw new ApiError(err.message, 500); + } +} + +// Shape of the data from the Docker Hub API. +interface DockerTag { + name: string; + digest: string; + tag_last_pushed: string; +} + +interface DockerResponse { + results: DockerTag[]; + next: string|null; +} + +// https://docs.docker.com/docker-hub/api/latest/#tag/repositories/ +// paths/~1v2~1namespaces~1%7Bnamespace%7D~1repositories~1%7Brepository%7D~1tags/get +async function listRepositoryTags(signal: AbortSignal): Promise{ + const tags: DockerTag[] = []; + + // In case of rate limiting, we will retry the request 20 times. + // This is for all pages, so we might hit the limit multiple times. + let MAX_RETRIES = 20; + + const url = new URL(Deps.DOCKER_ENDPOINT); + url.searchParams.set("page_size", "100"); + let next: string|null = url.toString(); + + // We assume have a maximum of 100 000 tags, if that is not enough, we will have to change this. + let MAX_LOOPS = 1000; + + while (next && MAX_LOOPS-- > 0) { + const response = await fetch(next, {signal, timeout: Deps.REQUEST_TIMEOUT}); + if (response.status === 429) { + // We hit the rate limit, let's wait a bit and try again. + await new Promise((resolve) => setTimeout(resolve, Deps.RETRY_TIMEOUT)); + if (signal.aborted) { + throw new Error("Aborted"); + } + if (MAX_RETRIES-- <= 0) { + throw new Error("Too many retries"); + } + continue; + } + if (response.status !== 200) { + throw new ApiError(await response.text(), response.status); + } + const json: DockerResponse = await response.json(); + tags.push(...json.results); + next = json.next; + } + if (MAX_LOOPS <= 0) { + throw new Error("Too many tags found"); + } + return tags; +} + +/** + * Helper for sorting in natural order (1.1.10 is after 1.1.9). + */ +function compare(prop: keyof T) { + return (a: T, b: T) => { + return naturalCompare(a[prop], b[prop]); + }; +} diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 3a8ea22f..0c62010d 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -106,6 +106,7 @@ export async function main(port: number, serverTypes: ServerType[], server.addHealthCheck(); if (includeHome || includeApp) { server.addBootPage(); + server.addUpdatesCheck(); } server.denyRequestsIfNotReady(); diff --git a/test/gen-server/UpdateChecks.ts b/test/gen-server/UpdateChecks.ts new file mode 100644 index 00000000..3633a401 --- /dev/null +++ b/test/gen-server/UpdateChecks.ts @@ -0,0 +1,347 @@ +import axios from "axios"; +import * as chai from "chai"; +import * as sinon from 'sinon'; + +import { configForUser } from "test/gen-server/testUtils"; +import * as testUtils from "test/server/testUtils"; +import { serveSomething, Serving } from "test/server/customUtil"; +import { Deps, LatestVersion } from "app/server/lib/UpdateManager"; +import { TestServer } from "test/gen-server/apiUtils"; +import { delay } from "app/common/delay"; + +const assert = chai.assert; + +let testServer: TestServer; + +const stop = async () => { + await testServer?.stop(); + testServer = null as any; +}; + +let homeUrl: string; +let dockerHub: Serving & { signal: () => Defer }; + +const chimpy = configForUser("Chimpy"); + +// Tests specific complex scenarios that may have previously resulted in wrong behavior. +describe("UpdateChecks", function () { + testUtils.setTmpLogLevel("error"); + + this.timeout("20s"); + + const sandbox = sinon.createSandbox(); + + before(async function () { + testUtils.EnvironmentSnapshot.push(); + dockerHub = await dummyDockerHub(); + assert.equal((await fetch(dockerHub.url + "/tags")).status, 200); + + // Start the server with correct configuration. + Object.assign(process.env, { + GRIST_TEST_SERVER_DEPLOYMENT_TYPE: "saas", + }); + sandbox.stub(Deps, "REQUEST_TIMEOUT").value(300); + sandbox.stub(Deps, "RETRY_TIMEOUT").value(400); + sandbox.stub(Deps, "GOOD_RESULT_TTL").value(500); + sandbox.stub(Deps, "BAD_RESULT_TTL").value(200); + sandbox.stub(Deps, "DOCKER_ENDPOINT").value(dockerHub.url + "/tags"); + + await startInProcess(this); + }); + + after(async function () { + sandbox.restore(); + await dockerHub.shutdown(); + await stop(); + testUtils.EnvironmentSnapshot.pop(); + }); + + afterEach(async function () { + await testServer.server.getUpdateManager().clear(); + }); + + it("should read latest version as anonymous user in happy path", async function () { + setEndpoint(dockerHub.url + "/tags"); + const resp = await axios.get(`${homeUrl}/api/version`); + assert.equal(resp.status, 200, `${homeUrl}/api/version`); + const result: LatestVersion = resp.data; + assert.equal(result.latestVersion, "10"); + + // Also works in post method. + const resp2 = await axios.post(`${homeUrl}/api/version`); + assert.equal(resp2.status, 200); + assert.deepEqual(resp2.data, result); + }); + + it("should read latest version as existing user", async function () { + setEndpoint(dockerHub.url + "/tags"); + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 200); + const result: LatestVersion = resp.data; + assert.equal(result.latestVersion, "10"); + }); + + it("passes errors to client", async function () { + setEndpoint(dockerHub.url + "/404"); + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { error: "Not Found" }); + }); + + it("retries on 429", async function () { + setEndpoint(dockerHub.url + "/429"); + + // First make sure that mock works. + assert.equal((await fetch(dockerHub.url + "/429")).status, 200); + assert.equal((await fetch(dockerHub.url + "/429")).status, 429); + assert.equal((await fetch(dockerHub.url + "/429")).status, 200); + assert.equal((await fetch(dockerHub.url + "/429")).status, 429); + + // Now make sure that 4 subsequent requests are successful. + const check = async () => { + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 200); + const result: LatestVersion = resp.data; + assert.equal(result.latestVersion, "10"); + }; + + await check(); + await check(); + await check(); + await check(); + }); + + it("throws when receives html", async function () { + setEndpoint(dockerHub.url + "/html"); + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 500); + }); + + it("caches data end errors", async function () { + setEndpoint(dockerHub.url + "/error"); + const r1 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r1.status, 500); + assert.equal(r1.data.error, "1"); + + const r2 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r2.status, 500); + assert.equal(r2.data.error, "1"); // since errors are cached for 200ms. + + await delay(300); // error is cached for 200ms + + const r3 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r3.status, 500); + assert.equal(r3.data.error, "2"); // second error is different, but still cached for 200ms. + + const r4 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r4.status, 500); + assert.equal(r4.data.error, "2"); + + await delay(300); + + // Now we should get correct result, but it will be cached for 500ms. + + const r5 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r5.status, 200); + assert.equal(r5.data.latestVersion, "3"); // first successful response is cached for 2 seconds. + + const r6 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r6.status, 200); + assert.equal(r6.data.latestVersion, "3"); + + await delay(700); + + const r7 = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(r7.status, 200); + assert.equal(r7.data.latestVersion, "4"); + }); + + it("can stop server when hangs", async function () { + setEndpoint(dockerHub.url + "/hang"); + const handCalled = dockerHub.signal(); + const resp = axios + .get(`${homeUrl}/api/version`, chimpy) + .catch((err) => ({ status: 999, data: null })); + await handCalled; + await stop(); + const result = await resp; + assert.equal(result.status, 500); + assert.match(result.data.error, /aborted/); + // Start server again, and make sure it works. + await startInProcess(this); + }); + + it("dosent starts for non saas deployment", async function () { + try { + testUtils.EnvironmentSnapshot.push(); + Object.assign(process.env, { + GRIST_TEST_SERVER_DEPLOYMENT_TYPE: "core", + }); + await stop(); + await startInProcess(this); + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 404); + } finally { + testUtils.EnvironmentSnapshot.pop(); + } + + // Start normal one again. + await stop(); + await startInProcess(this); + }); + + it("reports error when timeout happens", async function () { + setEndpoint(dockerHub.url + "/timeout"); + const resp = await axios.get(`${homeUrl}/api/version`, chimpy); + assert.equal(resp.status, 500); + assert.match(resp.data.error, /timeout/); + }); +}); + +async function dummyDockerHub() { + let odds = 0; + + // We offer a way to signal when request is received. + // Test can add a dummy promise using signal() method, and it is resolved + // when any request is received. + const signals: Defer[] = []; + let errorCount = 0; + + const tempServer = await serveSomething((app) => { + app.use((req, res, next) => { + signals.forEach((p) => p.resolve()); + signals.length = 0; + next(); + }); + app.get("/404", (_, res) => res.status(404).send("Not Found").end()); + app.get("/429", (_, res) => { + if (odds++ % 2) { + res.status(429).send("Too Many Requests"); + } else { + res.json(SECOND_PAGE); + } + }); + app.get("/timeout", (_, res) => { + setTimeout(() => res.status(200).json(SECOND_PAGE), 500); + }); + + app.get("/error", (_, res) => { + errorCount++; + // First 2 calls will return error, next will return numbers (3, 4, 5, 6, 7, 8, 9, 10) + if (errorCount <= 2) { + res.status(500).send(String(errorCount)); + } else { + res.json(VERSION(errorCount)); + } + }); + + app.get("/html", (_, res) => { + res.status(200).send(""); + }); + app.get("/hang", () => {}); + app.get("/tags", (_, res) => { + res.status(200).json(FIRST_PAGE(tempServer)); + }); + app.get("/next", (_, res) => { + res.status(200).json(SECOND_PAGE); + }); + }); + + return Object.assign(tempServer, { + signal() { + const p = defer(); + signals.push(p); + return p; + }, + }); +} + +function setEndpoint(endpoint: string) { + sinon.stub(Deps, "DOCKER_ENDPOINT").value(endpoint); +} + +async function startInProcess(context: Mocha.Context) { + testServer = new TestServer(context); + await testServer.start(["home"]); + homeUrl = testServer.serverUrl; +} + +const VERSION = (i: number) => ({ + results: [ + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "stable", + digest: "stable", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: i.toString(), + digest: "stable", + }, + ], + count: 2, + next: null, +}); + +const SECOND_PAGE = { + results: [ + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "stable", + digest: "stable", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "latest", + digest: "latest", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "1", + digest: "latest", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "1", + digest: "stable", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "9", + digest: "stable", + }, + { + tag_last_pushed: "2024-03-26T07:11:01.272113Z", + name: "10", + digest: "stable", + }, + ], + count: 6, + next: null, +}; + +const FIRST_PAGE = (tempServer: Serving) => ({ + results: [], + count: 0, + next: tempServer.url + "/next", +}); + +interface Defer { + then: Promise["then"]; + resolve: () => void; + reject: () => void; +} + +const defer = () => { + let resolve: () => void; + let reject: () => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }).catch(() => {}); + return { + then: promise.then.bind(promise), + resolve: resolve!, + reject: reject!, + }; +}; diff --git a/test/server/testUtils.ts b/test/server/testUtils.ts index a394a005..d91c71ba 100644 --- a/test/server/testUtils.ts +++ b/test/server/testUtils.ts @@ -302,11 +302,26 @@ export async function readFixtureDoc(docName: string) { // a class to store a snapshot of environment variables, can be reverted to by // calling .restore() export class EnvironmentSnapshot { + + public static push() { + this._stack.push(new EnvironmentSnapshot()); + } + + public static pop() { + const snapshot = this._stack.pop(); + if (!snapshot) { + throw new Error("EnvironmentSnapshot stack is empty"); + } + snapshot.restore(); + } + + private static _stack: EnvironmentSnapshot[] = []; + private _oldEnv: NodeJS.ProcessEnv; + public constructor() { this._oldEnv = clone(process.env); } - // Reset environment variables. public restore() { Object.assign(process.env, this._oldEnv); From 661f1c1804da0d2aa2f660ee2a374922fda6936b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 10 Apr 2024 09:31:10 +0200 Subject: [PATCH 06/45] (core) DuplicatePage function didn't duplicated collapsed widgets Summary: When one of 2 widget was collapsed, the resulting widget can become a root section. Then, when a page was duplicated, the layout was duplicated incorrectly (with wrong collapsed section). This resulted in a bug, when the root section was deleted, as it was the last section in the saved layout, but not the last section on the visible layout. Test Plan: Added 2 tests Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4227 --- app/client/components/ViewLayout.ts | 7 ++- app/client/components/duplicatePage.ts | 17 ++++-- test/nbrowser/ViewLayoutCollapse.ts | 79 +++++++++++++++++++++++++- test/nbrowser/gristUtils.ts | 21 +++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 12efaa90..f017f992 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -334,7 +334,12 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { function addToSpec(leafId: number) { const newBox = tmpLayout.buildLayoutBox({ leaf: leafId }); - const rows = tmpLayout.rootBox()!.childBoxes.peek(); + const root = tmpLayout.rootBox(); + if (!root || root.isDisposed()) { + tmpLayout.setRoot(newBox); + return newBox; + } + const rows = root.childBoxes.peek(); const lastRow = rows[rows.length - 1]; if (rows.length >= 1 && lastRow.isLeaf()) { // Add a new child to the last row. diff --git a/app/client/components/duplicatePage.ts b/app/client/components/duplicatePage.ts index a4e01775..c8e996f4 100644 --- a/app/client/components/duplicatePage.ts +++ b/app/client/components/duplicatePage.ts @@ -199,13 +199,20 @@ function newViewSectionAction(widget: IPageWidget, viewId: number) { /** * Replaces each `leaf` id in layoutSpec by its corresponding id in mapIds. Leave unchanged if id is * missing from mapIds. + * LayoutSpec is a tree structure with leaves (that have `leaf` property) or containers of leaves. The root + * container (or leaf) also includes a list of collapsed leaves in `collapsed` property. + * + * Example use: + * patchLayoutSpec({ + * leaf: 1, +* collapsed: [{leaf: 2}] + * }, {1: 10, 2: 20}) */ export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) { - return cloneDeepWith(layoutSpec, (val) => { - if (typeof val === 'object' && val !== null) { - if (mapIds[val.leaf]) { - return {...val, leaf: mapIds[val.leaf]}; - } + const cloned = cloneDeepWith(layoutSpec, (val, key) => { + if (key === 'leaf' && mapIds[val]) { + return mapIds[val]; } }); + return cloned; } diff --git a/test/nbrowser/ViewLayoutCollapse.ts b/test/nbrowser/ViewLayoutCollapse.ts index b1643e66..d157bc6b 100644 --- a/test/nbrowser/ViewLayoutCollapse.ts +++ b/test/nbrowser/ViewLayoutCollapse.ts @@ -16,11 +16,86 @@ describe("ViewLayoutCollapse", function() { before(async () => { session = await gu.session().login(); - await session.tempDoc(cleanup, 'Investment Research.grist'); - await gu.openPage("Overview"); + await session.tempNewDoc(cleanup); + }); + + it('fix:copies collapsed sections properly', async function() { + // When one of 2 widget was collapsed, the resulting widget can become a root section. Then, + // when a page was duplicated, the layout was duplicated incorrectly (with wrong collapsed + // section). This resulted in a bug, when the root section was deleted, as it was the last + // section in the saved layout, but not the last section on the visible layout. + + // Add new page with new table. + await gu.addNewPage('Table', 'New Table', { + tableName: 'Broken' + }); + + await gu.renameActiveSection('Collapsed'); + + // Add section here (with the same table). + await gu.addNewSection('Table', 'Broken'); + + // Rename it so that it is easier to find. + await gu.renameActiveSection('NotCollapsed'); + + // Now store the layout, by amending it (so move the collapsed widget below). + const {height} = await gu.getSection('NotCollapsed').getRect(); + await dragMain('Collapsed'); + await move(gu.getSection('NotCollapsed'), { x: 50, y: height / 2 }); + await driver.sleep(300); + await move(gu.getSection('NotCollapsed'), { x: 100, y: height / 2 }); + await driver.sleep(300); + await driver.withActions(actions => actions.release()); + // Wait for the debounced save. + await driver.sleep(1500); + await gu.waitForServer(); + + // Now collapse it. + await collapseByMenu('Collapsed'); + + // Now duplicate the page. + await gu.duplicatePage('Broken', 'Broken2'); + + // Now on this page we saw two uncollapsed sections (make sure this is not the case). + assert.deepEqual(await gu.getSectionTitles(), ['NotCollapsed']); + }); + + it('fix:can delete root section', async function() { + // But even if the layout spec was corrupted, we still should be able to delete the root section + // when replacing it with new one. + + // Break the spec. + const specJson: string = await driver.executeScript( + 'return gristDocPageModel.gristDoc.get().docModel.views.rowModels[3].layoutSpec()' + ); + + // To break the spec, we will replace id of the collapsed section, then viewLayout will try to fix it, + // by rendering the missing section without patching the layout spec (which is good, because this could + // happen on readonly doc or a snapshot). + const spec = JSON.parse(specJson); + spec.collapsed[0].leaf = -10; + + await driver.executeScript( + `gristDocPageModel.gristDoc.get().docModel.views.rowModels[3].layoutSpec.setAndSave('${JSON.stringify(spec)}')` + ); + + await gu.waitForServer(); + + // We now should see two sections. + assert.deepEqual(await gu.getSectionTitles(), ['NotCollapsed', 'Collapsed']); + + // And we should be able to delete the top one (NotCollapsed). + await gu.openSectionMenu('viewLayout', 'NotCollapsed'); + await driver.findContent('.test-cmd-name', 'Delete widget').click(); + await gu.waitForServer(); + + await gu.checkForErrors(); }); it('fix: custom widget should restart when added back after collapsing', async function() { + await session.tempDoc(cleanup, 'Investment Research.grist'); + await gu.openPage("Overview"); + const revert = await gu.begin(); // Add custom section. diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 96fce930..b53faa3a 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1186,6 +1186,27 @@ export async function addNewPage( await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000); } +export async function duplicatePage(name: string|RegExp, newName?: string) { + await openPageMenu(name); + await driver.find('.test-docpage-duplicate').click(); + + if (newName) { + // Input will select text on focus, which can alter the text we enter, + // so make sure we type correct value. + await waitToPass(async () => { + const input = driver.find('.test-modal-dialog input'); + await input.click(); + await selectAll(); + await driver.sendKeys(newName); + assert.equal(await input.value(), newName); + }); + } + + await driver.find('.test-modal-confirm').click(); + await driver.findContentWait('.test-docpage-label', newName ?? /copy/, 6000); + await waitForServer(); +} + export async function openAddWidgetToPage() { await driver.findWait('.test-dp-add-new', 2000).doClick(); await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick(); From 86062a8c28b377faf881fa9475e3c6b8f4ee539b Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 10 Apr 2024 23:50:30 -0700 Subject: [PATCH 07/45] (core) New Grist Forms styling and field options Summary: - New styling for forms. - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort). - Improved alignment of form fields in columns. - Support for additional select input keyboard shortcuts (Enter and Backspace). - Prevent submitting form on Enter if an input has focus. - Fix for changing form field type causing the field to disappear. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4223 --- app/client/components/FormRenderer.ts | 388 +++++++++++++++++++--- app/client/components/FormRendererCss.ts | 164 +++++++-- app/client/components/Forms/Columns.ts | 15 +- app/client/components/Forms/Field.ts | 334 +++++++++++++++---- app/client/components/Forms/FormConfig.ts | 108 +++++- app/client/components/Forms/FormView.ts | 116 +++---- app/client/components/Forms/Model.ts | 14 +- app/client/components/Forms/Section.ts | 1 - app/client/components/Forms/Submit.ts | 11 +- app/client/components/Forms/styles.ts | 179 +++++----- app/client/components/GristDoc.ts | 76 +++-- app/client/ui/FormAPI.ts | 47 ++- app/client/ui/FormContainer.ts | 156 +++++++-- app/client/ui/FormErrorPage.ts | 31 +- app/client/ui/FormPage.ts | 70 ++-- app/client/ui/FormPagesCss.ts | 139 -------- app/client/ui/FormSuccessPage.ts | 33 +- app/client/ui/RightPanelStyles.ts | 5 + app/client/ui2018/checkbox.ts | 1 + app/client/ui2018/radio.ts | 25 ++ app/client/widgets/ChoiceListCell.ts | 16 +- app/client/widgets/ChoiceTextBox.ts | 17 +- app/client/widgets/DateTextBox.js | 4 +- app/client/widgets/FieldBuilder.ts | 16 +- app/client/widgets/NTextBox.ts | 46 ++- app/client/widgets/NumericSpinner.ts | 172 ++++++++++ app/client/widgets/NumericTextBox.ts | 174 ++++------ app/client/widgets/Reference.ts | 9 +- app/client/widgets/ReferenceList.ts | 14 + app/client/widgets/Toggle.ts | 35 +- app/client/widgets/UserType.ts | 1 + app/client/widgets/UserTypeImpl.ts | 6 + app/common/gutil.ts | 15 + app/server/lib/DocApi.ts | 45 ++- test/nbrowser/FormView.ts | 270 +++++++++++++-- 35 files changed, 2037 insertions(+), 716 deletions(-) delete mode 100644 app/client/ui/FormPagesCss.ts create mode 100644 app/client/ui2018/radio.ts create mode 100644 app/client/widgets/NumericSpinner.ts diff --git a/app/client/components/FormRenderer.ts b/app/client/components/FormRenderer.ts index 857067a5..b9ff7e62 100644 --- a/app/client/components/FormRenderer.ts +++ b/app/client/components/FormRenderer.ts @@ -149,10 +149,14 @@ class SectionRenderer extends FormRenderer { class ColumnsRenderer extends FormRenderer { public render() { return css.columns( - {style: `--grist-columns-count: ${this.children.length || 1}`}, + {style: `--grist-columns-count: ${this._getColumnsCount()}`}, this.children.map((child) => child.render()), ); } + + private _getColumnsCount() { + return this.children.length || 1; + } } class SubmitRenderer extends FormRenderer { @@ -180,22 +184,7 @@ class SubmitRenderer extends FormRenderer { type: 'submit', value: this.context.rootLayoutNode.submitText || 'Submit', }, - dom.on('click', () => { - // Make sure that all choice or reference lists that are required have at least one option selected. - const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))'); - Array.from(lists).forEach(function(list) { - // If the form has at least one checkbox, make it required. - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.setAttribute('required', 'required'); - }); - - // All other required choice or reference lists with at least one option selected are no longer required. - const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)'); - Array.from(checkedLists).forEach(function(list) { - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.removeAttribute('required'); - }); - }), + dom.on('click', () => validateRequiredLists()), ) ), ), @@ -228,7 +217,7 @@ class FieldRenderer extends FormRenderer { } public render() { - return css.field(this.renderer.render()); + return this.renderer.render(); } public reset() { @@ -267,41 +256,120 @@ abstract class BaseFieldRenderer extends Disposable { } class TextRenderer extends BaseFieldRenderer { - protected type = 'text'; - private _value = Observable.create(this, ''); + protected inputType = 'text'; + + private _format = this.field.options.formTextFormat ?? 'singleline'; + private _lineCount = String(this.field.options.formTextLineCount || 3); + private _value = Observable.create(this, ''); public input() { - return dom('input', + if (this._format === 'singleline') { + return this._renderSingleLineInput(); + } else { + return this._renderMultiLineInput(); + } + } + + public resetInput(): void { + this._value.setAndTrigger(''); + } + + private _renderSingleLineInput() { + return css.textInput( { - type: this.type, + type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, dom.prop('value', this._value), + preventSubmitOnEnter(), + ); + } + + private _renderMultiLineInput() { + return css.textarea( + { + name: this.name(), + required: this.field.options.formRequired, + rows: this._lineCount, + }, + dom.prop('value', this._value), dom.on('input', (_e, elem) => this._value.set(elem.value)), ); } +} + +class NumericRenderer extends BaseFieldRenderer { + protected inputType = 'text'; + + private _format = this.field.options.formNumberFormat ?? 'text'; + private _value = Observable.create(this, ''); + private _spinnerValue = Observable.create(this, ''); + + public input() { + if (this._format === 'text') { + return this._renderTextInput(); + } else { + return this._renderSpinnerInput(); + } + } public resetInput(): void { - this._value.set(''); + this._value.setAndTrigger(''); + this._spinnerValue.setAndTrigger(''); + } + + private _renderTextInput() { + return css.textInput( + { + type: this.inputType, + name: this.name(), + required: this.field.options.formRequired, + }, + dom.prop('value', this._value), + preventSubmitOnEnter(), + ); + } + + private _renderSpinnerInput() { + return css.spinner( + this._spinnerValue, + { + setValueOnInput: true, + inputArgs: [ + { + name: this.name(), + required: this.field.options.formRequired, + }, + preventSubmitOnEnter(), + ], + } + ); } } class DateRenderer extends TextRenderer { - protected type = 'date'; + protected inputType = 'date'; } class DateTimeRenderer extends TextRenderer { - protected type = 'datetime-local'; + protected inputType = 'datetime-local'; } export const SELECT_PLACEHOLDER = 'Select...'; class ChoiceRenderer extends BaseFieldRenderer { - protected value = Observable.create(this, ''); + protected value: Observable; + private _choices: string[]; private _selectElement: HTMLElement; private _ctl?: PopupControl; + private _format = this.field.options.formSelectFormat ?? 'select'; + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + private _radioButtons: MutableObsArray<{ + label: string; + checked: Observable + }> = this.autoDispose(obsArray()); public constructor(field: FormField, context: FormRendererContext) { super(field, context); @@ -310,24 +378,59 @@ class ChoiceRenderer extends BaseFieldRenderer { if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { this._choices = []; } else { + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + choices.sort((a, b) => String(a).localeCompare(String(b))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } // Support for 1000 choices. TODO: make limit dynamic. this._choices = choices.slice(0, 1000); } + + this.value = Observable.create(this, ''); + + this._radioButtons.set(this._choices.map(choice => ({ + label: String(choice), + checked: Observable.create(this, null), + }))); } public input() { + if (this._format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + } + + public resetInput() { + this.value.set(''); + this._radioButtons.get().forEach(radioButton => { + radioButton.checked.set(null); + }); + } + + private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( {name: this.name(), required: this.field.options.formRequired}, - dom.prop('value', this.value), dom.on('input', (_e, elem) => this.value.set(elem.value)), dom('option', {value: ''}, SELECT_PLACEHOLDER), - this._choices.map((choice) => dom('option', {value: choice}, choice)), + this._choices.map((choice) => dom('option', + {value: choice}, + dom.prop('selected', use => use(this.value) === choice), + choice + )), dom.onKeyDown({ + Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + Backspace$: () => this.value.set(''), }), + preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( @@ -359,8 +462,29 @@ class ChoiceRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.value.set(''); + private _renderRadioInput() { + const required = this.field.options.formRequired; + return css.radioList( + css.radioList.cls('-horizontal', this._alignment === 'horizontal'), + dom.cls('grist-radio-list'), + dom.cls('required', Boolean(required)), + {name: this.name(), required}, + dom.forEach(this._radioButtons, (radioButton) => + css.radio( + dom('input', + dom.prop('checked', radioButton.checked), + dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), + { + type: 'radio', + name: `${this.name()}`, + value: radioButton.label, + }, + preventSubmitOnEnter(), + ), + dom('span', radioButton.label), + ) + ), + ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { @@ -375,8 +499,11 @@ class ChoiceRenderer extends BaseFieldRenderer { } class BoolRenderer extends BaseFieldRenderer { + protected inputType = 'checkbox'; protected checked = Observable.create(this, false); + private _format = this.field.options.formToggleFormat ?? 'switch'; + public render() { return css.field( dom('div', this.input()), @@ -384,16 +511,29 @@ class BoolRenderer extends BaseFieldRenderer { } public input() { - return css.toggle( + if (this._format === 'switch') { + return this._renderSwitchInput(); + } else { + return this._renderCheckboxInput(); + } + } + + public resetInput(): void { + this.checked.set(false); + } + + private _renderSwitchInput() { + return css.toggleSwitch( dom('input', dom.prop('checked', this.checked), + dom.prop('value', use => use(this.checked) ? '1' : '0'), dom.on('change', (_e, elem) => this.checked.set(elem.checked)), { - type: 'checkbox', + type: this.inputType, name: this.name(), - value: '1', required: this.field.options.formRequired, }, + preventSubmitOnEnter(), ), css.gristSwitch( css.gristSwitchSlider(), @@ -406,8 +546,24 @@ class BoolRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.checked.set(false); + private _renderCheckboxInput() { + return css.toggle( + dom('input', + dom.prop('checked', this.checked), + dom.prop('value', use => use(this.checked) ? '1' : '0'), + dom.on('change', (_e, elem) => this.checked.set(elem.checked)), + { + type: this.inputType, + name: this.name(), + required: this.field.options.formRequired, + }, + preventSubmitOnEnter(), + ), + css.toggleLabel( + css.label.cls('-required', Boolean(this.field.options.formRequired)), + this.field.question, + ), + ); } } @@ -417,6 +573,8 @@ class ChoiceListRenderer extends BaseFieldRenderer { checked: Observable }> = this.autoDispose(obsArray()); + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + public constructor(field: FormField, context: FormRendererContext) { super(field, context); @@ -424,6 +582,13 @@ class ChoiceListRenderer extends BaseFieldRenderer { if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { choices = []; } else { + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + choices.sort((a, b) => String(a).localeCompare(String(b))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } // Support for 30 choices. TODO: make limit dynamic. choices = choices.slice(0, 30); } @@ -437,6 +602,7 @@ class ChoiceListRenderer extends BaseFieldRenderer { public input() { const required = this.field.options.formRequired; return css.checkboxList( + css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, @@ -449,7 +615,8 @@ class ChoiceListRenderer extends BaseFieldRenderer { type: 'checkbox', name: `${this.name()}[]`, value: checkbox.label, - } + }, + preventSubmitOnEnter(), ), dom('span', checkbox.label), ) @@ -471,12 +638,20 @@ class RefListRenderer extends BaseFieldRenderer { checked: Observable }> = this.autoDispose(obsArray()); + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + public constructor(field: FormField, context: FormRendererContext) { super(field, context); const references = this.field.refValues ?? []; - // Sort by the second value, which is the display value. - references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + const sortOrder = this.field.options.formOptionsSortOrder; + if (sortOrder !== 'default') { + // Sort by the second value, which is the display value. + references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + if (sortOrder === 'descending') { + references.reverse(); + } + } // Support for 30 choices. TODO: make limit dynamic. references.splice(30); this.checkboxes.set(references.map(reference => ({ @@ -488,6 +663,7 @@ class RefListRenderer extends BaseFieldRenderer { public input() { const required = this.field.options.formRequired; return css.checkboxList( + css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, @@ -501,7 +677,8 @@ class RefListRenderer extends BaseFieldRenderer { 'data-grist-type': this.field.type, name: `${this.name()}[]`, value: checkbox.value, - } + }, + preventSubmitOnEnter(), ), dom('span', checkbox.label), ) @@ -518,15 +695,58 @@ class RefListRenderer extends BaseFieldRenderer { class RefRenderer extends BaseFieldRenderer { protected value = Observable.create(this, ''); + + private _format = this.field.options.formSelectFormat ?? 'select'; + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + private _choices: [number|string, CellValue][]; private _selectElement: HTMLElement; private _ctl?: PopupControl; + private _radioButtons: MutableObsArray<{ + label: string; + value: string; + checked: Observable + }> = this.autoDispose(obsArray()); + + public constructor(field: FormField, context: FormRendererContext) { + super(field, context); + + const choices: [number|string, CellValue][] = this.field.refValues ?? []; + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + // Sort by the second value, which is the display value. + choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } + // Support for 1000 choices. TODO: make limit dynamic. + this._choices = choices.slice(0, 1000); + + this.value = Observable.create(this, ''); + + this._radioButtons.set(this._choices.map(reference => ({ + label: String(reference[1]), + value: String(reference[0]), + checked: Observable.create(this, null), + }))); + } public input() { - const choices: [number|string, CellValue][] = this.field.refValues ?? []; - // Sort by the second value, which is the display value. - choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); - // Support for 1000 choices. TODO: make limit dynamic. - choices.splice(1000); + if (this._format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + } + + public resetInput(): void { + this.value.set(''); + this._radioButtons.get().forEach(radioButton => { + radioButton.checked.set(null); + }); + } + + private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( { @@ -534,27 +754,37 @@ class RefRenderer extends BaseFieldRenderer { 'data-grist-type': this.field.type, required: this.field.options.formRequired, }, - dom.prop('value', this.value), dom.on('input', (_e, elem) => this.value.set(elem.value)), - dom('option', {value: ''}, SELECT_PLACEHOLDER), - choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))), + dom('option', + {value: ''}, + SELECT_PLACEHOLDER, + dom.prop('selected', use => use(this.value) === ''), + ), + this._choices.map((choice) => dom('option', + {value: String(choice[0])}, + String(choice[1]), + dom.prop('selected', use => use(this.value) === String(choice[0])), + )), dom.onKeyDown({ + Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + Backspace$: () => this.value.set(''), }), + preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( dom('div', dom.text(use => { - const choice = choices.find((c) => String(c[0]) === use(this.value)); + const choice = this._choices.find((c) => String(c[0]) === use(this.value)); return String(choice?.[1] || SELECT_PLACEHOLDER); })), dropdownWithSearch({ action: (value) => this.value.set(value), options: () => [ {label: SELECT_PLACEHOLDER, value: '', placeholder: true}, - ...choices.map((choice) => ({ + ...this._choices.map((choice) => ({ label: String(choice[1]), value: String(choice[0]), }), @@ -577,8 +807,29 @@ class RefRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.value.set(''); + private _renderRadioInput() { + const required = this.field.options.formRequired; + return css.radioList( + css.radioList.cls('-horizontal', this._alignment === 'horizontal'), + dom.cls('grist-radio-list'), + dom.cls('required', Boolean(required)), + {name: this.name(), required, 'data-grist-type': this.field.type}, + dom.forEach(this._radioButtons, (radioButton) => + css.radio( + dom('input', + dom.prop('checked', radioButton.checked), + dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), + { + type: 'radio', + name: `${this.name()}`, + value: radioButton.value, + }, + preventSubmitOnEnter(), + ), + dom('span', radioButton.label), + ) + ), + ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { @@ -594,6 +845,8 @@ class RefRenderer extends BaseFieldRenderer { const FieldRenderers = { 'Text': TextRenderer, + 'Numeric': NumericRenderer, + 'Int': NumericRenderer, 'Choice': ChoiceRenderer, 'Bool': BoolRenderer, 'ChoiceList': ChoiceListRenderer, @@ -616,3 +869,36 @@ const FormRenderers = { 'Separator': ParagraphRenderer, 'Header': ParagraphRenderer, }; + +function preventSubmitOnEnter() { + return dom.onKeyDown({Enter$: (ev) => ev.preventDefault()}); +} + +/** + * Validates the required attribute of checkbox and radio lists, such as those + * used by Choice, Choice List, Reference, and Reference List fields. + * + * Since lists of checkboxes and radios don't natively support a required attribute, we + * simulate it by marking the first checkbox/radio of each required list as being a + * required input. Then, we make another pass and unmark all required checkbox/radio + * inputs if they belong to a list where at least one checkbox/radio is checked. If any + * inputs in a required are left as required, HTML validations that are triggered when + * submitting a form will catch them and prevent the submission. + */ +function validateRequiredLists() { + for (const type of ['checkbox', 'radio']) { + const requiredLists = document + .querySelectorAll(`.grist-${type}-list.required:not(:has(input:checked))`); + Array.from(requiredLists).forEach(function(list) { + const firstOption = list.querySelector(`input[type="${type}"]`); + firstOption?.setAttribute('required', 'required'); + }); + + const requiredListsWithCheckedOption = document + .querySelectorAll(`.grist-${type}-list.required:has(input:checked`); + Array.from(requiredListsWithCheckedOption).forEach(function(list) { + const firstOption = list.querySelector(`input[type="${type}"]`); + firstOption?.removeAttribute('required'); + }); + } +} diff --git a/app/client/components/FormRendererCss.ts b/app/client/components/FormRendererCss.ts index dc776819..461ddff9 100644 --- a/app/client/components/FormRendererCss.ts +++ b/app/client/components/FormRendererCss.ts @@ -1,5 +1,6 @@ import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {styled} from 'grainjs'; export const label = styled('div', ` @@ -26,20 +27,23 @@ export const section = styled('div', ` border-radius: 3px; border: 1px solid ${colors.darkGrey}; padding: 24px; - margin-top: 24px; + margin-top: 12px; + margin-bottom: 24px; & > div + div { - margin-top: 16px; + margin-top: 8px; + margin-bottom: 12px; } `); export const columns = styled('div', ` display: grid; grid-template-columns: repeat(var(--grist-columns-count), 1fr); - gap: 4px; + gap: 16px; `); export const submitButtons = styled('div', ` + margin-top: 16px; display: flex; justify-content: center; column-gap: 8px; @@ -100,32 +104,13 @@ export const submitButton = styled('div', ` export const field = styled('div', ` display: flex; flex-direction: column; + height: 100%; + justify-content: space-between; - & input[type="text"], - & input[type="date"], - & input[type="datetime-local"], - & input[type="number"] { - height: 27px; - padding: 4px 8px; - border: 1px solid ${colors.darkGrey}; - border-radius: 3px; - outline-color: ${vars.primaryBgHover}; - } - & input[type="text"] { - font-size: 13px; - line-height: inherit; - width: 100%; - color: ${colors.dark}; - background-color: ${colors.light}; - } - & input[type="datetime-local"], - & input[type="date"] { - width: 100%; - line-height: inherit; - } & input[type="checkbox"] { -webkit-appearance: none; -moz-appearance: none; + margin: 0; padding: 0; flex-shrink: 0; display: inline-block; @@ -195,19 +180,80 @@ export const field = styled('div', ` `); export const error = styled('div', ` + margin-top: 16px; text-align: center; color: ${colors.error}; min-height: 22px; `); +export const textInput = styled('input', ` + color: ${colors.dark}; + background-color: ${colors.light}; + height: 29px; + width: 100%; + font-size: 13px; + line-height: inherit; + padding: 4px 8px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + outline-color: ${vars.primaryBgHover}; +`); + +export const textarea = styled('textarea', ` + display: block; + color: ${colors.dark}; + background-color: ${colors.light}; + min-height: 29px; + width: 100%; + font-size: 13px; + line-height: inherit; + padding: 4px 8px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + outline-color: ${vars.primaryBgHover}; + resize: none; +`); + +export const spinner = styled(numericSpinner, ` + & input { + height: 29px; + border: none; + font-size: 13px; + line-height: inherit; + } + + &:focus-within { + outline: 2px solid ${vars.primaryBgHover}; + } +`); + export const toggle = styled('label', ` position: relative; - cursor: pointer; display: inline-flex; - align-items: center; + margin-top: 8px; + + &:hover { + --color: ${colors.hover}; + } +`); + +export const toggleSwitch = styled(toggle, ` + cursor: pointer; & input[type='checkbox'] { + margin: 0; position: absolute; + top: 1px; + left: 4px; + } + & input[type='checkbox'], + & input[type='checkbox']::before, + & input[type='checkbox']::after { + height: 1px; + width: 1px; + } + & input[type='checkbox']:focus { + outline: none; } & input[type='checkbox']:focus { outline: none; @@ -220,6 +266,8 @@ export const toggle = styled('label', ` export const toggleLabel = styled('span', ` font-size: 13px; font-weight: 700; + line-height: 16px; + overflow-wrap: anywhere; `); export const gristSwitchSlider = styled('div', ` @@ -233,10 +281,6 @@ export const gristSwitchSlider = styled('div', ` border-radius: 17px; -webkit-transition: background-color .4s; transition: background-color .4s; - - &:hover { - box-shadow: 0 0 1px #2196F3; - } `); export const gristSwitchCircle = styled('div', ` @@ -277,19 +321,67 @@ export const gristSwitch = styled('div', ` `); export const checkboxList = styled('div', ` - display: flex; + display: inline-flex; flex-direction: column; - gap: 4px; + gap: 8px; + + &-horizontal { + flex-direction: row; + flex-wrap: wrap; + column-gap: 16px; + } `); export const checkbox = styled('label', ` display: flex; + font-size: 13px; + line-height: 16px; + gap: 8px; + overflow-wrap: anywhere; + & input { + margin: 0px !important; + } &:hover { --color: ${colors.hover}; } `); +export const radioList = checkboxList; + +export const radio = styled('label', ` + position: relative; + display: inline-flex; + gap: 8px; + font-size: 13px; + line-height: 16px; + font-weight: normal; + min-width: 0px; + outline-color: ${vars.primaryBgHover}; + overflow-wrap: anywhere; + + & input { + flex-shrink: 0; + appearance: none; + width: 16px; + height: 16px; + margin: 0px; + border-radius: 50%; + background-clip: content-box; + border: 1px solid ${colors.darkGrey}; + background-color: transparent; + outline-color: ${vars.primaryBgHover}; + } + & input:hover { + border: 1px solid ${colors.hover}; + } + & input:checked { + padding: 2px; + background-color: ${vars.primaryBg}; + border: 1px solid ${vars.primaryBg}; + } +`); + export const hybridSelect = styled('div', ` position: relative; `); @@ -303,7 +395,7 @@ export const select = styled('select', ` outline: none; background: white; line-height: inherit; - height: 27px; + height: 29px; flex: auto; width: 100%; @@ -323,11 +415,11 @@ export const searchSelect = styled('div', ` position: relative; padding: 4px 8px; border-radius: 3px; - border: 1px solid ${colors.darkGrey}; + outline: 1px solid ${colors.darkGrey}; font-size: 13px; background: white; line-height: inherit; - height: 27px; + height: 29px; flex: auto; width: 100%; diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index eda80299..8232e760 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -5,6 +5,7 @@ import {buildMenu} from 'app/client/components/Forms/Menu'; import {BoxModel} from 'app/client/components/Forms/Model'; import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; +import {makeT} from 'app/client/lib/localization'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; import {inlineStyle, not} from 'app/common/gutil'; @@ -13,6 +14,8 @@ import {v4 as uuidv4} from 'uuid'; const testId = makeTestId('test-forms-'); +const t = makeT('FormView'); + export class ColumnsModel extends BoxModel { private _columnCount = Computed.create(this, use => use(this.children).length); @@ -64,7 +67,11 @@ export class ColumnsModel extends BoxModel { cssPlaceholder( testId('add'), icon('Plus'), - dom.on('click', () => this.placeAfterListChild()(Placeholder())), + dom.on('click', async () => { + await this.save(() => { + this.placeAfterListChild()(Placeholder()); + }); + }), style.cssColumn.cls('-add-button'), style.cssColumn.cls('-drag-over', dragHover), @@ -152,7 +159,7 @@ export class PlaceholderModel extends BoxModel { buildMenu({ box: this, insertBox, - customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')], + customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), t('Remove Column'))], }), dom.on('contextmenu', (ev) => { @@ -219,8 +226,8 @@ export class PlaceholderModel extends BoxModel { return box.parent.replace(box, childBox); } - function removeColumn() { - box.removeSelf(); + async function removeColumn() { + await box.deleteSelf(); } } } diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index d1eb0bcf..5d1864c3 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -4,10 +4,20 @@ import {FormView} from 'app/client/components/Forms/FormView'; import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; import * as css from 'app/client/components/Forms/styles'; import {stopEvent} from 'app/client/lib/domUtils'; +import {makeT} from 'app/client/lib/localization'; import {refRecord} from 'app/client/models/DocModel'; +import { + FormNumberFormat, + FormOptionsAlignment, + FormOptionsSortOrder, + FormSelectFormat, + FormTextFormat, + FormToggleFormat, +} from 'app/client/ui/FormAPI'; import {autoGrow} from 'app/client/ui/forms'; -import {squareCheckbox} from 'app/client/ui2018/checkbox'; +import {cssCheckboxSquare, cssLabel, squareCheckbox} from 'app/client/ui2018/checkbox'; import {colors} from 'app/client/ui2018/cssVars'; +import {cssRadioInput} from 'app/client/ui2018/radio'; import {isBlankValue} from 'app/common/gristTypes'; import {Constructor, not} from 'app/common/gutil'; import { @@ -22,13 +32,14 @@ import { MultiHolder, observable, Observable, - styled, - toKo + toKo, } from 'grainjs'; import * as ko from 'knockout'; const testId = makeTestId('test-forms-'); +const t = makeT('FormView'); + /** * Container class for all fields. */ @@ -86,9 +97,6 @@ export class FieldModel extends BoxModel { const field = use(this.field); return Boolean(use(field.widgetOptionsJson.prop('formRequired'))); }); - this.required.onWrite(value => { - this.field.peek().widgetOptionsJson.prop('formRequired').setAndSave(value).catch(reportError); - }); this.question.onWrite(value => { this.field.peek().question.setAndSave(value).catch(reportError); @@ -152,6 +160,8 @@ export class FieldModel extends BoxModel { } export abstract class Question extends Disposable { + protected field = this.model.field; + constructor(public model: FieldModel) { super(); } @@ -164,7 +174,7 @@ export abstract class Question extends Disposable { return css.cssQuestion( testId('question'), testType(this.model.colType), - this.renderLabel(props, dom.style('margin-bottom', '5px')), + this.renderLabel(props), this.renderInput(), css.cssQuestion.cls('-required', this.model.required), ...args @@ -223,7 +233,7 @@ export abstract class Question extends Disposable { css.cssRequiredWrapper( testId('label'), // When in edit - hide * and change display from grid to display - css.cssRequiredWrapper.cls('-required', use => Boolean(use(this.model.required) && !use(this.model.edit))), + css.cssRequiredWrapper.cls('-required', use => use(this.model.required) && !use(this.model.edit)), dom.maybe(props.edit, () => [ element = css.cssEditableLabel( controller, @@ -264,36 +274,156 @@ export abstract class Question extends Disposable { class TextModel extends Question { + private _format = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formTextFormat')) ?? 'singleline'; + }); + + private _rowCount = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formTextLineCount')) || 3; + }); + public renderInput() { + return dom.domComputed(this._format, (format) => { + switch (format) { + case 'singleline': { + return this._renderSingleLineInput(); + } + case 'multiline': { + return this._renderMultiLineInput(); + } + } + }); + } + + private _renderSingleLineInput() { return css.cssInput( - dom.prop('name', u => u(u(this.model.field).colId)), - {disabled: true}, + dom.prop('name', u => u(u(this.field).colId)), {type: 'text', tabIndex: "-1"}, ); } + + private _renderMultiLineInput() { + return css.cssTextArea( + dom.prop('name', u => u(u(this.field).colId)), + dom.prop('rows', this._rowCount), + {tabIndex: "-1"}, + ); + } } -class ChoiceModel extends Question { - protected choices: Computed = Computed.create(this, use => { - // Read choices from field. - const choices = use(use(this.model.field).widgetOptionsJson.prop('choices')); - - // Make sure it is an array of strings. - if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { - return []; - } else { - return choices; - } +class NumericModel extends Question { + private _format = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text'; }); - public renderInput(): HTMLElement { - const field = this.model.field; + public renderInput() { + return dom.domComputed(this._format, (format) => { + switch (format) { + case 'text': { + return this._renderTextInput(); + } + case 'spinner': { + return this._renderSpinnerInput(); + } + } + }); + } + + private _renderTextInput() { + return css.cssInput( + dom.prop('name', u => u(u(this.field).colId)), + {type: 'text', tabIndex: "-1"}, + ); + } + + private _renderSpinnerInput() { + return css.cssSpinner(observable(''), {}); + } +} + +class ChoiceModel extends Question { + protected choices: Computed; + + protected alignment = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; + }); + + private _format = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select'; + }); + + private _sortOrder = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default'; + }); + + constructor(model: FieldModel) { + super(model); + this.choices = Computed.create(this, use => { + // Read choices from field. + const field = use(this.field); + const choices = use(field.widgetOptionsJson.prop('choices'))?.slice() ?? []; + + // Make sure it is an array of strings. + if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { + return []; + } else { + const sort = use(this._sortOrder); + if (sort !== 'default') { + choices.sort((a, b) => a.localeCompare(b)); + if (sort === 'descending') { + choices.reverse(); + } + } + return choices; + } + }); + } + + public renderInput() { + return dom('div', + dom.domComputed(this._format, (format) => { + if (format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + }), + dom.maybe(use => use(this.choices).length === 0, () => [ + css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')), + ]), + ); + } + + private _renderSelectInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, - dom.prop('name', use => use(use(field).colId)), - dom('option', SELECT_PLACEHOLDER, {value: ''}), - dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})), + dom.prop('name', use => use(use(this.field).colId)), + dom('option', + SELECT_PLACEHOLDER, + {value: ''}, + ), + dom.forEach(this.choices, (choice) => dom('option', + choice, + {value: choice}, + )), + ); + } + + private _renderRadioInput() { + return css.cssRadioList( + css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), + dom.prop('name', use => use(use(this.field).colId)), + dom.forEach(this.choices, (choice) => css.cssRadioLabel( + cssRadioInput({type: 'radio'}), + choice, + )), ); } } @@ -305,21 +435,28 @@ class ChoiceListModel extends ChoiceModel { }); public renderInput() { - const field = this.model.field; - return dom('div', + const field = this.field; + return css.cssCheckboxList( + css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', use => use(use(field).colId)), dom.forEach(this._choices, (choice) => css.cssCheckboxLabel( - squareCheckbox(observable(false)), - choice + css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'), + cssCheckboxSquare({type: 'checkbox'}), + choice, )), dom.maybe(use => use(this._choices).length === 0, () => [ - dom('div', 'No choices defined'), + css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')), ]), ); } } class BoolModel extends Question { + private _format = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch'; + }); + public override buildDom(props: { edit: Observable, overlay: Observable, @@ -329,22 +466,37 @@ class BoolModel extends Question { return css.cssQuestion( testId('question'), testType(this.model.colType), - cssToggle( + css.cssToggle( this.renderInput(), this.renderLabel(props, css.cssLabelInline.cls('')), ), ); } + public override renderInput() { - const value = Observable.create(this, true); - return dom('div.widget_switch', + return dom.domComputed(this._format, (format) => { + if (format === 'switch') { + return this._renderSwitchInput(); + } else { + return this._renderCheckboxInput(); + } + }); + } + + private _renderSwitchInput() { + return css.cssWidgetSwitch( dom.style('--grist-actual-cell-color', colors.lightGreen.toString()), - dom.cls('switch_on', value), - dom.cls('switch_transition', true), + dom.cls('switch_transition'), dom('div.switch_slider'), dom('div.switch_circle'), ); } + + private _renderCheckboxInput() { + return cssLabel( + cssCheckboxSquare({type: 'checkbox'}), + ); + } } class DateModel extends Question { @@ -352,8 +504,8 @@ class DateModel extends Question { return dom('div', css.cssInput( dom.prop('name', this.model.colId), - {type: 'date', style: 'margin-right: 5px; width: 100%;' - }), + {type: 'date', style: 'margin-right: 5px;'}, + ), ); } } @@ -363,7 +515,7 @@ class DateTimeModel extends Question { return dom('div', css.cssInput( dom.prop('name', this.model.colId), - {type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'} + {type: 'datetime-local', style: 'margin-right: 5px;'}, ), dom.style('width', '100%'), ); @@ -371,19 +523,38 @@ class DateTimeModel extends Question { } class RefListModel extends Question { - protected options = this._getOptions(); + protected options: Computed<{label: string, value: string}[]>; + + protected alignment = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; + }); + + private _sortOrder = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default'; + }); + + constructor(model: FieldModel) { + super(model); + this.options = this._getOptions(); + } public renderInput() { - return dom('div', + return css.cssCheckboxList( + css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', this.model.colId), dom.forEach(this.options, (option) => css.cssCheckboxLabel( squareCheckbox(observable(false)), option.label, )), dom.maybe(use => use(this.options).length === 0, () => [ - dom('div', 'No values in show column of referenced table'), + css.cssWarningMessage( + css.cssWarningIcon('Warning'), + t('No values in show column of referenced table'), + ), ]), - ) as HTMLElement; + ); } private _getOptions() { @@ -394,39 +565,83 @@ class RefListModel extends Question { const colId = Computed.create(this, use => { const dispColumnIdObs = use(use(this.model.column).visibleColModel); - return use(dispColumnIdObs.colId); + return use(dispColumnIdObs.colId) || 'id'; }); const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId); return Computed.create(this, use => { - return use(observer) + const sort = use(this._sortOrder); + const values = use(observer) .filter(([_id, value]) => !isBlankValue(value)) - .map(([id, value]) => ({label: String(value), value: String(id)})) - .sort((a, b) => a.label.localeCompare(b.label)) - .slice(0, 30); // TODO: make limit dynamic. + .map(([id, value]) => ({label: String(value), value: String(id)})); + if (sort !== 'default') { + values.sort((a, b) => a.label.localeCompare(b.label)); + if (sort === 'descending') { + values.reverse(); + } + } + return values.slice(0, 30); }); } } class RefModel extends RefListModel { + private _format = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select'; + }); + public renderInput() { + return dom('div', + dom.domComputed(this._format, (format) => { + if (format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + }), + dom.maybe(use => use(this.options).length === 0, () => [ + css.cssWarningMessage( + css.cssWarningIcon('Warning'), + t('No values in show column of referenced table'), + ), + ]), + ); + } + + private _renderSelectInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', this.model.colId), - dom('option', SELECT_PLACEHOLDER, {value: ''}), - dom.forEach(this.options, ({label, value}) => dom('option', label, {value})), + dom('option', + SELECT_PLACEHOLDER, + {value: ''}, + ), + dom.forEach(this.options, ({label, value}) => dom('option', + label, + {value}, + )), + ); + } + + private _renderRadioInput() { + return css.cssRadioList( + css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), + dom.prop('name', use => use(use(this.field).colId)), + dom.forEach(this.options, ({label, value}) => css.cssRadioLabel( + cssRadioInput({type: 'radio'}), + label, + )), ); } } -// TODO: decide which one we need and implement rest. const AnyModel = TextModel; -const NumericModel = TextModel; -const IntModel = TextModel; -const AttachmentsModel = TextModel; +// Attachments are not currently supported. +const AttachmentsModel = TextModel; function fieldConstructor(type: string): Constructor { switch (type) { @@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor { case 'ChoiceList': return ChoiceListModel; case 'Date': return DateModel; case 'DateTime': return DateTimeModel; - case 'Int': return IntModel; + case 'Int': return NumericModel; case 'Numeric': return NumericModel; case 'Ref': return RefModel; case 'RefList': return RefListModel; @@ -451,12 +666,3 @@ function fieldConstructor(type: string): Constructor { function testType(value: BindableValue) { return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type')); } - -const cssToggle = styled('div', ` - display: grid; - align-items: center; - grid-template-columns: auto 1fr; - gap: 8px; - padding: 4px 0px; - --grist-actual-cell-color: ${colors.lightGreen}; -`); diff --git a/app/client/components/Forms/FormConfig.ts b/app/client/components/Forms/FormConfig.ts index 31c32db7..832a8558 100644 --- a/app/client/components/Forms/FormConfig.ts +++ b/app/client/components/Forms/FormConfig.ts @@ -1,25 +1,119 @@ import {fromKoSave} from 'app/client/lib/fromKoSave'; import {makeT} from 'app/client/lib/localization'; import {ViewFieldRec} from 'app/client/models/DocModel'; -import {KoSaveableObservable} from 'app/client/models/modelUtil'; -import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles'; +import {fieldWithDefault} from 'app/client/models/modelUtil'; +import {FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat} from 'app/client/ui/FormAPI'; +import { + cssLabel, + cssRow, + cssSeparator, +} from 'app/client/ui/RightPanelStyles'; +import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; -import {testId} from 'app/client/ui2018/cssVars'; -import {Disposable} from 'grainjs'; +import {select} from 'app/client/ui2018/menus'; +import {Disposable, dom, makeTestId} from 'grainjs'; const t = makeT('FormConfig'); -export class FieldRulesConfig extends Disposable { +const testId = makeTestId('test-form-'); + +export class FormSelectConfig extends Disposable { constructor(private _field: ViewFieldRec) { super(); } public buildDom() { - const requiredField: KoSaveableObservable = this._field.widgetOptionsJson.prop('formRequired'); + const format = fieldWithDefault( + this._field.widgetOptionsJson.prop('formSelectFormat'), + 'select' + ); + + return [ + cssLabel(t('Field Format')), + cssRow( + buttonSelect( + fromKoSave(format), + [ + {value: 'select', label: t('Select')}, + {value: 'radio', label: t('Radio')}, + ], + testId('field-format'), + ), + ), + dom.maybe(use => use(format) === 'radio', () => dom.create(FormOptionsAlignmentConfig, this._field)), + ]; + } +} + +export class FormOptionsAlignmentConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const alignment = fieldWithDefault( + this._field.widgetOptionsJson.prop('formOptionsAlignment'), + 'vertical' + ); + + return [ + cssLabel(t('Options Alignment')), + cssRow( + select( + fromKoSave(alignment), + [ + {value: 'vertical', label: t('Vertical')}, + {value: 'horizontal', label: t('Horizontal')}, + ], + {defaultLabel: t('Vertical')} + ), + ), + ]; + } +} + +export class FormOptionsSortConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const optionsSortOrder = fieldWithDefault( + this._field.widgetOptionsJson.prop('formOptionsSortOrder'), + 'default' + ); + + return [ + cssLabel(t('Options Sort Order')), + cssRow( + select( + fromKoSave(optionsSortOrder), + [ + {value: 'default', label: t('Default')}, + {value: 'ascending', label: t('Ascending')}, + {value: 'descending', label: t('Descending')}, + ], + {defaultLabel: t('Default')} + ), + ), + ]; + } +} + +export class FormFieldRulesConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const requiredField = fieldWithDefault( + this._field.widgetOptionsJson.prop('formRequired'), + false + ); return [ cssSeparator(), - cssLabel(t('Field rules')), + cssLabel(t('Field Rules')), cssRow(labeledSquareCheckbox( fromKoSave(requiredField), t('Required field'), diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index e53b21c5..6de4ac3a 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -16,6 +16,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; +import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil'; import {ShareRec} from 'app/client/models/entities/ShareRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; @@ -55,7 +56,8 @@ export class FormView extends Disposable { protected bundle: (clb: () => Promise) => Promise; private _formFields: Computed; - private _autoLayout: Computed; + private _layoutSpec: SaveableObjObservable; + private _layout: Computed; private _root: BoxModel; private _savedLayout: any; private _saving: boolean = false; @@ -67,7 +69,7 @@ export class FormView extends Disposable { private _showPublishedMessage: Observable; private _isOwner: boolean; private _openingForm: Observable; - private _formElement: HTMLElement; + private _formEditorBodyElement: HTMLElement; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); @@ -134,28 +136,30 @@ export class FormView extends Disposable { this._formFields = Computed.create(this, use => { const fields = use(use(this.viewSection.viewFields).getObservable()); - return fields.filter(f => use(use(f.column).isFormCol)); + return fields.filter(f => { + const column = use(f.column); + return ( + use(column.pureType) !== 'Attachments' && + !(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform')) + ); + }); }); - this._autoLayout = Computed.create(this, use => { + this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => { + return layoutSpec ?? buildDefaultFormLayout(this._formFields.get()); + }); + + this._layout = Computed.create(this, use => { const fields = use(this._formFields); - const layout = use(this.viewSection.layoutSpecObj); - if (!layout || !layout.id) { - return this._formTemplate(fields); - } else { - const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id()))); - if (!patchedLayout) { throw new Error('Invalid form layout spec'); } + const layoutSpec = use(this._layoutSpec); + const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id()))); + if (!patchedLayout) { throw new Error('Invalid form layout spec'); } - return patchedLayout; - } + return patchedLayout; }); - this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise) => { + this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise) => { await this.bundle(async () => { - // If the box is autogenerated we need to save it first. - if (!this.viewSection.layoutSpecObj.peek()?.id) { - await this.save(); - } if (clb) { await clb(); } @@ -163,7 +167,7 @@ export class FormView extends Disposable { }); }, this)); - this._autoLayout.addListener((v) => { + this._layout.addListener((v) => { if (this._saving) { console.warn('Layout changed while saving'); return; @@ -421,9 +425,9 @@ export class FormView extends Disposable { public buildDom() { return style.cssFormView( testId('editor'), - style.cssFormEditBody( + this._formEditorBodyElement = style.cssFormEditBody( style.cssFormContainer( - this._formElement = dom('div', dom.forEach(this._root.children, (child) => { + dom('div', dom.forEach(this._root.children, (child) => { if (!child) { return dom('div', 'Empty node'); } @@ -433,9 +437,9 @@ export class FormView extends Disposable { } return element; })), - this._buildPublisher(), ), ), + this._buildPublisher(), dom.on('click', () => this.selectedBox.set(null)), dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()), ); @@ -481,7 +485,7 @@ export class FormView extends Disposable { // If nothing has changed, don't bother. if (isEqual(newVersion, this._savedLayout)) { return; } this._savedLayout = newVersion; - await this.viewSection.layoutSpecObj.setAndSave(newVersion); + await this._layoutSpec.setAndSave(newVersion); } finally { this._saving = false; } @@ -861,17 +865,17 @@ export class FormView extends Disposable { ); } + private _getSectionCount() { + return [...this._root.filter(box => box.type === 'Section')].length; + } + private _getEstimatedFormHeightPx() { return ( - // Form content height. - this._formElement.scrollHeight + - // Plus top/bottom page padding. - (2 * 52) + - // Plus top/bottom form padding. - (2 * 20) + - // Plus minimum form error height. - 38 + - // Plus form footer height. + // Form height. + this._formEditorBodyElement.scrollHeight + + // Minus "+" button height in each section. + (-32 * this._getSectionCount()) + + // Plus form footer height (visible only in the preview and published form). 64 ); } @@ -902,30 +906,6 @@ export class FormView extends Disposable { }); } - /** - * Generates a form template based on the fields in the view section. - */ - private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode { - const boxes: FormLayoutNode[] = fields.map(f => { - return { - id: uuidv4(), - type: 'Field', - leaf: f.id(), - }; - }); - const section = components.Section(...boxes); - return { - id: uuidv4(), - type: 'Layout', - children: [ - {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, - {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, - section, - {id: uuidv4(), type: 'Submit'}, - ], - }; - } - private async _resetForm() { this.selectedBox.set(null); await this.gristDoc.docData.bundleActions('Reset form', async () => { @@ -951,11 +931,35 @@ export class FormView extends Disposable { ]); const fields = this.viewSection.viewFields().all().slice(0, 9); - await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields)); + await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields)); }); } } +/** + * Generates a default form layout based on the fields in the view section. + */ +export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode { + const boxes: FormLayoutNode[] = fields.map(f => { + return { + id: uuidv4(), + type: 'Field', + leaf: f.id(), + }; + }); + const section = components.Section(...boxes); + return { + id: uuidv4(), + type: 'Layout', + children: [ + {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, + {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, + section, + {id: uuidv4(), type: 'Submit'}, + ], + }; +} + // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts defaults(FormView.prototype, BaseView.prototype); Object.assign(FormView.prototype, BackboneEvents); diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 2f24d456..c7531472 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -1,7 +1,17 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; import * as elements from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; -import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; +import {MaybePromise} from 'app/plugin/gutil'; +import { + bundleChanges, + Computed, + Disposable, + dom, + IDomArgs, + MutableObsArray, + obsArray, + Observable, +} from 'grainjs'; type Callback = () => Promise; @@ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable { return this._props.hasOwnProperty(name); } - public async save(before?: () => Promise): Promise { + public async save(before?: () => MaybePromise): Promise { if (!this.parent) { throw new Error('Cannot save detached box'); } return this.parent.save(before); } diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index 9d0f0106..24c2ba0d 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -62,7 +62,6 @@ export class SectionModel extends BoxModel { ), ) )}, - style.cssSectionEditor.cls(''), ); } diff --git a/app/client/components/Forms/Submit.ts b/app/client/components/Forms/Submit.ts index 9989d8c4..be81eaff 100644 --- a/app/client/components/Forms/Submit.ts +++ b/app/client/components/Forms/Submit.ts @@ -1,3 +1,4 @@ +import * as css from "app/client/components/FormRendererCss"; import { BoxModel } from "app/client/components/Forms/Model"; import { makeTestId } from "app/client/lib/domUtils"; import { bigPrimaryButton } from "app/client/ui2018/buttons"; @@ -9,8 +10,14 @@ export class SubmitModel extends BoxModel { const text = this.view.viewSection.layoutSpecObj.prop('submitText'); return dom( "div", - { style: "text-align: center; margin-top: 20px;" }, - bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit")) + css.error(testId("error")), + css.submitButtons( + bigPrimaryButton( + dom.text(use => use(text) || 'Submit'), + { disabled: true }, + testId("submit"), + ), + ), ); } } diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index f6b921f9..6b5fa8d0 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,8 +1,10 @@ import {textarea} from 'app/client/ui/inputs'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons'; +import {cssLabel} from 'app/client/ui2018/checkbox'; import {colors, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; import {marked} from 'marked'; @@ -14,7 +16,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', ` align-items: center; justify-content: space-between; position: relative; - background-color: ${theme.leftPanelBg}; overflow: auto; min-height: 100%; width: 100%; @@ -22,7 +23,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', ` export const cssFormContainer = styled('div', ` background-color: ${theme.mainPanelBg}; - border: 1px solid ${theme.modalBorderDark}; color: ${theme.text}; width: 600px; align-self: center; @@ -31,10 +31,8 @@ export const cssFormContainer = styled('div', ` display: flex; flex-direction: column; max-width: calc(100% - 32px); - padding-top: 20px; - padding-left: 48px; - padding-right: 48px; gap: 8px; + line-height: 1.42857143; `); export const cssFieldEditor = styled('div.hover_border.field_editor', ` @@ -47,6 +45,11 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', ` margin-bottom: 4px; --hover-visible: hidden; transition: transform 0.2s ease-in-out; + &-Section { + outline: 1px solid ${theme.modalBorderDark}; + margin-bottom: 24px; + padding: 16px; + } &:hover:not(:has(.hover_border:hover),&-cut) { --hover-visible: visible; outline: 1px solid ${theme.controlPrimaryBg}; @@ -78,37 +81,40 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', ` } `); -export const cssSectionEditor = styled('div', ` - border-radius: 3px; - padding: 16px; - border: 1px solid ${theme.modalBorderDark}; -`); - - export const cssSection = styled('div', ` position: relative; color: ${theme.text}; margin: 0px auto; min-height: 50px; - .${cssFormView.className}-preview & { - background: transparent; - border-radius: unset; - padding: 0px; - min-height: auto; +`); + +export const cssCheckboxList = styled('div', ` + display: flex; + flex-direction: column; + gap: 8px; + + &-horizontal { + flex-direction: row; + flex-wrap: wrap; + column-gap: 16px; } `); -export const cssCheckboxLabel = styled('label', ` - font-size: 15px; +export const cssCheckboxLabel = styled(cssLabel, ` + font-size: 13px; + line-height: 16px; font-weight: normal; user-select: none; display: flex; - align-items: center; gap: 8px; margin: 0px; - margin-bottom: 8px; + overflow-wrap: anywhere; `); +export const cssRadioList = cssCheckboxList; + +export const cssRadioLabel = cssCheckboxLabel; + export function textbox(obs: Observable, ...args: DomElementArg[]): HTMLInputElement { return dom('input', dom.prop('value', u => u(obs) || ''), @@ -118,11 +124,14 @@ export function textbox(obs: Observable, ...args: DomElementAr } export const cssQuestion = styled('div', ` - + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; `); export const cssRequiredWrapper = styled('div', ` - margin-bottom: 8px; + margin: 8px 0px; min-height: 16px; &-required { display: grid; @@ -148,7 +157,7 @@ export const cssRenderedLabel = styled('div', ` min-height: 16px; color: ${theme.mediumText}; - font-size: 11px; + font-size: 13px; line-height: 16px; font-weight: 700; white-space: pre-wrap; @@ -186,17 +195,9 @@ export const cssEditableLabel = styled(textarea, ` `); export const cssLabelInline = styled('div', ` - margin-bottom: 0px; - & .${cssRenderedLabel.className} { - color: ${theme.mediumText}; - font-size: 15px; - font-weight: normal; - } - & .${cssEditableLabel.className} { - color: ${colors.darkText}; - font-size: 15px; - font-weight: normal; - } + line-height: 16px; + margin: 0px; + overflow-wrap: anywhere; `); export const cssDesc = styled('div', ` @@ -211,15 +212,19 @@ export const cssDesc = styled('div', ` `); export const cssInput = styled('input', ` - background-color: ${theme.inputDisabledBg}; + background-color: ${theme.inputBg}; font-size: inherit; - height: 27px; + height: 29px; padding: 4px 8px; border: 1px solid ${theme.inputBorder}; border-radius: 3px; outline: none; pointer-events: none; + &:disabled { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } &-invalid { color: ${theme.inputInvalid}; } @@ -228,10 +233,37 @@ export const cssInput = styled('input', ` } `); +export const cssTextArea = styled('textarea', ` + background-color: ${theme.inputBg}; + font-size: inherit; + min-height: 29px; + padding: 4px 8px; + border: 1px solid ${theme.inputBorder}; + border-radius: 3px; + outline: none; + pointer-events: none; + resize: none; + width: 100%; + + &:disabled { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } +`); + +export const cssSpinner = styled(numericSpinner, ` + height: 29px; + + &-hidden { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } +`); + export const cssSelect = styled('select', ` flex: auto; width: 100%; - background-color: ${theme.inputDisabledBg}; + background-color: ${theme.inputBg}; font-size: inherit; height: 27px; padding: 4px 8px; @@ -241,8 +273,34 @@ export const cssSelect = styled('select', ` pointer-events: none; `); -export const cssFieldEditorContent = styled('div', ` +export const cssToggle = styled('div', ` + display: grid; + grid-template-columns: auto 1fr; + margin-top: 12px; + gap: 8px; + --grist-actual-cell-color: ${colors.lightGreen}; +`); +export const cssWidgetSwitch = styled('div.widget_switch', ` + &-hidden { + opacity: 0.6; + } +`); + +export const cssWarningMessage = styled('div', ` + margin-top: 8px; + display: flex; + align-items: center; + column-gap: 8px; +`); + +export const cssWarningIcon = styled(icon, ` + --icon-color: ${colors.warning}; + flex-shrink: 0; +`); + +export const cssFieldEditorContent = styled('div', ` + height: 100%; `); export const cssSelectedOverlay = styled('div._cssSelectedOverlay', ` @@ -253,10 +311,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', ` .${cssFieldEditor.className}-selected > & { opacity: 1; } - - .${cssFormView.className}-preview & { - display: none; - } `); export const cssPlusButton = styled('div', ` @@ -288,22 +342,12 @@ export const cssPlusIcon = styled(icon, ` export const cssColumns = styled('div', ` - --css-columns-count: 2; display: grid; grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px; gap: 8px; padding: 8px 4px; - - .${cssFormView.className}-preview & { - background: transparent; - border-radius: unset; - padding: 0px; - grid-template-columns: repeat(var(--css-columns-count), 1fr); - min-height: auto; - } `); - export const cssColumn = styled('div', ` position: relative; &-empty, &-add-button { @@ -336,21 +380,6 @@ export const cssColumn = styled('div', ` &-drag-over { outline: 2px dashed ${theme.controlPrimaryBg}; } - - &-add-button { - } - - .${cssFormView.className}-preview &-add-button { - display: none; - } - - .${cssFormView.className}-preview &-empty { - background: transparent; - border-radius: unset; - padding: 0px; - min-height: auto; - border: 0px; - } `); export const cssButtonGroup = styled('div', ` @@ -511,16 +540,13 @@ export const cssPreview = styled('iframe', ` `); export const cssSwitcher = styled('div', ` - flex-shrink: 0; - margin-top: 24px; - border-top: 1px solid ${theme.modalBorder}; - margin-left: -48px; - margin-right: -48px; + border-top: 1px solid ${theme.menuBorder}; + width: 100%; `); export const cssSwitcherMessage = styled('div', ` display: flex; - padding: 0px 16px 0px 16px; + padding: 8px 16px; `); export const cssSwitcherMessageBody = styled('div', ` @@ -528,7 +554,7 @@ export const cssSwitcherMessageBody = styled('div', ` display: flex; justify-content: center; align-items: center; - padding: 10px 32px; + padding: 8px 16px; `); export const cssSwitcherMessageDismissButton = styled('div', ` @@ -551,8 +577,7 @@ export const cssParagraph = styled('div', ` export const cssFormEditBody = styled('div', ` width: 100%; overflow: auto; - padding-top: 52px; - padding-bottom: 24px; + padding: 20px; `); export const cssRemoveButton = styled('div', ` diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index a2d08077..89e08d36 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -14,6 +14,7 @@ import {DocComm} from 'app/client/components/DocComm'; import * as DocConfigTab from 'app/client/components/DocConfigTab'; import {Drafts} from "app/client/components/Drafts"; import {EditorMonitor} from "app/client/components/EditorMonitor"; +import {buildDefaultFormLayout} from 'app/client/components/Forms/FormView'; import GridView from 'app/client/components/GridView'; import {importFromFile, selectAndImport} from 'app/client/components/Importer'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; @@ -946,6 +947,9 @@ export class GristDoc extends DisposableWithEvents { if (val.type === 'chart') { await this._ensureOneNumericSeries(result.sectionRef); } + if (val.type === 'form') { + await this._setDefaultFormLayoutSpec(result.sectionRef); + } await this.saveLink(val.link, result.sectionRef); return result; } @@ -962,42 +966,48 @@ export class GristDoc extends DisposableWithEvents { }, }); - if (val.table === 'New Table') { - const name = await this._promptForName(); - if (name === undefined) { - return; - } - let newViewId: IDocPage; - if (val.type === WidgetType.Table) { - const result = await this.docData.sendAction(['AddEmptyTable', name]); - newViewId = result.views[0].id; + let viewRef: IDocPage; + let sectionRef: number | undefined; + await this.docData.bundleActions('Add new page', async () => { + if (val.table === 'New Table') { + const name = await this._promptForName(); + if (name === undefined) { + return; + } + if (val.type === WidgetType.Table) { + const result = await this.docData.sendAction(['AddEmptyTable', name]); + viewRef = result.views[0].id; + } else { + // This will create a new table and page. + const result = await this.docData.sendAction( + ['CreateViewSection', /* new table */0, 0, val.type, null, name] + ); + [viewRef, sectionRef] = [result.viewRef, result.sectionRef]; + } } else { - // This will create a new table and page. const result = await this.docData.sendAction( - ['CreateViewSection', /* new table */0, 0, val.type, null, name] - ); - newViewId = result.viewRef; - } - await this.openDocPage(newViewId); - } else { - let result: any; - await this.docData.bundleActions(`Add new page`, async () => { - result = await this.docData.sendAction( ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null] ); + [viewRef, sectionRef] = [result.viewRef, result.sectionRef]; if (val.type === 'chart') { - await this._ensureOneNumericSeries(result.sectionRef); + await this._ensureOneNumericSeries(sectionRef!); } - }); - await this.openDocPage(result.viewRef); - // The newly-added section should be given focus. - this.viewModel.activeSectionId(result.sectionRef); - - this._maybeShowEditCardLayoutTip(val.type).catch(reportError); - - if (AttachedCustomWidgets.guard(val.type)) { - this._handleNewAttachedCustomWidget(val.type).catch(reportError); } + if (val.type === 'form') { + await this._setDefaultFormLayoutSpec(sectionRef!); + } + }); + + await this.openDocPage(viewRef!); + if (sectionRef) { + // The newly-added section should be given focus. + this.viewModel.activeSectionId(sectionRef); + } + + this._maybeShowEditCardLayoutTip(val.type).catch(reportError); + + if (AttachedCustomWidgets.guard(val.type)) { + this._handleNewAttachedCustomWidget(val.type).catch(reportError); } } @@ -1425,6 +1435,8 @@ export class GristDoc extends DisposableWithEvents { const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1); const holder = Holder.create(owner); const listener = (tab: TableModel) => { + if (tab.tableData.tableId === '') { return; } + // Now subscribe to any data change in that table. const subs = MultiHolder.create(holder); subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle)); @@ -1921,6 +1933,12 @@ export class GristDoc extends DisposableWithEvents { } } + private async _setDefaultFormLayoutSpec(viewSectionId: number) { + const viewSection = this.docModel.viewSections.getRowModel(viewSectionId); + const viewFields = viewSection.viewFields.peek().peek(); + await viewSection.layoutSpecObj.setAndSave(buildDefaultFormLayout(viewFields)); + } + private _handleTriggerQueueOverflowMessage() { this.listenTo(this, 'webhookOverflowError', (err: any) => { this.app.topAppModel.notifier.createNotification({ diff --git a/app/client/ui/FormAPI.ts b/app/client/ui/FormAPI.ts index 51bd2c15..88de27d5 100644 --- a/app/client/ui/FormAPI.ts +++ b/app/client/ui/FormAPI.ts @@ -41,13 +41,52 @@ export interface FormField { refValues: [number, CellValue][] | null; } -interface FormFieldOptions { - /** True if the field is required to submit the form. */ - formRequired?: boolean; - /** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */ +export interface FormFieldOptions { + /** Choices for a Choice or Choice List field. */ choices?: string[]; + /** Text or Any field format. Defaults to `"singleline"`. */ + formTextFormat?: FormTextFormat; + /** Number of lines/rows for the `"multiline"` option of `formTextFormat`. Defaults to `3`. */ + formTextLineCount?: number; + /** Numeric or Int field format. Defaults to `"text"`. */ + formNumberFormat?: FormNumberFormat; + /** Toggle field format. Defaults to `"switch"`. */ + formToggleFormat?: FormToggleFormat; + /** Choice or Reference field format. Defaults to `"select"`. */ + formSelectFormat?: FormSelectFormat; + /** + * Field options alignment. + * + * Only applicable to Choice List and Reference List fields, and Choice and Reference fields + * when `formSelectFormat` is `"radio"`. + * + * Defaults to `"vertical"`. + */ + formOptionsAlignment?: FormOptionsAlignment; + /** + * Field options sort order. + * + * Only applicable to Choice, Choice List, Reference, and Reference List fields. + * + * Defaults to `"default"`. + */ + formOptionsSortOrder?: FormOptionsSortOrder; + /** True if the field is required. Defaults to `false`. */ + formRequired?: boolean; } +export type FormTextFormat = 'singleline' | 'multiline'; + +export type FormNumberFormat = 'text' | 'spinner'; + +export type FormToggleFormat = 'switch' | 'checkbox'; + +export type FormSelectFormat = 'select' | 'radio'; + +export type FormOptionsAlignment = 'vertical' | 'horizontal'; + +export type FormOptionsSortOrder = 'default' | 'ascending' | 'descending'; + export interface FormAPI { getForm(options: GetFormOptions): Promise
; createRecord(options: CreateRecordOptions): Promise; diff --git a/app/client/ui/FormContainer.ts b/app/client/ui/FormContainer.ts index 4ed7ee9b..80da4c82 100644 --- a/app/client/ui/FormContainer.ts +++ b/app/client/ui/FormContainer.ts @@ -1,36 +1,144 @@ import {makeT} from 'app/client/lib/localization'; -import * as css from 'app/client/ui/FormPagesCss'; +import {colors, mediaSmall} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {commonUrls} from 'app/common/gristUrls'; -import {DomContents, makeTestId} from 'grainjs'; +import {DomContents, DomElementArg, styled} from 'grainjs'; const t = makeT('FormContainer'); -const testId = makeTestId('test-form-'); - -export function buildFormContainer(buildBody: () => DomContents) { - return css.formContainer( - css.form( - css.formBody( +export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) { + return cssFormMessagePage( + cssFormMessage( + cssFormMessageBody( buildBody(), ), - css.formFooter( - css.poweredByGrist( - css.poweredByGristLink( - {href: commonUrls.forms, target: '_blank'}, - t('Powered by'), - css.gristLogo(), - ) - ), - css.buildForm( - css.buildFormLink( - {href: commonUrls.forms, target: '_blank'}, - t('Build your own form'), - icon('Expand'), - ), - ), + cssFormMessageFooter( + buildFormFooter(), ), ), - testId('container'), + ...args, ); } + +export function buildFormFooter() { + return [ + cssPoweredByGrist( + cssPoweredByGristLink( + {href: commonUrls.forms, target: '_blank'}, + t('Powered by'), + cssGristLogo(), + ) + ), + cssBuildForm( + cssBuildFormLink( + {href: commonUrls.forms, target: '_blank'}, + t('Build your own form'), + icon('Expand'), + ), + ), + ]; +} + +export const cssFormMessageImageContainer = styled('div', ` + margin-top: 28px; + display: flex; + justify-content: center; +`); + +export const cssFormMessageImage = styled('img', ` + height: 100%; + width: 100%; +`); + +export const cssFormMessageText = styled('div', ` + color: ${colors.dark}; + text-align: center; + font-weight: 600; + font-size: 16px; + line-height: 24px; + margin-top: 32px; + margin-bottom: 24px; +`); + +const cssFormMessagePage = styled('div', ` + padding: 16px; +`); + +const cssFormMessage = styled('div', ` + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + max-width: 600px; + margin: 0px auto; +`); + +const cssFormMessageBody = styled('div', ` + width: 100%; + padding: 20px 48px 20px 48px; + + @media ${mediaSmall} { + & { + padding: 20px; + } + } +`); + +const cssFormMessageFooter = styled('div', ` + border-top: 1px solid ${colors.darkGrey}; + padding: 8px 16px; + width: 100%; +`); + +const cssPoweredByGrist = styled('div', ` + color: ${colors.darkText}; + font-size: 13px; + font-style: normal; + font-weight: 600; + line-height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0px 10px; +`); + +const cssPoweredByGristLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: ${colors.darkText}; + text-decoration: none; +`); + +const cssGristLogo = styled('div', ` + width: 58px; + height: 20.416px; + flex-shrink: 0; + background: url(img/logo-grist.png); + background-position: 0 0; + background-size: contain; + background-color: transparent; + background-repeat: no-repeat; + margin-top: 3px; +`); + +const cssBuildForm = styled('div', ` + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; +`); + +const cssBuildFormLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 16px; + text-decoration-line: underline; + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; +`); diff --git a/app/client/ui/FormErrorPage.ts b/app/client/ui/FormErrorPage.ts index 2bc87333..e7786b0f 100644 --- a/app/client/ui/FormErrorPage.ts +++ b/app/client/ui/FormErrorPage.ts @@ -1,9 +1,13 @@ import {makeT} from 'app/client/lib/localization'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; -import * as css from 'app/client/ui/FormPagesCss'; +import { + buildFormMessagePage, + cssFormMessageImage, + cssFormMessageImageContainer, + cssFormMessageText, +} from 'app/client/ui/FormContainer'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; -import {Disposable, makeTestId} from 'grainjs'; +import {Disposable, makeTestId, styled} from 'grainjs'; const testId = makeTestId('test-form-'); @@ -16,11 +20,20 @@ export class FormErrorPage extends Disposable { } public buildDom() { - return buildFormContainer(() => [ - css.formErrorMessageImageContainer(css.formErrorMessageImage({ - src: 'img/form-error.svg', - })), - css.formMessageText(this._message, testId('error-text')), - ]); + return buildFormMessagePage(() => [ + cssFormErrorMessageImageContainer( + cssFormErrorMessageImage({src: 'img/form-error.svg'}), + ), + cssFormMessageText(this._message, testId('error-page-text')), + ], testId('error-page')); } } + +const cssFormErrorMessageImageContainer = styled(cssFormMessageImageContainer, ` + height: 281px; +`); + +const cssFormErrorMessageImage = styled(cssFormMessageImage, ` + max-height: 281px; + max-width: 250px; +`); diff --git a/app/client/ui/FormPage.ts b/app/client/ui/FormPage.ts index 97d233ca..ead02922 100644 --- a/app/client/ui/FormPage.ts +++ b/app/client/ui/FormPage.ts @@ -2,18 +2,19 @@ import {FormRenderer} from 'app/client/components/FormRenderer'; import {handleSubmit, TypedFormData} from 'app/client/lib/formUtils'; import {makeT} from 'app/client/lib/localization'; import {FormModel, FormModelImpl} from 'app/client/models/FormModel'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; +import {buildFormFooter} from 'app/client/ui/FormContainer'; import {FormErrorPage} from 'app/client/ui/FormErrorPage'; -import * as css from 'app/client/ui/FormPagesCss'; import {FormSuccessPage} from 'app/client/ui/FormSuccessPage'; import {colors} from 'app/client/ui2018/cssVars'; import {ApiError} from 'app/common/ApiError'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; -import {Disposable, dom, Observable, styled, subscribe} from 'grainjs'; +import {Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs'; const t = makeT('FormPage'); +const testId = makeTestId('test-form-'); + export class FormPage extends Disposable { private readonly _model: FormModel = new FormModelImpl(); private readonly _error = Observable.create(this, null); @@ -30,7 +31,7 @@ export class FormPage extends Disposable { } public buildDom() { - return css.pageContainer( + return cssPageContainer( dom.domComputed(use => { const error = use(this._model.error); if (error) { return dom.create(FormErrorPage, error); } @@ -38,12 +39,12 @@ export class FormPage extends Disposable { const submitted = use(this._model.submitted); if (submitted) { return dom.create(FormSuccessPage, this._model); } - return this._buildFormDom(); + return this._buildFormPageDom(); }), ); } - private _buildFormDom() { + private _buildFormPageDom() { return dom.domComputed(use => { const form = use(this._model.form); const rootLayoutNode = use(this._model.formLayout); @@ -56,16 +57,24 @@ export class FormPage extends Disposable { error: this._error, }); - return buildFormContainer(() => + return dom('div', cssForm( - dom.autoDispose(formRenderer), - formRenderer.render(), - handleSubmit(this._model.submitting, - (_formData, formElement) => this._handleFormSubmit(formElement), - () => this._handleFormSubmitSuccess(), - (e) => this._handleFormError(e), + cssFormBody( + cssFormContent( + dom.autoDispose(formRenderer), + formRenderer.render(), + handleSubmit(this._model.submitting, + (_formData, formElement) => this._handleFormSubmit(formElement), + () => this._handleFormSubmitSuccess(), + (e) => this._handleFormError(e), + ), + ), + ), + cssFormFooter( + buildFormFooter(), ), ), + testId('page'), ); }); } @@ -101,22 +110,40 @@ export class FormPage extends Disposable { } } -// TODO: see if we can move the rest of this to `FormRenderer.ts`. -const cssForm = styled('form', ` +const cssPageContainer = styled('div', ` + height: 100%; + width: 100%; + padding: 20px; + overflow: auto; +`); + +const cssForm = styled('div', ` + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + border-radius: 3px; + max-width: 600px; + margin: 0px auto; +`); + +const cssFormBody = styled('div', ` + width: 100%; +`); + +// TODO: break up and move to `FormRendererCss.ts`. +const cssFormContent = styled('form', ` color: ${colors.dark}; font-size: 15px; line-height: 1.42857143; - & > div + div { - margin-top: 16px; - } & h1, & h2, & h3, & h4, & h5, & h6 { - margin: 4px 0px; + margin: 8px 0px 12px 0px; font-weight: normal; } & h1 { @@ -149,3 +176,8 @@ const cssForm = styled('form', ` margin: 4px 0px; } `); + +const cssFormFooter = styled('div', ` + padding: 8px 16px; + width: 100%; +`); diff --git a/app/client/ui/FormPagesCss.ts b/app/client/ui/FormPagesCss.ts deleted file mode 100644 index 1f8311b3..00000000 --- a/app/client/ui/FormPagesCss.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {colors, mediaSmall} from 'app/client/ui2018/cssVars'; -import {styled} from 'grainjs'; - -export const pageContainer = styled('div', ` - background-color: ${colors.lightGrey}; - height: 100%; - width: 100%; - padding: 52px 0px 52px 0px; - overflow: auto; - - @media ${mediaSmall} { - & { - padding: 20px 0px 20px 0px; - } - } -`); - -export const formContainer = styled('div', ` - padding-left: 16px; - padding-right: 16px; -`); - -export const form = styled('div', ` - display: flex; - flex-direction: column; - align-items: center; - background-color: white; - border: 1px solid ${colors.darkGrey}; - border-radius: 3px; - max-width: 600px; - margin: 0px auto; -`); - -export const formBody = styled('div', ` - width: 100%; - padding: 20px 48px 20px 48px; - - @media ${mediaSmall} { - & { - padding: 20px; - } - } -`); - -const formMessageImageContainer = styled('div', ` - margin-top: 28px; - display: flex; - justify-content: center; -`); - -export const formErrorMessageImageContainer = styled(formMessageImageContainer, ` - height: 281px; -`); - -export const formSuccessMessageImageContainer = styled(formMessageImageContainer, ` - height: 215px; -`); - -export const formMessageImage = styled('img', ` - height: 100%; - width: 100%; -`); - -export const formErrorMessageImage = styled(formMessageImage, ` - max-height: 281px; - max-width: 250px; -`); - -export const formSuccessMessageImage = styled(formMessageImage, ` - max-height: 215px; - max-width: 250px; -`); - -export const formMessageText = styled('div', ` - color: ${colors.dark}; - text-align: center; - font-weight: 600; - font-size: 16px; - line-height: 24px; - margin-top: 32px; - margin-bottom: 24px; -`); - -export const formFooter = styled('div', ` - border-top: 1px solid ${colors.darkGrey}; - padding: 8px 16px; - width: 100%; -`); - -export const poweredByGrist = styled('div', ` - color: ${colors.darkText}; - font-size: 13px; - font-style: normal; - font-weight: 600; - line-height: 16px; - display: flex; - align-items: center; - justify-content: center; - padding: 0px 10px; -`); - -export const poweredByGristLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: ${colors.darkText}; - text-decoration: none; -`); - -export const buildForm = styled('div', ` - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; -`); - -export const buildFormLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - line-height: 16px; - text-decoration-line: underline; - color: ${colors.darkGreen}; - --icon-color: ${colors.darkGreen}; -`); - -export const gristLogo = styled('div', ` - width: 58px; - height: 20.416px; - flex-shrink: 0; - background: url(img/logo-grist.png); - background-position: 0 0; - background-size: contain; - background-color: transparent; - background-repeat: no-repeat; - margin-top: 3px; -`); diff --git a/app/client/ui/FormSuccessPage.ts b/app/client/ui/FormSuccessPage.ts index fa168fd3..f7163abd 100644 --- a/app/client/ui/FormSuccessPage.ts +++ b/app/client/ui/FormSuccessPage.ts @@ -1,7 +1,11 @@ import {makeT} from 'app/client/lib/localization'; -import {FormModel } from 'app/client/models/FormModel'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; -import * as css from 'app/client/ui/FormPagesCss'; +import {FormModel} from 'app/client/models/FormModel'; +import { + buildFormMessagePage, + cssFormMessageImage, + cssFormMessageImageContainer, + cssFormMessageText, +} from 'app/client/ui/FormContainer'; import {vars} from 'app/client/ui2018/cssVars'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; @@ -28,20 +32,20 @@ export class FormSuccessPage extends Disposable { } public buildDom() { - return buildFormContainer(() => [ - css.formSuccessMessageImageContainer(css.formSuccessMessageImage({ - src: 'img/form-success.svg', - })), - css.formMessageText(dom.text(this._successText), testId('success-text')), + return buildFormMessagePage(() => [ + cssFormSuccessMessageImageContainer( + cssFormSuccessMessageImage({src: 'img/form-success.svg'}), + ), + cssFormMessageText(dom.text(this._successText), testId('success-page-text')), dom.maybe(this._showNewResponseButton, () => cssFormButtons( cssFormNewResponseButton( - 'Submit new response', + t('Submit new response'), dom.on('click', () => this._handleClickNewResponseButton()), ), ) ), - ]); + ], testId('success-page')); } private async _handleClickNewResponseButton() { @@ -49,6 +53,15 @@ export class FormSuccessPage extends Disposable { } } +const cssFormSuccessMessageImageContainer = styled(cssFormMessageImageContainer, ` + height: 215px; +`); + +const cssFormSuccessMessageImage = styled(cssFormMessageImage, ` + max-height: 215px; + max-width: 250px; +`); + const cssFormButtons = styled('div', ` display: flex; justify-content: center; diff --git a/app/client/ui/RightPanelStyles.ts b/app/client/ui/RightPanelStyles.ts index fac081e5..b6ec7c0f 100644 --- a/app/client/ui/RightPanelStyles.ts +++ b/app/client/ui/RightPanelStyles.ts @@ -1,5 +1,6 @@ import {theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {styled} from 'grainjs'; export const cssIcon = styled(icon, ` @@ -89,3 +90,7 @@ export const cssPinButton = styled('div', ` background-color: ${theme.hover}; } `); + +export const cssNumericSpinner = styled(numericSpinner, ` + height: 28px; +`); diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index 35f09c77..5ede5ef9 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -23,6 +23,7 @@ export const cssLabel = styled('label', ` display: inline-flex; min-width: 0px; margin-bottom: 0px; + flex-shrink: 0; outline: none; user-select: none; diff --git a/app/client/ui2018/radio.ts b/app/client/ui2018/radio.ts new file mode 100644 index 00000000..fc15be3b --- /dev/null +++ b/app/client/ui2018/radio.ts @@ -0,0 +1,25 @@ +import {theme} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const cssRadioInput = styled('input', ` + appearance: none; + width: 16px; + height: 16px; + margin: 0px !important; + border-radius: 50%; + background-clip: content-box; + border: 1px solid ${theme.checkboxBorder}; + background-color: ${theme.checkboxBg}; + flex-shrink: 0; + &:hover { + border: 1px solid ${theme.checkboxBorderHover}; + } + &:disabled { + background-color: 1px solid ${theme.checkboxDisabledBg}; + } + &:checked { + padding: 2px; + background-color: ${theme.controlPrimaryBg}; + border: 1px solid ${theme.controlPrimaryBg}; + } +`); diff --git a/app/client/widgets/ChoiceListCell.ts b/app/client/widgets/ChoiceListCell.ts index dd33f47a..1269cf20 100644 --- a/app/client/widgets/ChoiceListCell.ts +++ b/app/client/widgets/ChoiceListCell.ts @@ -1,13 +1,18 @@ +import { + FormFieldRulesConfig, + FormOptionsAlignmentConfig, + FormOptionsSortConfig, +} from 'app/client/components/Forms/FormConfig'; import {DataRowModel} from 'app/client/models/DataRowModel'; import {testId} from 'app/client/ui2018/cssVars'; import { ChoiceOptionsByName, ChoiceTextBox, } from 'app/client/widgets/ChoiceTextBox'; +import {choiceToken} from 'app/client/widgets/ChoiceToken'; import {CellValue} from 'app/common/DocActions'; import {decodeObject} from 'app/plugin/objtypes'; import {dom, styled} from 'grainjs'; -import {choiceToken} from 'app/client/widgets/ChoiceToken'; /** * ChoiceListCell - A cell that renders a list of choice tokens. @@ -49,6 +54,15 @@ export class ChoiceListCell extends ChoiceTextBox { }), ); } + + public buildFormConfigDom() { + return [ + this.buildChoicesConfigDom(), + dom.create(FormOptionsAlignmentConfig, this.field), + dom.create(FormOptionsSortConfig, this.field), + dom.create(FormFieldRulesConfig, this.field), + ]; + } } export const cssChoiceList = styled('div', ` diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index e41753e1..f5a4208d 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -1,3 +1,8 @@ +import { + FormFieldRulesConfig, + FormOptionsSortConfig, + FormSelectConfig, +} from 'app/client/components/Forms/FormConfig'; import {makeT} from 'app/client/lib/localization'; import {DataRowModel} from 'app/client/models/DataRowModel'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; @@ -76,7 +81,7 @@ export class ChoiceTextBox extends NTextBox { public buildConfigDom() { return [ super.buildConfigDom(), - this._buildChoicesConfigDom(), + this.buildChoicesConfigDom(), ]; } @@ -86,14 +91,16 @@ export class ChoiceTextBox extends NTextBox { public buildFormConfigDom() { return [ - this._buildChoicesConfigDom(), - super.buildFormConfigDom(), + this.buildChoicesConfigDom(), + dom.create(FormSelectConfig, this.field), + dom.create(FormOptionsSortConfig, this.field), + dom.create(FormFieldRulesConfig, this.field), ]; } public buildFormTransformConfigDom() { return [ - this._buildChoicesConfigDom(), + this.buildChoicesConfigDom(), ]; } @@ -113,7 +120,7 @@ export class ChoiceTextBox extends NTextBox { return this.field.config.updateChoices(renames, options); } - private _buildChoicesConfigDom() { + protected buildChoicesConfigDom() { const disabled = Computed.create(null, use => use(this.field.disableModify) || use(use(this.field.column).disableEditData) diff --git a/app/client/widgets/DateTextBox.js b/app/client/widgets/DateTextBox.js index a378a0f4..ebceb506 100644 --- a/app/client/widgets/DateTextBox.js +++ b/app/client/widgets/DateTextBox.js @@ -6,7 +6,7 @@ var kd = require('../lib/koDom'); var kf = require('../lib/koForm'); var AbstractWidget = require('./AbstractWidget'); -const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig'); +const {FormFieldRulesConfig} = require('app/client/components/Forms/FormConfig'); const {fromKoSave} = require('app/client/lib/fromKoSave'); const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect'); const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles'); @@ -82,7 +82,7 @@ DateTextBox.prototype.buildTransformConfigDom = function() { DateTextBox.prototype.buildFormConfigDom = function() { return [ - gdom.create(FieldRulesConfig, this.field), + gdom.create(FormFieldRulesConfig, this.field), ]; }; diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index ef41d1a8..1e4511fa 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -108,12 +108,11 @@ export class FieldBuilder extends Disposable { private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>; private readonly _docModel: DocModel; private readonly _readonly: Computed; + private readonly _isForm: ko.Computed; private readonly _comments: ko.Computed; private readonly _showRefConfigPopup: ko.Observable; private readonly _isEditorActive = Observable.create(this, false); - - public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) { super(); @@ -128,9 +127,13 @@ export class FieldBuilder extends Disposable { this._readonly = Computed.create(this, (use) => use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview)); + this._isForm = this.autoDispose(ko.computed(() => { + return this.field.viewSection().widgetType() === WidgetType.Form; + })); + // Observable with a list of available types. this._availableTypes = Computed.create(this, (use) => { - const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form; + const isForm = use(this._isForm); const isFormula = use(this.origColumn.isFormula); const types: Array> = []; _.each(UserType.typeDefs, (def: any, key: string|number) => { @@ -201,8 +204,11 @@ export class FieldBuilder extends Disposable { // Returns the constructor for the widget, and only notifies subscribers on changes. this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => { - return UserTypeImpl.getWidgetConstructor(this.options().widget, - this._readOnlyPureType()); + if (this._isForm()) { + return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType()); + } else { + return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType()); + } })).onlyNotifyUnequal()); // Computed builder for the widget. diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index c756ef0b..e939c315 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -1,14 +1,16 @@ -import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; +import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; import { fromKoSave } from 'app/client/lib/fromKoSave'; +import { makeT } from 'app/client/lib/localization'; import { DataRowModel } from 'app/client/models/DataRowModel'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; -import { cssRow } from 'app/client/ui/RightPanelStyles'; -import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; +import { fieldWithDefault } from 'app/client/models/modelUtil'; +import { FormTextFormat } from 'app/client/ui/FormAPI'; +import { cssLabel, cssNumericSpinner, cssRow } from 'app/client/ui/RightPanelStyles'; +import { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; import { testId } from 'app/client/ui2018/cssVars'; import { makeLinks } from 'app/client/ui2018/links'; import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs'; -import { makeT } from 'app/client/lib/localization'; const t = makeT('NTextBox'); @@ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget { } public buildFormConfigDom(): DomContents { + const format = fieldWithDefault( + this.field.widgetOptionsJson.prop('formTextFormat'), + 'singleline' + ); + const lineCount = fieldWithDefault( + this.field.widgetOptionsJson.prop('formTextLineCount'), + '' + ); + return [ - dom.create(FieldRulesConfig, this.field), + cssLabel(t('Field Format')), + cssRow( + buttonSelect( + fromKoSave(format), + [ + {value: 'singleline', label: t('Single line')}, + {value: 'multiline', label: t('Multi line')}, + ], + testId('tb-form-field-format'), + ), + ), + dom.maybe(use => use(format) === 'multiline', () => + cssRow( + cssNumericSpinner( + fromKo(lineCount), + { + label: t('Lines'), + defaultValue: 3, + minValue: 1, + maxValue: 99, + save: async (val) => lineCount.setAndSave((val && Math.floor(val)) ?? ''), + }, + ), + ), + ), + dom.create(FormFieldRulesConfig, this.field), ]; } diff --git a/app/client/widgets/NumericSpinner.ts b/app/client/widgets/NumericSpinner.ts new file mode 100644 index 00000000..f5eb79c3 --- /dev/null +++ b/app/client/widgets/NumericSpinner.ts @@ -0,0 +1,172 @@ +import {theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {clamp, numberOrDefault} from 'app/common/gutil'; +import {MaybePromise} from 'app/plugin/gutil'; +import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs'; + +const testId = makeTestId('test-numeric-spinner-'); + +export interface NumericSpinnerOptions { + /** Defaults to `false`. */ + setValueOnInput?: boolean; + label?: string; + defaultValue?: number | Observable; + /** No minimum if unset. */ + minValue?: number; + /** No maximum if unset. */ + maxValue?: number; + disabled?: BindableValue; + inputArgs?: IDomArgs; + /** Called on blur and spinner button click. */ + save?: (val?: number) => MaybePromise, +} + +export function numericSpinner( + value: Observable, + options: NumericSpinnerOptions = {}, + ...args: DomElementArg[] +) { + const { + setValueOnInput = false, + label, + defaultValue, + minValue = Number.NEGATIVE_INFINITY, + maxValue = Number.POSITIVE_INFINITY, + disabled, + inputArgs = [], + save, + } = options; + + const getDefaultValue = () => { + if (defaultValue === undefined) { + return 0; + } else if (typeof defaultValue === 'number') { + return defaultValue; + } else { + return defaultValue.get(); + } + }; + + let inputElement: HTMLInputElement; + + const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => { + const {saveValue} = opts; + const currentValue = numberOrDefault(inputElement.value, getDefaultValue()); + const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue); + if (setValueOnInput) { value.set(newValue); } + if (saveValue) { await save?.(newValue); } + return newValue; + }; + const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts); + const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts); + + return cssNumericSpinner( + disabled ? cssNumericSpinner.cls('-disabled', disabled) : null, + label ? cssNumLabel(label) : null, + inputElement = cssNumInput( + {type: 'number'}, + dom.prop('value', value), + defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null, + dom.onKeyDown({ + ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); }, + ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); }, + Enter$: async (_ev, elem) => save && elem.blur(), + }), + !setValueOnInput ? null : dom.on('input', (_ev, elem) => { + value.set(Number.parseFloat(elem.value)); + }), + !save ? null : dom.on('blur', async () => { + let newValue = numberOrDefault(inputElement.value, undefined); + if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); } + await save(newValue); + }), + dom.on('focus', (_ev, elem) => elem.select()), + ...inputArgs, + ), + cssSpinner( + cssSpinnerBtn( + cssSpinnerTop('DropdownUp'), + dom.on('click', async () => incrementValue({saveValue: true})), + testId('increment'), + ), + cssSpinnerBtn( + cssSpinnerBottom('Dropdown'), + dom.on('click', async () => decrementValue({saveValue: true})), + testId('decrement'), + ), + ), + ...args + ); +} + +const cssNumericSpinner = styled('div', ` + position: relative; + flex: auto; + font-weight: normal; + display: flex; + align-items: center; + outline: 1px solid ${theme.inputBorder}; + background-color: ${theme.inputBg}; + border-radius: 3px; + &-disabled { + opacity: 0.4; + pointer-events: none; + } +`); + +const cssNumLabel = styled('div', ` + color: ${theme.lightText}; + flex-shrink: 0; + padding-left: 8px; + pointer-events: none; +`); + +const cssNumInput = styled('input', ` + flex-grow: 1; + padding: 4px 32px 4px 8px; + width: 100%; + text-align: right; + appearance: none; + color: ${theme.inputFg}; + background-color: transparent; + border: none; + outline: none; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +`); + +const cssSpinner = styled('div', ` + position: absolute; + right: 8px; + width: 16px; + height: 100%; + display: flex; + flex-direction: column; +`); + +const cssSpinnerBtn = styled('div', ` + --icon-color: ${theme.controlSecondaryFg}; + flex: 1 1 0px; + min-height: 0px; + position: relative; + cursor: pointer; + overflow: hidden; + &:hover { + --icon-color: ${theme.controlSecondaryHoverFg}; + } +`); + +const cssSpinnerTop = styled(icon, ` + position: absolute; + top: 0px; +`); + +const cssSpinnerBottom = styled(icon, ` + position: absolute; + bottom: 0px; +`); diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 48a3b342..04470433 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -1,23 +1,25 @@ /** * See app/common/NumberFormat for description of options we support. */ +import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig'; +import {fromKoSave} from 'app/client/lib/fromKoSave'; import {makeT} from 'app/client/lib/localization'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {reportError} from 'app/client/models/errors'; -import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; -import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; +import {fieldWithDefault} from 'app/client/models/modelUtil'; +import {FormNumberFormat} from 'app/client/ui/FormAPI'; +import {cssLabel, cssNumericSpinner, cssRow} from 'app/client/ui/RightPanelStyles'; +import {buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; import {testId, theme} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; import {NTextBox} from 'app/client/widgets/NTextBox'; -import {clamp} from 'app/common/gutil'; +import {numberOrDefault} from 'app/common/gutil'; import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat'; -import {BindableValue, Computed, dom, DomContents, DomElementArg, - fromKo, MultiHolder, Observable, styled} from 'grainjs'; +import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs'; import * as LocaleCurrency from 'locale-currency'; - const t = makeT('NumericTextBox'); + const modeOptions: Array> = [ {value: 'currency', label: '$'}, {value: 'decimal', label: ','}, @@ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox { }; // Prepare setters for the UI elements. - // Min/max fraction digits may range from 0 to 20; other values are invalid. - const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20)); - const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20)); + // If defined, `val` will be a floating point number between 0 and 20; make sure it's + // saved as an integer. + const setMinDecimals = (val?: number) => setSave('decimals', val && Math.floor(val)); + const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && Math.floor(val)); // Mode and Sign behave as toggles: clicking a selected on deselects it. const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined); const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined); @@ -105,16 +108,56 @@ export class NumericTextBox extends NTextBox { ]), cssLabel(t('Decimals')), cssRow( - decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')), - decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')), + cssNumericSpinner( + minDecimals, + { + label: t('min'), + minValue: 0, + maxValue: 20, + defaultValue: defaultMin, + disabled, + save: setMinDecimals, + }, + testId('numeric-min-decimals'), + ), + cssNumericSpinner( + maxDecimals, + { + label: t('max'), + minValue: 0, + maxValue: 20, + defaultValue: defaultMax, + disabled, + save: setMaxDecimals, + }, + testId('numeric-max-decimals'), + ), ), ]; } -} -function numberOrDefault(value: unknown, def: T): number | T { - return value !== null && value !== undefined ? Number(value) : def; + public buildFormConfigDom(): DomContents { + const format = fieldWithDefault( + this.field.widgetOptionsJson.prop('formNumberFormat'), + 'text' + ); + + return [ + cssLabel(t('Field Format')), + cssRow( + buttonSelect( + fromKoSave(format), + [ + {value: 'text', label: t('Text')}, + {value: 'spinner', label: t('Spinner')}, + ], + testId('numeric-form-field-format'), + ), + ), + dom.create(FormFieldRulesConfig, this.field), + ]; + } } // Helper used by setSave() above to reset some properties when switching modes. @@ -126,107 +169,6 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial return {}; } -function decimals( - label: string, - value: Observable, - defaultValue: Observable, - setFunc: (val?: number) => void, - disabled: BindableValue, - ...args: DomElementArg[] -) { - return cssDecimalsBox( - cssDecimalsBox.cls('-disabled', disabled), - cssNumLabel(label), - cssNumInput({type: 'text', size: '2', min: '0'}, - dom.prop('value', value), - dom.prop('placeholder', defaultValue), - dom.on('change', (ev, elem) => { - const newVal = parseInt(elem.value, 10); - // Set value explicitly before its updated via setFunc; this way the value reflects the - // observable in the case the observable is left unchanged (e.g. because of clamping). - elem.value = String(value.get()); - setFunc(Number.isNaN(newVal) ? undefined : newVal); - elem.blur(); - }), - dom.on('focus', (ev, elem) => elem.select()), - ), - cssSpinner( - cssSpinnerBtn(cssSpinnerTop('DropdownUp'), - dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))), - cssSpinnerBtn(cssSpinnerBottom('Dropdown'), - dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))), - ), - ...args - ); -} - -const cssDecimalsBox = styled('div', ` - position: relative; - flex: auto; - --icon-color: ${theme.lightText}; - color: ${theme.lightText}; - font-weight: normal; - display: flex; - align-items: center; - &:first-child { - margin-right: 16px; - } - &-disabled { - opacity: 0.4; - pointer-events: none; - } -`); - -const cssNumLabel = styled('div', ` - position: absolute; - padding-left: 8px; - pointer-events: none; -`); - -const cssNumInput = styled('input', ` - padding: 4px 32px 4px 40px; - border: 1px solid ${theme.inputBorder}; - border-radius: 3px; - background-color: ${theme.inputBg}; - color: ${theme.inputFg}; - width: 100%; - text-align: right; - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; -`); - -const cssSpinner = styled('div', ` - position: absolute; - right: 8px; - width: 16px; - height: 100%; - display: flex; - flex-direction: column; -`); - -const cssSpinnerBtn = styled('div', ` - --icon-color: ${theme.controlSecondaryFg}; - flex: 1 1 0px; - min-height: 0px; - position: relative; - cursor: pointer; - overflow: hidden; - &:hover { - --icon-color: ${theme.controlSecondaryHoverFg}; - } -`); - -const cssSpinnerTop = styled(icon, ` - position: absolute; - top: 0px; -`); - -const cssSpinnerBottom = styled(icon, ` - position: absolute; - bottom: 0px; -`); - const cssModeSelect = styled(makeButtonSelect, ` flex: 4 4 0px; background-color: ${theme.inputBg}; diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index 38529bf8..282388ac 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -1,3 +1,8 @@ +import { + FormFieldRulesConfig, + FormOptionsSortConfig, + FormSelectConfig +} from 'app/client/components/Forms/FormConfig'; import {makeT} from 'app/client/lib/localization'; import {DataRowModel} from 'app/client/models/DataRowModel'; import {TableRec} from 'app/client/models/DocModel'; @@ -72,7 +77,9 @@ export class Reference extends NTextBox { public buildFormConfigDom() { return [ this.buildTransformConfigDom(), - super.buildFormConfigDom(), + dom.create(FormSelectConfig, this.field), + dom.create(FormOptionsSortConfig, this.field), + dom.create(FormFieldRulesConfig, this.field), ]; } diff --git a/app/client/widgets/ReferenceList.ts b/app/client/widgets/ReferenceList.ts index bddff202..0429b7c3 100644 --- a/app/client/widgets/ReferenceList.ts +++ b/app/client/widgets/ReferenceList.ts @@ -1,3 +1,8 @@ +import { + FormFieldRulesConfig, + FormOptionsAlignmentConfig, + FormOptionsSortConfig, +} from 'app/client/components/Forms/FormConfig'; import {DataRowModel} from 'app/client/models/DataRowModel'; import {urlState} from 'app/client/models/gristUrlState'; import {testId, theme} from 'app/client/ui2018/cssVars'; @@ -103,6 +108,15 @@ export class ReferenceList extends Reference { }), ); } + + public buildFormConfigDom() { + return [ + this.buildTransformConfigDom(), + dom.create(FormOptionsAlignmentConfig, this.field), + dom.create(FormOptionsSortConfig, this.field), + dom.create(FormFieldRulesConfig, this.field), + ]; + } } const cssRefIcon = styled(icon, ` diff --git a/app/client/widgets/Toggle.ts b/app/client/widgets/Toggle.ts index 5a6f440d..4ad16f6f 100644 --- a/app/client/widgets/Toggle.ts +++ b/app/client/widgets/Toggle.ts @@ -1,19 +1,44 @@ import * as commands from 'app/client/components/commands'; -import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; +import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; +import { fromKoSave } from 'app/client/lib/fromKoSave'; +import { makeT } from 'app/client/lib/localization'; import { DataRowModel } from 'app/client/models/DataRowModel'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; -import { KoSaveableObservable } from 'app/client/models/modelUtil'; -import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; +import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil'; +import { FormToggleFormat } from 'app/client/ui/FormAPI'; +import { cssLabel, cssRow } from 'app/client/ui/RightPanelStyles'; +import { buttonSelect } from 'app/client/ui2018/buttonSelect'; import { theme } from 'app/client/ui2018/cssVars'; -import { dom, DomContents } from 'grainjs'; +import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; +import { dom, DomContents, makeTestId } from 'grainjs'; + +const t = makeT('Toggle'); + +const testId = makeTestId('test-toggle-'); /** * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch. */ abstract class ToggleBase extends NewAbstractWidget { public buildFormConfigDom(): DomContents { + const format = fieldWithDefault( + this.field.widgetOptionsJson.prop('formToggleFormat'), + 'switch' + ); + return [ - dom.create(FieldRulesConfig, this.field), + cssLabel(t('Field Format')), + cssRow( + buttonSelect( + fromKoSave(format), + [ + {value: 'switch', label: t('Switch')}, + {value: 'checkbox', label: t('Checkbox')}, + ], + testId('form-field-format'), + ), + ), + dom.create(FormFieldRulesConfig, this.field), ]; } diff --git a/app/client/widgets/UserType.ts b/app/client/widgets/UserType.ts index c1a1fb20..06586c53 100644 --- a/app/client/widgets/UserType.ts +++ b/app/client/widgets/UserType.ts @@ -154,6 +154,7 @@ export const typeDefs: any = { widgets: { TextBox: { cons: 'TextBox', + formCons: 'Switch', editCons: 'TextEditor', icon: 'FieldTextbox', options: { diff --git a/app/client/widgets/UserTypeImpl.ts b/app/client/widgets/UserTypeImpl.ts index e9c5b25e..e2907595 100644 --- a/app/client/widgets/UserTypeImpl.ts +++ b/app/client/widgets/UserTypeImpl.ts @@ -65,6 +65,12 @@ export function getWidgetConstructor(widget: string, type: string): WidgetConstr return nameToWidget[config.cons as keyof typeof nameToWidget] as any; } +/** return a good class to instantiate for viewing a form widget/type combination */ +export function getFormWidgetConstructor(widget: string, type: string): WidgetConstructor { + const {config} = getWidgetConfiguration(widget, type as GristType); + return nameToWidget[(config.formCons || config.cons) as keyof typeof nameToWidget] as any; +} + /** return a good class to instantiate for editing a widget/type combination */ export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor { const {config} = getWidgetConfiguration(widget, type as GristType); diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 2ff8f3b5..bea94bf1 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -175,6 +175,21 @@ export async function firstDefined(...list: Array<() => Promise>): Promise return undefined; } +/** + * Returns the number repesentation of `value`, or `defaultVal` if it cannot + * be represented as a valid number. + */ +export function numberOrDefault(value: unknown, defaultVal: T): number | T { + if (typeof value === 'number') { + return !Number.isNaN(value) ? value : defaultVal; + } else if (typeof value === 'string') { + const maybeNumber = Number.parseFloat(value); + return !Number.isNaN(maybeNumber) ? maybeNumber : defaultVal; + } else { + return defaultVal; + } +} + /** * Parses json and returns the result, or returns defaultVal if parsing fails. */ diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 350fc8a4..8f01df16 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -12,7 +12,13 @@ import { UserAction } from 'app/common/DocActions'; import {DocData} from 'app/common/DocData'; -import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes"; +import { + extractTypeFromColType, + getReferencedTableId, + isBlankValue, + isFullReferencingType, + isRaisedException, +} from "app/common/gristTypes"; import {INITIAL_FIELDS_COUNT} from "app/common/Forms"; import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; @@ -260,9 +266,15 @@ export class DocWorkerApi { } function asRecords( - columnData: TableColValues, opts?: { optTableId?: string; includeHidden?: boolean }): TableRecordValue[] { + columnData: TableColValues, + opts?: { + optTableId?: string; + includeHidden?: boolean; + includeId?: boolean; + } + ): TableRecordValue[] { const fieldNames = Object.keys(columnData).filter((k) => { - if (k === "id") { + if (!opts?.includeId && k === "id") { return false; } if ( @@ -1451,9 +1463,8 @@ export class DocWorkerApi { } // Cache the table reads based on tableId. We are caching only the promise, not the result. - const table = _.memoize( - (tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r)) - ); + const table = _.memoize((tableId: string) => + readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r, {includeId: true}))); const getTableValues = async (tableId: string, colId: string) => { const records = await table(tableId); @@ -1463,19 +1474,17 @@ export class DocWorkerApi { const Tables = activeDoc.docData.getMetaTable('_grist_Tables'); const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => { - const refId = col.visibleCol; - if (!refId) { return [] as any; } + const refTableId = getReferencedTableId(col.type); + let refColId: string; + if (col.visibleCol) { + const refCol = Tables_column.getRecord(col.visibleCol); + if (!refCol) { return []; } - const refCol = Tables_column.getRecord(refId); - if (!refCol) { return []; } - - const refTable = Tables.getRecord(refCol.parentId); - if (!refTable) { return []; } - - const refTableId = refTable.tableId as string; - const refColId = refCol.colId as string; - if (!refTableId || !refColId) { return () => []; } - if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } + refColId = refCol.colId as string; + } else { + refColId = 'id'; + } + if (!refTableId || typeof refTableId !== 'string' || !refColId) { return []; } const values = await getTableValues(refTableId, refColId); return values.filter(([_id, value]) => !isBlankValue(value)); diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts index 1aeb71dc..bfcb970b 100644 --- a/test/nbrowser/FormView.ts +++ b/test/nbrowser/FormView.ts @@ -5,7 +5,7 @@ import * as gu from 'test/nbrowser/gristUtils'; import {setupTestSuite} from 'test/nbrowser/testUtils'; describe('FormView', function() { - this.timeout('90s'); + this.timeout('2m'); gu.bigScreen(); let api: UserAPI; @@ -80,9 +80,9 @@ describe('FormView', function() { async function waitForConfirm() { await gu.waitForServer(); await gu.waitToPass(async () => { - assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.isTrue(await driver.findWait('.test-form-success-page', 2000).isDisplayed()); assert.equal( - await driver.find('.test-form-success-text').getText(), + await driver.find('.test-form-success-page-text').getText(), 'Thank you! Your response has been recorded.' ); }); @@ -96,6 +96,12 @@ describe('FormView', function() { assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values); } + async function assertSubmitOnEnterIsDisabled() { + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.isFalse(await driver.find('.test-form-success-page').isPresent()); + } + describe('on personal site', async function() { before(async function() { const session = await gu.session().login(); @@ -157,7 +163,7 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Text field', async function() { + it('can submit a form with single-line Text field', async function() { const formUrl = await createFormWith('Text'); // We are in a new window. await gu.onNewTab(async () => { @@ -170,6 +176,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('Hello World'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -178,7 +185,32 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Numeric field', async function() { + it('can submit a form with multi-line Text field', async function() { + const formUrl = await createFormWith('Text'); + await gu.openColumnPanel(); + await gu.waitForSidePanel(); + await driver.findContent('.test-tb-form-field-format .test-select-button', /Multi line/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('textarea[name="D"]', 2000).click(); + await gu.sendKeys('Hello'); + assert.equal(await driver.find('textarea[name="D"]').value(), 'Hello'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('textarea[name="D"]').value(), ''); + await driver.find('textarea[name="D"]').click(); + await gu.sendKeys('Hello,', Key.ENTER, 'World'); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle('Hello,\nWorld'); + await removeForm(); + }); + + it('can submit a form with text Numeric field', async function() { const formUrl = await createFormWith('Numeric'); // We are in a new window. await gu.onNewTab(async () => { @@ -191,6 +223,38 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle(1984); + await removeForm(); + }); + + it('can submit a form with spinner Numeric field', async function() { + const formUrl = await createFormWith('Numeric'); + await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); + await gu.sendKeys('1984', Key.ARROW_UP); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await gu.sendKeys(Key.ARROW_DOWN); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await driver.find('.test-numeric-spinner-increment').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await driver.find('.test-numeric-spinner-decrement').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -212,6 +276,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').getAttribute('value'), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('01012000'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -220,17 +285,14 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Choice field', async function() { + it('can submit a form with select Choice field', async function() { const formUrl = await createFormWith('Choice'); // Add some options. - await gu.openColumnPanel(); - await gu.choicesEditor.edit(); await gu.choicesEditor.add('Foo'); await gu.choicesEditor.add('Bar'); await gu.choicesEditor.add('Baz'); await gu.choicesEditor.save(); - await gu.toggleSidePanel('right', 'close'); // We need to press view, as form is not saved yet. await gu.scrollActiveViewTop(); @@ -256,6 +318,12 @@ describe('FormView', function() { assert.equal(await driver.find('select[name="D"]').value(), ''); await driver.find('.test-form-search-select').click(); await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + // Check keyboard shortcuts work. + assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar'); + await gu.sendKeys(Key.BACK_SPACE); + assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...'); + await gu.sendKeys(Key.ENTER); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -263,7 +331,41 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Integer field', async function() { + it('can submit a form with radio Choice field', async function() { + const formUrl = await createFormWith('Choice'); + await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click(); + await gu.waitForServer(); + await gu.choicesEditor.edit(); + await gu.choicesEditor.add('Foo'); + await gu.choicesEditor.add('Bar'); + await gu.choicesEditor.add('Baz'); + await gu.choicesEditor.save(); + await gu.scrollActiveViewTop(); + await gu.waitToPass(async () => { + assert.isTrue(await driver.find('.test-forms-view').isDisplayed()); + }); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000); + assert.deepEqual( + await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz'] + ); + await driver.find('input[name="D"][value="Baz"]').click(); + assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), null); + await driver.find('input[name="D"][value="Bar"]').click(); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectSingle('Bar'); + await removeForm(); + }); + + it('can submit a form with text Integer field', async function() { const formUrl = await createFormWith('Integer', true); // We are in a new window. await gu.onNewTab(async () => { @@ -276,6 +378,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -284,7 +387,38 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Toggle field', async function() { + it('can submit a form with spinner Integer field', async function() { + const formUrl = await createFormWith('Integer', true); + await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); + await gu.sendKeys('1984', Key.ARROW_UP); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await gu.sendKeys(Key.ARROW_DOWN); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await driver.find('.test-numeric-spinner-increment').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await driver.find('.test-numeric-spinner-decrement').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle(1984); + await removeForm(); + }); + + it('can submit a form with switch Toggle field', async function() { const formUrl = await createFormWith('Toggle', true); // We are in a new window. await gu.onNewTab(async () => { @@ -295,6 +429,39 @@ describe('FormView', function() { await driver.find('.test-modal-confirm').click(); assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null); await driver.find('input[name="D"]').findClosest("label").click(); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectSingle(true); + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[type="submit"]', 2000).click(); + await waitForConfirm(); + }); + await expectInD([true, false]); + + // Remove the additional record added just now. + await gu.sendActions([ + ['RemoveRecord', 'Table1', 2], + ]); + await removeForm(); + }); + + it('can submit a form with checkbox Toggle field', async function() { + const formUrl = await createFormWith('Toggle', true); + await driver.findContent('.test-toggle-form-field-format .test-select-button', /Checkbox/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).findClosest("label").click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null); + await driver.find('input[name="D"]').findClosest("label").click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -334,6 +501,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null); await driver.find('input[name="D[]"][value="Foo"]').click(); await driver.find('input[name="D[]"][value="Baz"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -342,7 +510,7 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Ref field', async function() { + it('can submit a form with select Ref field', async function() { const formUrl = await createFormWith('Reference', true); // Add some options. await gu.openColumnPanel(); @@ -353,22 +521,21 @@ describe('FormView', function() { ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2 ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3 ]); - await gu.toggleSidePanel('right', 'close'); // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('select[name="D"]', 2000); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.getText()), - ['Select...', ...['Bar', 'Baz', 'Foo']] + ['Select...', 'Foo', 'Bar', 'Baz'] ); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.value()), - ['', ...['2', '3', '1']] + ['', '1', '2', '3'] ); await driver.find('.test-form-search-select').click(); assert.deepEqual( - await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo'] + await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz'] ); await gu.sendKeys('Baz', Key.ENTER); assert.equal(await driver.find('select[name="D"]').value(), '3'); @@ -377,6 +544,51 @@ describe('FormView', function() { assert.equal(await driver.find('select[name="D"]').value(), ''); await driver.find('.test-form-search-select').click(); await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + // Check keyboard shortcuts work. + assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar'); + await gu.sendKeys(Key.BACK_SPACE); + assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...'); + await gu.sendKeys(Key.ENTER); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectInD([0, 0, 0, 2]); + + // Remove 3 records. + await gu.sendActions([ + ['BulkRemoveRecord', 'Table1', [1, 2, 3, 4]], + ]); + + await removeForm(); + }); + + it('can submit a form with radio Ref field', async function() { + const formUrl = await createFormWith('Reference', true); + await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click(); + await gu.waitForServer(); + await gu.setRefShowColumn('A'); + await gu.sendActions([ + ['AddRecord', 'Table1', null, {A: 'Foo'}], + ['AddRecord', 'Table1', null, {A: 'Bar'}], + ['AddRecord', 'Table1', null, {A: 'Baz'}], + ]); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000); + assert.deepEqual( + await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz'] + ); + assert.equal(await driver.find('label:has(input[name="D"][value="3"])').getText(), 'Baz'); + await driver.find('input[name="D"][value="3"]').click(); + assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), null); + assert.equal(await driver.find('label:has(input[name="D"][value="2"])').getText(), 'Bar'); + await driver.find('input[name="D"][value="2"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -393,8 +605,6 @@ describe('FormView', function() { it('can submit a form with RefList field', async function() { const formUrl = await createFormWith('Reference List', true); // Add some options. - await gu.openColumnPanel(); - await gu.setRefShowColumn('A'); // Add 3 records to this table (it is now empty). await gu.sendActions([ @@ -416,6 +626,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null); await driver.find('input[name="D[]"][value="1"]').click(); await driver.find('input[name="D[]"][value="2"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -542,9 +753,9 @@ describe('FormView', function() { await gu.waitForServer(); await gu.onNewTab(async () => { await driver.get(formUrl); - assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.isTrue(await driver.findWait('.test-form-error-page', 2000).isDisplayed()); assert.equal( - await driver.find('.test-form-error-text').getText(), + await driver.find('.test-form-error-page-text').getText(), 'Oops! This form is no longer published.' ); }); @@ -739,8 +950,8 @@ describe('FormView', function() { // Now B is selected. assert.equal(await selectedLabel(), 'B'); - // Click on the edit button. - await driver.find('.test-forms-submit').click(); + // Click the blank space above the submit button. + await driver.find('.test-forms-error').click(); // Now nothing is selected. assert.isFalse(await isSelected(), 'Something is selected'); @@ -825,7 +1036,6 @@ describe('FormView', function() { assert.deepEqual(await hiddenColumns(), []); // Now hide it using Delete key. - await driver.find('.test-forms-submit').click(); await question('Choice').click(); await gu.sendKeys(Key.DELETE); await gu.waitForServer(); @@ -833,8 +1043,20 @@ describe('FormView', function() { // It should be hidden again. assert.deepEqual(await hiddenColumns(), ['Choice']); assert.deepEqual(await readLabels(), ['A', 'B', 'C']); + }); - + it('changing field types works', async function() { + await gu.openColumnPanel(); + assert.equal(await questionType('A'), 'Any'); + await question('A').click(); + await gu.setType('Text'); + assert.equal(await questionType('A'), 'Text'); + await gu.sendActions([['AddRecord', 'Form', null, {A: 'Foo'}]]); + await question('A').click(); + await gu.setType('Numeric', {apply: true}); + assert.equal(await questionType('A'), 'Numeric'); + await gu.sendActions([['RemoveRecord', 'Form', 1]]); + await gu.undo(2); await gu.toggleSidePanel('right', 'close'); }); From 8c53585bd708e73e6eb14c8887b4e7007ce8a571 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 12 Apr 2024 10:00:05 -0700 Subject: [PATCH 08/45] (core) Break overflowing text Summary: Text could overflow its container in a few instances. Test Plan: Manual. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4231 --- app/client/components/FormRendererCss.ts | 3 +++ app/client/components/Forms/styles.ts | 4 ++++ app/client/ui2018/modals.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/app/client/components/FormRendererCss.ts b/app/client/components/FormRendererCss.ts index 461ddff9..3dcccf34 100644 --- a/app/client/components/FormRendererCss.ts +++ b/app/client/components/FormRendererCss.ts @@ -12,6 +12,8 @@ export const label = styled('div', ` `); export const paragraph = styled('div', ` + overflow-wrap: break-word; + &-alignment-left { text-align: left; } @@ -176,6 +178,7 @@ export const field = styled('div', ` margin-top: 8px; margin-bottom: 8px; display: block; + overflow-wrap: break-word; } `); diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index 6b5fa8d0..e75578bd 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -133,6 +133,8 @@ export const cssQuestion = styled('div', ` export const cssRequiredWrapper = styled('div', ` margin: 8px 0px; min-height: 16px; + overflow-wrap: break-word; + &-required { display: grid; grid-template-columns: auto 1fr; @@ -426,6 +428,8 @@ export const cssSmallButton = styled(basicButton, ` export const cssMarkdownRendered = styled('div', ` min-height: 1.5rem; font-size: 15px; + overflow-wrap: break-word; + & textarea { font-size: 15px; } diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 4ed74ab6..f0bb242d 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -618,6 +618,7 @@ export const cssModalTitle = styled('div', ` export const cssModalBody = styled('div', ` color: ${theme.text}; margin: 16px 0; + overflow-wrap: break-word; `); export const cssModalButtons = styled('div', ` From 013040944717018f590fab6fe52ae77f54afb498 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 5 Apr 2024 12:37:15 -0700 Subject: [PATCH 09/45] (core) Fix null references in form fields bug Summary: Shares and documents would both produce a rule set for the same column if the document rule set was for multiple columns. In this case, it was causing one of the rules to be overwritten by the other (specifically, the rule granting access to form references was not being applied in shares). The symptom was `null` values in place of the referenced table's values. We address this by splitting any rule sets for multiple columns that are also affected by shares, so that they can be overridden by shares without causing a conflicting rule set to be created (i.e. 2 column rule sets containing the same column). Test Plan: Server tests. Reviewers: dsagal, paulfitz Reviewed By: dsagal, paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4208 --- app/common/ACLRuleCollection.ts | 45 +-- app/common/ACLRulesReader.ts | 454 ++++++++++++++++++++++++++++++ app/common/ACLShareRules.ts | 296 ------------------- test/server/lib/ACLRulesReader.ts | 443 +++++++++++++++++++++++++++++ 4 files changed, 904 insertions(+), 334 deletions(-) create mode 100644 app/common/ACLRulesReader.ts delete mode 100644 app/common/ACLShareRules.ts create mode 100644 test/server/lib/ACLRulesReader.ts diff --git a/app/common/ACLRuleCollection.ts b/app/common/ACLRuleCollection.ts index 434dc151..f2f004dd 100644 --- a/app/common/ACLRuleCollection.ts +++ b/app/common/ACLRuleCollection.ts @@ -1,14 +1,12 @@ import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions'; import {AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions} from 'app/common/ACLPermissions'; -import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules'; +import {ACLRulesReader} from 'app/common/ACLRulesReader'; import {AclRuleProblem} from 'app/common/ActiveDocAPI'; import {DocData} from 'app/common/DocData'; import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; import {getSetMapValue, isNonNullish} from 'app/common/gutil'; -import {ShareOptions} from 'app/common/ShareOptions'; import {MetaRowRecord} from 'app/common/TableData'; import {decodeObject} from 'app/plugin/objtypes'; -import sortBy = require('lodash/sortBy'); export type ILogger = Pick; @@ -463,39 +461,16 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log: * UserAttributeRules. This is used by both client-side code and server-side. */ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults { - // Wrap resources and rules tables so we can have "virtual" rules - // to implement special shares. - const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources')); - const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules')); - const sharesTable = docData.getMetaTable('_grist_Shares'); - const ruleSets: RuleSet[] = []; const userAttributes: UserAttributeRule[] = []; - let hasShares: boolean = false; - const shares = sharesTable.getRecords(); - // ACLShareRules is used to edit resourcesTable and rulesTable in place. - const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable); - // Add virtual rules to implement shares, if there are any. - // Add the virtual rules only when implementing/interpreting them, as - // opposed to accessing them for presentation or manipulation in the UI. - if (enrichRulesForImplementation && shares.length > 0) { - for (const share of shares) { - const options: ShareOptions = JSON.parse(share.options || '{}'); - shareRules.addRulesForShare(share.id, options); - } - shareRules.addDefaultRulesForShares(); - hasShares = true; - } + const aclRulesReader = new ACLRulesReader(docData, { + addShareRules: enrichRulesForImplementation, + }); // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet. - const rulesByResource = new Map>>(); - for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) { - getSetMapValue(rulesByResource, ruleRecord.resource, () => []).push(ruleRecord); - } - - for (const [resourceId, rules] of rulesByResource.entries()) { - const resourceRec = resourcesTable.getRecord(resourceId); + for (const [resourceId, rules] of aclRulesReader.entries()) { + const resourceRec = aclRulesReader.getResourceById(resourceId); if (!resourceRec) { throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`); } @@ -531,13 +506,7 @@ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementat } else if (rule.aclFormula && !rule.aclFormulaParsed) { throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`); } else { - let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); - // If we have "virtual" rules to implement shares, then regular - // rules need to be tweaked so that they don't apply when the - // share is active. - if (hasShares && rule.id >= 0) { - aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed}); - } + const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); let permissions = parsePermissions(String(rule.permissionsText)); if (tableId !== '*' && tableId !== SPECIAL_RULES_TABLE_ID) { const availableBits = (colIds === '*') ? AVAILABLE_BITS_TABLES : AVAILABLE_BITS_COLUMNS; diff --git a/app/common/ACLRulesReader.ts b/app/common/ACLRulesReader.ts new file mode 100644 index 00000000..ed4154ce --- /dev/null +++ b/app/common/ACLRulesReader.ts @@ -0,0 +1,454 @@ +import { DocData } from 'app/common/DocData'; +import { getSetMapValue } from 'app/common/gutil'; +import { SchemaTypes } from 'app/common/schema'; +import { ShareOptions } from 'app/common/ShareOptions'; +import { MetaRowRecord, MetaTableData } from 'app/common/TableData'; +import { isEqual, sortBy } from 'lodash'; + +/** + * For special shares, we need to refer to resources that may not + * be listed in the _grist_ACLResources table, and have rules that + * aren't backed by storage in _grist_ACLRules. So we implement + * a small helper to add an overlay of extra resources and rules. + * They are distinguishable from real, stored resources and rules + * by having negative IDs. + */ +export class TableWithOverlay { + private _extraRecords = new Array>(); + private _extraRecordsById = new Map>(); + private _excludedRecordIds = new Set(); + private _nextFreeVirtualId: number = -1; + + public constructor(private _originalTable: MetaTableData) {} + + // Add a record to the table, but only as an overlay - no + // persistent changes are made. Uses negative row IDs. + // Returns the ID assigned to the record. The passed in + // record is expected to have an ID of zero. + public addRecord(rec: MetaRowRecord): number { + if (rec.id !== 0) { throw new Error('Expected a zero ID'); } + const id = this._nextFreeVirtualId; + const recWithCorrectId: MetaRowRecord = {...rec, id}; + this._extraRecords.push({...rec, id}); + this._extraRecordsById.set(id, recWithCorrectId); + this._nextFreeVirtualId--; + return id; + } + + public excludeRecord(id: number) { + this._excludedRecordIds.add(id); + } + + // Support the few MetaTableData methods we actually use + // in ACLRulesReader. + + public getRecord(id: number) { + if (this._excludedRecordIds.has(id)) { return undefined; } + + if (id < 0) { + // Reroute negative IDs to our local stash of records. + return this._extraRecordsById.get(id); + } else { + // Everything else, we just pass along. + return this._originalTable.getRecord(id); + } + } + + public getRecords() { + return this._filterExcludedRecords([ + ...this._originalTable.getRecords(), + ...this._extraRecords, + ]); + } + + public filterRecords(properties: Partial>): Array> { + const originalRecords = this._originalTable.filterRecords(properties); + const extraRecords = this._extraRecords.filter((rec) => Object.keys(properties) + .every((p) => isEqual((rec as any)[p], (properties as any)[p]))); + return this._filterExcludedRecords([...originalRecords, ...extraRecords]); + } + + public findMatchingRowId(properties: Partial>): number { + const rowId = ( + this._originalTable.findMatchingRowId(properties) || + this._extraRecords.find((rec) => Object.keys(properties).every((p) => + isEqual((rec as any)[p], (properties as any)[p])) + )?.id + ); + return rowId && !this._excludedRecordIds.has(rowId) ? rowId : 0; + } + + private _filterExcludedRecords(records: MetaRowRecord[]) { + return records.filter(({id}) => !this._excludedRecordIds.has(id)); + } +} + +export interface ACLRulesReaderOptions { + /** + * Adds virtual rules for all shares in the document. + * + * If set to `true` and there are shares in the document, regular rules are + * modified so that they don't apply when a document is being accessed through + * a share, and new rules are added to grant access to the resources specified by + * the shares. + * + * This will also "split" any resources (and their rules) if they apply to multiple + * resources. Splitting produces copies of the original resource and rules + * rules, but with modifications in place so that each copy applies to a single + * resource. Normalizing the original rules in this way allows for a simpler mechanism + * to override the original rules/resources with share rules, for situations where a + * share needs to grant access to a resource that is protected by access rules (shares + * and access rules are mutually exclusive at this time). + * + * Note: a value of `true` will *not* cause any persistent modifications to be made to + * rules; all changes are "virtual" in the sense that they are applied on top of the + * persisted rules to enable shares. + * + * Defaults to `false`. + */ + addShareRules?: boolean; +} + +/** + * Helper class for reading ACL rules from DocData. + */ +export class ACLRulesReader { + private _resourcesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLResources')); + private _rulesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLRules')); + private _sharesTable = this.docData.getMetaTable('_grist_Shares'); + private _hasShares = this._options.addShareRules && this._sharesTable.numRecords() > 0; + /** Maps 'tableId:colId' to the comma-separated list of column IDs from the associated resource. */ + private _resourceColIdsByTableAndColId: Map = new Map(); + + public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) { + this._addOriginalRules(); + this._maybeAddShareRules(); + } + + public entries() { + const rulesByResourceId = new Map>>(); + for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) { + // If we have "virtual" rules to implement shares, then regular + // rules need to be tweaked so that they don't apply when the + // share is active. + if (this._hasShares && rule.id >= 0) { + disableRuleInShare(rule); + } + + getSetMapValue(rulesByResourceId, rule.resource, () => []).push(rule); + } + return rulesByResourceId.entries(); + } + + public getResourceById(id: number) { + return this._resourcesTable.getRecord(id); + } + + private _addOriginalRules() { + for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) { + const resource = this.getResourceById(rule.resource); + if (!resource) { + throw new Error(`ACLRule ${rule.id} refers to an invalid ACLResource ${rule.resource}`); + } + + if (resource.tableId !== '*' && resource.colIds !== '*') { + const colIds = resource.colIds.split(','); + if (colIds.length === 1) { continue; } + + for (const colId of colIds) { + this._resourceColIdsByTableAndColId.set(`${resource.tableId}:${colId}`, resource.colIds); + } + } + } + } + + private _maybeAddShareRules() { + if (!this._hasShares) { return; } + + for (const share of this._sharesTable.getRecords()) { + this._addRulesForShare(share); + } + this._addDefaultShareRules(); + } + + /** + * Add any rules needed for the specified share. + * + * The only kind of share we support for now is form endpoint + * sharing. + */ + private _addRulesForShare(share: MetaRowRecord<'_grist_Shares'>) { + // TODO: Unpublished shares could and should be blocked earlier, + // by home server + const {publish}: ShareOptions = JSON.parse(share.options || '{}'); + if (!publish) { + this._blockShare(share.id); + return; + } + + // Let's go looking for sections related to the share. + // It was decided that the relationship between sections and + // shares is via pages. Every section on a given page can belong + // to at most one share. + // Ignore sections which do not have `publish` set to `true` in + // `shareOptions`. + const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({ + shareRef: share.id, + }); + const parentViews = new Set(pages.map(page => page.viewRef)); + const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter( + section => { + if (!parentViews.has(section.parentId)) { return false; } + const options = JSON.parse(section.shareOptions || '{}'); + return Boolean(options.publish) && Boolean(options.form); + } + ); + + const tableRefs = new Set(sections.map(section => section.tableRef)); + const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( + table => tableRefs.has(table.id) + ); + + // For tables associated with forms, allow creation of records, + // and reading of referenced columns. + // TODO: should probably be limiting to a set of columns associated + // with section - but for form widget that could potentially be very + // confusing since it may not be easy to see that certain columns + // haven't been made visible for it? For now, just working at table + // level. + for (const table of tables) { + this._shareTableForForm(table, share.id); + } + } + + /** + * When accessing a document via a share, by default no user tables are + * accessible. Everything added to the share gives additional + * access, and never reduces access, making it easy to grant + * access to multiple parts of the document. + * + * We do leave access unchanged for metadata tables, since they are + * censored via an alternative mechanism. + */ + private _addDefaultShareRules() { + // Block access to each table. + const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords() + .map(table => table.tableId) + .filter(tableId => !tableId.startsWith('_grist_')) + .sort(); + for (const tableId of tableIds) { + this._addShareRule(this._findOrAddResource({tableId, colIds: '*'}), '-CRUDS'); + } + + // Block schema access at the default level. + this._addShareRule(this._findOrAddResource({tableId: '*', colIds: '*'}), '-S'); + } + + /** + * Allow creating records in a table. + */ + private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const resource = this._findOrAddResource({ + tableId: table.tableId, + colIds: '*', + }); + let aclFormula = `user.ShareRef == ${shareRef}`; + let aclFormulaParsed = JSON.stringify([ + 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + [ 'Const', shareRef ] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+C', + })); + + // This is a hack to grant read schema access, needed for forms - + // Should not be needed once forms are actually available, but + // until them is very handy to allow using the web client to + // submit records. + aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`; + aclFormulaParsed = JSON.stringify( + [ 'And', + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ], + [ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + + this._shareTableReferencesForForm(table, shareRef); + } + + /** + * Give read access to referenced columns. + */ + private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const tables = this.docData.getMetaTable('_grist_Tables'); + const columns = this.docData.getMetaTable('_grist_Tables_column'); + const tableColumns = columns.filterRecords({ + parentId: table.id, + }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); + for (const column of tableColumns) { + const visibleColRef = column.visibleCol; + // This could be blank in tests, not sure about real life. + if (!visibleColRef) { continue; } + const visibleCol = columns.getRecord(visibleColRef); + if (!visibleCol) { continue; } + const referencedTable = tables.getRecord(visibleCol.parentId); + if (!referencedTable) { continue; } + + const tableId = referencedTable.tableId; + const colId = visibleCol.colId; + const resourceColIds = this._resourceColIdsByTableAndColId.get(`${tableId}:${colId}`) ?? colId; + const maybeResourceId = this._resourcesTable.findMatchingRowId({tableId, colIds: resourceColIds}); + if (maybeResourceId !== 0) { + this._maybeSplitResourceForShares(maybeResourceId); + } + const resource = this._findOrAddResource({tableId, colIds: colId}); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + } + } + + /** + * Splits a resource into multiple resources that are suitable for being + * overridden by shares. Rules are copied to each resource, with modifications + * that disable them in shares. + * + * Ignores resources for single columns, and resources created for shares + * (i.e. those with a negative ID); the former can already be overridden + * by shares without any additional work, and the latter are guaranteed to + * only be for single columns. + * + * The motivation for this method is to normalize document access rules so + * that rule sets apply to at most a single column. Document shares may + * automatically grant limited access to parts of a document, such as columns + * that are referenced from a form field. But for this to happen, extra rules + * first need to be added to the original or new resource, which requires looking + * up the resource by column ID to see if it exists. This lookup only works if + * the rule set of the resource is for a single column; otherwise, the lookup + * will fail and cause a new resource to be created, which consequently causes + * 2 resources to exist that both contain the same column. Since this is an + * unsupported scenario with ambiguous evaluation semantics, we pre-emptively call + * this method to avoid such scenarios altogether. + */ + private _maybeSplitResourceForShares(resourceId: number) { + if (resourceId < 0) { return; } + + const resource = this.getResourceById(resourceId); + if (!resource) { + throw new Error(`Unable to find ACLResource with ID ${resourceId}`); + } + + const {tableId} = resource; + const colIds = resource.colIds.split(','); + if (colIds.length === 1) { return; } + + const rules = sortBy(this._rulesTable.filterRecords({resource: resourceId}), 'rulePos') + .map(r => disableRuleInShare(r)); + // Prepare a new resource for each column, with copies of the original resource's rules. + for (const colId of colIds) { + const newResourceId = this._resourcesTable.addRecord({id: 0, tableId, colIds: colId}); + for (const rule of rules) { + this._rulesTable.addRecord({...rule, id: 0, resource: newResourceId}); + } + } + // Exclude the original resource and rules. + this._resourcesTable.excludeRecord(resourceId); + for (const rule of rules) { + this._rulesTable.excludeRecord(rule.id); + } + } + + /** + * Find a resource we need, and return its rowId. The resource is + * added if it is not already present. + */ + private _findOrAddResource(properties: { + tableId: string, + colIds: string, + }): number { + const resource = this._resourcesTable.findMatchingRowId(properties); + if (resource !== 0) { return resource; } + return this._resourcesTable.addRecord({ + id: 0, + ...properties, + }); + } + + private _addShareRule(resourceRef: number, permissionsText: string) { + const aclFormula = 'user.ShareRef is not None'; + const aclFormulaParsed = JSON.stringify([ + 'NotEq', + ['Attr', ['Name', 'user'], 'ShareRef'], + ['Const', null], + ]); + this._rulesTable.addRecord(this._makeRule({ + resource: resourceRef, aclFormula, aclFormulaParsed, permissionsText, + })); + } + + private _blockShare(shareRef: number) { + const resource = this._findOrAddResource({ + tableId: '*', colIds: '*', + }); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', + })); + } + + private _makeRule(options: { + resource: number, + aclFormula: string, + aclFormulaParsed: string, + permissionsText: string, + }): MetaRowRecord<'_grist_ACLRules'> { + const {resource, aclFormula, aclFormulaParsed, permissionsText} = options; + return { + id: 0, + resource, + aclFormula, + aclFormulaParsed, + memo: '', + permissionsText, + userAttributes: '', + rulePos: 0, + + // The following fields are unused and deprecated. + aclColumn: 0, + permissions: 0, + principals: '', + }; + } +} + +/** + * Updates the ACL formula of `rule` such that it's disabled if a document is being + * accessed via a share. + * + * Modifies `rule` in place. + */ +function disableRuleInShare(rule: MetaRowRecord<'_grist_ACLRules'>) { + const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); + const newAclFormulaParsed = [ + 'And', + [ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ], + aclFormulaParsed || [ 'Const', true ] + ]; + rule.aclFormula = 'user.ShareRef is None and (' + String(rule.aclFormula || 'True') + ')'; + rule.aclFormulaParsed = JSON.stringify(newAclFormulaParsed); + return rule; +} diff --git a/app/common/ACLShareRules.ts b/app/common/ACLShareRules.ts deleted file mode 100644 index 24e3c2ea..00000000 --- a/app/common/ACLShareRules.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { DocData } from 'app/common/DocData'; -import { SchemaTypes } from 'app/common/schema'; -import { ShareOptions } from 'app/common/ShareOptions'; -import { MetaRowRecord, MetaTableData } from 'app/common/TableData'; -import { isEqual } from 'lodash'; - -/** - * For special shares, we need to refer to resources that may not - * be listed in the _grist_ACLResources table, and have rules that - * aren't backed by storage in _grist_ACLRules. So we implement - * a small helper to add an overlay of extra resources and rules. - * They are distinguishable from real, stored resources and rules - * by having negative IDs. - */ -export class TableWithOverlay { - private _extraRecords = new Array>(); - private _extraRecordsById = new Map>(); - private _nextFreeVirtualId: number = -1; - - public constructor(private _originalTable: MetaTableData) {} - - // Add a record to the table, but only as an overlay - no - // persistent changes are made. Uses negative row IDs. - // Returns the ID assigned to the record. The passed in - // record is expected to have an ID of zero. - public addRecord(rec: MetaRowRecord): number { - if (rec.id !== 0) { throw new Error('Expected a zero ID'); } - const id = this._nextFreeVirtualId; - const recWithCorrectId: MetaRowRecord = {...rec, id}; - this._extraRecords.push({...rec, id}); - this._extraRecordsById.set(id, recWithCorrectId); - this._nextFreeVirtualId--; - return id; - } - - // Support the few MetaTableData methods we actually use - // in ACLRuleCollection and ACLShareRules. - - public getRecord(resourceId: number) { - // Reroute negative IDs to our local stash of records. - if (resourceId < 0) { - return this._extraRecordsById.get(resourceId); - } - // Everything else, we just pass along. - return this._originalTable.getRecord(resourceId); - } - - public getRecords() { - return [...this._originalTable.getRecords(), ...this._extraRecords]; - } - - public findMatchingRowId(properties: Partial>): number { - // Check stored records. - const rowId = this._originalTable.findMatchingRowId(properties); - if (rowId) { return rowId; } - // Check overlay. - return this._extraRecords.find((rec) => - Object.keys(properties).every((p) => isEqual( - (rec as any)[p], - (properties as any)[p])))?.id || 0; - } -} - -/** - * Helper for managing special share rules. - */ -export class ACLShareRules { - - public constructor( - public docData: DocData, - public resourcesTable: TableWithOverlay<'_grist_ACLResources'>, - public rulesTable: TableWithOverlay<'_grist_ACLRules'>, - ) {} - - /** - * Add any rules needed for the specified share. - * - * The only kind of share we support for now is form endpoint - * sharing. - */ - public addRulesForShare(shareRef: number, shareOptions: ShareOptions) { - // TODO: Unpublished shares could and should be blocked earlier, - // by home server - if (!shareOptions.publish) { - this._blockShare(shareRef); - return; - } - - // Let's go looking for sections related to the share. - // It was decided that the relationship between sections and - // shares is via pages. Every section on a given page can belong - // to at most one share. - // Ignore sections which do not have `publish` set to `true` in - // `shareOptions`. - const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({ - shareRef, - }); - const parentViews = new Set(pages.map(page => page.viewRef)); - const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter( - section => { - if (!parentViews.has(section.parentId)) { return false; } - const options = JSON.parse(section.shareOptions || '{}'); - return Boolean(options.publish) && Boolean(options.form); - } - ); - - const tableRefs = new Set(sections.map(section => section.tableRef)); - const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( - table => tableRefs.has(table.id) - ); - - // For tables associated with forms, allow creation of records, - // and reading of referenced columns. - // TODO: should probably be limiting to a set of columns associated - // with section - but for form widget that could potentially be very - // confusing since it may not be easy to see that certain columns - // haven't been made visible for it? For now, just working at table - // level. - for (const table of tables) { - this._shareTableForForm(table, shareRef); - } - } - - /** - * When accessing a document via a share, by default no user tables are - * accessible. Everything added to the share gives additional - * access, and never reduces access, making it easy to grant - * access to multiple parts of the document. - * - * We do leave access unchanged for metadata tables, since they are - * censored via an alternative mechanism. - */ - public addDefaultRulesForShares() { - const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords() - .map(table => table.tableId) - .filter(tableId => !tableId.startsWith('_grist_')) - .sort(); - for (const tableId of tableIds) { - const resource = this._findOrAddResource({ - tableId, colIds: '*', - }); - const aclFormula = `user.ShareRef is not None`; - const aclFormulaParsed = JSON.stringify([ - 'NotEq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', null] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', - })); - } - } - - /** - * When accessing a document via a share, any regular granular access - * rules should not apply. This requires an extra conditional. - */ - public transformNonShareRules(state: { - rule: MetaRowRecord<'_grist_ACLRules'>, - aclFormulaParsed: object, - }) { - state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')'; - state.aclFormulaParsed = [ - 'And', - [ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ], - state.aclFormulaParsed || [ 'Const', true ] - ]; - state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed); - return state.aclFormulaParsed; - } - - /** - * Allow creating records in a table. - */ - private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { - const resource = this._findOrAddResource({ - tableId: table.tableId, - colIds: '*', - }); - let aclFormula = `user.ShareRef == ${shareRef}`; - let aclFormulaParsed = JSON.stringify([ - 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - [ 'Const', shareRef ] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+C', - })); - - // This is a hack to grant read schema access, needed for forms - - // Should not be needed once forms are actually available, but - // until them is very handy to allow using the web client to - // submit records. - aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`; - aclFormulaParsed = JSON.stringify( - [ 'And', - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ], - [ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+R', - })); - - this._shareTableReferencesForForm(table, shareRef); - } - - /** - * Give read access to referenced columns. - */ - private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { - const tables = this.docData.getMetaTable('_grist_Tables'); - const columns = this.docData.getMetaTable('_grist_Tables_column'); - const tableColumns = columns.filterRecords({ - parentId: table.id, - }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); - for (const column of tableColumns) { - const visibleColRef = column.visibleCol; - // This could be blank in tests, not sure about real life. - if (!visibleColRef) { continue; } - const visibleCol = columns.getRecord(visibleColRef); - if (!visibleCol) { continue; } - const referencedTable = tables.getRecord(visibleCol.parentId); - if (!referencedTable) { continue; } - - const tableId = referencedTable.tableId; - const colId = visibleCol.colId; - const resource = this._findOrAddResource({ - tableId: tableId, - colIds: colId, - }); - const aclFormula = `user.ShareRef == ${shareRef}`; - const aclFormulaParsed = JSON.stringify( - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+R', - })); - } - } - - /** - * Find a resource we need, and return its rowId. The resource is - * added if it is not already present. - */ - private _findOrAddResource(properties: { - tableId: string, - colIds: string, - }): number { - const resource = this.resourcesTable.findMatchingRowId(properties); - if (resource !== 0) { return resource; } - return this.resourcesTable.addRecord({ - id: 0, - ...properties, - }); - } - - private _blockShare(shareRef: number) { - const resource = this._findOrAddResource({ - tableId: '*', colIds: '*', - }); - const aclFormula = `user.ShareRef == ${shareRef}`; - const aclFormulaParsed = JSON.stringify( - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', - })); - } - - private _makeRule(options: { - resource: number, - aclFormula: string, - aclFormulaParsed: string, - permissionsText: string, - }): MetaRowRecord<'_grist_ACLRules'> { - const {resource, aclFormula, aclFormulaParsed, permissionsText} = options; - return { - id: 0, - resource, - aclFormula, - aclFormulaParsed, - memo: '', - permissionsText, - userAttributes: '', - rulePos: 0, - - // The following fields are unused and deprecated. - aclColumn: 0, - permissions: 0, - principals: '', - }; - } -} diff --git a/test/server/lib/ACLRulesReader.ts b/test/server/lib/ACLRulesReader.ts new file mode 100644 index 00000000..7d91a359 --- /dev/null +++ b/test/server/lib/ACLRulesReader.ts @@ -0,0 +1,443 @@ +import {ACLRulesReader} from 'app/common/ACLRulesReader'; +import {DocData} from 'app/common/DocData'; +import {MetaRowRecord} from 'app/common/TableData'; +import {CellValue} from 'app/plugin/GristData'; +import {ActiveDoc} from 'app/server/lib/ActiveDoc'; +import {makeExceptionalDocSession} from 'app/server/lib/DocSession'; +import {assert} from 'chai'; +import * as sinon from 'sinon'; +import {createDocTools} from 'test/server/docTools'; + +describe('ACLRulesReader', function() { + this.timeout(10000); + + const docTools = createDocTools({persistAcrossCases: true}); + const fakeSession = makeExceptionalDocSession('system'); + + let activeDoc: ActiveDoc; + let docData: DocData; + + before(async function () { + activeDoc = await docTools.createDoc('ACLRulesReader'); + docData = activeDoc.docData!; + }); + + describe('without shares', function() { + it('entries', async function() { + // Check output of reading the resources and rules of an empty document. + for (const options of [undefined, {addShareRules: true}]) { + assertResourcesAndRules(new ACLRulesReader(docData, options), [ + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + } + + // Add some table and default rules and re-check output. + await activeDoc.applyUserActions(fakeSession, [ + ['AddTable', 'Private', [{id: 'A'}]], + ['AddTable', 'PartialPrivate', [{id: 'A'}]], + ['AddRecord', 'PartialPrivate', null, { A: 0 }], + ['AddRecord', 'PartialPrivate', null, { A: 1 }], + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -3, {tableId: 'PartialPrivate', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, + aclFormula: 'user.Access == "owners"', + permissionsText: 'all', + memo: 'owner check', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: '', permissionsText: 'none', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-S', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -3, aclFormula: 'user.Access != "owners" and rec.A > 0', permissionsText: 'none', + }], + ['AddTable', 'Public', [{id: 'A'}]], + ]); + for (const options of [undefined, {addShareRules: true}]) { + assertResourcesAndRules(new ACLRulesReader(docData, options), [ + { + resource: {id: 2, tableId: 'Private', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access == "owners"', + permissionsText: 'all', + }, + { + aclFormula: '', + permissionsText: 'none', + }, + ], + }, + { + resource: {id: 3, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access != "owners"', + permissionsText: '-S', + }, + ], + }, + { + resource: {id: 4, tableId: 'PartialPrivate', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access != "owners" and rec.A > 0', + permissionsText: 'none', + }, + ], + }, + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + } + }); + + it('getResourceById', async function() { + for (const options of [undefined, {addShareRules: true}]) { + // Check output of valid resource ids. + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(2), + {id: 2, tableId: 'Private', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(3), + {id: 3, tableId: '*', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(4), + {id: 4, tableId: 'PartialPrivate', colIds: '*'} + ); + + // Check output of non-existent resource ids. + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(-1)); + } + }); + }); + + describe('with shares', function() { + before(async function() { + sinon.stub(ActiveDoc.prototype as any, '_getHomeDbManagerOrFail').returns({ + syncShares: () => Promise.resolve(), + }); + activeDoc = await docTools.loadFixtureDoc('FilmsWithImages.grist'); + docData = activeDoc.docData!; + await activeDoc.applyUserActions(fakeSession, [ + ['AddRecord', '_grist_Shares', null, { + linkId: 'x', + options: '{"publish": true}' + }], + ]); + }); + + after(function() { + sinon.restore(); + }); + + it('entries', async function() { + // Check output of reading the resources and rules of an empty document. + assertResourcesAndRules(new ACLRulesReader(docData), [ + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + + // Check output of reading the resources and rules of an empty document, with share rules. + assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [ + { + resource: {id: -1, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -2, tableId: 'Friends', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -3, tableId: 'Performances', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-S', + }, + ], + }, + { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: '', + }, + ], + }, + ]); + + // Add some default, table, and column rules. + await activeDoc.applyUserActions(fakeSession, [ + ['UpdateRecord', '_grist_Views_section', 7, + {shareOptions: '{"publish": true, "form": true}'}], + ['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}], + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Poster,PosterDup'}], + ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -3, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: 'user.access != OWNER', permissionsText: '-R', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -2, aclFormula: 'True', permissionsText: 'all', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -3, aclFormula: 'True', permissionsText: 'all', + }], + ]); + + // Re-check output without share rules. + assertResourcesAndRules(new ACLRulesReader(docData), [ + { + resource: {id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'}, + rules: [ + { + aclFormula: 'user.access != OWNER', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 3, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'True', + permissionsText: 'all', + }, + ], + }, + { + resource: {id: 4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'True', + permissionsText: 'all', + }, + ], + }, + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + + // Re-check output with share rules. + assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [ + { + resource: {id: -1, tableId: 'Friends', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef == 1', + permissionsText: '+C', + }, + { + aclFormula: 'user.ShareRef == 1 and rec.id == 0', + permissionsText: '+R', + }, + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + // Resource -2, -3, and -4, were split from resource 2. + { + resource: {id: -2, tableId: 'Films', colIds: 'Title'}, + rules: [ + { + aclFormula: 'user.ShareRef == 1', + permissionsText: '+R', + }, + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 3, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: 'all', + }, + ], + }, + { + resource: {id: -5, tableId: 'Performances', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: 4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-S', + }, + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: 'all', + }, + ], + }, + // Resource -3 and -4 were split from resource 2. + { + resource: {id: -3, tableId: 'Films', colIds: 'Poster'}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: -4, tableId: 'Films', colIds: 'PosterDup'}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: '', + }, + ], + }, + ]); + }); + + it('getResourceById', async function() { + // Check output of valid resource ids. + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(2), + {id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(3), + {id: 3, tableId: 'Films', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(4), + {id: 4, tableId: '*', colIds: '*'} + ); + + // Check output of non-existent resource ids. + assert.isUndefined(new ACLRulesReader(docData).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData).getResourceById(-1)); + + // Check output of valid resource ids (with share rules). + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(2)); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(3), + {id: 3, tableId: 'Films', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(4), + {id: 4, tableId: '*', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-1), + {id: -1, tableId: 'Friends', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-2), + {id: -2, tableId: 'Films', colIds: 'Title'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-3), + {id: -3, tableId: 'Films', colIds: 'Poster'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-4), + {id: -4, tableId: 'Films', colIds: 'PosterDup'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-5), + {id: -5, tableId: 'Performances', colIds: '*'} + ); + + // Check output of non-existent resource ids (with share rules). + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-6)); + }); + }); +}); + +interface ACLResourceAndRules { + resource: MetaRowRecord<'_grist_ACLResources'>|undefined; + rules: {aclFormula: CellValue, permissionsText: CellValue}[]; +} + +function assertResourcesAndRules( + aclRulesReader: ACLRulesReader, + expected: ACLResourceAndRules[] +) { + const actual: ACLResourceAndRules[] = [...aclRulesReader.entries()].map(([resourceId, rules]) => { + return { + resource: aclRulesReader.getResourceById(resourceId), + rules: rules.map(({aclFormula, permissionsText}) => ({aclFormula, permissionsText})), + }; + }); + assert.deepEqual(actual, expected); +} + +/** + * An unused resource and rule that's automatically included in every Grist document. + * + * See comment in `UserActions.InitNewDoc` (from `useractions.py`) for context. + */ +const DEFAULT_UNUSED_RESOURCE_AND_RULE: ACLResourceAndRules = { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [{aclFormula: '', permissionsText: ''}], +}; From 6f3cd1ec2f98075a2d3b1431273c0947d627211f Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Thu, 11 Apr 2024 11:24:38 -0700 Subject: [PATCH 10/45] (core) Remove forms popup Summary: The forms announcement popup should no longer be shown. Test Plan: N/A Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4229 --- app/client/models/DocPageModel.ts | 5 ----- app/client/ui/GristTooltips.ts | 27 --------------------------- app/common/Prefs.ts | 4 +++- test/nbrowser/BehavioralPrompts.ts | 5 ----- 4 files changed, 3 insertions(+), 38 deletions(-) diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 8034bd57..2fb0a91f 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -221,11 +221,6 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` }), - activeDoc.behavioralPromptsManager.attachPopup('formsAreHere', { - popupOptions: { - placement: 'right', - }, - }), testId('dp-add-new'), dom.cls('tour-add-new'), ), diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 3421ffb0..8f26142d 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -1,7 +1,6 @@ import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; -import {basicButtonLink} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; @@ -29,17 +28,6 @@ const cssIcon = styled(icon, ` width: 18px; `); -const cssNewsPopupLearnMoreButton = styled(basicButtonLink, ` - color: white; - border: 1px solid white; - padding: 3px; - - &:hover, &:focus, &:visited { - color: white; - border-color: white; - } -`); - export type Tooltip = | 'dataSize' | 'setTriggerFormula' @@ -321,19 +309,4 @@ data.")), ), deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, - formsAreHere: { - popupType: 'news', - audience: 'signed-in-users', - title: () => t('Forms are here!'), - content: (...args: DomElementArg[]) => cssTooltipContent( - dom('div', t('Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}', { - learnMoreButton: cssNewsPopupLearnMoreButton(t('Learn more'), { - href: commonUrls.forms, - target: '_blank', - }), - })), - ...args, - ), - deploymentTypes: ['saas', 'core', 'enterprise'], - }, }; diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index ab1f1da2..3c6c3786 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -88,7 +88,9 @@ export const BehavioralPrompt = StringUnion( 'rickRow', 'customURL', 'calendarConfig', - 'formsAreHere', + + // The following were used in the past and should not be re-used. + // 'formsAreHere', ); export type BehavioralPrompt = typeof BehavioralPrompt.type; diff --git a/test/nbrowser/BehavioralPrompts.ts b/test/nbrowser/BehavioralPrompts.ts index d340a776..b8bda15a 100644 --- a/test/nbrowser/BehavioralPrompts.ts +++ b/test/nbrowser/BehavioralPrompts.ts @@ -39,11 +39,6 @@ describe('BehavioralPrompts', function() { }); }); - it('should show an announcement for forms', async function() { - await assertPromptTitle('Forms are here!'); - await gu.dismissBehavioralPrompts(); - }); - describe('when anonymous', function() { before(async () => { const anonymousSession = await gu.session().anon.login({ From 192e2f36ba77ec67069c58035d35205978b9215e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:56:04 -0400 Subject: [PATCH 11/45] automated update to translation keys (#936) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 41 +++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index e3570a0d..d20814e8 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -941,7 +941,11 @@ }, "NTextBox": { "false": "false", - "true": "true" + "true": "true", + "Field Format": "Field Format", + "Lines": "Lines", + "Multi line": "Multi line", + "Single line": "Single line" }, "ACLUsers": { "Example Users": "Example Users", @@ -1044,7 +1048,12 @@ "Currency": "Currency", "Decimals": "Decimals", "Default currency ({{defaultCurrency}})": "Default currency ({{defaultCurrency}})", - "Number Format": "Number Format" + "Number Format": "Number Format", + "Field Format": "Field Format", + "Spinner": "Spinner", + "Text": "Text", + "max": "max", + "min": "min" }, "Reference": { "CELL FORMAT": "CELL FORMAT", @@ -1393,7 +1402,18 @@ }, "FormConfig": { "Field rules": "Field rules", - "Required field": "Required field" + "Required field": "Required field", + "Ascending": "Ascending", + "Default": "Default", + "Descending": "Descending", + "Field Format": "Field Format", + "Field Rules": "Field Rules", + "Horizontal": "Horizontal", + "Options Alignment": "Options Alignment", + "Options Sort Order": "Options Sort Order", + "Radio": "Radio", + "Select": "Select", + "Vertical": "Vertical" }, "CustomView": { "Some required columns aren't mapped": "Some required columns aren't mapped", @@ -1417,7 +1437,8 @@ }, "FormSuccessPage": { "Form Submitted": "Form Submitted", - "Thank you! Your response has been recorded.": "Thank you! Your response has been recorded." + "Thank you! Your response has been recorded.": "Thank you! Your response has been recorded.", + "Submit new response": "Submit new response" }, "DateRangeOptions": { "Last 30 days": "Last 30 days", @@ -1466,5 +1487,17 @@ "Support Grist Labs on GitHub": "Support Grist Labs on GitHub", "Telemetry": "Telemetry", "Version": "Version" + }, + "Columns": { + "Remove Column": "Remove Column" + }, + "Field": { + "No choices configured": "No choices configured", + "No values in show column of referenced table": "No values in show column of referenced table" + }, + "Toggle": { + "Checkbox": "Checkbox", + "Field Format": "Field Format", + "Switch": "Switch" } } From 5753d07980f4670ec4d25dc60bf84ff9ae76e0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 15 Apr 2024 13:39:09 -0400 Subject: [PATCH 12/45] v1.1.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 371f14a8..417abef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grist-core", - "version": "1.1.12", + "version": "1.1.13", "license": "Apache-2.0", "description": "Grist is the evolution of spreadsheets", "homepage": "https://github.com/gristlabs/grist-core", From d4a7660a21c0f3b994cd82274dc471ab1a73c949 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Mon, 15 Apr 2024 11:03:53 -0400 Subject: [PATCH 13/45] (core) update grist-saas after team site and redis changes Summary: Stop maintaining a copy of redis stubs. Bring back product stub for grist-ee. Test Plan: existing tests should pass Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4232 --- test/fixtures/docs/Hello.grist | Bin 62464 -> 62464 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index 6db956dc00545afb18909d98527711755944bedd..d1a809824ebd48c269e195ddfdc50fce41f3bb09 100644 GIT binary patch delta 111 zcmZp8!QAkId4jZ{Bm)D3EfB+i!bBZoM#+r{wc?Cgo9~HhOyKfjVBF2%#njE5JaM8G zS5uw>ySTPCW2^Ay+pAO Date: Mon, 15 Apr 2024 08:09:21 +0000 Subject: [PATCH 14/45] Translated using Weblate (French) Currently translated at 98.7% (1167 of 1182 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/ --- static/locales/fr.client.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 1c37d97b..163ab625 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -1168,7 +1168,21 @@ }, "WebhookPage": { "Clear Queue": "Effacer la file d'attente", - "Webhook Settings": "Paramètres des points d’ancrage Web" + "Webhook Settings": "Paramètres des points d’ancrage Web", + "Cleared webhook queue.": "Effacement de la file d'attente du point d'ancrage Web.", + "Columns to check when update (separated by ;)": "Colonnes à vérifier lors de la mise à jour (séparées par des ;)", + "Event Types": "Types d'événements", + "Memo": "Mémo", + "Ready Column": "Colonne de déclenchement", + "Removed webhook.": "Suppression du webhook.", + "Filter for changes in these columns (semicolon-separated ids)": "Filtrer les changements dans ces colonnes (identifiants séparés par des points-virgules)", + "Status": "Statut", + "URL": "URL", + "Webhook Id": "Id du point d'ancrage Web", + "Table": "Table", + "Enabled": "Activé", + "Name": "Nom", + "Sorry, not all fields can be edited.": "Désolé, tous les champs ne peuvent pas être modifiés." }, "FormulaAssistant": { "Grist's AI Formula Assistance. ": "Assistance des formules de l'IA de Grist ", From a0b5ba90812f9170bfa8b3e732f3720f71fe29d5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 18 Apr 2024 11:41:30 -0400 Subject: [PATCH 15/45] Move CONTRIBUTING.md to the repository's root to be more visible. (#940) --- .github/CONTRIBUTING.md => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/CONTRIBUTING.md => CONTRIBUTING.md (100%) diff --git a/.github/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to CONTRIBUTING.md From 33c34afd3a9fcc244527ed1c1e39e0aa7b8a3298 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Thu, 18 Apr 2024 07:36:17 +0000 Subject: [PATCH 16/45] Translated using Weblate (Spanish) Currently translated at 100.0% (1223 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 83 ++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 38da408c..3c074613 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -69,7 +69,7 @@ "Sign Out": "Cerrar Sesión", "Sign in": "Iniciar Sesión", "Switch Accounts": "Cambiar de Cuenta", - "Toggle Mobile Mode": "Alternar Modo Móvil", + "Toggle Mobile Mode": "Alternar con el modo móvil", "Activation": "Activación", "Billing Account": "Cuenta de facturación", "Support Grist": "Soporte Grist", @@ -429,7 +429,8 @@ "Welcome to {{- orgName}}": "Bienvenido a {{- orgName}}", "Visit our {{link}} to learn more about Grist.": "Visita nuestra {{link}} para obtener más información sobre Grist.", "Sign in": "Iniciar sesión", - "To use Grist, please either sign up or sign in.": "Para utilizar Grist, regístrate o inicia sesión." + "To use Grist, please either sign up or sign in.": "Para utilizar Grist, regístrate o inicia sesión.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Obtenga más información en nuestro {{helpCenterLink}}, o busque un experto a través de nuestro {{sproutsProgram}}." }, "HomeLeftPane": { "Access Details": "Detalles de Acceso", @@ -482,7 +483,11 @@ }, "NTextBox": { "false": "falso", - "true": "verdadero" + "true": "verdadero", + "Field Format": "Formato del campo", + "Lines": "Líneas", + "Multi line": "Línea múltiple", + "Single line": "Una sola línea" }, "NotifyUI": { "Ask for help": "Solicitar ayuda", @@ -965,7 +970,7 @@ "Reference List": "Lista de referencia", "Attachment": "Adjunto", "Any": "Cualquiera", - "Toggle": "Cambiar", + "Toggle": "Alternar", "Search columns": "Búsqueda por columnas" }, "modals": { @@ -1079,7 +1084,7 @@ "Flying higher": "Volando más alto", "Reference": "Referencia", "Start with {{equal}} to enter a formula.": "Comience con {{equal}} para introducir una fórmula.", - "Toggle the {{creatorPanel}} to format columns, ": "Active {{creatorPanel}} para dar formato a las columnas, ", + "Toggle the {{creatorPanel}} to format columns, ": "Alternar el {{creatorPanel}} para dar formato a las columnas, ", "Welcome to Grist!": "¡Bienvenido a Grist!", "template library": "biblioteca de plantillas", "creator panel": "panel creador", @@ -1105,7 +1110,12 @@ "Currency": "Moneda", "Default currency ({{defaultCurrency}})": "Moneda predeterminada ({{defaultCurrency}})", "Number Format": "Formato de número", - "Decimals": "Decimales" + "Decimals": "Decimales", + "Field Format": "Formato del campo", + "Spinner": "Spinner", + "Text": "Texto", + "min": "mín.", + "max": "Máx." }, "ChoiceTextBox": { "CHOICES": "OPCIONES" @@ -1223,7 +1233,21 @@ }, "WebhookPage": { "Clear Queue": "Borrar la cola", - "Webhook Settings": "Ajustes del gancho web" + "Webhook Settings": "Ajustes del gancho web", + "Cleared webhook queue.": "Cola de webhooks vacía.", + "Columns to check when update (separated by ;)": "Columnas a comprobar al actualizar (separadas por ;)", + "Removed webhook.": "Webhook eliminado.", + "Sorry, not all fields can be edited.": "Lo sentimos, no se pueden editar todos los campos.", + "Status": "Situación", + "URL": "URL", + "Webhook Id": "Id. del webhook", + "Ready Column": "Columna lista", + "Filter for changes in these columns (semicolon-separated ids)": "Filtrar los cambios en estas columnas (identificadores separados por punto y coma)", + "Table": "Tabla", + "Enabled": "Activado", + "Event Types": "Tipos de eventos", + "Memo": "Memorándum", + "Name": "Nombre" }, "FormulaAssistant": { "Regenerate": "Regenerar", @@ -1279,7 +1303,8 @@ "Support Grist page": "Página de soporte de Grist", "Close": "Cerrar", "Contribute": "Contribuir", - "Admin Panel": "Panel de control" + "Admin Panel": "Panel de control", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "¡Muchas gracias!. Su confianza y apoyo son muy apreciados. Puedes darte de baja en cualquier momento desde el {{link}} del menú de usuario." }, "SupportGristPage": { "GitHub": "GitHub", @@ -1431,7 +1456,18 @@ }, "FormConfig": { "Field rules": "Reglas del campo", - "Required field": "Campo obligatorio" + "Required field": "Campo obligatorio", + "Ascending": "Ascendente", + "Default": "Por defecto", + "Descending": "Descendente", + "Field Format": "Formato del campo", + "Field Rules": "Reglas del campo", + "Horizontal": "Horizontal", + "Options Alignment": "Alineación de las opciones", + "Options Sort Order": "Orden de clasificación de las opciones", + "Radio": "Radio", + "Select": "Seleccionar", + "Vertical": "Vertical" }, "CustomView": { "Some required columns aren't mapped": "Algunas columnas obligatorias no están asignadas", @@ -1455,7 +1491,8 @@ }, "FormSuccessPage": { "Form Submitted": "Formulario enviado", - "Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada." + "Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada.", + "Submit new response": "Enviar una nueva respuesta" }, "DateRangeOptions": { "Last 30 days": "Últimos 30 días", @@ -1490,5 +1527,31 @@ "Current version of Grist": "Versión actual de Grist", "Admin Panel": "Panel de control", "Support Grist Labs on GitHub": "Apoya a Grist Labs en GitHub" + }, + "CreateTeamModal": { + "Cancel": "Cancelar", + "Choose a name and url for your team site": "Elija un nombre y una url para la página web de su equipo", + "Create site": "Crear página web", + "Domain name is invalid": "El dominio no es válido", + "Domain name is required": "Nombre del dominio obligatorio", + "Go to your site": "Ir a su página web", + "Team name": "Nombre del equipo", + "Team name is required": "Nombre del equipo obligatorio", + "Team site created": "Creada una página web para el equipo", + "Team url": "URL del equipo", + "Work as a Team": "Trabajar en equipo", + "Billing is not supported in grist-core": "La facturación no es compatible con grist-core" + }, + "Columns": { + "Remove Column": "Quitar columna" + }, + "Field": { + "No choices configured": "Sin opciones configuradas", + "No values in show column of referenced table": "No hay valores en la columna Mostrar de la tabla referenciada" + }, + "Toggle": { + "Checkbox": "Casilla de verificación", + "Field Format": "Formato del campo", + "Switch": "Cambiar" } } From 8087a46866831e372dcd7be4e8d35242cda507f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Thu, 18 Apr 2024 19:29:48 +0000 Subject: [PATCH 17/45] Translated using Weblate (Russian) Currently translated at 96.4% (1180 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index d46da98e..777ae14d 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -507,7 +507,8 @@ "Welcome to {{- orgName}}": "Добро пожаловать в {{- orgName}}", "Visit our {{link}} to learn more about Grist.": "Посетите наш {{link}} чтобы узнать больше о Grist.", "Sign in": "Вход", - "To use Grist, please either sign up or sign in.": "Для использования Grist, зарегистрируйтесь или войдите в систему." + "To use Grist, please either sign up or sign in.": "Для использования Grist, зарегистрируйтесь или войдите в систему.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Узнайте больше в {{helpCenterLink}}, или найдите специалиста с {{sproutsProgram}}." }, "HomeLeftPane": { "Import Document": "Импорт документа", @@ -900,7 +901,12 @@ "Number Format": "Числовой формат", "Currency": "Валюта", "Default currency ({{defaultCurrency}})": "Валюта по умолчанию ({{defaultCurrency}})", - "Decimals": "Десятичные" + "Decimals": "Десятичные", + "Text": "Текст", + "max": "максимум", + "min": "минимум", + "Spinner": "Спиннер", + "Field Format": "Формат поля" }, "WelcomeTour": { "convert to card view, select data, and more.": "преобразовать в представление карточки, выбрать данные, и более.", @@ -1017,7 +1023,11 @@ }, "NTextBox": { "false": "ложь", - "true": "истина" + "true": "истина", + "Field Format": "Формат поля", + "Lines": "Линейность", + "Multi line": "Многострочный", + "Single line": "Однострочный" }, "pages": { "Duplicate Page": "Дублировать страницу", @@ -1169,7 +1179,14 @@ }, "WebhookPage": { "Clear Queue": "Очистить очередь", - "Webhook Settings": "Настройки вебхука" + "Webhook Settings": "Настройки вебхука", + "Cleared webhook queue.": "Очистка очереди вебхуков.", + "Enabled": "Активировано", + "Columns to check when update (separated by ;)": "Столбцы для проверки при обновлении (разделитель ;)", + "Event Types": "Типы событий", + "Ready Column": "Триггерный столбец", + "Memo": "Заметка", + "Name": "Название" }, "FormulaAssistant": { "Ask the bot.": "Спроси у бота.", From f2c5684ab1ff7b5287f38028ad50426fdfafce54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Fri, 19 Apr 2024 19:42:55 +0000 Subject: [PATCH 18/45] Translated using Weblate (Russian) Currently translated at 99.5% (1218 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 54 ++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index 777ae14d..563eae00 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -1186,7 +1186,14 @@ "Event Types": "Типы событий", "Ready Column": "Триггерный столбец", "Memo": "Заметка", - "Name": "Название" + "Name": "Название", + "Filter for changes in these columns (semicolon-separated ids)": "Фильтрация изменений в этих столбцах (идентификаторы, разделенные точкой с запятой)", + "Removed webhook.": "Удаленный вебхук.", + "Sorry, not all fields can be edited.": "К сожалению, не все поля можно редактировать.", + "Status": "Статус", + "URL": "URL", + "Webhook Id": "Вебхук Id", + "Table": "Таблица" }, "FormulaAssistant": { "Ask the bot.": "Спроси у бота.", @@ -1287,7 +1294,8 @@ "Support Grist page": "Страница поддержки Grist", "Contribute": "Участвовать", "Opted In": "Подключено", - "Admin Panel": "Панель администратора" + "Admin Panel": "Панель администратора", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Спасибо! Ваше доверие и поддержка очень ценны. Откажитесь в любой момент с помощью {{link}} в меню пользователя." }, "SupportGristPage": { "GitHub": "GitHub", @@ -1394,7 +1402,18 @@ }, "FormConfig": { "Field rules": "Правила полей", - "Required field": "Обязательное поле" + "Required field": "Обязательное поле", + "Ascending": "Восходящий", + "Default": "По умолчанию", + "Descending": "Нисходящий", + "Field Format": "Формат поля", + "Field Rules": "Правила поля", + "Horizontal": "Горизонтальный", + "Options Alignment": "Выравнивание параметров", + "Options Sort Order": "Параметры Порядка Сортировки", + "Radio": "Радио", + "Select": "Выбрать", + "Vertical": "Вертикальный" }, "CustomView": { "Some required columns aren't mapped": "Некоторые обязательные столбцы не сопоставлены", @@ -1418,7 +1437,8 @@ }, "FormSuccessPage": { "Form Submitted": "Форма отправлена", - "Thank you! Your response has been recorded.": "Спасибо! Ваш ответ учтен." + "Thank you! Your response has been recorded.": "Спасибо! Ваш ответ учтен.", + "Submit new response": "Отправить новый ответ" }, "DateRangeOptions": { "Today": "Сегодня", @@ -1453,5 +1473,31 @@ "Version": "Версия", "Admin Panel": "Панель администратора", "Telemetry": "Телеметрия" + }, + "CreateTeamModal": { + "Billing is not supported in grist-core": "Выставление счетов в grist-core не поддерживается", + "Cancel": "Отменить", + "Choose a name and url for your team site": "Выберите название и URL-адрес для сайта вашей команды", + "Create site": "Создать сайт", + "Domain name is invalid": "Доменное имя недействительно", + "Domain name is required": "Требуется доменное имя", + "Go to your site": "Перейдите на ваш сайт", + "Team name": "Название команды", + "Team name is required": "Требуется указать название команды", + "Team site created": "Создан сайт команды", + "Team url": "URL-адрес команды", + "Work as a Team": "Работайте в команде" + }, + "Field": { + "No choices configured": "Опции отсутствуют", + "No values in show column of referenced table": "No values in show column of referenced table" + }, + "Columns": { + "Remove Column": "Удалить столбец" + }, + "Toggle": { + "Checkbox": "Флажок", + "Field Format": "Формат поля", + "Switch": "Тумблер" } } From e0c3934ad434ed2acea6873d625bbfdbe70739ed Mon Sep 17 00:00:00 2001 From: Riccardo Polignieri Date: Fri, 19 Apr 2024 16:48:02 +0000 Subject: [PATCH 19/45] Translated using Weblate (Italian) Currently translated at 100.0% (1223 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/it/ --- static/locales/it.client.json | 192 +++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 14 deletions(-) diff --git a/static/locales/it.client.json b/static/locales/it.client.json index bc75e100..3af9cb55 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -24,7 +24,8 @@ "Welcome to {{- orgName}}": "Benvenuto, {{- orgName}}", "Visit our {{link}} to learn more about Grist.": "Vai a {{link}} per saperne di più su Grist.", "Sign in": "Accedi", - "To use Grist, please either sign up or sign in.": "Per usare Grist, iscriviti o accedi." + "To use Grist, please either sign up or sign in.": "Per usare Grist, iscriviti o accedi.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Approfondisci nel nostro {{helpCenterLink}}, o trova un esperto con il nostro {{sproutsProgram}}." }, "HomeLeftPane": { "Manage Users": "Gestisci gli utenti", @@ -68,7 +69,9 @@ "You do not have write access to this site": "Non hai accesso in scrittura a questo sito", "Remove all data but keep the structure to use as a template": "Rimuovi tutti i dati, mantieni la struttura per usarla come template", "Remove document history (can significantly reduce file size)": "Rimuovi la storia del documento (può ridurre molto le dimensioni del file)", - "Download full document and history": "Scarica tutto il documento e la storia" + "Download full document and history": "Scarica tutto il documento e la storia", + "Download": "Scarica", + "Download document": "Scarica documento" }, "Importer": { "Update existing records": "Aggiorna i record esistenti", @@ -163,7 +166,9 @@ "Enter redirect URL": "Inserisci URL di re-indirizzamento", "Configuration": "Configurazione", "Default field value": "Valore di default del campo", - "Field rules": "Regole per il campo" + "Field rules": "Regole per il campo", + "Select a field in the form widget to configure.": "Seleziona un campo nel widget del modulo per configurarlo.", + "No field selected": "Nessun campo selezionato" }, "RowContextMenu": { "Copy anchor link": "Copia link", @@ -173,7 +178,8 @@ "Insert row": "Inserisci riga", "Insert row above": "Inserisci riga sopra", "Insert row below": "Inserisci riga sotto", - "View as card": "Vedi come scheda" + "View as card": "Vedi come scheda", + "Use as table headers": "Usa come intestazioni di tabella" }, "SortFilterConfig": { "Save": "Salva", @@ -360,7 +366,12 @@ "Decimals": "Decimali", "Default currency ({{defaultCurrency}})": "Valuta di default ({{defaultCurrency}})", "Currency": "Valuta", - "Number Format": "Formato numerico" + "Number Format": "Formato numerico", + "Field Format": "Formato del campo", + "Text": "Testo", + "min": "min", + "Spinner": "Spinner", + "max": "max" }, "HyperLinkEditor": { "[link label] url": "[testo link] URL" @@ -507,7 +518,8 @@ "Seed rules": "Regole pre-inserite", "When adding table rules, automatically add a rule to grant OWNER full access.": "Quando si mettono regole per la tabella, inserire sempre una che dà pieno accesso a OWNER.", "This default should be changed if editors' access is to be limited. ": "Questo valore di default può essere cambiato se si vuole limitare l'accesso agli editor. ", - "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permette agli editor di modificare la struttura (es., modificare ed eliminare tabelle, colonne, viste) e scrivere formule, cosa che permette l'accesso ai dati indipendentemente dalle restrizioni in lettura." + "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permette agli editor di modificare la struttura (es., modificare ed eliminare tabelle, colonne, viste) e scrivere formule, cosa che permette l'accesso ai dati indipendentemente dalle restrizioni in lettura.", + "Add Table-wide Rule": "Aggiungi regola per tutta la tabella" }, "ACUserManager": { "Enter email address": "Inserisci indirizzo e-mail", @@ -612,7 +624,8 @@ "Snapshots": "Snapshot", "Snapshots are unavailable.": "Le snapshot non sono disponibili.", "Activity": "Attvità", - "Beta": "Beta" + "Beta": "Beta", + "Only owners have access to snapshots for documents with access rules.": "Solo i proprietari hanno accesso agli snapshot per i documenti con regole di accesso." }, "DocMenu": { "(The organization needs a paid plan)": "(L'organizzazione deve avere un piano a pagamento)", @@ -900,7 +913,12 @@ "Unsaved": "Non salvato", "Work on a Copy": "Lavora su una copia", "Share": "Condividi", - "Download...": "Scarica..." + "Download...": "Scarica...", + "DOO Separated Values (.dsv)": "Valori separati da delimitatore (.dsv)", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Tab Separated Values (.tsv)": "Valori separati da tabulazione (.tsv)", + "Comma Separated Values (.csv)": "Valori separati da virgola (.csv)", + "Export as...": "Esporta come..." }, "SiteSwitcher": { "Create new team site": "Crea un nuovo sito per il team", @@ -1043,7 +1061,11 @@ }, "NTextBox": { "false": "falso", - "true": "vero" + "true": "vero", + "Field Format": "Formato del campo", + "Lines": "Righe", + "Multi line": "Multiriga", + "Single line": "Riga singola" }, "ACLUsers": { "Example Users": "Utenti di esempio", @@ -1119,7 +1141,11 @@ "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Non trovi la colonna giusta? Fai clic su \"Cambia widget\" per selezionare la tabella con i dati degli eventi.", "Use reference columns to relate data in different tables.": "Usa colonne di riferimenti per collegare dati da altre tabelle.", "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Le formule supportano molte funzioni di Excel, la sintassi completa di Python, e includono un utile assistente AI.", - "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Puoi scegliere tra i widget disponibili nella lista, o incorporare il tuo fornendo la sua URL completa." + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Puoi scegliere tra i widget disponibili nella lista, o incorporare il tuo fornendo la sua URL completa.", + "These rules are applied after all column rules have been processed, if applicable.": "Queste regole sono applicate dopo che tutte le regole delle colonne sono state applicate, se possibile.", + "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Costruisci del semplici moduli e condividili rapidamente con il nostro nuovo widget. {{learnMoreButton}}", + "Forms are here!": "Sono arrivati i moduli!", + "Learn more": "Approfondisci" }, "DescriptionConfig": { "DESCRIPTION": "DESCRIZIONE" @@ -1149,7 +1175,21 @@ }, "WebhookPage": { "Clear Queue": "Pulisci la coda", - "Webhook Settings": "Impostazioni web hook" + "Webhook Settings": "Impostazioni web hook", + "Ready Column": "Colonna Ready", + "Removed webhook.": "Webhook rimosso.", + "Cleared webhook queue.": "Svuotata la coda dei webhook.", + "Enabled": "Abilitato", + "Columns to check when update (separated by ;)": "Colonne da controllare quando si aggiorna (separate da ;)", + "Event Types": "Tipi di evento", + "Memo": "Memo", + "Name": "Nome", + "Webhook Id": "Id Webhook", + "Table": "Tabella", + "Sorry, not all fields can be edited.": "Spiacente, non tutti i campi possono essere modificati.", + "Status": "Status", + "URL": "URL", + "Filter for changes in these columns (semicolon-separated ids)": "FIltrare i cambiamenti in queste colonne (id separati da ;)" }, "Clipboard": { "Unavailable Command": "Comando non disponibile", @@ -1208,7 +1248,9 @@ "Help Center": "Centro Aiuto", "Opt in to Telemetry": "Accetta la telemetria", "Opted In": "Accettato", - "Support Grist": "Sostieni Grist" + "Support Grist": "Sostieni Grist", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Grazie! Apprezziamo molto la tua fiducia e il tuo supporto. Puoi disdire in qualsiasi momento con {{link}} nel menu utente.", + "Admin Panel": "Pannello di amministrazione" }, "SupportGristPage": { "GitHub": "GitHub", @@ -1226,7 +1268,8 @@ "You have opted out of telemetry.": "Hai disattivato la telemetria.", "GitHub Sponsors page": "Pagina Sponsor GitHub", "Opt in to Telemetry": "Accetta la telemetria", - "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Raccogliamo solo statistiche di utilizzo, mai contenuti dei documenti, come spiegato in {{link}}." + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Raccogliamo solo statistiche di utilizzo, mai contenuti dei documenti, come spiegato in {{link}}.", + "Sponsor": "Sponsor" }, "buildViewSectionDom": { "No row selected in {{title}}": "Nessuna riga selezionata in {{title}}", @@ -1323,7 +1366,21 @@ "Unpublish": "Non pubblicare", "Publish": "Pubblica", "Publish your form?": "Pubblicare il modulo?", - "Unpublish your form?": "Ritirare la pubblicazione del modulo?" + "Unpublish your form?": "Ritirare la pubblicazione del modulo?", + "Anyone with the link below can see the empty form and submit a response.": "Tutti quelli che hanno il link sottostante possono vedere il modulo vuoto e inviare una risposta.", + "Preview": "Anteprima", + "Embed this form": "Incorpora questo modulo", + "Copy code": "Copia codice", + "Copy link": "Copia link", + "Reset form": "Resetta il modulo", + "Reset": "Reset", + "Save your document to publish this form.": "Salva il tuo documento per pubblicare questo modulo.", + "Share": "Condividi", + "Share this form": "Condividi questo modulo", + "Are you sure you want to reset your form?": "Sei sicuro di voler resettare il tuo modulo?", + "Code copied to clipboard": "Codice copiato negli Appunti", + "Link copied to clipboard": "Link copiato negli Appunti", + "View": "Vedi" }, "UnmappedFieldsConfig": { "Clear": "Pulisci", @@ -1335,5 +1392,112 @@ }, "Editor": { "Delete": "Elimina" + }, + "FormContainer": { + "Build your own form": "Costruisci il tuo modulo", + "Powered by": "Fatto con" + }, + "FormModel": { + "There was a problem loading the form.": "C'è stato un problema nel caricamento del modulo.", + "Oops! The form you're looking for doesn't exist.": "Ops! Il modulo che cerchi non esiste.", + "You don't have access to this form.": "Non hai accesso a questo modulo.", + "Oops! This form is no longer published.": "Ops! Questo modulo non è più pubblico." + }, + "FormPage": { + "There was an error submitting your form. Please try again.": "C'è stato un errore nell'invio del modulo. Per favore prova di nuovo." + }, + "AdminPanel": { + "Admin Panel": "Pannello di amministrazione", + "Current version of Grist": "Versione attuale di Grist", + "Home": "Inizio", + "Help us make Grist better": "Aiutaci a migliorare Grist", + "Support Grist": "Supporta Grist", + "Support Grist Labs on GitHub": "Supporta Grist Labs su GitHub", + "Sponsor": "Sponsor", + "Current": "Attuale", + "Telemetry": "Telemetria", + "Version": "Versione" + }, + "WelcomeCoachingCall": { + "Maybe Later": "Forse più tardi", + "free coaching call": "chiamata di assistenza gratuita", + "On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Durante la chiamata, ci prendiamo del tempo per capire i tuoi bisogni e adattarci a quelli. Possiamo spiegarti i fondamenti di Grist, o iniziare a lavorare subito con i tuoi dati e creare le dashboard che ti servono.", + "Schedule Call": "Prenota una chiamata", + "Schedule your {{freeCoachingCall}} with a member of our team.": "Prenota la tua {{freeCoachingCall}} con un membro del nostro team." + }, + "FormConfig": { + "Field rules": "Regole del campo", + "Ascending": "Ascendente", + "Field Format": "Formato campo", + "Default": "Default", + "Field Rules": "Regole campo", + "Descending": "Discendente", + "Horizontal": "Orizzontale", + "Options Sort Order": "Opzioni ordinamento", + "Select": "Seleziona", + "Options Alignment": "Opzioni allineamento", + "Radio": "Radio", + "Vertical": "Verticale", + "Required field": "Campo obbligatorio" + }, + "CustomView": { + "Some required columns aren't mapped": "Alcune colonne necessarie non sono mappate", + "To use this widget, please map all non-optional columns from the creator panel on the right.": "Per usare questo widget, mappare tutte le colonne non opzionali dal pannello di creazione a destra." + }, + "CreateTeamModal": { + "Domain name is required": "Il nome di dominio è obbligatorio", + "Go to your site": "Vai al tuo sito", + "Team name": "Nome del team", + "Team name is required": "Il nome del team è obbligatorio", + "Team site created": "Il sito del team è stato creato", + "Team url": "Url del team", + "Work as a Team": "Lavora come un team", + "Cancel": "Annulla", + "Choose a name and url for your team site": "Scegli un nome e una url per il sito del tuo team", + "Create site": "Crea un sito", + "Domain name is invalid": "Il nome di dominio non è valido", + "Billing is not supported in grist-core": "La fatturazione non è supportata in grist-core" + }, + "Toggle": { + "Checkbox": "Checkbox", + "Switch": "Cambia", + "Field Format": "Formato campo" + }, + "FormSuccessPage": { + "Thank you! Your response has been recorded.": "Grazie! La tua risposta è stata registrata.", + "Form Submitted": "Modulo inviato", + "Submit new response": "Invia una nuova risposta" + }, + "DateRangeOptions": { + "Next 7 days": "Prossimi 7 giorni", + "This month": "Questo mese", + "This week": "Questa settimana", + "This year": "Quest'anno", + "Today": "Oggi", + "Last 30 days": "Ultimi 30 giorni", + "Last 7 days": "Ultimi 7 giorni", + "Last Week": "Ultima settimana" + }, + "MappedFieldsConfig": { + "Clear": "Svuota", + "Unmap fields": "Rimuovi mappatura campi", + "Unmapped": "Mappatura rimossa", + "Map fields": "Mappa i campi", + "Mapped": "Mappato", + "Select All": "Seleziona tutto" + }, + "Section": { + "Insert section above": "Inserisci una sezione sopra", + "Insert section below": "Inserisci una sezione sotto" + }, + "Columns": { + "Remove Column": "Rimuovi colonna" + }, + "Field": { + "No choices configured": "Nessuna scelta configurata", + "No values in show column of referenced table": "Nessun valore nella colonna da mostrare nella tabella collegata" + }, + "FormErrorPage": { + "Error": "Errore" } } From 45b7058156badca63c5b995d9b4980f0218f763f Mon Sep 17 00:00:00 2001 From: Ettore Atalan Date: Sat, 20 Apr 2024 19:20:03 +0000 Subject: [PATCH 20/45] Translated using Weblate (German) Currently translated at 98.3% (1203 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 58 +++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index eb08d570..aac00997 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -513,7 +513,8 @@ "Welcome to {{- orgName}}": "Willkommen bei {{-orgName}}", "Visit our {{link}} to learn more about Grist.": "Besuchen Sie unsere {{link}}, um mehr über Grist zu erfahren.", "Sign in": "Anmelden", - "To use Grist, please either sign up or sign in.": "Um Grist zu nutzen, melden Sie sich bitte an oder registrieren Sie sich." + "To use Grist, please either sign up or sign in.": "Um Grist zu nutzen, melden Sie sich bitte an oder registrieren Sie sich.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Erfahren Sie mehr in unserem {{helpCenterLink}}, oder finden Sie einen Experten über unser {{sproutsProgram}}." }, "HomeLeftPane": { "Access Details": "Zugangsdetails", @@ -586,7 +587,11 @@ }, "NTextBox": { "false": "falsch", - "true": "wahr" + "true": "wahr", + "Lines": "Linien", + "Field Format": "Feldformat", + "Multi line": "Mehrere Linien", + "Single line": "Einzelne Linie" }, "NotifyUI": { "Ask for help": "Bitte um Hilfe", @@ -1134,7 +1139,11 @@ "Currency": "Währung", "Decimals": "Dezimalstellen", "Default currency ({{defaultCurrency}})": "Standardwährung ({{defaultCurrency}})", - "Number Format": "Zahlenformat" + "Number Format": "Zahlenformat", + "min": "min.", + "Text": "Text", + "max": "max.", + "Field Format": "Feldformat" }, "FieldEditor": { "It should be impossible to save a plain data value into a formula column": "Es sollte unmöglich sein, einen einfachen Datenwert in eine Formelspalte zu speichern", @@ -1233,7 +1242,15 @@ }, "WebhookPage": { "Clear Queue": "Warteschlange löschen", - "Webhook Settings": "Webhaken Einstellungen" + "Webhook Settings": "Webhaken Einstellungen", + "Enabled": "Aktiviert", + "Event Types": "Ereignisarten", + "Memo": "Memo", + "Name": "Name", + "Sorry, not all fields can be edited.": "Leider können nicht alle Felder bearbeitet werden.", + "Status": "Status", + "URL": "URL", + "Table": "Tabelle" }, "FormulaAssistant": { "Ask the bot.": "Fragen Sie den Bot.", @@ -1454,7 +1471,17 @@ }, "FormConfig": { "Field rules": "Feldregeln", - "Required field": "Erforderliches Feld" + "Required field": "Erforderliches Feld", + "Options Sort Order": "Sortierreihenfolge der Optionen", + "Options Alignment": "Ausrichtung der Optionen", + "Select": "Auswählen", + "Vertical": "Vertikal", + "Ascending": "Aufsteigend", + "Default": "Standard", + "Descending": "Absteigend", + "Field Format": "Feldformat", + "Field Rules": "Feldregeln", + "Horizontal": "Horizontal" }, "CustomView": { "Some required columns aren't mapped": "Einige erforderliche Spalten sind nicht zugeordnet", @@ -1462,7 +1489,8 @@ }, "FormSuccessPage": { "Form Submitted": "Formular eingereicht", - "Thank you! Your response has been recorded.": "Danke! Ihre Antwort wurde aufgezeichnet." + "Thank you! Your response has been recorded.": "Danke! Ihre Antwort wurde aufgezeichnet.", + "Submit new response": "Neue Antwort absenden" }, "FormPage": { "There was an error submitting your form. Please try again.": "Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." @@ -1500,5 +1528,23 @@ "Section": { "Insert section above": "Abschnitt oben einfügen", "Insert section below": "Abschnitt unten einfügen" + }, + "CreateTeamModal": { + "Cancel": "Abbrechen", + "Create site": "Seite erstellen", + "Domain name is required": "Domainname ist erforderlich", + "Domain name is invalid": "Domainname ist ungültig", + "Work as a Team": "Als Team arbeiten" + }, + "Columns": { + "Remove Column": "Spalte entfernen" + }, + "Field": { + "No choices configured": "Keine Auswahlmöglichkeiten konfiguriert" + }, + "Toggle": { + "Checkbox": "Kontrollkästchen", + "Field Format": "Feldformat", + "Switch": "Schalter" } } From 65966e4cfd0da65343b5cbaaa42aceca4e6b39a2 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 19 Apr 2024 13:59:32 -0400 Subject: [PATCH 21/45] (core) use visibility information when sharing referenced columns with forms Summary: This tightens down the set of referenced columns made available to forms for dropdowns. Previous access to columns was computed at the level of shared tables. Now it is calculated at the level of shared sections. That means that we can now refrain from following hidden references, and make the referred data unavailable to forms, since they should not need it. Test Plan: extended test Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4234 --- app/common/ACLRulesReader.ts | 48 +++++++++++++++++------ app/common/DocActions.ts | 9 ++++- app/common/UserAPI.ts | 18 ++++++++- test/fixtures/docs/FilmsWithImages.grist | Bin 92160 -> 93184 bytes 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/app/common/ACLRulesReader.ts b/app/common/ACLRulesReader.ts index ed4154ce..741a719e 100644 --- a/app/common/ACLRulesReader.ts +++ b/app/common/ACLRulesReader.ts @@ -109,6 +109,12 @@ export interface ACLRulesReaderOptions { addShareRules?: boolean; } +interface ShareContext { + shareRef: number; + sections: MetaRowRecord<"_grist_Views_section">[]; + columns: MetaRowRecord<"_grist_Tables_column">[]; +} + /** * Helper class for reading ACL rules from DocData. */ @@ -204,6 +210,19 @@ export class ACLRulesReader { } ); + const sectionIds = new Set(sections.map(section => section.id)); + const fields = this.docData.getMetaTable('_grist_Views_section_field').getRecords().filter( + field => { + return sectionIds.has(field.parentId); + } + ); + const columnIds = new Set(fields.map(field => field.colRef)); + const columns = this.docData.getMetaTable('_grist_Tables_column').getRecords().filter( + column => { + return columnIds.has(column.id); + } + ); + const tableRefs = new Set(sections.map(section => section.tableRef)); const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( table => tableRefs.has(table.id) @@ -211,13 +230,12 @@ export class ACLRulesReader { // For tables associated with forms, allow creation of records, // and reading of referenced columns. - // TODO: should probably be limiting to a set of columns associated - // with section - but for form widget that could potentially be very - // confusing since it may not be easy to see that certain columns - // haven't been made visible for it? For now, just working at table - // level. + // TODO: tighten access control on creation since it may be broader + // than users expect - hidden columns could be written. for (const table of tables) { - this._shareTableForForm(table, share.id); + this._shareTableForForm(table, { + shareRef: share.id, sections, columns, + }); } } @@ -248,10 +266,12 @@ export class ACLRulesReader { * Allow creating records in a table. */ private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { + shareContext: ShareContext) { + const { shareRef } = shareContext; const resource = this._findOrAddResource({ tableId: table.tableId, - colIds: '*', + colIds: '*', // At creation, allow all columns to be + // initialized. }); let aclFormula = `user.ShareRef == ${shareRef}`; let aclFormulaParsed = JSON.stringify([ @@ -277,19 +297,21 @@ export class ACLRulesReader { resource, aclFormula, aclFormulaParsed, permissionsText: '+R', })); - this._shareTableReferencesForForm(table, shareRef); + this._shareTableReferencesForForm(table, shareContext); } /** * Give read access to referenced columns. */ private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { + shareContext: ShareContext) { + const { shareRef } = shareContext; + const tables = this.docData.getMetaTable('_grist_Tables'); const columns = this.docData.getMetaTable('_grist_Tables_column'); - const tableColumns = columns.filterRecords({ - parentId: table.id, - }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); + const tableColumns = shareContext.columns.filter(c => + c.parentId === table.id && + (c.type.startsWith('Ref:') || c.type.startsWith('RefList:'))); for (const column of tableColumns) { const visibleColRef = column.visibleCol; // This could be blank in tests, not sure about real life. diff --git a/app/common/DocActions.ts b/app/common/DocActions.ts index b89246e7..8770f67d 100644 --- a/app/common/DocActions.ts +++ b/app/common/DocActions.ts @@ -133,8 +133,15 @@ export interface TableRecordValues { records: TableRecordValue[]; } -export interface TableRecordValue { +export interface TableRecordValuesWithoutIds { + records: TableRecordValueWithoutId[]; +} + +export interface TableRecordValue extends TableRecordValueWithoutId { id: number | string; +} + +export interface TableRecordValueWithoutId { fields: { [colId: string]: CellValue }; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 9b1cccdb..df4d0788 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -5,7 +5,8 @@ import {BaseAPI, IOptions} from 'app/common/BaseAPI'; import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI'; import {BrowserSettings} from 'app/common/BrowserSettings'; import {ICustomWidget} from 'app/common/CustomWidget'; -import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions'; +import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, + TableRecordValuesWithoutIds, UserAction} from 'app/common/DocActions'; import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {OrgUsageSummary} from 'app/common/DocUsage'; import {Product} from 'app/common/Features'; @@ -441,6 +442,10 @@ interface GetRowsParams { immediate?: boolean; } +interface SqlResult extends TableRecordValuesWithoutIds { + statement: string; +} + /** * Collect endpoints related to the content of a single document that we've been thinking * of as the (restful) "Doc API". A few endpoints that could be here are not, for historical @@ -452,6 +457,7 @@ export interface DocAPI { // opening a document are irrelevant. getRows(tableId: string, options?: GetRowsParams): Promise; getRecords(tableId: string, options?: GetRowsParams): Promise; + sql(sql: string, args?: any[]): Promise; updateRows(tableId: string, changes: TableColValues): Promise; addRows(tableId: string, additions: BulkColValues): Promise; removeRows(tableId: string, removals: number[]): Promise; @@ -925,6 +931,16 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { return response.records; } + public async sql(sql: string, args?: any[]): Promise { + return this.requestJson(`${this._url}/sql`, { + body: JSON.stringify({ + sql, + ...(args ? { args } : {}), + }), + method: 'POST', + }); + } + public async updateRows(tableId: string, changes: TableColValues): Promise { return this.requestJson(`${this._url}/tables/${tableId}/data`, { body: JSON.stringify(changes), diff --git a/test/fixtures/docs/FilmsWithImages.grist b/test/fixtures/docs/FilmsWithImages.grist index 3881b6cf3a87d56acc2ae6382e43f5fa217bc9d8..2fdb578616d165f9ffa17eb5716c7aa7034347fc 100644 GIT binary patch delta 2829 zcmaJ@eNa`$72mVF&wF{i4;~1Y4Bm~!j-q_^o`rJ+#gcgI}nxQMv{?=#FdOBMM08aIE}L* zv00Er%Shx|NF;9b&)7YrWSwr|mjRyDubUJaz1px;FeK&rq;$g$>Eecx=3w`6wATqM zWvyR5{aB*#rzi>7!swq^@uzTy(7SMlM(Zy52ABt7mZW56!jM;Z z-Kh{II^1WZ`A&DBzT-OKkQII+z6H~sD+JCqm`-PWZB-Oi@UjZMyq%9Y-A zHBHXibu~3;@~OejfWI%~@9p_7KxSsP*pZh57U4~d*MFLq3ui1`mN~gX=m#`GSn_&A z9&ZF$yX}P>a2POc{t9>%Vlcpo+WRYDAJ`+>!E)HGeb4}>+BC6t5Td+Obu#=&eYF*^!n){c?Sdqh4t+ve zU64Sd;S=~X{0_$99GrmH;AQBCKHbHg&~Gg<<5)qq;upir)@NskG|EzpV$`%%wk~~a zv@@G^$qbQNEsGyx?G|g%W2247S_@~0jL$AWQNn)bp<7$oboOvq7tKIXX30l!+PjVF zN!7PZ&y3;WO(pOyq|)z@R5}KB1&LX4xnQVSl-4Ifl4!0!oA!D8v%WVNQUev+`lfcL z$X~k*6eDzEc)sY;{OVem)GVVxl1%5+in7<2?_qPRQS_R;XQS zNcFj$72c-Grm8iSPn-vRvXRP0)|y4{5%vx{%672^=A^oVw*hYUf5F#9al6+*c%>YP z&&Nu)C~bGy5Yd`vLKQZSbBN*gblW0S zWpPv`p(;)!x24z%T3eR1W-!z#5!hSp$+kj-<=YVez zQX?Tdh2k&JuYNf<#&VG`LN}3%WQYu50uR9k@$0b?^H6-Uq~zql z-VTwuD-F^j1Nh>u9YTBYqH#95)$U%gdiN@DPg}S+zWZfx8j`-I|7SAW#2%u*EYKIf zG)|#epZq4}akLh%u~!V2ry)ewlUL|*bh=l&R0{>7PD|9{>tMeaJy->qqFz(PrGs%o z9@}i>bGdsJkmk9wl%)0 zHPwezd^~#CW<;8?3Bo?YJ97eK{0k=D6Y17y;3NrxTRQ@(j~+Q>@mN^}j_?AZUx3%i z1r*Y-<&%X~h`FO>uu!y*7HQX>2M-%)b8DBXU>+>MM4O;RSHri#rJbmTbeL}hMfZ zdyZM?HTna(9dmI971zukfg}z`cYrJ=qKhn?Tq5KW>!jD<2*&lCd0<_dq-1BqpsK}J zf$OoC9WpLrpTDO^6_f7xtii<@lH&2e@WN2Xm%7yUEdhTp)LyQ3bp>bETZ}<+L#;Wg zp+pl^Fvk?=eWA)HUayRcF|uuMZ^-RjQCC+}>8%wvE8^&s&Db-c?R~0&)Am<^$eIHo z@`g&xC(f$cipq`7r|qA$%?LT`YCi{%^;zfH1Ig)mYvEati73lo?lMD)VeiB)c z&QBp*tn(isTcYzHBU`HT^T>*IeqmCA5;6DQxAgtvK?cw1{4F#r)A?m&%XR(^+RJqQ z9!g%F{{h(wo&V`-l<^AtyPLwIGj}JMsTAF=)NXQ!)0KbTVX#W)_m7#ODtwYcBbX}D zT#Zh4gm-h;%i#GimjOFo6Xr31UDk&AY=hT@d7{DV!+egx*M{*9L*5YP=>~5MSI{_~V(LP6;5hc@({c1LuPU|GrRy$5hX#T)7m}(w19cCmmHX|V&dhQZ+`$z7~ zcg}sB^E>C>d+zTWowJP2SjIAZag4DJ`tG$E>WF&-(m#Rr;sX8)Z{j@u75^?hV(+>61-^|;oH~hhYe}@YNwmh0aHo?F$Wq{feZ+7SYk3ZJ zl4*k4jFFoQA-UxG6Xc$o-^b+}8P&29M`N9|i1;?cJNQq0+e~m=#C9iISO(~IL&3nn z{$a}+Zec#qn*&2n3=Z{o4Ri;F(FPV~#OQUwzW!k+CNU0v27X@7+$!4tH%#J&QCbS$ zZ9Cr0@uE8k@D)z=kde1$K$#F?Ac zT?D>6{x=Sx>zo~A@CiF89VG*DJTD*YYGdYXULn|yCTZI0F_;ZG4lY+3EvH0pidoYK~gw(6%2HoK1-MU!N8yTEujjVfoyvi2piPenSS~%J+Gwajk&l@r! zF>-Wqr55vAgB|R-N9&!DFh3|Jll72rf4QgrF36Q57EI%5Qfn$UJ^K4#rtSK~68{?2 zOf`egp_*S})%^G4itO0_|BuKBk-tDT0GE)2M zRJp7sM4j|VY`rFx`qWx_1Mb?9z`!=E&SdcMT3elku<|2VdEME4QCO-Qg|!*U&`^do zDp6{+ee@h_vR4NQ(+#81n^7DUi7e?keXKVm$Dd4*FZYJvYmuwn5Ih7K^4Gyz#5ym3dkNM? zW=}ua;eiI`1cT!o7Ww2P0Uy0@epI5f^d$ccO zn+^S)oQ}er8!U|XyvvaB4)!iYQ!@E6s50{N;b^|RI+_JuDx?=|p=Z)HHThi8>ZG|i z`WVReP#JjSSm?A2^`@E4M}DVcJ1(0ZkppJgk1lighPm!~81C&F3hY;xWAnxI4R&|Qzx_Dd<>UjD+5}@0{0dC4H!v0BRbHy`E7CVr19|d^ zsnTPURgfjOOui)#ytos5@((YLSuvG$_V17vGQ9H8OB=y2&%LzEqOSFp{MOlG$R#5~ zPMNNqI5dn+20OuC%# zLv!rIVT4js+@h5-Q!J1wH^m|uRGQ*0jUG0|7o;|w7cdtC#o_rF@N&q1%>gTDsi4T! zlQ-Ou7&hWz0ih-=lHjxrwuWDI!5*+|A%kry(!uZ{H#~>1JuI?;(CfmYfDm9uSZq+b zJ}gR<-We7ZN;ibXW~Cd$qDJYau-LBjqhV1G%l3_rIEmO}iPnh}Pf-P}a{kgP=Y#eY zoSz6Ev&09S4_L#PO!vK?NNLjiJw69c^Wmjd(77GYF^(aIA)KWRu{g8*uJu#4J zICW>HuIu7H)$Q!>>Iu+ja<&r+SAU#vn_)3O4!4P_Zh)LE@k4y%Da4hYkh{xLt~+DbdM(eQ(Gbh*70Q;HyOUd{{lBDjdzI3&XzwYBYAwH7;+*-_2I;r zwcTYk9#o&*um1nQKVbX=I)Q847D|uNjH)U)vO~J*m(0uE;Gobhv$p(#7;hF~v5&3@ J_MUWz{{cwCHd6oq From 6e451fa6e7acbd84a4de6f7bfc7efc2efd716056 Mon Sep 17 00:00:00 2001 From: Camille L Date: Tue, 23 Apr 2024 10:01:37 +0000 Subject: [PATCH 22/45] Translated using Weblate (French) Currently translated at 96.8% (1185 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/ --- static/locales/fr.client.json | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 163ab625..828246d1 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -478,7 +478,8 @@ "Welcome to {{- orgName}}": "Bienvenue sur {{- orgName}}", "Visit our {{link}} to learn more about Grist.": "Visitez notre {{link}} pour en savoir plus sur Grist.", "Sign in": "Connexion", - "To use Grist, please either sign up or sign in.": "Pour utiliser Grist, connectez-vous ou créez-vous un compte." + "To use Grist, please either sign up or sign in.": "Pour utiliser Grist, connectez-vous ou créez-vous un compte.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Pour en savoir plus, consultez notre {{helpCenterLink}}, ou trouvez un expert via notre {{sproutsProgram}}." }, "HomeLeftPane": { "All Documents": "Tous les documents", @@ -694,7 +695,10 @@ "Share": "Partager", "Download...": "Télécharger...", "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", - "Export as...": "Exporter en tant que..." + "Export as...": "Exporter en tant que...", + "Comma Separated Values (.csv)": "Comma Separated Values (.csv)", + "DOO Separated Values (.dsv)": "DOO Separated Values (.dsv)", + "Tab Separated Values (.tsv)": "Tab Separated Values (.tsv)" }, "SiteSwitcher": { "Switch Sites": "Changer d’espace", @@ -931,7 +935,10 @@ }, "NTextBox": { "false": "faux", - "true": "vrai" + "true": "vrai", + "Single line": "Ligne unique", + "Multi line": "Multi-lignes", + "Lines": "Lignes" }, "ViewAsDropdown": { "View As": "Voir en tant que", @@ -1065,7 +1072,10 @@ "Decimals": "Décimales", "Currency": "Devise", "Default currency ({{defaultCurrency}})": "Devise par défaut ({{defaultCurrency}})", - "Number Format": "Format de nombre" + "Number Format": "Format de nombre", + "min": "min", + "Text": "Texte", + "max": "max" }, "LanguageMenu": { "Language": "Langue" @@ -1230,7 +1240,8 @@ "Opt in to Telemetry": "S'inscrire à l'envoi de données de télémétrie", "Opted In": "Accepté", "Support Grist page": "Soutenir Grist", - "Admin Panel": "Panneau d'administration" + "Admin Panel": "Panneau d'administration", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Nous vous remercions ! Votre confiance et votre soutien sont très appréciés. Vous pouvez vous désinscrire à tout moment en cliquant sur le {{link}} dans le menu utilisateur." }, "GridView": { "Click to insert": "Cliquer pour insérer" @@ -1387,7 +1398,14 @@ }, "FormConfig": { "Field rules": "Règles du champ", - "Required field": "Champ obligatoire" + "Required field": "Champ obligatoire", + "Ascending": "Croissant", + "Default": "Par défaut", + "Descending": "Décroissant", + "Field Format": "Format du champ", + "Field Rules": "Règles du champ", + "Horizontal": "Horizontale", + "Options Alignment": "Option d'alignement" }, "Editor": { "Delete": "Supprimer" @@ -1449,5 +1467,8 @@ "Sponsor": "Parrainage", "Support Grist": "Soutenir Grist", "Version": "Version" + }, + "Field": { + "No choices configured": "Aucun choix configuré" } } From c2768b3e25b37a6212d49ba052a3cae2055cc779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Mon, 22 Apr 2024 18:22:14 +0000 Subject: [PATCH 23/45] Translated using Weblate (Russian) Currently translated at 99.5% (1218 of 1223 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index 563eae00..9c508257 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -1274,7 +1274,7 @@ "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Пользователь наследует разрешения от {{parent}}. Чтобы удалить, установите для параметра 'Наследовать доступ' значение 'None'.", "Allow anyone with the link to open.": "Разрешить открывать всем, у кого есть ссылка.", "Grist support": "Grist поддержка", - "Invite people to {{resourceType}}": "Пригласите людей в {{resourceType}}", + "Invite people to {{resourceType}}": "Пригласите людей к {{resourceType}}", "Create a team to share with more people": "Создайте команду, чтобы поделиться с большим количеством людей", "Link copied to clipboard": "Ссылка скопирована в буфер обмена", "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Отсутствие доступа по умолчанию позволяет предоставлять доступ к отдельным документам или рабочим областям, а не ко всему групповому сайту.", From dc70cf59d305ee878fe8242c25b7ca3112dff475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Tue, 23 Apr 2024 11:42:12 -0400 Subject: [PATCH 24/45] docs: better explain GRIST_DATA_DIR This little variable has unexpected complexity: it defaults to being relative to the working directory, which usually is the same as the app directory. However, in practice we instruct people to explicitly set this to `/persist/docs`, so we should mention this common use case. Some day I would like to simplify some of the logic around this variable. closes: #931 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1509f475..774b1205 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,6 @@ GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, h GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories). GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup GRIST_BOOT_KEY | if set, offer diagnostics at /boot/GRIST_BOOT_KEY -GRIST_DATA_DIR | directory in which to store document caches. GRIST_DEFAULT_EMAIL | if set, login as this user if no other credentials presented GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of new sites. See names of PRODUCTS in Product.ts. GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the browser locale. @@ -299,6 +298,7 @@ GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the chec GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000} GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made GRIST_PROMCLIENT_PORT | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️. +| GRIST_DATA_DIR | Directory in which to store documents. Defaults to `docs/` relative to the Grist application directory. In Grist's default Docker image, its default value is /persist/docs so that it will be used as a mounted volume. | #### AI Formula Assistant related variables (all optional): From 5fad8a2baa7ae9be16111ba5440c174f55c09b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Tue, 23 Apr 2024 11:42:58 -0400 Subject: [PATCH 25/45] (docs) README.md: reformat envvars table This is just a source-level reformatting to realign it. --- README.md | 130 +++++++++++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 774b1205..c950d741 100644 --- a/README.md +++ b/README.md @@ -233,72 +233,72 @@ For more on Grist Labs' history and principles, see our [About Us](https://www.g Grist can be configured in many ways. Here are the main environment variables it is sensitive to: -Variable | Purpose --------- | ------- -ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. -APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis) -APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). Defaults to `APP_DOC_URL` -APP_HOME_URL | url prefix for home api (home and doc servers need this) -APP_STATIC_URL | url prefix for static resources -APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages -APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. -GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, host header should be trustworthy) -GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories). -GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup -GRIST_BOOT_KEY | if set, offer diagnostics at /boot/GRIST_BOOT_KEY -GRIST_DEFAULT_EMAIL | if set, login as this user if no other credentials presented -GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of new sites. See names of PRODUCTS in Product.ts. -GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the browser locale. -GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com". -GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins -GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default. -GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled. -GRIST_HOST | hostname to use when listening on a port. -GRIST_HTTPS_PROXY | if set, use this proxy for webhook payload delivery. -GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*. -GRIST_IGNORE_SESSION | if set, Grist will not use a session for authentication. -GRIST_INCLUDE_CUSTOM_SCRIPT_URL | if set, will load the referenced URL in a `