mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Merge branch 'main' into last-connection
This commit is contained in:
commit
feafda8fda
132
README.md
132
README.md
@ -233,72 +233,72 @@ For more on Grist Labs' history and principles, see our [About Us](https://www.g
|
||||
|
||||
Grist can be configured in many ways. Here are the main environment variables it is sensitive to:
|
||||
|
||||
Variable | Purpose
|
||||
-------- | -------
|
||||
ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation.
|
||||
APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis)
|
||||
APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). Defaults to `APP_DOC_URL`
|
||||
APP_HOME_URL | url prefix for home api (home and doc servers need this)
|
||||
APP_STATIC_URL | url prefix for static resources
|
||||
APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages
|
||||
APP_UNTRUSTED_URL | URL at which to serve/expect plugin content.
|
||||
GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, host header should be trustworthy)
|
||||
GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories).
|
||||
GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup
|
||||
GRIST_BOOT_KEY | if set, offer diagnostics at /boot/GRIST_BOOT_KEY
|
||||
GRIST_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());
|
||||
|
||||
|
197
app/client/components/DropdownConditionConfig.ts
Normal file
197
app/client/components/DropdownConditionConfig.ts
Normal file
@ -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%;
|
||||
`);
|
234
app/client/components/DropdownConditionEditor.ts
Normal file
234
app/client/components/DropdownConditionEditor.ts
Normal file
@ -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);
|
||||
|
||||
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
|
||||
const sortOrder = this.field.options.formOptionsSortOrder ?? 'default';
|
||||
if (sortOrder !== 'default') {
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
if (sortOrder === 'descending') {
|
||||
choices.reverse();
|
||||
}
|
||||
}
|
||||
// Support for 1000 choices. TODO: make limit dynamic.
|
||||
this._choices = choices.slice(0, 1000);
|
||||
|
||||
this.value = Observable.create<string>(this, '');
|
||||
|
||||
this._radioButtons.set(this._choices.map(reference => ({
|
||||
label: String(reference[1]),
|
||||
value: String(reference[0]),
|
||||
checked: Observable.create(this, null),
|
||||
})));
|
||||
}
|
||||
|
||||
public input() {
|
||||
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
// Support for 1000 choices. TODO: make limit dynamic.
|
||||
choices.splice(1000);
|
||||
if (this._format === 'select') {
|
||||
return this._renderSelectInput();
|
||||
} else {
|
||||
return this._renderRadioInput();
|
||||
}
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.value.set('');
|
||||
this._radioButtons.get().forEach(radioButton => {
|
||||
radioButton.checked.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
private _renderSelectInput() {
|
||||
return css.hybridSelect(
|
||||
this._selectElement = css.select(
|
||||
{
|
||||
@ -534,27 +754,37 @@ class RefRenderer extends BaseFieldRenderer {
|
||||
'data-grist-type': this.field.type,
|
||||
required: this.field.options.formRequired,
|
||||
},
|
||||
dom.prop('value', this.value),
|
||||
dom.on('input', (_e, elem) => this.value.set(elem.value)),
|
||||
dom('option', {value: ''}, SELECT_PLACEHOLDER),
|
||||
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
|
||||
dom('option',
|
||||
{value: ''},
|
||||
SELECT_PLACEHOLDER,
|
||||
dom.prop('selected', use => use(this.value) === ''),
|
||||
),
|
||||
this._choices.map((choice) => dom('option',
|
||||
{value: String(choice[0])},
|
||||
String(choice[1]),
|
||||
dom.prop('selected', use => use(this.value) === String(choice[0])),
|
||||
)),
|
||||
dom.onKeyDown({
|
||||
Enter$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
' $': (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
Backspace$: () => this.value.set(''),
|
||||
}),
|
||||
preventSubmitOnEnter(),
|
||||
),
|
||||
dom.maybe(use => !use(isXSmallScreenObs()), () =>
|
||||
css.searchSelect(
|
||||
dom('div', dom.text(use => {
|
||||
const choice = choices.find((c) => String(c[0]) === use(this.value));
|
||||
const choice = this._choices.find((c) => String(c[0]) === use(this.value));
|
||||
return String(choice?.[1] || SELECT_PLACEHOLDER);
|
||||
})),
|
||||
dropdownWithSearch<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.model.field).colId)),
|
||||
{disabled: true},
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
|
||||
private _renderMultiLineInput() {
|
||||
return css.cssTextArea(
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
dom.prop('rows', this._rowCount),
|
||||
{tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceModel extends Question {
|
||||
protected choices: Computed<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;
|
||||
}
|
||||
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(): HTMLElement {
|
||||
const field = this.model.field;
|
||||
public renderInput() {
|
||||
return dom.domComputed(this._format, (format) => {
|
||||
switch (format) {
|
||||
case 'text': {
|
||||
return this._renderTextInput();
|
||||
}
|
||||
case 'spinner': {
|
||||
return this._renderSpinnerInput();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderTextInput() {
|
||||
return css.cssInput(
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSpinnerInput() {
|
||||
return css.cssSpinner(observable(''), {});
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceModel extends Question {
|
||||
protected choices: Computed<string[]>;
|
||||
|
||||
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
|
||||
});
|
||||
|
||||
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 requiredField: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
|
||||
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 = 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._autoLayout = Computed.create(this, use => {
|
||||
this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
|
||||
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
|
||||
});
|
||||
|
||||
this._layout = Computed.create(this, use => {
|
||||
const fields = use(this._formFields);
|
||||
const layout = use(this.viewSection.layoutSpecObj);
|
||||
if (!layout || !layout.id) {
|
||||
return this._formTemplate(fields);
|
||||
} else {
|
||||
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
|
||||
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
||||
const layoutSpec = use(this._layoutSpec);
|
||||
const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id())));
|
||||
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
||||
|
||||
return patchedLayout;
|
||||
}
|
||||
return patchedLayout;
|
||||
});
|
||||
|
||||
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<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);
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(result.sectionRef);
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
if (val.type === 'form') {
|
||||
await this._setDefaultFormLayoutSpec(sectionRef!);
|
||||
}
|
||||
});
|
||||
|
||||
await this.openDocPage(viewRef!);
|
||||
if (sectionRef) {
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(sectionRef);
|
||||
}
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1425,6 +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;
|
||||
`);
|
||||
|
5
app/client/lib/imports.d.ts
vendored
5
app/client/lib/imports.d.ts
vendored
@ -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" */);
|
||||
|
14
app/client/lib/nameUtils.ts
Normal file
14
app/client/lib/nameUtils.ts
Normal file
@ -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);
|
||||
}
|
27
app/client/lib/timeUtils.ts
Normal file
27
app/client/lib/timeUtils.ts
Normal file
@ -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();
|
||||
}
|
165
app/client/models/AdminChecks.ts
Normal file
165
app/client/models/AdminChecks.ts
Normal file
@ -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';
|
||||
import {gristThemeObs} from 'app/client/ui2018/theme';
|
||||
import {
|
||||
BindableValue,
|
||||
Disposable,
|
||||
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;
|
||||
interface BuildCodeHighlighterOptions {
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
code: BindableValue<string>, options: ICodeOptions, ...args: DomElementArg[]
|
||||
owner: Disposable,
|
||||
code: BindableValue<string>,
|
||||
options: BuildHighlightedCodeOptions,
|
||||
...args: DomElementArg[]
|
||||
): HTMLElement {
|
||||
const {gristTheme, placeholder, maxLines} = options;
|
||||
const {enableCustomCss} = getGristConfig();
|
||||
const {placeholder, maxLines} = options;
|
||||
const codeText = Observable.create(owner, '');
|
||||
const codeTheme = Observable.create(owner, gristThemeObs().get());
|
||||
|
||||
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();
|
||||
|
||||
const codeText = Observable.create(null, '');
|
||||
const codeTheme = Observable.create(null, gristTheme.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 { icon } from "app/client/ui2018/icons";
|
||||
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';
|
||||
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";
|
||||
|
||||
|
||||
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', `
|
||||
|
25
app/client/ui2018/radio.ts
Normal file
25
app/client/ui2018/radio.ts
Normal file
@ -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};
|
||||
}
|
||||
`);
|
191
app/client/ui2018/theme.ts
Normal file
191
app/client/ui2018/theme.ts
Normal file
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
172
app/client/widgets/NumericSpinner.ts
Normal file
172
app/client/widgets/NumericSpinner.ts
Normal file
@ -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;
|
||||
|
477
app/common/ACLRulesReader.ts
Normal file
477
app/common/ACLRulesReader.ts
Normal file
@ -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…
Reference in New Issue
Block a user