Merge branch 'main' into last-connection

pull/935/head
CamilleLegeron 3 weeks ago
commit feafda8fda

@ -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_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.
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 `<script>` tag on all app pages.
GRIST_INST_DIR | path to Grist instance configuration files, for Grist server.
GRIST_LIST_PUBLIC_SITES | if set to true, sites shared with the public will be listed for anonymous users. Defaults to false.
GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone
GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited).
GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited).
GRIST_OFFER_ALL_LANGUAGES | if set, all translated langauages are offered to the user (by default, only languages with a special 'good enough' key set are offered to user).
GRIST_ORG_IN_PATH | if true, encode org in path rather than domain
GRIST_PAGE_TITLE_SUFFIX | a string to append to the end of the `<title>` in HTML documents. Defaults to `" - Grist"`. Set to `_blank` for no suffix at all.
~GRIST_PROXY_AUTH_HEADER~ | Deprecated, and interpreted as a synonym for GRIST_FORWARD_AUTH_HEADER.
GRIST_ROUTER_URL | optional url for an api that allows servers to be (un)registered with a load balancer
GRIST_SERVE_SAME_ORIGIN | set to "true" to access home server and doc workers on the same protocol-host-port as the top-level page, same as for custom domains (careful, host header should be trustworthy)
GRIST_SERVERS | the types of server to setup. Comma separated values which may contain "home", "docs", static" and/or "app". Defaults to "home,docs,static".
GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie
GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN
GRIST_SESSION_SECRET | a key used to encode sessions
GRIST_SKIP_BUNDLED_WIDGETS | if set, Grist will ignore any bundled widgets included via NPM packages.
GRIST_ANON_PLAYGROUND | When set to 'false' deny anonymous users access to the home page
GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymous access at all (features like sharing docs publicly requires authentication)
GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org
GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org
GRIST_HELP_CENTER | set the help center link ref
FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool)
GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form)
GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default)
GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way.
GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`.
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications.
GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`.
GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie
HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.
PORT | port number to listen on for Grist server
REDIS_URL | optional redis server for browser sessions and db query caching
GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the checksum in Redis differs with the one in your S3 backend storage. You may turn it on if your backend storage implements the [read-after-write consistency](https://aws.amazon.com/fr/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/). Defaults to false.
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 ⚠️.
| 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_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. |
| 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 `<script>` tag on all app pages. |
| GRIST_INST_DIR | path to Grist instance configuration files, for Grist server. |
| GRIST_LIST_PUBLIC_SITES | if set to true, sites shared with the public will be listed for anonymous users. Defaults to false. |
| GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone |
| GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited). |
| GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited). |
| GRIST_OFFER_ALL_LANGUAGES | if set, all translated langauages are offered to the user (by default, only languages with a special 'good enough' key set are offered to user). |
| GRIST_ORG_IN_PATH | if true, encode org in path rather than domain |
| GRIST_PAGE_TITLE_SUFFIX | a string to append to the end of the `<title>` in HTML documents. Defaults to `" - Grist"`. Set to `_blank` for no suffix at all. |
| ~GRIST_PROXY_AUTH_HEADER~ | Deprecated, and interpreted as a synonym for GRIST_FORWARD_AUTH_HEADER. |
| GRIST_ROUTER_URL | optional url for an api that allows servers to be (un)registered with a load balancer |
| GRIST_SERVE_SAME_ORIGIN | set to "true" to access home server and doc workers on the same protocol-host-port as the top-level page, same as for custom domains (careful, host header should be trustworthy) |
| GRIST_SERVERS | the types of server to setup. Comma separated values which may contain "home", "docs", static" and/or "app". Defaults to "home,docs,static". |
| GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie |
| GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN |
| GRIST_SESSION_SECRET | a key used to encode sessions |
| GRIST_SKIP_BUNDLED_WIDGETS | if set, Grist will ignore any bundled widgets included via NPM packages. |
| GRIST_ANON_PLAYGROUND | When set to 'false' deny anonymous users access to the home page |
| GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymous access at all (features like sharing docs publicly requires authentication) |
| GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org |
| GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org |
| GRIST_HELP_CENTER | set the help center link ref |
| FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool) |
| GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form) |
| GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) |
| GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. |
| GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`. |
| GRIST_THROTTLE_CPU | if set, CPU throttling is enabled |
| GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications. |
| GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`. |
| GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. |
| GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL. |
| GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used |
| COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie |
| HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port. |
| PORT | port number to listen on for Grist server |
| REDIS_URL | optional redis server for browser sessions and db query caching |
| GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the checksum in Redis differs with the one in your S3 backend storage. You may turn it on if your backend storage implements the [read-after-write consistency](https://aws.amazon.com/fr/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/). Defaults to false. |
| 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 ⚠️. |
#### AI Formula Assistant related variables (all optional):

@ -1,13 +1,12 @@
import ace, {Ace} from 'ace-builds';
import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions';
import {theme} from 'app/client/ui2018/cssVars';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomArg, Listener, Observable, styled} from 'grainjs';
import {dom, DomArg, Observable, styled} from 'grainjs';
import debounce from 'lodash/debounce';
export interface ACLFormulaOptions {
gristTheme: Computed<Theme>;
initialValue: string;
readOnly: boolean;
placeholder: DomArg;
@ -22,19 +21,15 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
const editor: Ace.Editor = ace.edit(editorElem);
// Set various editor options.
function setAceTheme(gristTheme: Theme) {
const {enableCustomCss} = getGristConfig();
const gristAppearance = gristTheme.appearance;
const aceTheme = gristAppearance === 'dark' && !enableCustomCss ? 'dracula' : 'chrome';
function setAceTheme(newTheme: Theme) {
const {appearance} = newTheme;
const aceTheme = appearance === 'dark' ? 'dracula' : 'chrome';
editor.setTheme(`ace/theme/${aceTheme}`);
}
setAceTheme(options.gristTheme.get());
let themeListener: Listener | undefined;
if (!getGristConfig().enableCustomCss) {
themeListener = options.gristTheme.addListener((gristTheme) => {
setAceTheme(gristTheme);
});
}
setAceTheme(gristThemeObs().get());
const themeListener = gristThemeObs().addListener((newTheme) => {
setAceTheme(newTheme);
});
// ACE editor resizes automatically when maxLines is set.
editor.setOptions({enableLiveAutocompletion: true, maxLines: 10});
editor.renderer.setShowGutter(false); // Default line numbers to hidden

@ -36,14 +36,13 @@ import {ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID} from 'a
import {AclRuleProblem, AclTableDescription, getTableTitle} from 'app/common/ActiveDocAPI';
import {BulkColValues, getColValues, RowRecord, UserAction} from 'app/common/DocActions';
import {
FormulaProperties,
getFormulaProperties,
RulePart,
RuleSet,
UserAttributeRule
} from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes';
import {isNonNullish, unwrap} from 'app/common/gutil';
import {getPredicateFormulaProperties, PredicateFormulaProperties} from 'app/common/PredicateFormula';
import {SchemaTypes} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData';
import {
@ -496,7 +495,7 @@ export class AccessRules extends Disposable {
removeItem(this._userAttrRules, userAttr);
}
public async checkAclFormula(text: string): Promise<FormulaProperties> {
public async checkAclFormula(text: string): Promise<PredicateFormulaProperties> {
if (text) {
return this.gristDoc.docComm.checkAclFormula(text);
}
@ -1465,7 +1464,6 @@ class ObsUserAttributeRule extends Disposable {
cssColumnGroup(
cssCell1(
aclFormulaEditor({
gristTheme: this._accessRules.gristDoc.currentTheme,
initialValue: this._charId.get(),
readOnly: false,
setValue: (text) => this._setUserAttr(text),
@ -1598,7 +1596,8 @@ class ObsRulePart extends Disposable {
// If the formula failed validation, the error message to show. Blank if valid.
private _formulaError = Observable.create(this, '');
private _formulaProperties = Observable.create<FormulaProperties>(this, getAclFormulaProperties(this._rulePart));
private _formulaProperties = Observable.create<PredicateFormulaProperties>(this,
getAclFormulaProperties(this._rulePart));
// Error message if any validation failed.
private _error: Computed<string>;
@ -1618,7 +1617,7 @@ class ObsRulePart extends Disposable {
this._error = Computed.create(this, (use) => {
return use(this._formulaError) ||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
this._warnInvalidColIds(use(this._formulaProperties).recColIds) ||
( !this._ruleSet.isLastCondition(use, this) &&
use(this._aclFormula) === '' &&
permissionSetToText(use(this._permissions)) !== '' ?
@ -1690,7 +1689,6 @@ class ObsRulePart extends Disposable {
cssCell2(
wide ? cssCell4.cls('') : null,
aclFormulaEditor({
gristTheme: this._ruleSet.accessRules.gristDoc.currentTheme,
initialValue: this._aclFormula.get(),
readOnly: this.isBuiltIn(),
setValue: (value) => this._setAclFormula(value),
@ -1913,9 +1911,9 @@ function getChangedStatus(value: boolean): RuleStatus {
return value ? RuleStatus.ChangedValid : RuleStatus.Unchanged;
}
function getAclFormulaProperties(part?: RulePart): FormulaProperties {
function getAclFormulaProperties(part?: RulePart): PredicateFormulaProperties {
const aclFormulaParsed = part?.origRecord?.aclFormulaParsed;
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
return aclFormulaParsed ? getPredicateFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
}
// Return a rule set if it applies to one of the specified columns.

@ -1,8 +1,8 @@
import { AppModel } from 'app/client/models/AppModel';
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
import { createAppPage } from 'app/client/ui/createAppPage';
import { pagePanels } from 'app/client/ui/PagePanels';
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
@ -30,24 +30,14 @@ const cssResult = styled('div', `
*/
export class Boot extends Disposable {
// The back end will offer a set of probes (diagnostics) we
// can use. Probes have unique IDs.
public probes: Observable<BootProbeInfo[]>;
// Keep track of probe results we have received, by probe ID.
public results: Map<string, Observable<BootProbeResult>>;
// Keep track of probe requests we are making, by probe ID.
public requests: Map<string, BootProbe>;
private _checks: AdminChecks;
constructor(_appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this,
// based on other similar pages.
document.title = 'Booting Grist';
this.probes = Observable.create(this, []);
this.results = new Map();
this.requests = new Map();
this._checks = new AdminChecks(this);
}
/**
@ -55,20 +45,10 @@ export class Boot extends Disposable {
* side panel, just for convenience. Could be made a lot prettier.
*/
public buildDom() {
this._checks.fetchAvailableChecks().catch(e => reportError(e));
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
fetch(url.href).then(async resp => {
const _probes = await resp.json();
this.probes.set(_probes.probes);
}).catch(e => reportError(e));
}
const rootNode = dom('div',
dom.domComputed(
use => {
@ -99,21 +79,10 @@ export class Boot extends Disposable {
return cssBody(cssResult(this.buildError()));
}
return cssBody([
...use(this.probes).map(probe => {
const {id} = probe;
let result = this.results.get(id);
if (!result) {
result = Observable.create(this, {});
this.results.set(id, result);
}
let request = this.requests.get(id);
if (!request) {
request = new BootProbe(id, this);
this.requests.set(id, request);
}
request.start();
...use(this._checks.probes).map(probe => {
const req = this._checks.requestCheck(probe);
return cssResult(
this.buildResult(probe, use(result), probeDetails[id]));
this.buildResult(req.probe, use(req.result), req.details));
}),
]);
}
@ -164,7 +133,7 @@ export class Boot extends Disposable {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
key,
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
@ -172,31 +141,6 @@ export class Boot extends Disposable {
}
}
/**
* Represents a single diagnostic.
*/
export class BootProbe {
constructor(public id: string, public boot: Boot) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
const ob = boot.results.get(id);
if (ob) {
ob.set(_probes);
}
}).catch(e => console.error(e));
}
public start() {
let result = this.boot.results.get(this.id);
if (!result) {
result = Observable.create(this.boot, {});
this.boot.results.set(this.id, result);
}
}
}
/**
* Create a stripped down page to show boot information.
* Make sure the API isn't used since it may well be unreachable
@ -208,52 +152,9 @@ createAppPage(appModel => {
useApi: false,
});
/**
* Basic information about diagnostics is kept on the server,
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
or make GRIST_BOOT_KEY long and cryptographically secure.
`,
},
'health-check': {
info: `
Grist has a small built-in health check often used when running
it as a container.
`,
},
'host-header': {
info: `
Requests arriving to Grist should have an accurate Host
header. This is essential when GRIST_SERVE_SAME_ORIGIN
is set.
`,
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
`,
},
'reachable': {
info: `
The main page of Grist should be available.
`
},
};
/**
* Information about the probe.
*/
interface ProbeDetails {
info: string;
}
export const cssLabel = styled('div', `
display: inline-block;
min-width: 100px;
text-align: right;
padding-right: 5px;
`);

@ -7,10 +7,10 @@ require('ace-builds/src-noconflict/theme-chrome');
require('ace-builds/src-noconflict/theme-dracula');
require('ace-builds/src-noconflict/ext-language_tools');
var {setupAceEditorCompletions} = require('./AceEditorCompletions');
var {getGristConfig} = require('../../common/urlUtils');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var modelUtil = require('../models/modelUtil');
var {gristThemeObs} = require('../ui2018/theme');
/**
* A class to help set up the ace editor with standard formatting and convenience functions
@ -28,10 +28,9 @@ function AceEditor(options) {
this.observable = options.observable || null;
this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
this.calcSize = options.calcSize || ((_elem, size) => size);
this.gristDoc = options.gristDoc || null;
this.column = options.column || null;
this.editorState = options.editorState || null;
this._readonly = options.readonly || false;
this._getSuggestions = options.getSuggestions || null;
this.editor = null;
this.editorDom = null;
@ -185,19 +184,8 @@ AceEditor.prototype.setFontSize = function(pxVal) {
AceEditor.prototype._setup = function() {
// Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc && this.column) {
const getSuggestions = (prefix) => {
const section = this.gristDoc.viewModel.activeSection();
// If section is disposed or is pointing to an empty row, don't try to autocomplete.
if (!section?.getRowId()) {
return [];
}
const tableId = section.table().tableId();
const columnId = this.column.colId();
const rowId = section.activeRowId();
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
};
setupAceEditorCompletions(this.editor, {getSuggestions});
if (this._getSuggestions) {
setupAceEditorCompletions(this.editor, {getSuggestions: this._getSuggestions});
}
this.editor.setOptions({
enableLiveAutocompletion: true, // use autocompletion without needing special activation.
@ -205,13 +193,10 @@ AceEditor.prototype._setup = function() {
this.session = this.editor.getSession();
this.session.setMode('ace/mode/python');
const gristTheme = this.gristDoc?.currentTheme;
this._setAceTheme(gristTheme?.get());
if (!getGristConfig().enableCustomCss && gristTheme) {
this.autoDispose(gristTheme.addListener((theme) => {
this._setAceTheme(theme);
}));
}
this._setAceTheme(gristThemeObs().get());
this.autoDispose(gristThemeObs().addListener((newTheme) => {
this._setAceTheme(newTheme);
}));
// Default line numbers to hidden
this.editor.renderer.setShowGutter(false);
@ -283,10 +268,9 @@ AceEditor.prototype._getContentHeight = function() {
return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight;
};
AceEditor.prototype._setAceTheme = function(gristTheme) {
const {enableCustomCss} = getGristConfig();
const gristAppearance = gristTheme?.appearance;
const aceTheme = gristAppearance === 'dark' && !enableCustomCss ? 'dracula' : 'chrome';
AceEditor.prototype._setAceTheme = function(newTheme) {
const {appearance} = newTheme;
const aceTheme = appearance === 'dark' ? 'dracula' : 'chrome';
this.editor.setTheme(`ace/theme/${aceTheme}`);
};

@ -17,6 +17,7 @@ import {cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from 'app/cl
import {IconName} from 'app/client/ui2018/IconList';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, linkSelect, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
@ -229,7 +230,7 @@ export class ChartView extends Disposable {
this.listenTo(this.sortedRows, 'rowNotify', this._update);
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
this.autoDispose(this._formatterComp.subscribe(this._update));
this.autoDispose(this.gristDoc.currentTheme.addListener(() => this._update()));
this.autoDispose(gristThemeObs().addListener(() => this._update()));
}
public prepareToPrint(onOff: boolean) {
@ -387,8 +388,7 @@ export class ChartView extends Disposable {
}
private _getPlotlyTheme(): Partial<Layout> {
const appModel = this.gristDoc.docPageModel.appModel;
const {colors} = appModel.currentTheme.get();
const {colors} = gristThemeObs().get();
return {
paper_bgcolor: colors['chart-bg'],
plot_bgcolor: colors['chart-bg'],

@ -91,9 +91,9 @@ export class ColumnTransform extends Disposable {
protected buildEditorDom(optInit?: string) {
if (!this.editor) {
this.editor = this.autoDispose(AceEditor.create({
gristDoc: this.gristDoc,
observable: this.transformColumn.formula,
saveValueOnBlurEvent: false,
// TODO: set `getSuggestions` (see `FormulaEditor.ts` for an example).
}));
}
return this.editor.buildDom((aceObj: any) => {

@ -321,7 +321,7 @@ export class CustomView extends Disposable {
}),
new MinimumLevel(AccessLevel.none)); // none access is enough
frame.useEvents(
ThemeNotifier.create(frame, this.gristDoc.currentTheme),
ThemeNotifier.create(frame),
new MinimumLevel(AccessLevel.none));
},
onElem: (iframe) => onFrameFocus(iframe, () => {

@ -48,6 +48,8 @@ export class DocComm extends Disposable implements ActiveDocAPI {
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
public getAccessToken = this._wrapMethod("getAccessToken");
public getShare = this._wrapMethod("getShare");
public startTiming = this._wrapMethod("startTiming");
public stopTiming = this._wrapMethod("stopTiming");
public changeUrlIdEmitter = this.autoDispose(new Emitter());

@ -0,0 +1,197 @@
import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {textButton } from 'app/client/ui2018/buttons';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
const t = makeT('DropdownConditionConfig');
/**
* Right panel configuration for dropdown conditions.
*
* Contains an instance of `DropdownConditionEditor`, the class responsible
* for setting dropdown conditions.
*/
export class DropdownConditionConfig extends Disposable {
private _text = Computed.create(this, use => {
const dropdownCondition = use(this._field.dropdownCondition);
if (!dropdownCondition) { return ''; }
return dropdownCondition.text;
});
private _saveError = Observable.create<string | null>(this, null);
private _properties = Computed.create(this, use => {
const dropdownCondition = use(this._field.dropdownCondition);
if (!dropdownCondition?.parsed) { return null; }
return getPredicateFormulaProperties(JSON.parse(dropdownCondition.parsed));
});
private _column = Computed.create(this, use => use(this._field.column));
private _columns = Computed.create(this, use => use(use(use(this._column).table).visibleColumns));
private _refColumns = Computed.create(this, use => {
const refTable = use(use(this._column).refTable);
if (!refTable) { return null; }
return use(refTable.visibleColumns);
});
private _propertiesError = Computed.create<string | null>(this, use => {
const properties = use(this._properties);
if (!properties) { return null; }
const {recColIds = [], choiceColIds = []} = properties;
const columns = use(this._columns);
const validRecColIds = new Set(columns.map((({colId}) => use(colId))));
const invalidRecColIds = recColIds.filter(colId => !validRecColIds.has(colId));
if (invalidRecColIds.length > 0) {
return t('Invalid columns: {{colIds}}', {colIds: invalidRecColIds.join(', ')});
}
const refColumns = use(this._refColumns);
if (refColumns) {
const validChoiceColIds = new Set(['id', ...refColumns.map((({colId}) => use(colId)))]);
const invalidChoiceColIds = choiceColIds.filter(colId => !validChoiceColIds.has(colId));
if (invalidChoiceColIds.length > 0) {
return t('Invalid columns: {{colIds}}', {colIds: invalidChoiceColIds.join(', ')});
}
}
return null;
});
private _error = Computed.create<string | null>(this, (use) => {
const maybeSaveError = use(this._saveError);
if (maybeSaveError) { return maybeSaveError; }
const maybeCompiled = use(this._field.dropdownConditionCompiled);
if (maybeCompiled?.kind === 'failure') { return maybeCompiled.error; }
const maybePropertiesError = use(this._propertiesError);
if (maybePropertiesError) { return maybePropertiesError; }
return null;
});
private _disabled = Computed.create(this, use =>
use(this._field.disableModify) ||
use(use(this._column).disableEditData) ||
use(this._field.config.multiselect)
);
private _isEditingCondition = Observable.create(this, false);
private _isRefField = Computed.create(this, (use) =>
['Ref', 'RefList'].includes(use(use(this._column).pureType)));
private _tooltip = Computed.create(this, use => use(this._isRefField)
? 'setRefDropdownCondition'
: 'setChoiceDropdownCondition');
private _editorElement: HTMLElement;
constructor(private _field: ViewFieldRec) {
super();
this.autoDispose(this._text.addListener(() => {
this._saveError.set('');
}));
}
public buildDom() {
return [
dom.maybe((use) => !(use(this._isEditingCondition) || Boolean(use(this._text))), () => [
cssSetDropdownConditionRow(
dom.domComputed(use => withInfoTooltip(
textButton(
t('Set dropdown condition'),
dom.on('click', () => {
this._isEditingCondition.set(true);
setTimeout(() => this._editorElement.focus(), 0);
}),
dom.prop('disabled', this._disabled),
testId('field-set-dropdown-condition'),
),
use(this._tooltip),
)),
),
]),
dom.maybe((use) => use(this._isEditingCondition) || Boolean(use(this._text)), () => [
cssLabel(t('Dropdown Condition')),
cssRow(
dom.create(buildDropdownConditionEditor,
{
value: this._text,
disabled: this._disabled,
getAutocompleteSuggestions: () => this._getAutocompleteSuggestions(),
onSave: async (value) => {
try {
const widgetOptions = this._field.widgetOptionsJson.peek();
if (value.trim() === '') {
delete widgetOptions.dropdownCondition;
} else {
widgetOptions.dropdownCondition = {text: value};
}
await this._field.widgetOptionsJson.setAndSave(widgetOptions);
} catch (e) {
if (e?.code === 'ACL_DENY') {
reportError(e);
} else {
this._saveError.set(e.message.replace(/^\[Sandbox\]/, '').trim());
}
}
},
onDispose: () => {
this._isEditingCondition.set(false);
},
},
(el) => { this._editorElement = el; },
testId('field-dropdown-condition'),
),
),
dom.maybe(this._error, (error) => cssRow(
cssDropdownConditionError(error), testId('field-dropdown-condition-error')),
),
]),
];
}
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
const variables = ['choice'];
const refColumns = this._refColumns.get();
if (refColumns) {
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`));
}
const columns = this._columns.get();
variables.push(
...columns.map(({colId}) => `$${colId.peek()}`),
...columns.map(({colId}) => `rec.${colId.peek()}`),
);
const suggestions = [
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
'OWNER', 'EDITOR', 'VIEWER',
...variables,
];
return suggestions.map(suggestion => [suggestion, null]);
}
}
const cssSetDropdownConditionRow = styled(cssRow, `
margin-top: 16px;
`);
const cssDropdownConditionError = styled('div', `
color: ${theme.errorText};
margin-top: 4px;
width: 100%;
`);

@ -0,0 +1,234 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {theme} from 'app/client/ui2018/cssVars';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {initializeAceOptions} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup} from 'app/client/widgets/NewBaseEditor';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {
Computed,
Disposable,
dom,
DomElementArg,
Holder,
IDisposableOwner,
Observable,
styled,
} from 'grainjs';
const t = makeT('DropdownConditionEditor');
interface BuildDropdownConditionEditorOptions {
value: Computed<string>;
disabled: Computed<boolean>;
onSave(value: string): Promise<void>;
onDispose(): void;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}
/**
* Builds an editor for dropdown conditions.
*
* Dropdown conditions are client-evaluated predicate formulas used to filter
* items shown in autocomplete dropdowns for Choice and Reference type columns.
*
* Unlike Python formulas, dropdown conditions only support a very limited set of
* features. They are a close relative of ACL formulas, sharing the same underlying
* parser and compiler.
*
* See `sandbox/grist/predicate_formula.py` and `app/common/PredicateFormula.ts` for
* more details on parsing and compiling, respectively.
*/
export function buildDropdownConditionEditor(
owner: IDisposableOwner,
options: BuildDropdownConditionEditorOptions,
...args: DomElementArg[]
) {
const {value, disabled, onSave, onDispose, getAutocompleteSuggestions} = options;
return dom.create(buildHighlightedCode,
value,
{maxLines: 1},
dom.cls(cssDropdownConditionField.className),
dom.cls('disabled'),
cssDropdownConditionField.cls('-disabled', disabled),
{tabIndex: '-1'},
dom.on('focus', (_, refElem) => openDropdownConditionEditor(owner, {
refElem,
value,
onSave,
onDispose,
getAutocompleteSuggestions,
})),
...args,
);
}
function openDropdownConditionEditor(owner: IDisposableOwner, options: {
refElem: Element;
value: Computed<string>;
onSave: (value: string) => Promise<void>;
onDispose: () => void;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}) {
const {refElem, value, onSave, onDispose, getAutocompleteSuggestions} = options;
const saveAndDispose = async () => {
const editorValue = editor.getValue();
if (editorValue !== value.get()) {
await onSave(editorValue);
}
if (editor.isDisposed()) { return; }
editor.dispose();
};
const commands: IEditorCommandGroup = {
fieldEditCancel: () => editor.dispose(),
fieldEditSaveHere: () => editor.blur(),
fieldEditSave: () => editor.blur(),
};
const editor = DropdownConditionEditor.create(owner, {
editValue: value.get(),
commands,
onBlur: saveAndDispose,
getAutocompleteSuggestions,
});
editor.attach(refElem);
editor.onDispose(() => onDispose());
}
interface DropdownConditionEditorOptions {
editValue: string;
commands: IEditorCommandGroup;
onBlur(): Promise<void>;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}
class DropdownConditionEditor extends Disposable {
private _aceEditor: any;
private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement;
private _placementHolder = Holder.create(this);
private _isEmpty: Computed<boolean>;
constructor(private _options: DropdownConditionEditorOptions) {
super();
const initialValue = _options.editValue;
const editorState = Observable.create(this, initialValue);
this._aceEditor = this.autoDispose(AceEditor.create({
calcSize: this._calcSize.bind(this),
editorState,
getSuggestions: _options.getAutocompleteSuggestions,
}));
this._isEmpty = Computed.create(this, editorState, (_use, state) => state === '');
this.autoDispose(this._isEmpty.addListener(() => this._updateEditorPlaceholder()));
const commandGroup = this.autoDispose(createGroup({
..._options.commands,
}, this, true));
this._dom = cssDropdownConditionEditorWrapper(
cssDropdownConditionEditor(
createMobileButtons(_options.commands),
this._aceEditor.buildDom((aceObj: any) => {
initializeAceOptions(aceObj);
const val = initialValue;
const pos = val.length;
this._aceEditor.setValue(val, pos);
this._aceEditor.attachCommandGroup(commandGroup);
if (val === '') {
this._updateEditorPlaceholder();
}
})
),
);
}
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this._placementHolder, this._dom, cellElem, {
margins: getButtonMargins(),
});
this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));
this._aceEditor.onAttach();
this._updateEditorPlaceholder();
this._aceEditor.resize();
this._aceEditor.getEditor().focus();
this._aceEditor.getEditor().on('blur', () => this._options.onBlur());
}
public getValue(): string {
return this._aceEditor.getValue();
}
public blur() {
this._aceEditor.getEditor().blur();
}
private _updateEditorPlaceholder() {
const editor = this._aceEditor.getEditor();
const shouldShowPlaceholder = editor.session.getValue().length === 0;
if (editor.renderer.emptyMessageNode) {
// Remove the current placeholder if one is present.
editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);
}
if (!shouldShowPlaceholder) {
editor.renderer.emptyMessageNode = null;
} else {
editor.renderer.emptyMessageNode = cssDropdownConditionPlaceholder(t('Enter condition.'));
editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);
}
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;
if (placeholder) {
return this._editorPlacement.calcSizeWithPadding(elem, {
width: placeholder.scrollWidth,
height: placeholder.scrollHeight,
});
} else {
return this._editorPlacement.calcSizeWithPadding(elem, {
width: desiredElemSize.width,
height: desiredElemSize.height,
});
}
}
}
const cssDropdownConditionField = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 4px;
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssDropdownConditionEditorWrapper = styled('div.default_editor.formula_editor_wrapper', `
border-radius: 3px;
`);
const cssDropdownConditionEditor = styled('div', `
background-color: ${theme.aceEditorBg};
padding: 5px;
z-index: 10;
overflow: hidden;
flex: none;
min-height: 22px;
border-radius: 3px;
`);
const cssDropdownConditionPlaceholder = styled('div', `
color: ${theme.lightText};
font-style: italic;
white-space: nowrap;
`);

@ -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<string>(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<string>(this, '');
private _spinnerValue = Observable.create<number|''>(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<string>(this, '');
protected value: Observable<string>;
private _choices: string[];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
private _format = this.field.options.formSelectFormat ?? 'select';
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
private _radioButtons: MutableObsArray<{
label: string;
checked: Observable<string|null>
}> = 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<string>(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<boolean>(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<string|null>
}> = 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<string|null>
}> = 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<IPopupOptions>;
private _radioButtons: MutableObsArray<{
label: string;
value: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
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])));
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.
choices.splice(1000);
this._choices = choices.slice(0, 1000);
this.value = Observable.create<string>(this, '');
this._radioButtons.set(this._choices.map(reference => ({
label: String(reference[1]),
value: String(reference[0]),
checked: Observable.create(this, null),
})));
}
public input() {
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<string>({
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');
});
}
}

@ -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', `
@ -11,6 +12,8 @@ export const label = styled('div', `
`);
export const paragraph = styled('div', `
overflow-wrap: break-word;
&-alignment-left {
text-align: left;
}
@ -26,20 +29,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 +106,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;
@ -191,23 +178,85 @@ export const field = styled('div', `
margin-top: 8px;
margin-bottom: 8px;
display: block;
overflow-wrap: break-word;
}
`);
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 +269,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 +284,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 +324,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 +398,7 @@ export const select = styled('select', `
outline: none;
background: white;
line-height: inherit;
height: 27px;
height: 29px;
flex: auto;
width: 100%;
@ -323,11 +418,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%;

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

@ -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<FormTextFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formTextFormat')) ?? 'singleline';
});
private _rowCount = Computed.create<number>(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.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 NumericModel extends Question {
private _format = Computed.create<FormNumberFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text';
});
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.model.field).colId)),
{disabled: true},
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<string[]> = 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;
}
protected choices: Computed<string[]>;
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
});
public renderInput(): HTMLElement {
const field = this.model.field;
private _format = Computed.create<FormSelectFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
});
private _sortOrder = Computed.create<FormOptionsSortOrder>(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<FormToggleFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch';
});
public override buildDom(props: {
edit: Observable<boolean>,
overlay: Observable<boolean>,
@ -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<FormOptionsAlignment>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
});
private _sortOrder = Computed.create<FormOptionsSortOrder>(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<FormSelectFormat>(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<Question> {
switch (type) {
@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor<Question> {
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<Question> {
function testType(value: BindableValue<string>) {
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};
`);

@ -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 format = fieldWithDefault<FormSelectFormat>(
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<FormOptionsAlignment>(
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<FormOptionsSortOrder>(
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: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
const requiredField = fieldWithDefault<boolean>(
this._field.widgetOptionsJson.prop('formRequired'),
false
);
return [
cssSeparator(),
cssLabel(t('Field rules')),
cssLabel(t('Field Rules')),
cssRow(labeledSquareCheckbox(
fromKoSave(requiredField),
t('Required field'),

@ -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<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>;
private _layoutSpec: SaveableObjObservable<FormLayoutNode>;
private _layout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
@ -67,7 +69,7 @@ export class FormView extends Disposable {
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
private _openingForm: Observable<boolean>;
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._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
});
this._autoLayout = Computed.create(this, use => {
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<void>) => {
this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => {
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);

@ -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<void>;
@ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable {
return this._props.hasOwnProperty(name);
}
public async save(before?: () => Promise<void>): Promise<void> {
public async save(before?: () => MaybePromise<void>): Promise<void> {
if (!this.parent) { throw new Error('Cannot save detached box'); }
return this.parent.save(before);
}

@ -62,7 +62,6 @@ export class SectionModel extends BoxModel {
),
)
)},
style.cssSectionEditor.cls(''),
);
}

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

@ -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<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
return dom('input',
dom.prop('value', u => u(obs) || ''),
@ -118,12 +124,17 @@ export function textbox(obs: Observable<string|undefined>, ...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;
overflow-wrap: break-word;
&-required {
display: grid;
grid-template-columns: auto 1fr;
@ -148,7 +159,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 +197,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 +214,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 +235,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 +275,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 +313,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
.${cssFieldEditor.className}-selected > & {
opacity: 1;
}
.${cssFormView.className}-preview & {
display: none;
}
`);
export const cssPlusButton = styled('div', `
@ -288,22 +344,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 +382,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', `
@ -397,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;
}
@ -511,16 +544,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 +558,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 +581,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', `

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

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

@ -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';
@ -191,8 +192,6 @@ export class GristDoc extends DisposableWithEvents {
// Holder for the popped up formula editor.
public readonly formulaPopup = Holder.create(this);
public readonly currentTheme = this.docPageModel.appModel.currentTheme;
public get docApi() {
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
}
@ -237,7 +236,6 @@ export class GristDoc extends DisposableWithEvents {
untrustedContentOrigin: app.topAppModel.getUntrustedContentOrigin(),
docComm: this.docComm,
clientScope: app.clientScope,
theme: this.currentTheme,
});
// Maintain the MetaRowModel for the global document info, including docId and peers.
@ -946,6 +944,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 +963,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);
}
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(result.sectionRef);
this.viewModel.activeSectionId(sectionRef);
}
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
}
@ -1425,6 +1432,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 +1930,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({

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

@ -1345,8 +1345,9 @@ export class Importer extends DisposableWithEvents {
const column = use(field.column);
return use(column.formula);
});
const codeOptions = {gristTheme: this._gristDoc.currentTheme, placeholder: 'Skip', maxLines: 1};
return cssFieldFormula(formula, codeOptions,
const codeOptions = {placeholder: 'Skip', maxLines: 1};
return dom.create(buildHighlightedCode, formula, codeOptions,
dom.cls(cssFieldFormula.className),
dom.cls('disabled'),
dom.cls('formula_field_sidepane'),
{tabIndex: '-1'},
@ -1701,7 +1702,7 @@ const cssColumnMatchRow = styled('div', `
}
`);
const cssFieldFormula = styled(buildHighlightedCode, `
const cssFieldFormula = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 1px;

@ -2,7 +2,7 @@ import {CustomView} from 'app/client/components/CustomView';
import {DataRowModel} from 'app/client/models/DataRowModel';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {prefersDarkMode, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersColorSchemeDark, prefersColorSchemeDarkObs} from 'app/client/ui2018/theme';
import {dom} from 'grainjs';
type RowId = number|'new';
@ -45,7 +45,7 @@ export async function printViewSection(layout: any, viewSection: ViewSectionRec)
// is Grist temporarily reverting to the light theme until the print dialog is dismissed.
// As a workaround, we'll temporarily pause our listener, and unpause after the print dialog
// is dismissed.
prefersDarkModeObs().pause();
prefersColorSchemeDarkObs().pause();
// Hide all layout boxes that do NOT contain the section to be printed.
layout?.forEachBox((box: any) => {
@ -87,10 +87,10 @@ export async function printViewSection(layout: any, viewSection: ViewSectionRec)
prepareToPrint(false);
}
delete (window as any).afterPrintCallback;
prefersDarkModeObs().pause(false);
prefersColorSchemeDarkObs().pause(false);
// This may have changed while window.print() was blocking.
prefersDarkModeObs().set(prefersDarkMode());
prefersColorSchemeDarkObs().set(prefersColorSchemeDark());
});
// Running print on a timeout makes it possible to test printing using selenium, and doesn't

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

@ -7,11 +7,11 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {makeTestId} from 'app/client/lib/domUtils';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {
AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView,
@ -696,10 +696,10 @@ export class ConfigNotifier extends BaseEventSource {
* Notifies about theme changes. Exposed in the API as `onThemeChange`.
*/
export class ThemeNotifier extends BaseEventSource {
constructor(private _theme: Computed<Theme>) {
constructor() {
super();
this.autoDispose(
this._theme.addListener((newTheme, oldTheme) => {
gristThemeObs().addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
this._update();
@ -715,7 +715,7 @@ export class ThemeNotifier extends BaseEventSource {
if (this.isDisposed()) { return; }
this._notify({
theme: this._theme.get(),
theme: gristThemeObs().get(),
fromReady,
});
}

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

@ -51,6 +51,9 @@ export interface ACResults<Item extends ACItem> {
// Matching items in order from best match to worst.
items: Item[];
// Additional items to show (e.g. the "Add New" item, for Choice and Reference fields).
extraItems: Item[];
// May be used to highlight matches using buildHighlightedDom().
highlightFunc: HighlightFunc;
@ -159,7 +162,7 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
if (!cleanedSearchText) {
// In this case we are just returning the first few items.
return {items, highlightFunc: highlightNone, selectIndex: -1};
return {items, extraItems: [], highlightFunc: highlightNone, selectIndex: -1};
}
const highlightFunc = highlightMatches.bind(null, searchWords);
@ -170,7 +173,7 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
if (selectIndex >= 0 && !startsWithText(items[selectIndex], cleanedSearchText, searchWords)) {
selectIndex = -1;
}
return {items, highlightFunc, selectIndex};
return {items, extraItems: [], highlightFunc, selectIndex};
}
/**

@ -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";
@ -97,7 +98,7 @@ export function buildACMemberEmail(
label: text,
id: 0,
};
results.items.push(newObject);
results.extraItems.push(newObject);
}
return results;
};
@ -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")
)

@ -4,8 +4,6 @@ import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Theme} from 'app/common/ThemePrefs';
import {Computed} from 'grainjs';
import {Rpc} from 'grain-rpc';
/**
@ -18,7 +16,6 @@ export class DocPluginManager {
private _clientScope = this._options.clientScope;
private _docComm = this._options.docComm;
private _localPlugins = this._options.plugins;
private _theme = this._options.theme;
private _untrustedContentOrigin = this._options.untrustedContentOrigin;
constructor(private _options: {
@ -26,7 +23,6 @@ export class DocPluginManager {
untrustedContentOrigin: string,
docComm: ActiveDocAPI,
clientScope: ClientScope,
theme: Computed<Theme>,
}) {
this.pluginsList = [];
for (const plugin of this._localPlugins) {
@ -38,7 +34,6 @@ export class DocPluginManager {
clientScope: this._clientScope,
untrustedContentOrigin: this._untrustedContentOrigin,
mainPath: components.safeBrowser,
theme: this._theme,
});
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);

@ -2,8 +2,6 @@ import {ClientScope} from 'app/client/components/ClientScope';
import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Theme} from 'app/common/ThemePrefs';
import {Computed} from 'grainjs';
/**
* Home plugins are all plugins that contributes to a general Grist management tasks.
@ -19,9 +17,8 @@ export class HomePluginManager {
localPlugins: LocalPlugin[],
untrustedContentOrigin: string,
clientScope: ClientScope,
theme: Computed<Theme>,
}) {
const {localPlugins, untrustedContentOrigin, clientScope, theme} = options;
const {localPlugins, untrustedContentOrigin, clientScope} = options;
this.pluginsList = [];
for (const plugin of localPlugins) {
try {
@ -41,7 +38,6 @@ export class HomePluginManager {
clientScope,
untrustedContentOrigin,
mainPath: components.safeBrowser,
theme,
});
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);

@ -1,22 +1,36 @@
import {ACIndex, ACResults} from 'app/client/lib/ACIndex';
import {makeT} from 'app/client/lib/localization';
import {ICellItem} from 'app/client/models/ColumnACIndexes';
import {ColumnCache} from 'app/client/models/ColumnCache';
import {DocData} from 'app/client/models/DocData';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {TableData} from 'app/client/models/TableData';
import {getReferencedTableId, isRefListType} from 'app/common/gristTypes';
import {EmptyRecordView} from 'app/common/PredicateFormula';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Disposable, dom, Observable} from 'grainjs';
const t = makeT('ReferenceUtils');
/**
* Utilities for common operations involving Ref[List] fields.
*/
export class ReferenceUtils {
export class ReferenceUtils extends Disposable {
public readonly refTableId: string;
public readonly tableData: TableData;
public readonly visibleColFormatter: BaseFormatter;
public readonly visibleColModel: ColumnRec;
public readonly visibleColId: string;
public readonly isRefList: boolean;
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
private _dropdownConditionError = Observable.create<string | null>(this, null);
constructor(public readonly field: ViewFieldRec, private readonly _docData: DocData) {
super();
constructor(public readonly field: ViewFieldRec, docData: DocData) {
const colType = field.column().type();
const refTableId = getReferencedTableId(colType);
if (!refTableId) {
@ -24,7 +38,7 @@ export class ReferenceUtils {
}
this.refTableId = refTableId;
const tableData = docData.getTable(refTableId);
const tableData = _docData.getTable(refTableId);
if (!tableData) {
throw new Error("Invalid referenced table " + refTableId);
}
@ -34,6 +48,8 @@ export class ReferenceUtils {
this.visibleColModel = field.visibleColModel();
this.visibleColId = this.visibleColModel.colId() || 'id';
this.isRefList = isRefListType(colType);
this._columnCache = new ColumnCache<ACIndex<ICellItem>>(this.tableData);
}
public idToText(value: unknown) {
@ -43,10 +59,86 @@ export class ReferenceUtils {
return String(value || '');
}
public autocompleteSearch(text: string) {
const acIndex = this.tableData.columnACIndexes.getColACIndex(this.visibleColId, this.visibleColFormatter);
/**
* Searches the autocomplete index for the given `text`, returning
* all matching results and related metadata.
*
* If a dropdown condition is set, results are dependent on the `rowId`
* that the autocomplete dropdown is open in. Otherwise, `rowId` has no
* effect.
*/
public autocompleteSearch(text: string, rowId: number): ACResults<ICellItem> {
let acIndex: ACIndex<ICellItem>;
if (this.hasDropdownCondition) {
try {
acIndex = this._getDropdownConditionACIndex(rowId);
} catch (e) {
this._dropdownConditionError?.set(e);
return {items: [], extraItems: [], highlightFunc: () => [], selectIndex: -1};
}
} else {
acIndex = this.tableData.columnACIndexes.getColACIndex(
this.visibleColId,
this.visibleColFormatter,
);
}
return acIndex.search(text);
}
public buildNoItemsMessage() {
return dom.domComputed(use => {
const error = use(this._dropdownConditionError);
if (error) { return t('Error in dropdown condition'); }
return this.hasDropdownCondition
? t('No choices matching condition')
: t('No choices to select');
});
}
/**
* Returns a column index for the visible column, filtering the items in the
* index according to the set dropdown condition.
*
* This method is similar to `this.tableData.columnACIndexes.getColACIndex`,
* but whereas that method caches indexes globally, this method does so
* locally (as a new instances of this class is created each time a Reference
* or Reference List editor is created).
*
* It's important that this method be used when a dropdown condition is set,
* as items in indexes that don't satisfy the dropdown condition need to be
* filtered.
*/
private _getDropdownConditionACIndex(rowId: number) {
return this._columnCache.getValue(
this.visibleColId,
() => this.tableData.columnACIndexes.buildColACIndex(
this.visibleColId,
this.visibleColFormatter,
this._buildDropdownConditionACFilter(rowId)
)
);
}
private _buildDropdownConditionACFilter(rowId: number) {
const dropdownConditionCompiled = this.field.dropdownConditionCompiled.get();
if (dropdownConditionCompiled?.kind !== 'success') {
throw new Error('Dropdown condition is not compiled');
}
const tableId = this.field.tableId.peek();
const table = this._docData.getTable(tableId);
if (!table) { throw new Error(`Table ${tableId} not found`); }
const {result: predicate} = dropdownConditionCompiled;
const rec = table.getRecord(rowId) || new EmptyRecordView();
return (item: ICellItem) => {
const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);
if (!choice) { throw new Error(`Reference ${item.rowId} not found`); }
return predicate({rec, choice});
};
}
}
export function nocaseEqual(a: string, b: string) {

@ -33,6 +33,7 @@ import { ClientScope } from 'app/client/components/ClientScope';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import dom from 'app/client/lib/dom';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { gristThemeObs } from 'app/client/ui2018/theme';
import { ActionRouter } from 'app/common/ActionRouter';
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
import { tbind } from 'app/common/tbind';
@ -41,7 +42,7 @@ import { getOriginUrl } from 'app/common/urlUtils';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
import { checkers } from 'app/plugin/TypeCheckers';
import { Computed, dom as grainjsDom, Observable } from 'grainjs';
import { dom as grainjsDom, Observable } from 'grainjs';
import { IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from 'grain-rpc';
import { Disposable } from './dispose';
import isEqual from 'lodash/isEqual';
@ -73,8 +74,6 @@ export class SafeBrowser extends BaseComponent {
new IframeProcess(safeBrowser, rpc, src);
}
public theme = this._options.theme;
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
// events to all processes (such as doc actions which will need soon).
@ -94,7 +93,6 @@ export class SafeBrowser extends BaseComponent {
pluginInstance: PluginInstance,
clientScope: ClientScope,
untrustedContentOrigin: string,
theme: Computed<Theme>,
mainPath?: string,
baseLogger?: BaseLogger,
rpcLogger?: IRpcLogger,
@ -312,7 +310,7 @@ class IframeProcess extends ViewProcess {
const listener = async (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
if (event.data.mtype === MsgType.Ready) {
await this._sendTheme({theme: safeBrowser.theme.get(), fromReady: true});
await this._sendTheme({theme: gristThemeObs().get(), fromReady: true});
}
if (event.data.data?.message === 'themeInitialized') {
@ -328,15 +326,11 @@ class IframeProcess extends ViewProcess {
});
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
if (safeBrowser.theme) {
this.autoDispose(
safeBrowser.theme.addListener(async (newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
this.autoDispose(gristThemeObs().addListener(async (newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
await this._sendTheme({theme: newTheme});
})
);
}
await this._sendTheme({theme: newTheme});
}));
}
private async _sendTheme({theme, fromReady = false}: {theme: Theme, fromReady?: boolean}) {

@ -4,7 +4,8 @@
import {createPopper, Modifier, Instance as Popper, Options as PopperOptions} from '@popperjs/core';
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
import {reportError} from 'app/client/models/errors';
import {Disposable, dom, EventCB, IDisposable} from 'grainjs';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, DomContents, EventCB, IDisposable} from 'grainjs';
import {obsArray, onKeyElem, styled} from 'grainjs';
import merge = require('lodash/merge');
import maxSize from 'popper-max-size-modifier';
@ -26,6 +27,9 @@ export interface IAutocompleteOptions<Item extends ACItem> {
// Defaults to the document body.
attach?: Element|string|null;
// If provided, builds and shows the message when there are no items (excluding any extra items).
buildNoItemsMessage?: () => DomContents;
// Given a search term, return the list of Items to render.
search(searchText: string): Promise<ACResults<Item>>;
@ -46,7 +50,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
// The UL element containing the actual menu items.
protected _menuContent: HTMLElement;
// Index into _items as well as into _menuContent, -1 if nothing selected.
// Index into _menuContent, -1 if nothing selected.
protected _selectedIndex: number = -1;
// Currently selected element.
@ -56,6 +60,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
private _mouseOver: {reset(): void};
private _lastAsTyped: string;
private _items = this.autoDispose(obsArray<Item>([]));
private _extraItems = this.autoDispose(obsArray<Item>([]));
private _highlightFunc: HighlightFunc;
constructor(
@ -65,14 +70,19 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
super();
const content = cssMenuWrap(
this._menuContent = cssMenu({class: _options.menuCssClass || ''},
dom.forEach(this._items, (item) => _options.renderItem(item, this._highlightFunc)),
cssMenu(
{class: _options.menuCssClass || ''},
dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (_options.onClick) { _options.onClick(); }
})
this._maybeShowNoItemsMessage(),
this._menuContent = dom('div',
dom.forEach(this._items, (item) => _options.renderItem(item, this._highlightFunc)),
dom.forEach(this._extraItems, (item) => _options.renderItem(item, this._highlightFunc)),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (_options.onClick) { _options.onClick(); }
}),
),
),
// Prevent trigger element from being blurred on click.
dom.on('mousedown', (ev) => ev.preventDefault()),
@ -104,7 +114,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
}
public getSelectedItem(): Item|undefined {
return this._items.get()[this._selectedIndex];
return this._allItems[this._selectedIndex];
}
public search(findMatch?: (items: Item[]) => number) {
@ -145,7 +155,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
private _getNext(step: 1 | -1): number {
// Pretend there is an extra element at the end to mean "nothing selected".
const xsize = this._items.get().length + 1;
const xsize = this._allItems.length + 1;
const next = (this._selectedIndex + step + xsize) % xsize;
return (next === xsize - 1) ? -1 : next;
}
@ -157,6 +167,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
const acResults = await this._options.search(inputVal);
this._highlightFunc = acResults.highlightFunc;
this._items.set(acResults.items);
this._extraItems.set(acResults.extraItems);
// Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling
// before the positions are updated, it causes the entire page to scroll horizontally.
@ -166,12 +177,24 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
let index: number;
if (findMatch) {
index = findMatch(this._items.get());
index = findMatch(this._allItems);
} else {
index = inputVal ? acResults.selectIndex : -1;
}
this._setSelected(index, false);
}
private get _allItems() {
return [...this._items.get(), ...this._extraItems.get()];
}
private _maybeShowNoItemsMessage() {
const {buildNoItemsMessage} = this._options;
if (!buildNoItemsMessage) { return null; }
return dom.maybe(use => use(this._items).length === 0, () =>
cssNoItemsMessage(buildNoItemsMessage(), testId('autocomplete-no-items-message')));
}
}
@ -253,3 +276,10 @@ const cssMenuWrap = styled('div', `
flex-direction: column;
outline: none;
`);
const cssNoItemsMessage = styled('div', `
color: ${theme.lightText};
padding: var(--weaseljs-menu-item-padding, 8px 24px);
text-align: center;
user-select: none;
`);

@ -6,17 +6,20 @@ import * as GristDocModule from 'app/client/components/GristDoc';
import * as ViewPane from 'app/client/components/ViewPane';
import * as UserManagerModule from 'app/client/ui/UserManager';
import * as searchModule from 'app/client/ui2018/search';
import * as ace from 'ace-builds';
import * as momentTimezone from 'moment-timezone';
import * as plotly from 'plotly.js';
export type PlotlyType = typeof plotly;
export type Ace = typeof ace;
export type MomentTimezone = typeof momentTimezone;
export type PlotlyType = typeof plotly;
export function loadAccountPage(): Promise<typeof AccountPageModule>;
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadAdminPanel(): Promise<typeof AdminPanelModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadAce(): Promise<Ace>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;
export function loadSearch(): Promise<typeof searchModule>;

@ -13,6 +13,17 @@ exports.loadAdminPanel = () => import('app/client/ui/AdminPanel' /* webpackChunk
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
// When importing this way, the module is under the "default" member, not sure why (maybe
// esbuild-loader's doing).
exports.loadAce = () => import('ace-builds')
.then(async (m) => {
await Promise.all([
import('ace-builds/src-noconflict/ext-static_highlight'),
import('ace-builds/src-noconflict/mode-python'),
import('ace-builds/src-noconflict/theme-chrome'),
import('ace-builds/src-noconflict/theme-dracula'),
]);
return m.default;
});
exports.loadMomentTimezone = () => import('moment-timezone').then(m => m.default);
exports.loadPlotly = () => import('plotly.js-basic-dist' /* webpackChunkName: "plotly" */);
exports.loadSearch = () => import('app/client/ui2018/search' /* webpackChunkName: "search" */);

@ -0,0 +1,14 @@
/**
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
* more precise about what exactly to allow).
*/
// eslint-disable-next-line no-control-regex
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
/**
* Test name against various rules to check if it is a valid username.
*/
export function checkName(name: string): boolean {
return VALID_NAME_REGEXP.test(name);
}

@ -0,0 +1,27 @@
import moment from 'moment';
/**
* Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
* relative time to now - e.g. 'yesterday', '2 days ago'.
*/
export function getTimeFromNow(utcDateISO: string): string
/**
* Given a unix timestamp (in milliseconds), gives a reader-friendly
* relative time to now - e.g. 'yesterday', '2 days ago'.
*/
export function getTimeFromNow(ms: number): string
export function getTimeFromNow(isoOrTimestamp: string|number): string {
const time = moment.utc(isoOrTimestamp);
const now = moment();
const diff = now.diff(time, 's');
if (diff < 0 && diff > -60) {
// If the time appears to be in the future, but less than a minute
// in the future, chalk it up to a difference in time
// synchronization and don't claim the resource will be changed in
// the future. For larger differences, just report them
// literally, there's a more serious problem or lack of
// synchronization.
return now.fromNow();
}
return time.fromNow();
}

@ -0,0 +1,165 @@
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, Observable, UseCBOwner } from 'grainjs';
/**
* Manage a collection of checks about the status of Grist, for
* presentation on the admin panel or the boot page.
*/
export class AdminChecks {
// The back end will offer a set of probes (diagnostics) we
// can use. Probes have unique IDs.
public probes: Observable<BootProbeInfo[]>;
// Keep track of probe requests we are making, by probe ID.
private _requests: Map<string, AdminCheckRunner>;
// Keep track of probe results we have received, by probe ID.
private _results: Map<string, Observable<BootProbeResult>>;
constructor(private _parent: Disposable) {
this.probes = Observable.create(_parent, []);
this._results = new Map();
this._requests = new Map();
}
/**
* Fetch a list of available checks from the server.
*/
public async fetchAvailableChecks() {
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
//
// We have been careful to make URLs available with appropriate
// middleware relative to both of the admin panel and the boot page.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
const resp = await fetch(url.href);
const _probes = await resp.json();
this.probes.set(_probes.probes);
}
}
/**
* Request the result of one of the available checks. Returns information
* about the check and a way to observe the result when it arrives.
*/
public requestCheck(probe: BootProbeInfo): AdminCheckRequest {
const {id} = probe;
let result = this._results.get(id);
if (!result) {
result = Observable.create(this._parent, {});
this._results.set(id, result);
}
let request = this._requests.get(id);
if (!request) {
request = new AdminCheckRunner(id, this._results, this._parent);
this._requests.set(id, request);
}
request.start();
return {
probe,
result,
details: probeDetails[id],
};
}
/**
* Request the result of a check, by its id.
*/
public requestCheckById(use: UseCBOwner, id: BootProbeIds): AdminCheckRequest|undefined {
const probe = use(this.probes).find(p => p.id === id);
if (!probe) { return; }
return this.requestCheck(probe);
}
}
/**
* Information about a check and a way to observe its result once available.
*/
export interface AdminCheckRequest {
probe: BootProbeInfo,
result: Observable<BootProbeResult>,
details: ProbeDetails,
}
/**
* Manage a single check.
*/
export class AdminCheckRunner {
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
public parent: Disposable) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
const ob = results.get(id);
if (ob) {
ob.set(_probes);
}
}).catch(e => console.error(e));
}
public start() {
let result = this.results.get(this.id);
if (!result) {
result = Observable.create(this.parent, {});
this.results.set(this.id, result);
}
}
}
/**
* Basic information about diagnostics is kept on the server,
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
or make GRIST_BOOT_KEY long and cryptographically secure.
`,
},
'health-check': {
info: `
Grist has a small built-in health check often used when running
it as a container.
`,
},
'host-header': {
info: `
Requests arriving to Grist should have an accurate Host
header. This is essential when GRIST_SERVE_SAME_ORIGIN
is set.
`,
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
`,
},
'reachable': {
info: `
The main page of Grist should be available.
`
},
};
/**
* Information about the probe.
*/
export interface ProbeDetails {
info: string;
}

@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {gristThemePrefs} from 'app/client/ui2018/theme';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage';
@ -21,9 +21,7 @@ import {LocalPlugin} from 'app/common/plugin';
import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs';
import {isOwner, isOwnerOrEditor} from 'app/common/roles';
import {getTagManagerScript} from 'app/common/tagManager';
import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getDefaultThemePrefs, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {ExtendedUser} from 'app/common/UserAPI';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
@ -118,7 +116,6 @@ export interface AppModel {
userPrefsObs: Observable<UserPrefs>;
themePrefs: Observable<ThemePrefs>;
currentTheme: Computed<Theme>;
/**
* Popups that user has seen.
*/
@ -170,8 +167,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}) {
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
@ -307,7 +305,6 @@ export class AppModelImpl extends Disposable implements AppModel {
defaultValue: getDefaultThemePrefs(),
checker: ThemePrefsChecker,
}) as Observable<ThemePrefs>;
public readonly currentTheme = this._getCurrentThemeObs();
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
@ -359,6 +356,11 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly orgError?: OrgError,
) {
super();
// Whenever theme preferences change, update the global `gristThemePrefs` observable; this triggers
// an automatic update to the global `gristThemeObs` computed observable.
this.autoDispose(subscribe(this.themePrefs, (_use, themePrefs) => gristThemePrefs.set(themePrefs)));
this._recordSignUpIfIsNewUser();
const state = urlState().state.get();
@ -493,41 +495,14 @@ export class AppModelImpl extends Disposable implements AppModel {
dataLayer.push({event: 'new-sign-up'});
getUserPrefObs(this.userPrefsObs, 'recordSignUpEvent').set(undefined);
}
}
private _getCurrentThemeObs() {
return Computed.create(this, this.themePrefs, prefersDarkModeObs(),
(_use, themePrefs, prefersDarkMode) => {
let {appearance, syncWithOS} = themePrefs;
const urlParams = urlState().state.get().params;
if (urlParams?.themeAppearance) {
appearance = urlParams?.themeAppearance;
}
if (urlParams?.themeSyncWithOs !== undefined) {
syncWithOS = urlParams?.themeSyncWithOs;
}
if (syncWithOS) {
appearance = prefersDarkMode ? 'dark' : 'light';
}
let nameOrColors = themePrefs.colors[appearance];
if (urlParams?.themeName) {
nameOrColors = urlParams?.themeName;
}
let colors: ThemeColors;
if (typeof nameOrColors === 'string') {
colors = getThemeColors(nameOrColors);
} else {
colors = nameOrColors;
}
return {appearance, colors};
},
);
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
if (!org) { return ''; }
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
return "@Guest";
}
return getOrgName(org);
}
export function getHomeUrl(): string {
@ -541,11 +516,3 @@ export function newUserAPIImpl(): UserAPIImpl {
fetch: hooks.fetch,
});
}
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
if (!org) { return ''; }
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
return "@Guest";
}
return getOrgName(org);
}

@ -21,7 +21,6 @@ export interface ICellItem {
cleanText: string; // Trimmed lowercase text for searching.
}
export class ColumnACIndexes {
private _columnCache = new ColumnCache<ACIndex<ICellItem>>(this._tableData);
@ -33,22 +32,28 @@ export class ColumnACIndexes {
* getColACIndex() is called for the same column with the the same formatter.
*/
public getColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
return this._columnCache.getValue(colId, () => this._buildColACIndex(colId, formatter));
return this._columnCache.getValue(colId, () => this.buildColACIndex(colId, formatter));
}
private _buildColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
public buildColACIndex(
colId: string,
formatter: BaseFormatter,
filter?: (item: ICellItem) => boolean
): ACIndex<ICellItem> {
const rowIds = this._tableData.getRowIds();
const valColumn = this._tableData.getColValues(colId);
if (!valColumn) {
throw new UserError(`Invalid column ${this._tableData.tableId}.${colId}`);
}
const items: ICellItem[] = valColumn.map((val, i) => {
const rowId = rowIds[i];
const text = formatter.formatAny(val);
const cleanText = normalizeText(text);
return {rowId, text, cleanText};
});
items.sort(itemCompare);
const items: ICellItem[] = valColumn
.map((val, i) => {
const rowId = rowIds[i];
const text = formatter.formatAny(val);
const cleanText = normalizeText(text);
return {rowId, text, cleanText};
})
.filter((item) => filter?.(item) ?? true)
.sort(itemCompare);
return new ACIndexImpl(items);
}
}

@ -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'),
),

@ -14,30 +14,11 @@ import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Document, Organization, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import moment from 'moment';
import flatten = require('lodash/flatten');
import sortBy = require('lodash/sortBy');
const DELAY_BEFORE_SPINNER_MS = 500;
// Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
// relative time to now - e.g. 'yesterday', '2 days ago'.
export function getTimeFromNow(utcDateISO: string): string {
const time = moment.utc(utcDateISO);
const now = moment();
const diff = now.diff(time, 's');
if (diff < 0 && diff > -60) {
// If the time appears to be in the future, but less than a minute
// in the future, chalk it up to a difference in time
// synchronization and don't claim the resource will be changed in
// the future. For larger differences, just report them
// literally, there's a more serious problem or lack of
// synchronization.
return now.fromNow();
}
return time.fromNow();
}
export interface HomeModel {
// PageType value, one of the discriminated union values used by AppModel.
pageType: "home";
@ -190,7 +171,6 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
localPlugins: _app.topAppModel.plugins,
untrustedContentOrigin: _app.topAppModel.getUntrustedContentOrigin()!,
clientScope,
theme: _app.currentTheme,
});
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
this.importSources.set(importSources);

@ -2,12 +2,15 @@ import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRe
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import { HeaderStyle, Style } from 'app/client/models/Styles';
import {HeaderStyle, Style} from 'app/client/models/Styles';
import {ViewFieldConfig} from 'app/client/models/ViewFieldConfig';
import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings';
import {DropdownCondition, DropdownConditionCompilationResult} from 'app/common/DropdownCondition';
import {compilePredicateFormula} from 'app/common/PredicateFormula';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {createParser} from 'app/common/ValueParser';
import {Computed} from 'grainjs';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
@ -106,6 +109,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
/** Label in FormView. By default FormView uses label, use this to override it. */
question: modelUtil.KoSaveableObservable<string|undefined>;
dropdownCondition: modelUtil.KoSaveableObservable<DropdownCondition|undefined>;
dropdownConditionCompiled: Computed<DropdownConditionCompilationResult|null>;
createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula.
@ -316,4 +322,21 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.disableModify = this.autoDispose(ko.pureComputed(() => this.column().disableModify()));
this.disableEditData = this.autoDispose(ko.pureComputed(() => this.column().disableEditData()));
this.dropdownCondition = this.widgetOptionsJson.prop('dropdownCondition');
this.dropdownConditionCompiled = Computed.create(this, use => {
const dropdownCondition = use(this.dropdownCondition);
if (!dropdownCondition?.parsed) { return null; }
try {
return {
kind: 'success',
result: compilePredicateFormula(JSON.parse(dropdownCondition.parsed), {
variant: 'dropdown-condition',
}),
};
} catch (e) {
return {kind: 'failure', error: e.message};
}
});
}

@ -1,4 +1,5 @@
import {detectCurrentLang, makeT} from 'app/client/lib/localization';
import {checkName} from 'app/client/lib/nameUtils';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import * as css from 'app/client/ui/AccountPageCss';
@ -249,23 +250,6 @@ designed to ensure that you're the only person who can access your account, even
}
}
/**
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
* more precise about what exactly to allow).
*/
// eslint-disable-next-line no-control-regex
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
/**
* Test name against various rules to check if it is a valid username.
*/
export function checkName(name: string): boolean {
return VALID_NAME_REGEXP.test(name);
}
const cssWarnings = styled(css.warning, `
margin: -8px 0 0 110px;
`);

@ -1,9 +1,9 @@
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import * as version from 'app/common/version';
import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {AdminChecks} from 'app/client/models/AdminChecks';
import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
@ -12,10 +12,20 @@ import {SupportGristPage} from 'app/client/ui/SupportGristPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {transition} from 'app/client/ui/transitions';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {basicButton} from 'app/client/ui2018/buttons';
import {toggle} from 'app/client/ui2018/checkbox';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
import {cssLink, makeLinks} from 'app/client/ui2018/links';
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
import {naturalCompare} from 'app/common/SortFunc';
import {getGristConfig} from 'app/common/urlUtils';
import * as version from 'app/common/version';
import {Computed, Disposable, dom, DomContents, IDisposable,
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
const t = makeT('AdminPanel');
@ -26,13 +36,19 @@ export function getAdminPanelName() {
export class AdminPanel extends Disposable {
private _supportGrist = SupportGristPage.create(this, this._appModel);
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
private _checks: AdminChecks;
constructor(private _appModel: AppModel) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
this._checks = new AdminChecks(this);
}
public buildDom() {
this._checks.fetchAvailableChecks().catch(err => {
reportError(err);
});
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
@ -62,7 +78,7 @@ export class AdminPanel extends Disposable {
);
}
private _buildMainContent(owner: IDisposableOwner) {
private _buildMainContent(owner: MultiHolder) {
return cssPageContainer(
dom.cls('clipboard'),
{tabIndex: "-1"},
@ -83,6 +99,16 @@ export class AdminPanel extends Disposable {
expandedContent: this._supportGrist.buildSponsorshipSection(),
}),
),
cssSection(
cssSectionTitle(t('Security Settings')),
this._buildItem(owner, {
id: 'sandboxing',
name: t('Sandboxing'),
description: t('Sandbox settings for data engine'),
value: this._buildSandboxingDisplay(owner),
expandedContent: this._buildSandboxingNotice(),
}),
),
cssSection(
cssSectionTitle(t('Version')),
this._buildItem(owner, {
@ -91,11 +117,48 @@ export class AdminPanel extends Disposable {
description: t('Current version of Grist'),
value: cssValueLabel(`Version ${version.version}`),
}),
this._buildUpdates(owner),
),
testId('admin-panel'),
);
}
private _buildSandboxingDisplay(owner: IDisposableOwner) {
return dom.domComputed(
use => {
const req = this._checks.requestCheckById(use, 'sandboxing');
const result = req ? use(req.result) : undefined;
const success = result?.success;
const details = result?.details as SandboxingBootProbeDetails|undefined;
if (!details) {
return cssValueLabel(t('unknown'));
}
const flavor = details.flavor;
const configured = details.configured;
return cssValueLabel(
configured ?
(success ? cssHappy(t('OK') + `: ${flavor}`) :
cssError(t('Error') + `: ${flavor}`)) :
cssError(t('unconfigured')));
}
);
}
private _buildSandboxingNotice() {
return [
t('Grist allows for very powerful formulas, using Python. \
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
if your hardware supports it (most will), \
to run formulas in each document within a sandbox \
isolated from other documents and isolated from the network.'),
dom(
'div',
{style: 'margin-top: 8px'},
cssLink({href: commonUrls.helpSandboxing, target: '_blank'}, t('Learn more.'))
),
];
}
private _buildItem(owner: IDisposableOwner, options: {
id: string,
name: DomContents,
@ -138,18 +201,229 @@ export class AdminPanel extends Disposable {
);
}
}
private _buildUpdates(owner: MultiHolder) {
// We can be in those states:
enum State {
// Never checked before (no last version or last check time).
// Shows "No information available" [Check now]
NEVER,
// Did check previously, but it was a while ago, user should press the button to check.
// Shows "Last checked X days ago" [Check now]
STALE,
// In the middle of checking for updates.
CHECKING,
// Transient state, shown after Check now is clicked.
// Grist is up to date (state only shown after a successful check), or even upfront.
// Won't be shown after page is reloaded.
// Shows "Checking for updates..."
CURRENT,
// A newer version is available. Can be shown after reload if last
// version that was checked is newer than the current version.
// Shows "Newer version available" [version]
AVAILABLE,
// Error occurred during this check. If the error occurred during last check
// it is not stored.
// Shows "Error checking for updates" [Check now]
ERROR,
}
// Are updates enabled at all.
const defaultValue = {
onLoad: false,
lastCheckDate: null as number|null,
lastVersion: null as string|null,
};
const prop = <T extends keyof typeof defaultValue>(key: T) => {
const computed = Computed.create(owner, (use) => use(settings)[key]);
computed.onWrite((val) => settings.set({...settings.get(), [key]: val}));
return computed as Observable<typeof defaultValue[T]>;
};
const settings = owner.autoDispose(localStorageJsonObs('new-version-check', defaultValue));
const onLoad = prop('onLoad');
const latestVersion = prop('lastVersion');
const lastCheckDate = prop('lastCheckDate');
const comparison = Computed.create(owner, (use) => {
const versions = [version.version, use(latestVersion)];
if (!versions[1]) {
return null;
}
// Sort them in natural order, so that "1.10" comes after "1.9".
versions.sort(naturalCompare).reverse();
if (versions[0] === version.version) {
return 'old';
} else {
return 'new';
}
});
// Observable state of the updates check.
const state: Observable<State> = Observable.create(owner, State.NEVER);
// The background task that checks for updates, can be disposed (cancelled) when needed.
let backgroundTask: IDisposable|null = null;
// By default we link to the GitHub releases page, but the endpoint might say something different.
let releaseURL = 'https://github.com/gristlabs/grist-core/releases';
// All the events that might occur
const actions = {
checkForUpdates: async () => {
state.set(State.CHECKING);
latestVersion.set(null);
// We can be disabled, why the check is in progress.
const controller = new AbortController();
backgroundTask = {
dispose() {
if (controller.signal.aborted) { return; }
backgroundTask = null;
controller.abort();
}
};
owner.autoDispose(backgroundTask);
try {
const result = await this._installAPI.checkUpdates();
if (controller.signal.aborted) { return; }
actions.gotLatestVersion(result);
} catch(err) {
if (controller.signal.aborted) { return; }
state.set(State.ERROR);
reportError(err);
}
},
disableAutoCheck: () => {
backgroundTask?.dispose();
backgroundTask = null;
onLoad.set(false);
},
enableAutoCheck: () => {
onLoad.set(true);
if (state.get() !== State.CHECKING && state.get() !== State.AVAILABLE) {
actions.checkForUpdates().catch(reportError);
}
},
gotLatestVersion: (data: LatestVersion) => {
lastCheckDate.set(Date.now());
latestVersion.set(data.latestVersion);
releaseURL = data.updateURL || releaseURL;
const result = comparison.get();
switch (result) {
case 'old': state.set(State.CURRENT); break;
case 'new': state.set(State.AVAILABLE); break;
// This should not happen, but if it does, we should show the error.
default: state.set(State.ERROR); break;
}
}
};
const description = Computed.create(owner, (use) => {
switch (use(state)) {
case State.NEVER: return t('No information available');
case State.CHECKING: return '⌛ ' + t('Checking for updates...');
case State.CURRENT: return '✅ ' + t('Grist is up to date');
case State.AVAILABLE: return t('Newer version available');
case State.ERROR: return '❌ ' + t('Error checking for updates');
case State.STALE: {
const lastCheck = use(lastCheckDate);
return t('Last checked {{time}}', {time: lastCheck ? getTimeFromNow(lastCheck) : 'n/a'});
}
}
});
// Now trigger the initial state, by checking if we should auto-check.
if (onLoad.get()) {
actions.checkForUpdates().catch(reportError);
} else {
if (comparison.get() === 'new') {
state.set(State.AVAILABLE);
} else if (comparison.get() === 'old') {
state.set(State.STALE);
} else {
state.set(State.NEVER); // default one.
}
}
// Toggle component operates on a boolean observable, without a way to set the value. So
// create a controller for it to intercept the write and call the appropriate action.
const enabledController = Computed.create(owner, (use) => use(onLoad));
enabledController.onWrite((val) => {
if (val) {
actions.enableAutoCheck();
} else {
actions.disableAutoCheck();
}
});
const upperCheckNowVisible = Computed.create(owner, (use) => {
switch (use(state)) {
case State.CHECKING:
case State.CURRENT:
case State.AVAILABLE:
return false;
default:
return true;
}
});
return this._buildItem(owner, {
id: 'updates',
name: t('Updates'),
description: dom('span', testId('admin-panel-updates-message'), dom.text(description)),
value: cssValueButton(
dom.domComputed(use => {
if (use(state) === State.CHECKING) {
return null;
}
if (use(upperCheckNowVisible)) {
return basicButton(
t('Check now'),
dom.on('click', actions.checkForUpdates),
testId('admin-panel-updates-upper-check-now')
);
}
if (use(latestVersion)) {
return cssValueLabel(`Version ${use(latestVersion)}`, testId('admin-panel-updates-version'));
}
throw new Error('Invalid state');
})
),
expandedContent: cssColumns(
cssColumn(
cssColumn.cls('-left'),
dom('div', t('Grist releases are at '), makeLinks(releaseURL)),
dom.maybe(lastCheckDate, ms => dom('div',
dom('span', t('Last checked {{time}}', {time: getTimeFromNow(ms)})),
dom('span', ' '),
// Format date in local format.
cssGrayed(new Date(ms).toLocaleString()),
)),
dom('div', t('Auto-check when this page loads')),
),
cssColumn(
cssColumn.cls('-right'),
// `Check now` button, only shown when auto checks are enabled and we are not in the
// middle of checking. Otherwise the button is shown in the summary row, and there is
// no need to duplicate it.
dom.maybe(use => !use(upperCheckNowVisible), () => [
cssCheckNowButton(
t('Check now'),
testId('admin-panel-updates-lower-check-now'),
dom.on('click', actions.checkForUpdates),
dom.prop('disabled', use => use(state) === State.CHECKING),
),
]),
toggle(enabledController, testId('admin-panel-updates-auto-check')),
),
)
});
}
}
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
return dom('div.widget_switch',
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
dom.hide((use) => use(value) === null),
dom.cls('switch_on', (use) => use(value) || false),
dom.cls('switch_transition', true),
dom.on('click', () => value.set(!value.get())),
dom('div.switch_slider'),
dom('div.switch_circle'),
);
return toggle(value, dom.hide((use) => use(value) === null));
}
const cssPageContainer = styled('div', `
@ -215,7 +489,7 @@ const cssItemName = styled('div', `
font-weight: bold;
font-size: ${vars.largeFontSize};
&:first-child {
margin-left: 28px;
margin-left: 24px;
}
@media ${mediaSmall} {
& {
@ -267,4 +541,60 @@ const cssValueLabel = styled('div', `
color: ${theme.text};
border: 1px solid ${theme.inputBorder};
border-radius: ${vars.controlBorderRadius};
&-empty {
visibility: hidden;
content: " ";
}
`);
// A wrapper for the version details panel. Shows two columns.
// First grows as needed, second shrinks as needed and is aligned to the bottom.
const cssColumns = styled('div', `
display: flex;
align-items: flex-end;
& > div:first-child {
flex-grow: 1;
flex-shrink: 0;
}
& > div:last-child {
flex-shrink: 1;
}
`);
const cssColumn = styled('div', `
display: flex;
flex-direction: column;
gap: 8px;
flex-grow: 1;
flex-shrink: 1;
margin-block: 1px; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */
&-left {
align-items: flex-start;
}
&-right {
align-items: flex-end;
justify-content: flex-end;
}
`);
const cssValueButton = styled('div', `
height: 30px;
`);
const cssCheckNowButton = styled(basicButton, `
&-hidden {
visibility: hidden;
}
`);
const cssGrayed = styled('span', `
color: ${theme.lightText};
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);
export const cssHappy = styled('div', `
color: ${theme.controlFg};
`);

@ -14,6 +14,7 @@ import {setUpErrorHandling} from 'app/client/models/errors';
import {createAppUI} from 'app/client/ui/AppUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {CommDocError} from 'app/common/CommTypes';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
@ -183,6 +184,7 @@ export class App extends DisposableWithEvents {
// Add the cssRootVars class to enable the variables in cssVars.
attachCssRootVars(this.topAppModel.productFlavor);
attachTheme();
addViewportTag();
this.autoDispose(createAppUI(this.topAppModel, this));
}

@ -17,7 +17,7 @@ import {pagePanels} from 'app/client/ui/PagePanels';
import {RightPanel} from 'app/client/ui/RightPanel';
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
import {WelcomePage} from 'app/client/ui/WelcomePage';
import {attachTheme, testId} from 'app/client/ui2018/cssVars';
import {testId} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs';
@ -27,9 +27,7 @@ import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent
// TODO once #newui is gone, we don't need to worry about this being disposable.
// appObj is the App object from app/client/ui/App.ts
export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable {
const content = dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
const content = dom.maybe(topAppModel.appObs, (appModel) => {
return [
createMainPage(appModel, appObj),
buildSnackbarDom(appModel.notifier, appModel),

@ -1,45 +1,112 @@
import * as ace from 'ace-builds';
import {Ace, loadAce} from 'app/client/lib/imports';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {BindableValue, Computed, dom, DomElementArg, Observable, styled, subscribeElem} from 'grainjs';
// ace-builds also has a minified build (src-min-noconflict), but we don't
// use it since webpack already handles minification.
require('ace-builds/src-noconflict/ext-static_highlight');
require('ace-builds/src-noconflict/mode-python');
require('ace-builds/src-noconflict/theme-chrome');
require('ace-builds/src-noconflict/theme-dracula');
export interface ICodeOptions {
gristTheme: Computed<Theme>;
placeholder?: string;
import {gristThemeObs} from 'app/client/ui2018/theme';
import {
BindableValue,
Disposable,
DomElementArg,
Observable,
styled,
subscribeElem,
} from 'grainjs';
interface BuildCodeHighlighterOptions {
maxLines?: number;
}
export function buildHighlightedCode(
code: BindableValue<string>, options: ICodeOptions, ...args: DomElementArg[]
): HTMLElement {
const {gristTheme, placeholder, maxLines} = options;
const {enableCustomCss} = getGristConfig();
let _ace: Ace;
let _highlighter: any;
let _PythonMode: any;
let _aceDom: any;
let _chrome: any;
let _dracula: any;
let _mode: any;
async function fetchAceModules() {
return {
ace: _ace || (_ace = await loadAce()),
highlighter: _highlighter || (_highlighter = _ace.require('ace/ext/static_highlight')),
PythonMode: _PythonMode || (_PythonMode = _ace.require('ace/mode/python').Mode),
aceDom: _aceDom || (_aceDom = _ace.require('ace/lib/dom')),
chrome: _chrome || (_chrome = _ace.require('ace/theme/chrome')),
dracula: _dracula || (_dracula = _ace.require('ace/theme/dracula')),
mode: _mode || (_mode = new _PythonMode()),
};
}
/**
* Returns a function that accepts a string of text representing code and returns
* a highlighted version of it as an HTML string.
*
* This is useful for scenarios where highlighted code needs to be displayed outside of
* grainjs. For example, when using `marked`'s `highlight` option to highlight code
* blocks in a Markdown string.
*/
export async function buildCodeHighlighter(options: BuildCodeHighlighterOptions = {}) {
const {maxLines} = options;
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
return (code: string) => {
if (maxLines) {
// If requested, trim to maxLines, and add an ellipsis at the end.
// (Long lines are also truncated with an ellpsis via text-overflow style.)
const lines = code.split(/\n/);
if (lines.length > maxLines) {
code = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis
}
}
let aceThemeName: 'chrome' | 'dracula';
let aceTheme: any;
if (gristThemeObs().get().appearance === 'dark') {
aceThemeName = 'dracula';
aceTheme = dracula;
} else {
aceThemeName = 'chrome';
aceTheme = chrome;
}
// Rendering highlighted code gives you back the HTML to insert into the DOM, as well
// as the CSS styles needed to apply the theme. The latter typically isn't included in
// the document until an Ace editor is opened, so we explicitly import it here to avoid
// leaving highlighted code blocks without a theme applied.
const {html, css} = highlighter.render(code, mode, aceTheme, 1, true);
aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);
return html;
};
}
const highlighter = ace.require('ace/ext/static_highlight');
const PythonMode = ace.require('ace/mode/python').Mode;
const aceDom = ace.require('ace/lib/dom');
const chrome = ace.require('ace/theme/chrome');
const dracula = ace.require('ace/theme/dracula');
const mode = new PythonMode();
interface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {
placeholder?: string;
}
const codeText = Observable.create(null, '');
const codeTheme = Observable.create(null, gristTheme.get());
/**
* Builds a block of highlighted `code`.
*
* Highlighting applies an appropriate Ace theme (Chrome or Dracula) based on
* the current Grist theme, and automatically re-applies it whenever the Grist
* theme changes.
*/
export function buildHighlightedCode(
owner: Disposable,
code: BindableValue<string>,
options: BuildHighlightedCodeOptions,
...args: DomElementArg[]
): HTMLElement {
const {placeholder, maxLines} = options;
const codeText = Observable.create(owner, '');
const codeTheme = Observable.create(owner, gristThemeObs().get());
function updateHighlightedCode(elem: HTMLElement) {
async function updateHighlightedCode(elem: HTMLElement) {
let text = codeText.get();
if (!text) {
elem.textContent = placeholder || '';
return;
}
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
if (owner.isDisposed()) { return; }
if (maxLines) {
// If requested, trim to maxLines, and add an ellipsis at the end.
// (Long lines are also truncated with an ellpsis via text-overflow style.)
@ -51,7 +118,7 @@ export function buildHighlightedCode(
let aceThemeName: 'chrome' | 'dracula';
let aceTheme: any;
if (codeTheme.get().appearance === 'dark' && !enableCustomCss) {
if (codeTheme.get().appearance === 'dark') {
aceThemeName = 'dracula';
aceTheme = dracula;
} else {
@ -69,15 +136,13 @@ export function buildHighlightedCode(
}
return cssHighlightedCode(
dom.autoDispose(codeText),
dom.autoDispose(codeTheme),
elem => subscribeElem(elem, code, (newCodeText) => {
elem => subscribeElem(elem, code, async (newCodeText) => {
codeText.set(newCodeText);
updateHighlightedCode(elem);
await updateHighlightedCode(elem);
}),
elem => subscribeElem(elem, gristTheme, (newCodeTheme) => {
elem => subscribeElem(elem, gristThemeObs(), async (newCodeTheme) => {
codeTheme.set(newCodeTheme);
updateHighlightedCode(elem);
await updateHighlightedCode(elem);
}),
...args,
);
@ -95,9 +160,7 @@ export const cssCodeBlock = styled('div', `
const cssHighlightedCode = styled(cssCodeBlock, `
position: relative;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid ${theme.highlightedCodeBorder};
border-radius: 3px;
min-height: 28px;
@ -110,20 +173,6 @@ const cssHighlightedCode = styled(cssCodeBlock, `
& .ace_line {
overflow: hidden;
text-overflow: ellipsis;
}
`);
export const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.iconDisabled};
}
&-disabled {
pointer-events: none;
white-space: nowrap;
}
`);

@ -1,9 +1,9 @@
import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {reportError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow} from 'app/client/models/HomeModel';
import {buildConfigContainer} from 'app/client/ui/RightPanel';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';

@ -4,9 +4,10 @@
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
*/
import {loadUserManager} from 'app/client/lib/imports';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {attachAddNewTip} from 'app/client/ui/AddNewTip';
import * as css from 'app/client/ui/DocMenuCss';

@ -2,7 +2,7 @@ import {makeT} from 'app/client/lib/localization';
import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {cssBlockedCursor, cssFieldFormula, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
import {textButton} from 'app/client/ui2018/buttons';
@ -13,7 +13,6 @@ import {IconName} from 'app/client/ui2018/IconList';
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
import {sanitizeIdent} from 'app/common/gutil';
import {Theme} from 'app/common/ThemePrefs';
import {CursorPos} from 'app/plugin/GristAPI';
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder,
Observable, styled} from 'grainjs';
@ -139,6 +138,8 @@ export function buildFormulaConfig(
// And close it dispose it when user opens up behavior menu.
let formulaField: HTMLElement|null = null;
const focusFormulaField = () => setTimeout(() => formulaField?.focus(), 0);
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
const clearState = () => bundleChanges(() => {
// For a detached editor, we may have already been disposed when user switched page.
@ -242,7 +243,7 @@ export function buildFormulaConfig(
// Converts data column to formula column.
const convertDataColumnToFormulaOption = () => selectOption(
() => (maybeFormula.set(true), formulaField?.focus()),
() => (maybeFormula.set(true), focusFormulaField()),
t("Clear and make into formula"), 'Script');
// Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula)
@ -270,15 +271,15 @@ export function buildFormulaConfig(
const convertDataColumnToTriggerColumn = () => {
maybeTrigger.set(true);
// Open the formula editor.
formulaField?.focus();
focusFormulaField();
};
// Converts formula column to trigger formula column.
const convertFormulaToTrigger = () =>
gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: false});
const setFormula = () => (maybeFormula.set(true), formulaField?.focus());
const setTrigger = () => (maybeTrigger.set(true), formulaField?.focus());
const setFormula = () => { maybeFormula.set(true); focusFormulaField(); };
const setTrigger = () => { maybeTrigger.set(true); focusFormulaField(); };
// Actions on save formula. Those actions are using column that comes from FormulaEditor.
// Formula editor scope is broader then RightPanel, it can be disposed after RightPanel is closed,
@ -325,16 +326,19 @@ export function buildFormulaConfig(
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: SaveHandler, canDetach?: boolean) => [
cssRow(formulaField = buildFormula(
origColumn,
buildEditor,
{
gristTheme: gristDoc.currentTheme,
disabled: disableOtherActions,
canDetach,
onSave,
onCancel: clearState,
})),
cssRow(
buildFormula(
origColumn,
buildEditor,
{
disabled: disableOtherActions,
canDetach,
onSave,
onCancel: clearState,
},
(el) => { formulaField = el; },
)
),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
];
@ -419,7 +423,6 @@ export function buildFormulaConfig(
}
interface BuildFormulaOptions {
gristTheme: Computed<Theme>;
disabled: Observable<boolean>;
canDetach?: boolean;
onSave?: SaveHandler;
@ -429,10 +432,12 @@ interface BuildFormulaOptions {
function buildFormula(
column: ColumnRec,
buildEditor: BuildEditor,
options: BuildFormulaOptions
options: BuildFormulaOptions,
...args: DomElementArg[]
) {
const {gristTheme, disabled, canDetach = true, onSave, onCancel} = options;
return cssFieldFormula(column.formula, {gristTheme, maxLines: 2},
const {disabled, canDetach = true, onSave, onCancel} = options;
return dom.create(buildHighlightedCode, column.formula, {maxLines: 2},
dom.cls(cssFieldFormula.className),
dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', disabled),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
@ -447,24 +452,10 @@ function buildFormula(
onSave,
onCancel,
})),
...args,
);
}
export const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.lightText};
}
&-disabled {
pointer-events: none;
}
`);
const cssToggleButton = styled(cssIconButton, `
margin-left: 8px;
background-color: ${theme.rightPanelToggleButtonDisabledBg};

@ -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<Form>;
createRecord(options: CreateRecordOptions): Promise<void>;

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

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

@ -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<string|null>(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%;
`);

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

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

@ -1,7 +1,7 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
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 +29,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'
@ -51,7 +40,9 @@ export type Tooltip =
| 'uuid'
| 'lookups'
| 'formulaColumn'
| 'accessRulesTableWide';
| 'accessRulesTableWide'
| 'setChoiceDropdownCondition'
| 'setRefDropdownCondition';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@ -137,7 +128,29 @@ see or edit which parts of your document.')
...args,
),
accessRulesTableWide: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('These rules are applied after all column rules have been processed, if applicable.'))
dom('div', t('These rules are applied after all column rules have been processed, if applicable.')),
...args,
),
setChoiceDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Filter displayed dropdown values with a condition.')
),
dom('div', {style: 'margin-top: 8px;'}, t('Example: {{example}}', {
example: dom.create(buildHighlightedCode, 'choice not in $Categories', {}, {style: 'margin-top: 8px;'}),
})),
...args,
),
setRefDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Filter displayed dropdown values with a condition.')
),
dom('div', {style: 'margin-top: 8px;'}, t('Example: {{example}}', {
example: dom.create(buildHighlightedCode, 'choice.Role == "Manager"', {}, {style: 'margin-top: 8px;'}),
})),
dom('div',
cssLink({href: commonUrls.helpFilteringReferenceChoices, target: '_blank'}, t('Learn more.')),
),
...args,
),
};
@ -321,19 +334,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'],
},
};

@ -6,6 +6,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
import {IProgress} from 'app/client/models/NotifyModel';
import {openFilePicker} from 'app/client/ui/FileDialog';
import {byteString} from 'app/common/gutil';
import { AxiosProgressEvent } from 'axios';
import {Disposable} from 'grainjs';
/**
@ -39,9 +40,9 @@ export async function fileImport(
const timezone = await guessTimezone();
if (workspaceId === "unsaved") {
function onUploadProgress(ev: ProgressEvent) {
if (ev.lengthComputable) {
progress.setUploadProgress(ev.loaded / ev.total * 100); // percentage complete
function onUploadProgress(ev: AxiosProgressEvent) {
if (ev.event.lengthComputable) {
progress.setUploadProgress(ev.event.loaded / ev.event.total * 100); // percentage complete
}
}
return await app.api.importUnsavedDoc(files[0], {timezone, onUploadProgress});

@ -1,5 +1,6 @@
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel} from 'app/client/models/HomeModel';
import {HomeModel} from 'app/client/models/HomeModel';
import {makeDocOptionsMenu, makeRemovedDocOptionsMenu} from 'app/client/ui/DocMenu';
import {transientInput} from 'app/client/ui/transientInput';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';

@ -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,22 @@ export const cssPinButton = styled('div', `
background-color: ${theme.hover};
}
`);
export const cssNumericSpinner = styled(numericSpinner, `
height: 28px;
`);
export const cssFieldFormula = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.iconDisabled};
}
&-disabled {
pointer-events: none;
}
`);

@ -2,7 +2,7 @@ import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import * as css from 'app/client/ui/AccountPageCss';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersColorSchemeDarkObs} from 'app/client/ui2018/theme';
import {select} from 'app/client/ui2018/menus';
import {ThemeAppearance} from 'app/common/ThemePrefs';
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
@ -20,10 +20,10 @@ export class ThemeConfig extends Disposable {
private _appearance = Computed.create(this,
this._themePrefs,
this._syncWithOS,
prefersDarkModeObs(),
(_use, prefs, syncWithOS, prefersDarkMode) => {
prefersColorSchemeDarkObs(),
(_use, prefs, syncWithOS, prefersColorSchemeDark) => {
if (syncWithOS) {
return prefersDarkMode ? 'dark' : 'light';
return prefersColorSchemeDark ? 'dark' : 'light';
} else {
return prefs.appearance;
}

@ -4,7 +4,8 @@ import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/A
import {reportError, setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme} from 'app/client/ui2018/cssVars';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
@ -16,22 +17,22 @@ const G = getBrowserGlobals('document', 'window');
*/
export function createAppPage(
buildAppPage: (appModel: AppModel) => DomContents,
modelOptions: TopAppModelOptions = {}) {
modelOptions: TopAppModelOptions = {}
) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
addViewportTag();
attachCssRootVars(topAppModel.productFlavor);
attachTheme();
setupLocale().catch(reportError);
// Add globals needed by test utils.
G.window.gristApp = {
testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),
};
dom.update(document.body, dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => {
return [
buildAppPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),

@ -4,7 +4,8 @@ import {reportError, setErrorNotifier, setUpErrorHandling} from 'app/client/mode
import {Notifier} from 'app/client/models/NotifyModel';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme, prefersColorSchemeThemeObs} from 'app/client/ui2018/cssVars';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
@ -21,6 +22,7 @@ export function createPage(buildPage: () => DomContents, options: {disableTheme?
addViewportTag();
attachCssRootVars('grist');
if (!disableTheme) { attachTheme(); }
setupLocale().catch(reportError);
// Add globals needed by test utils.
@ -32,7 +34,6 @@ export function createPage(buildPage: () => DomContents, options: {disableTheme?
setErrorNotifier(notifier);
dom.update(document.body, () => [
disableTheme ? null : dom.autoDispose(attachTheme(prefersColorSchemeThemeObs())),
buildPage(),
buildSnackbarDom(notifier, null),
]);

@ -2,16 +2,17 @@
// keyboard. Dropdown features a search input and reoders the list of
// items to bring best matches at the top.
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
normalizeText } from "app/client/lib/ACIndex";
import { menuDivider } from "app/client/ui2018/menus";
import { makeT } from 'app/client/lib/localization';
import { getOptionFull, SimpleList } from "app/client/lib/simpleList";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { icon } from "app/client/ui2018/icons";
import { menuDivider } from "app/client/ui2018/menus";
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import mergeWith from "lodash/mergeWith";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
import { mergeWith } from "lodash";
import { getOptionFull, SimpleList } from "../lib/simpleList";
import { makeT } from 'app/client/lib/localization';
const t = makeT('searchDropdown');

@ -582,7 +582,7 @@ const cssInfoTooltipPopup = styled('div', `
display: flex;
flex-direction: column;
background-color: ${theme.popupBg};
max-width: 200px;
max-width: 240px;
margin: 4px;
padding: 0px;
`);

@ -16,13 +16,14 @@
*/
import {testId, theme} from 'app/client/ui2018/cssVars';
import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs';
import {Computed, dom, DomArg, DomContents, DomElementArg, Observable, styled} from 'grainjs';
export const cssLabel = styled('label', `
position: relative;
display: inline-flex;
min-width: 0px;
margin-bottom: 0px;
flex-shrink: 0;
outline: none;
user-select: none;
@ -193,6 +194,19 @@ export const cssRadioCheckboxOptions = styled('div', `
gap: 10px;
`);
export function toggle(value: Observable<boolean|null>, ...domArgs: DomElementArg[]): DomContents {
return dom('div.widget_switch',
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
dom.hide((use) => use(value) === null),
dom.cls('switch_on', (use) => use(value) || false),
dom.cls('switch_transition', true),
dom.on('click', () => value.set(!value.get())),
dom('div.switch_slider'),
dom('div.switch_circle'),
...domArgs
);
}
// We need to reset top and left of ::before element, as it is wrongly set
// on the inline checkbox.
// To simulate radio button behavior, we will block user input after option is selected, because

@ -6,16 +6,10 @@
* https://css-tricks.com/snippets/css/system-font-stack/
*
*/
import {createPausableObs, PausableObservable} from 'app/client/lib/pausableObs';
import {getStorage} from 'app/client/lib/storage';
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {Theme, ThemeAppearance} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import {dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import debounce = require('lodash/debounce');
import isEqual = require('lodash/isEqual');
import values = require('lodash/values');
const VAR_PREFIX = 'grist';
@ -1021,51 +1015,6 @@ export function isScreenResizing(): Observable<boolean> {
return _isScreenResizingObs;
}
export function prefersDarkMode(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let _prefersDarkModeObs: PausableObservable<boolean>|undefined;
/**
* Returns a singleton observable for whether the user agent prefers dark mode.
*/
export function prefersDarkModeObs(): PausableObservable<boolean> {
if (!_prefersDarkModeObs) {
const query = window.matchMedia('(prefers-color-scheme: dark)');
const obs = createPausableObs<boolean>(null, query.matches);
query.addEventListener('change', event => obs.set(event.matches));
_prefersDarkModeObs = obs;
}
return _prefersDarkModeObs;
}
let _prefersColorSchemeThemeObs: Computed<Theme>|undefined;
/**
* Returns a singleton observable for the Grist theme matching the current
* user agent color scheme preference ("light" or "dark").
*/
export function prefersColorSchemeThemeObs(): Computed<Theme> {
if (!_prefersColorSchemeThemeObs) {
const obs = Computed.create(null, prefersDarkModeObs(), (_use, prefersDarkTheme) => {
if (prefersDarkTheme) {
return {
appearance: 'dark',
colors: getThemeColors('GristDark'),
} as const;
} else {
return {
appearance: 'light',
colors: getThemeColors('GristLight'),
} as const;
}
});
_prefersColorSchemeThemeObs = obs;
}
return _prefersColorSchemeThemeObs;
}
/**
* Attaches the global css properties to the document's root to make them available in the page.
*/
@ -1081,96 +1030,6 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea
document.body.classList.add(`interface-${interfaceStyle}`);
}
export function attachTheme(themeObs: Observable<Theme>) {
// Attach the current theme to the DOM.
attachCssThemeVars(themeObs.get());
// Whenever the theme changes, re-attach it to the DOM.
return themeObs.addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
});
}
/**
* Attaches theme-related css properties to the theme style element.
*/
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
// Prepare the custom properties needed for applying the theme.
const properties = Object.entries(themeColors)
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
// Include properties for styling the scrollbar.
properties.push(...getCssScrollbarProperties(appearance));
// Include properties for picking an appropriate background image.
properties.push(...getCssThemeBackgroundProperties(appearance));
// Apply the properties to the theme style element.
getOrCreateStyleElement('grist-theme').textContent = `:root {
${properties.join('\n')}
}`;
// Make the browser aware of the color scheme.
document.documentElement.style.setProperty(`color-scheme`, appearance);
// Cache the appearance in local storage; this is currently used to apply a suitable
// background image that's shown while the application is loading.
getStorage().setItem('appearance', appearance);
}
/**
* Gets scrollbar-related css properties that are appropriate for the given `appearance`.
*
* Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization
* is non-standard and unsupported by Firefox. If support matures, we could expose some of these in
* custom themes, but for now we'll just go with reasonable presets.
*/
function getCssScrollbarProperties(appearance: ThemeAppearance) {
return [
'--scroll-bar-fg: ' +
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
'--scroll-bar-hover-fg: ' +
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
'--scroll-bar-active-fg: ' +
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
'--scroll-bar-bg: ' +
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
];
}
/**
* Gets background-related css properties that are appropriate for the given `appearance`.
*
* Currently, this sets a property for showing a background image that's visible while a page
* is loading.
*/
function getCssThemeBackgroundProperties(appearance: ThemeAppearance) {
const value = appearance === 'dark'
? 'url("img/prismpattern.png")'
: 'url("img/gplaypattern.png")';
return [`--grist-theme-bg: ${value};`];
}
/**
* Gets or creates a style element in the head of the document with the given `id`.
*
* Useful for grouping CSS values such as theme custom properties without needing to
* pollute the document with in-line styles.
*/
function getOrCreateStyleElement(id: string) {
let style = document.head.querySelector(`#${id}`);
if (style) { return style; }
style = document.createElement('style');
style.setAttribute('id', id);
document.head.append(style);
return style;
}
// A dom method to hide element in print view
export function hideInPrintView(): DomElementMethod {
return cssHideInPrint.cls('');

@ -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', `

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

@ -0,0 +1,191 @@
import { createPausableObs, PausableObservable } from 'app/client/lib/pausableObs';
import { getStorage } from 'app/client/lib/storage';
import { urlState } from 'app/client/models/gristUrlState';
import { Theme, ThemeAppearance, ThemeColors, ThemePrefs } from 'app/common/ThemePrefs';
import { getThemeColors } from 'app/common/Themes';
import { getGristConfig } from 'app/common/urlUtils';
import { Computed, Observable } from 'grainjs';
import isEqual from 'lodash/isEqual';
const DEFAULT_LIGHT_THEME: Theme = {appearance: 'light', colors: getThemeColors('GristLight')};
const DEFAULT_DARK_THEME: Theme = {appearance: 'dark', colors: getThemeColors('GristDark')};
/**
* A singleton observable for the current user's Grist theme preferences.
*
* Set by `AppModel`, which populates it from `UserPrefs`.
*/
export const gristThemePrefs = Observable.create<ThemePrefs | null>(null, null);
/**
* Returns `true` if the user agent prefers a dark color scheme.
*/
export function prefersColorSchemeDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let _prefersColorSchemeDarkObs: PausableObservable<boolean> | undefined;
/**
* Returns a singleton observable for whether the user agent prefers a
* dark color scheme.
*/
export function prefersColorSchemeDarkObs(): PausableObservable<boolean> {
if (!_prefersColorSchemeDarkObs) {
const query = window.matchMedia('(prefers-color-scheme: dark)');
const obs = createPausableObs<boolean>(null, query.matches);
query.addEventListener('change', event => obs.set(event.matches));
_prefersColorSchemeDarkObs = obs;
}
return _prefersColorSchemeDarkObs;
}
let _gristThemeObs: Computed<Theme> | undefined;
/**
* A singleton observable for the current Grist theme.
*/
export function gristThemeObs() {
if (!_gristThemeObs) {
_gristThemeObs = Computed.create(null, (use) => {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return DEFAULT_LIGHT_THEME; }
// If a user's preference is known, return it.
const themePrefs = use(gristThemePrefs);
const userAgentPrefersDarkTheme = use(prefersColorSchemeDarkObs());
if (themePrefs) { return getThemeFromPrefs(themePrefs, userAgentPrefersDarkTheme); }
// Otherwise, fall back to the user agent's preference.
return userAgentPrefersDarkTheme ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;
});
}
return _gristThemeObs;
}
/**
* Attaches the current theme's CSS variables to the document, and
* re-attaches them whenever the theme changes.
*/
export function attachTheme() {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
// Attach the current theme's variables to the DOM.
attachCssThemeVars(gristThemeObs().get());
// Whenever the theme changes, re-attach its variables to the DOM.
gristThemeObs().addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
});
}
/**
* Returns the `Theme` from the given `themePrefs`.
*
* If theme query parameters are present (`themeName`, `themeAppearance`, `themeSyncWithOs`),
* they will take precedence over their respective values in `themePrefs`.
*/
function getThemeFromPrefs(themePrefs: ThemePrefs, userAgentPrefersDarkTheme: boolean): Theme {
let {appearance, syncWithOS} = themePrefs;
const urlParams = urlState().state.get().params;
if (urlParams?.themeAppearance) {
appearance = urlParams?.themeAppearance;
}
if (urlParams?.themeSyncWithOs !== undefined) {
syncWithOS = urlParams?.themeSyncWithOs;
}
if (syncWithOS) {
appearance = userAgentPrefersDarkTheme ? 'dark' : 'light';
}
let nameOrColors = themePrefs.colors[appearance];
if (urlParams?.themeName) {
nameOrColors = urlParams?.themeName;
}
let colors: ThemeColors;
if (typeof nameOrColors === 'string') {
colors = getThemeColors(nameOrColors);
} else {
colors = nameOrColors;
}
return {appearance, colors};
}
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
// Prepare the custom properties needed for applying the theme.
const properties = Object.entries(themeColors)
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
// Include properties for styling the scrollbar.
properties.push(...getCssThemeScrollbarProperties(appearance));
// Include properties for picking an appropriate background image.
properties.push(...getCssThemeBackgroundProperties(appearance));
// Apply the properties to the theme style element.
getOrCreateStyleElement('grist-theme').textContent = `:root {
${properties.join('\n')}
}`;
// Make the browser aware of the color scheme.
document.documentElement.style.setProperty(`color-scheme`, appearance);
// Cache the appearance in local storage; this is currently used to apply a suitable
// background image that's shown while the application is loading.
getStorage().setItem('appearance', appearance);
}
/**
* Gets scrollbar-related css properties that are appropriate for the given `appearance`.
*
* Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization
* is non-standard and unsupported by Firefox. If support matures, we could expose some of these in
* custom themes, but for now we'll just go with reasonable presets.
*/
function getCssThemeScrollbarProperties(appearance: ThemeAppearance) {
return [
'--scroll-bar-fg: ' +
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
'--scroll-bar-hover-fg: ' +
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
'--scroll-bar-active-fg: ' +
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
'--scroll-bar-bg: ' +
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
];
}
/**
* Gets background-related css properties that are appropriate for the given `appearance`.
*
* Currently, this sets a property for showing a background image that's visible while a page
* is loading.
*/
function getCssThemeBackgroundProperties(appearance: ThemeAppearance) {
const value = appearance === 'dark'
? 'url("img/prismpattern.png")'
: 'url("img/gplaypattern.png")';
return [`--grist-theme-bg: ${value};`];
}
/**
* Gets or creates a style element in the head of the document with the given `id`.
*
* Useful for grouping CSS values such as theme custom properties without needing to
* pollute the document with in-line styles.
*/
function getOrCreateStyleElement(id: string) {
let style = document.head.querySelector(`#${id}`);
if (style) { return style; }
style = document.createElement('style');
style.setAttribute('id', id);
document.head.append(style);
return style;
}

@ -4,13 +4,22 @@ var TextEditor = require('app/client/widgets/TextEditor');
const {Autocomplete} = require('app/client/lib/autocomplete');
const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex');
const {ChoiceItem, cssChoiceList, cssMatchText, cssPlusButton,
cssPlusIcon} = require('app/client/widgets/ChoiceListEditor');
const {makeT} = require('app/client/lib/localization');
const {
buildDropdownConditionFilter,
ChoiceItem,
cssChoiceList,
cssMatchText,
cssPlusButton,
cssPlusIcon,
} = require('app/client/widgets/ChoiceListEditor');
const {icon} = require('app/client/ui2018/icons');
const {menuCssClass} = require('app/client/ui2018/menus');
const {testId, theme} = require('app/client/ui2018/cssVars');
const {choiceToken, cssChoiceACItem} = require('app/client/widgets/ChoiceToken');
const {dom, styled} = require('grainjs');
const {icon} = require('../ui2018/icons');
const t = makeT('ChoiceEditor');
/**
* ChoiceEditor - TextEditor with a dropdown for possible choices.
@ -18,15 +27,46 @@ const {icon} = require('../ui2018/icons');
function ChoiceEditor(options) {
TextEditor.call(this, options);
this.choices = options.field.widgetOptionsJson.peek().choices || [];
this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {};
this.widgetOptionsJson = options.field.widgetOptionsJson;
this.choices = this.widgetOptionsJson.peek().choices || [];
this.choicesSet = new Set(this.choices);
this.choiceOptions = this.widgetOptionsJson.peek().choiceOptions || {};
this.hasDropdownCondition = Boolean(options.field.dropdownCondition.peek()?.text);
this.dropdownConditionError;
let acItems = this.choices.map(c => new ChoiceItem(c, false, false));
if (this.hasDropdownCondition) {
try {
const dropdownConditionFilter = this.buildDropdownConditionFilter();
acItems = acItems.filter((item) => dropdownConditionFilter(item));
} catch (e) {
acItems = [];
this.dropdownConditionError = e.message;
}
}
const acIndex = new ACIndexImpl(acItems);
this._acOptions = {
popperOptions: {
placement: 'bottom'
},
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
buildNoItemsMessage: this.buildNoItemsMessage.bind(this),
search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
onClick: () => this.options.commands.fieldEditSave(),
};
if (!options.readonly && options.field.viewSection().parentKey() === "single") {
this.cellEditorDiv.classList.add(cssChoiceEditor.className);
this.cellEditorDiv.appendChild(cssChoiceEditIcon('Dropdown'));
}
// Whether to include a button to show a new choice.
// TODO: Disable when the user cannot change column configuration.
this.enableAddNew = true;
this.enableAddNew = !this.hasDropdownCondition;
}
dispose.makeDisposable(ChoiceEditor);
@ -66,20 +106,7 @@ ChoiceEditor.prototype.attach = function(cellElem) {
// Don't create autocomplete if readonly.
if (this.options.readonly) { return; }
const acItems = this.choices.map(c => new ChoiceItem(c, false, false));
const acIndex = new ACIndexImpl(acItems);
const acOptions = {
popperOptions: {
placement: 'bottom'
},
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
onClick: () => this.options.commands.fieldEditSave(),
};
this.autocomplete = Autocomplete.create(this, this.textInput, acOptions);
this.autocomplete = Autocomplete.create(this, this.textInput, this._acOptions);
}
/**
@ -89,11 +116,35 @@ ChoiceEditor.prototype.attach = function(cellElem) {
ChoiceEditor.prototype.prepForSave = async function() {
const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();
if (selectedItem && selectedItem.isNew) {
const choices = this.options.field.widgetOptionsJson.prop('choices');
const choices = this.widgetOptionsJson.prop('choices');
await choices.saveOnly([...(choices.peek() || []), selectedItem.label]);
}
}
ChoiceEditor.prototype.buildDropdownConditionFilter = function() {
const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();
if (dropdownConditionCompiled?.kind !== 'success') {
throw new Error('Dropdown condition is not compiled');
}
return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData,
tableId: this.options.field.tableId(),
rowId: this.options.rowId,
});
}
ChoiceEditor.prototype.buildNoItemsMessage = function() {
if (this.dropdownConditionError) {
return t('Error in dropdown condition');
} else if (this.hasDropdownCondition) {
return t('No choices matching condition');
} else {
return t('No choices to select');
}
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
@ -103,15 +154,21 @@ ChoiceEditor.prototype.maybeShowAddNew = function(result, text) {
// TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor.
// See if there's anything common we can factor out and re-use.
this.showAddNew = false;
if (!this.enableAddNew) {
return result;
}
const trimmedText = text.trim();
if (!this.enableAddNew || !trimmedText) { return result; }
if (!trimmedText || this.choicesSet.has(trimmedText)) {
return result;
}
const addNewItem = new ChoiceItem(trimmedText, false, false, true);
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
return result;
}
result.items.push(addNewItem);
result.extraItems.push(addNewItem);
this.showAddNew = true;
return result;

@ -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', `

@ -2,7 +2,9 @@ import {createGroup} from 'app/client/components/commands';
import {ACIndexImpl, ACItem, ACResults,
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {makeT} from 'app/client/lib/localization';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
import {DocData} from 'app/client/models/DocData';
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
@ -10,12 +12,15 @@ import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions";
import {CompiledPredicateFormula, EmptyRecordView} from 'app/common/PredicateFormula';
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons';
import {dom, styled} from 'grainjs';
const t = makeT('ChoiceListEditor');
export class ChoiceItem implements ACItem, IToken {
public cleanText: string = normalizeText(this.label);
constructor(
@ -38,25 +43,37 @@ export class ChoiceListEditor extends NewBaseEditor {
private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string;
private _widgetOptionsJson = this.options.field.widgetOptionsJson.peek();
private _choices: string[] = this._widgetOptionsJson.choices || [];
private _choicesSet: Set<string> = new Set(this._choices);
private _choiceOptionsByName: ChoiceOptions = this._widgetOptionsJson.choiceOptions || {};
// Whether to include a button to show a new choice.
// TODO: Disable when the user cannot change column configuration.
private _enableAddNew: boolean = true;
private _enableAddNew: boolean;
private _showAddNew: boolean = false;
private _choiceOptionsByName: ChoiceOptions;
private _hasDropdownCondition = Boolean(this.options.field.dropdownCondition.peek()?.text);
private _dropdownConditionError: string | undefined;
constructor(protected options: FieldOptions) {
super(options);
const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];
this._choiceOptionsByName = options.field.widgetOptionsJson
.peek().choiceOptions || {};
const acItems = choices.map(c => new ChoiceItem(c, false, false));
const choiceSet = new Set(choices);
let acItems = this._choices.map(c => new ChoiceItem(c, false, false));
if (this._hasDropdownCondition) {
try {
const dropdownConditionFilter = this._buildDropdownConditionFilter();
acItems = acItems.filter((item) => dropdownConditionFilter(item));
} catch (e) {
acItems = [];
this._dropdownConditionError = e.message;
}
}
const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
const acOptions: IAutocompleteOptions<ChoiceItem> = {
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
buildNoItemsMessage: this._buildNoItemsMessage.bind(this),
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
@ -65,12 +82,13 @@ export class ChoiceListEditor extends NewBaseEditor {
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
// If starting to edit by typing in a string, ignore previous tokens.
const cellValue = decodeObject(options.cellValue);
const startLabels: unknown[] = options.editValue !== undefined || !Array.isArray(cellValue) ? [] : cellValue;
const startTokens = startLabels.map(label => new ChoiceItem(
String(label),
!choiceSet.has(String(label)),
!this._choicesSet.has(String(label)),
String(label).trim() === ''
));
@ -87,7 +105,7 @@ export class ChoiceListEditor extends NewBaseEditor {
cssChoiceToken.cls('-invalid', item.isInvalid),
cssChoiceToken.cls('-blank', item.isBlank),
],
createToken: label => new ChoiceItem(label, !choiceSet.has(label), label.trim() === ''),
createToken: label => new ChoiceItem(label, !this._choicesSet.has(label), label.trim() === ''),
acOptions,
openAutocompleteOnFocus: true,
readonly : options.readonly,
@ -118,6 +136,8 @@ export class ChoiceListEditor extends NewBaseEditor {
dom.prop('value', options.editValue || ''),
this.commandGroup.attach(),
);
this._enableAddNew = !this._hasDropdownCondition;
}
public attach(cellElem: Element): void {
@ -150,7 +170,7 @@ export class ChoiceListEditor extends NewBaseEditor {
}
public getTextValue() {
const values = this._tokenField.tokensObs.get().map(t => t.label);
const values = this._tokenField.tokensObs.get().map(token => token.label);
return csvEncodeRow(values, {prettier: true});
}
@ -164,7 +184,7 @@ export class ChoiceListEditor extends NewBaseEditor {
*/
public async prepForSave() {
const tokens = this._tokenField.tokensObs.get();
const newChoices = tokens.filter(t => t.isNew).map(t => t.label);
const newChoices = tokens.filter(({isNew}) => isNew).map(({label}) => label);
if (newChoices.length > 0) {
const choices = this.options.field.widgetOptionsJson.prop('choices');
await choices.saveOnly([...(choices.peek() || []), ...new Set(newChoices)]);
@ -218,6 +238,30 @@ export class ChoiceListEditor extends NewBaseEditor {
this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px';
}
private _buildDropdownConditionFilter() {
const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();
if (dropdownConditionCompiled?.kind !== 'success') {
throw new Error('Dropdown condition is not compiled');
}
return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData,
tableId: this.options.field.tableId(),
rowId: this.options.rowId,
});
}
private _buildNoItemsMessage(): string {
if (this._dropdownConditionError) {
return t('Error in dropdown condition');
} else if (this._hasDropdownCondition) {
return t('No choices matching condition');
} else {
return t('No choices to select');
}
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
@ -225,15 +269,21 @@ export class ChoiceListEditor extends NewBaseEditor {
*/
private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {
this._showAddNew = false;
if (!this._enableAddNew) {
return result;
}
const trimmedText = text.trim();
if (!this._enableAddNew || !trimmedText) { return result; }
if (!trimmedText || this._choicesSet.has(trimmedText)) {
return result;
}
const addNewItem = new ChoiceItem(trimmedText, false, false, true);
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
return result;
}
result.items.push(addNewItem);
result.extraItems.push(addNewItem);
this._showAddNew = true;
return result;
@ -259,6 +309,24 @@ export class ChoiceListEditor extends NewBaseEditor {
}
}
export interface GetACFilterFuncParams {
dropdownConditionCompiled: CompiledPredicateFormula;
docData: DocData;
tableId: string;
rowId: number;
}
export function buildDropdownConditionFilter(
params: GetACFilterFuncParams
): (item: ChoiceItem) => boolean {
const {dropdownConditionCompiled, docData, tableId, rowId} = params;
const table = docData.getTable(tableId);
if (!table) { throw new Error(`Table ${tableId} not found`); }
const rec = table.getRecord(rowId) || new EmptyRecordView();
return (item: ChoiceItem) => dropdownConditionCompiled({rec, choice: item.label});
}
const cssCellEditor = styled('div', `
background-color: ${theme.cellEditorBg};
font-family: var(--grist-font-family-data);

@ -605,7 +605,6 @@ const cssButtonRow = styled('div', `
gap: 8px;
display: flex;
margin-top: 8px;
margin-bottom: 16px;
`);
const cssDeleteButton = styled('div', `

@ -1,3 +1,9 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig,
} from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@ -76,24 +82,30 @@ export class ChoiceTextBox extends NTextBox {
public buildConfigDom() {
return [
super.buildConfigDom(),
this._buildChoicesConfigDom(),
this.buildChoicesConfigDom(),
dom.create(DropdownConditionConfig, this.field),
];
}
public buildTransformConfigDom() {
return this.buildConfigDom();
return [
super.buildConfigDom(),
this.buildChoicesConfigDom(),
];
}
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 +125,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)

@ -4,7 +4,8 @@ import {ColumnRec} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {cssFieldFormula} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
@ -180,10 +181,11 @@ export class ConditionalStyle extends Disposable {
column: ColumnRec,
hasError: Observable<boolean>
) {
return cssFieldFormula(
return dom.create(buildHighlightedCode,
formula,
{ gristTheme: this._gristDoc.currentTheme, maxLines: 1 },
{ maxLines: 1 },
dom.cls('formula_field_sidepane'),
dom.cls(cssFieldFormula.className),
dom.cls(cssErrorBorder.className, hasError),
{ tabIndex: '-1' },
dom.on('focus', (_, refElem) => {

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

@ -10,8 +10,8 @@ import {dom, styled} from 'grainjs';
export function createMobileButtons(commands: IEditorCommandGroup) {
// TODO A better check may be to detect a physical keyboard or touch support.
return isDesktop() ? null : [
cssCancelBtn(cssIconWrap(cssFinishIcon('CrossSmall')), dom.on('click', commands.fieldEditCancel)),
cssSaveBtn(cssIconWrap(cssFinishIcon('Tick')), dom.on('click', commands.fieldEditSaveHere)),
cssCancelBtn(cssIconWrap(cssFinishIcon('CrossSmall')), dom.on('mousedown', commands.fieldEditCancel)),
cssSaveBtn(cssIconWrap(cssFinishIcon('Tick')), dom.on('mousedown', commands.fieldEditSaveHere)),
];
}

@ -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<boolean>;
private readonly _isForm: ko.Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
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<IOptionFull<string>> = [];
_.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.

@ -9,12 +9,13 @@ import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
import {ChatMessage} from 'app/client/models/entities/ColumnRec';
import {HAS_FORMULA_ASSISTANT, WHICH_FORMULA_ASSISTANT} from 'app/client/models/features';
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {buildCodeHighlighter, buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {autoGrow} from 'app/client/ui/forms';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {createUserImage} from 'app/client/ui/UserImage';
import {basicButton, bigPrimaryButtonLink, primaryButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingDots} from 'app/client/ui2018/loaders';
@ -1009,26 +1010,19 @@ class ChatHistory extends Disposable {
* Renders the message as markdown if possible, otherwise as a code block.
*/
private _render(message: string, ...args: DomElementArg[]) {
const doc = this._options.gristDoc;
if (this.supportsMarkdown()) {
return dom('div',
(el) => subscribeElem(el, doc.currentTheme, () => {
(el) => subscribeElem(el, gristThemeObs(), async () => {
const highlightCode = await buildCodeHighlighter({maxLines: 60});
const content = sanitizeHTML(marked(message, {
highlight: (code) => {
const codeBlock = buildHighlightedCode(code, {
gristTheme: doc.currentTheme,
maxLines: 60,
});
return codeBlock.innerHTML;
},
highlight: (code) => highlightCode(code)
}));
el.innerHTML = content;
}),
...args
);
} else {
return buildHighlightedCode(message, {
gristTheme: doc.currentTheme,
return dom.create(buildHighlightedCode, message, {
maxLines: 100,
});
}

@ -74,15 +74,13 @@ export class FormulaEditor extends NewBaseEditor {
this._aceEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created.
column: options.column,
calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc,
saveValueOnBlurEvent: !options.readonly,
editorState : this.editorState,
readonly: options.readonly
readonly: options.readonly,
getSuggestions: this._getSuggestions.bind(this),
});
// For editable editor we will grab the cursor when we are in the formula editing mode.
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
@ -201,10 +199,7 @@ export class FormulaEditor extends NewBaseEditor {
cssFormulaEditor.cls('-detached', this.isDetached),
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
this._aceEditor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
initializeAceOptions(aceObj);
const val = initialValue;
const pos = Math.min(options.cursorPos, val.length);
this._aceEditor.setValue(val, pos);
@ -405,6 +400,17 @@ export class FormulaEditor extends NewBaseEditor {
return result;
}
private _getSuggestions(prefix: string) {
const section = this.options.gristDoc.viewModel.activeSection();
// If section is disposed or is pointing to an empty row, don't try to autocomplete.
if (!section?.getRowId()) { return []; }
const tableId = section.table().tableId();
const columnId = this.options.column.colId();
const rowId = section.activeRowId();
return this.options.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
}
// TODO: update regexes to unicode?
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
// Don't do anything when we are readonly.
@ -714,6 +720,13 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
return errorMessage;
}
export function initializeAceOptions(aceObj: any) {
aceObj.setFontSize(11);
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
}
const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px;
--icon-color: ${colors.slate};

@ -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<FormTextFormat>(
this.field.widgetOptionsJson.prop('formTextFormat'),
'singleline'
);
const lineCount = fieldWithDefault<number|"">(
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),
];
}

@ -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<number>;
/** No minimum if unset. */
minValue?: number;
/** No maximum if unset. */
maxValue?: number;
disabled?: BindableValue<boolean>;
inputArgs?: IDomArgs<HTMLInputElement>;
/** Called on blur and spinner button click. */
save?: (val?: number) => MaybePromise<void>,
}
export function numericSpinner(
value: Observable<number | ''>,
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;
`);

@ -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<ISelectorOption<NumMode>> = [
{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<T>(value: unknown, def: T): number | T {
return value !== null && value !== undefined ? Number(value) : def;
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormNumberFormat>(
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<number | ''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void,
disabled: BindableValue<boolean>,
...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};

@ -1,3 +1,9 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig
} from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
@ -50,6 +56,7 @@ export class Reference extends NTextBox {
public buildConfigDom() {
return [
this.buildTransformConfigDom(),
dom.create(DropdownConditionConfig, this.field),
cssLabel(t('CELL FORMAT')),
super.buildConfigDom(),
];
@ -72,7 +79,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),
];
}

@ -11,7 +11,6 @@ import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { undef } from 'app/common/gutil';
import { styled } from 'grainjs';
/**
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
*/
@ -28,7 +27,12 @@ export class ReferenceEditor extends NTextEditor {
this._utils = new ReferenceUtils(options.field, docData);
const vcol = this._utils.visibleColModel;
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
this._enableAddNew = (
vcol &&
!vcol.isRealFormula() &&
!!vcol.colId() &&
!this._utils.hasDropdownCondition
);
// Decorate the editor to look like a reference column value (with a "link" icon).
// But not on readonly mode - here we will reuse default decoration
@ -65,7 +69,8 @@ export class ReferenceEditor extends NTextEditor {
// don't create autocomplete for readonly mode
if (this.options.readonly) { return; }
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
menuCssClass: menuCssClass + ' ' + cssRefList.className,
menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,
buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),
search: this._doSearch.bind(this),
renderItem: this._renderItem.bind(this),
getItemText: (item) => item.text,
@ -110,7 +115,7 @@ export class ReferenceEditor extends NTextEditor {
* Also see: prepForSave.
*/
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
const result = this._utils.autocompleteSearch(text);
const result = this._utils.autocompleteSearch(text, this.options.rowId);
this._showAddNew = false;
if (!this._enableAddNew || !text) { return result; }
@ -120,7 +125,7 @@ export class ReferenceEditor extends NTextEditor {
return result;
}
result.items.push({rowId: 'new', text, cleanText});
result.extraItems.push({rowId: 'new', text, cleanText});
this._showAddNew = true;
return result;

@ -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, `

@ -59,10 +59,16 @@ export class ReferenceListEditor extends NewBaseEditor {
this._utils = new ReferenceUtils(options.field, docData);
const vcol = this._utils.visibleColModel;
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
this._enableAddNew = (
vcol &&
!vcol.isRealFormula() &&
!!vcol.colId() &&
!this._utils.hasDropdownCondition
);
const acOptions: IAutocompleteOptions<ReferenceItem> = {
menuCssClass: `${menuCssClass} ${cssRefList.className}`,
menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,
buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),
search: this._doSearch.bind(this),
renderItem: this._renderItem.bind(this),
getItemText: (item) => item.text,
@ -166,12 +172,14 @@ export class ReferenceListEditor extends NewBaseEditor {
}
public getCellValue(): CellValue {
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? t.rowId : t.text);
const rowIds = this._tokenField.tokensObs.get()
.map(token => typeof token.rowId === 'number' ? token.rowId : token.text);
return encodeObject(rowIds);
}
public getTextValue(): string {
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? String(t.rowId) : t.text);
const rowIds = this._tokenField.tokensObs.get()
.map(token => typeof token.rowId === 'number' ? String(token.rowId) : token.text);
return csvEncodeRow(rowIds, {prettier: true});
}
@ -184,19 +192,19 @@ export class ReferenceListEditor extends NewBaseEditor {
*/
public async prepForSave() {
const tokens = this._tokenField.tokensObs.get();
const newValues = tokens.filter(t => t.rowId === 'new');
const newValues = tokens.filter(({rowId})=> rowId === 'new');
if (newValues.length === 0) { return; }
// Add the new items to the referenced table.
const colInfo = {[this._utils.visibleColId]: newValues.map(t => t.text)};
const colInfo = {[this._utils.visibleColId]: newValues.map(({text}) => text)};
const rowIds = await this._utils.tableData.sendTableAction(
["BulkAddRecord", new Array(newValues.length).fill(null), colInfo]
);
// Update the TokenField tokens with the returned row ids.
let i = 0;
const newTokens = tokens.map(t => {
return t.rowId === 'new' ? new ReferenceItem(t.text, rowIds[i++]) : t;
const newTokens = tokens.map(token => {
return token.rowId === 'new' ? new ReferenceItem(token.text, rowIds[i++]) : token;
});
this._tokenField.setTokens(newTokens);
}
@ -254,11 +262,12 @@ export class ReferenceListEditor extends NewBaseEditor {
* Also see: prepForSave.
*/
private async _doSearch(text: string): Promise<ACResults<ReferenceItem>> {
const {items, selectIndex, highlightFunc} = this._utils.autocompleteSearch(text);
const {items, selectIndex, highlightFunc} = this._utils.autocompleteSearch(text, this.options.rowId);
const result: ACResults<ReferenceItem> = {
selectIndex,
highlightFunc,
items: items.map(i => new ReferenceItem(i.text, i.rowId))
items: items.map(i => new ReferenceItem(i.text, i.rowId)),
extraItems: [],
};
this._showAddNew = false;
@ -269,7 +278,7 @@ export class ReferenceListEditor extends NewBaseEditor {
return result;
}
result.items.push(new ReferenceItem(text, 'new'));
result.extraItems.push(new ReferenceItem(text, 'new'));
this._showAddNew = true;
return result;

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

@ -154,6 +154,7 @@ export const typeDefs: any = {
widgets: {
TextBox: {
cons: 'TextBox',
formCons: 'Switch',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {

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

@ -1,18 +1,17 @@
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 {RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {ShareOptions} from 'app/common/ShareOptions';
import {CompiledPredicateFormula, ParsedPredicateFormula} from 'app/common/PredicateFormula';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
const defaultMatchFunc: AclMatchFunc = () => true;
const defaultMatchFunc: CompiledPredicateFormula = () => true;
export const SPECIAL_RULES_TABLE_ID = '*SPECIAL';
@ -22,12 +21,12 @@ const DEFAULT_RULE_SET: RuleSet = {
colIds: '*',
body: [{
aclFormula: "user.Access in [EDITOR, OWNER]",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "user.Access in [VIEWER]",
matchFunc: (input) => ['viewers'].includes(String(input.user.Access)),
matchFunc: (input) => ['viewers'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R-CUDS'),
permissionsText: '+R',
}, {
@ -50,7 +49,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['SchemaEdit'],
body: [{
aclFormula: "user.Access in [EDITOR, OWNER]",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+S'),
permissionsText: '+S',
}, {
@ -65,7 +64,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['AccessRules'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
@ -80,7 +79,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['FullCopies'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
@ -104,7 +103,7 @@ const EMERGENCY_RULE_SET: RuleSet = {
colIds: '*',
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
@ -383,7 +382,7 @@ export class ACLRuleCollection {
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
compile?: (parsed: ParsedPredicateFormula) => CompiledPredicateFormula;
// If true, add and modify access rules in some special ways.
// Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,
// and use ACLShareRules to implement any special shares as access rules.
@ -463,39 +462,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<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
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 +507,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;

@ -0,0 +1,477 @@
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 from 'lodash/isEqual';
import sortBy from 'lodash/sortBy';
/**
* 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<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _excludedRecordIds = new Set<number>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// 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<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...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<MetaRowRecord<T>>): Array<MetaRowRecord<T>> {
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<MetaRowRecord<T>>): 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<T>[]) {
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;
}
interface ShareContext {
shareRef: number;
sections: MetaRowRecord<"_grist_Views_section">[];
columns: MetaRowRecord<"_grist_Tables_column">[];
}
/**
* 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<string, string> = new Map();
public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) {
this._addOriginalRules();
this._maybeAddShareRules();
}
public entries() {
const rulesByResourceId = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
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 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)
);
// For tables associated with forms, allow creation of records,
// and reading of referenced columns.
// 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, {
shareRef: share.id, sections, columns,
});
}
}
/**
* 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'>,
shareContext: ShareContext) {
const { shareRef } = shareContext;
const resource = this._findOrAddResource({
tableId: table.tableId,
colIds: '*', // At creation, allow all columns to be
// initialized.
});
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, shareContext);
}
/**
* Give read access to referenced columns.
*/
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
shareContext: ShareContext) {
const { shareRef } = shareContext;
const tables = this.docData.getMetaTable('_grist_Tables');
const columns = this.docData.getMetaTable('_grist_Tables_column');
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.
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;
}

@ -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<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// 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<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...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<MetaRowRecord<T>>): 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: '',
};
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save