Merge branch 'main' into include-context-in-exported-filename

pull/872/head
CamilleLegeron 3 months ago
commit 3a3c5bf94a

@ -1,8 +1,8 @@
# Welcome to the contribution guide for Grist!
You are eager to contribute to Grist? That's awesome! See below some contributions you can make:
- [translate](/documentation/translate.md)
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help)
- [translate](/documentation/translations.md)
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
- [develop](/documentation/develop.md)
- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new)

@ -0,0 +1,259 @@
import { AppModel } from 'app/client/models/AppModel';
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';
const cssBody = styled('div', `
padding: 20px;
overflow: auto;
`);
const cssHeader = styled('div', `
padding: 20px;
`);
const cssResult = styled('div', `
max-width: 500px;
`);
/**
*
* A "boot" page for inspecting the state of the Grist installation.
*
* TODO: deferring using any localization machinery so as not
* to have to worry about its failure modes yet, but it should be
* fine as long as assets served locally are used.
*
*/
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>;
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();
}
/**
* Set up the page. Uses the generic Grist layout with an empty
* side panel, just for convenience. Could be made a lot prettier.
*/
public buildDom() {
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 => {
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false),
hideOpener: true,
header: null,
content: null,
},
headerMain: cssHeader(dom('h1', 'Grist Boot')),
contentMain: this.buildBody(use, {errMessage}),
});
}
),
);
return rootNode;
}
/**
* The body of the page is very simple right now, basically a
* placeholder. Make a section for each probe, and kick them off in
* parallel, showing results as they come in.
*/
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
if (options.errMessage) {
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();
return cssResult(
this.buildResult(probe, use(result), probeDetails[id]));
}),
]);
}
/**
* This is used when there is an attempt to access the boot page
* but something isn't right - either the page isn't enabled, or
* the key in the URL is wrong. Give the user some information about
* how to set things up.
*/
public buildError() {
return dom(
'div',
dom('p',
'A diagnostics page can be made available at:',
dom('blockquote', '/boot/GRIST_BOOT_KEY'),
'GRIST_BOOT_KEY is an environment variable ',
' set before Grist starts. It should only',
' contain characters that are valid in a URL.',
' It should be a secret, since no authentication is needed',
' to visit the diagnostics page.'),
dom('p',
'You are seeing this page because either the key is not set,',
' or it is not in the URL.'),
);
}
/**
* An ugly rendering of information returned by the probe.
*/
public buildResult(info: BootProbeInfo, result: BootProbeResult,
details: ProbeDetails|undefined) {
const out: (HTMLElement|string|null)[] = [];
out.push(dom('h2', info.name));
if (details) {
out.push(dom('p', '> ', details.info));
}
if (result.verdict) {
out.push(dom('pre', result.verdict));
}
if (result.success !== undefined) {
out.push(result.success ? '✅' : '❌');
}
if (result.done === true) {
out.push(dom('p', 'no fault detected'));
}
if (result.details) {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
key,
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
return out;
}
}
/**
* 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
* due to a misconfiguration, especially in multi-server setups.
*/
createAppPage(appModel => {
return dom.create(Boot, 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;
}

@ -146,6 +146,11 @@ export interface AppModel {
switchUser(user: FullUser, org?: string): Promise<void>;
}
export interface TopAppModelOptions {
/** Defaults to true. */
useApi?: boolean;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly isSingleOrg: boolean;
public readonly productFlavor: ProductFlavor;
@ -163,14 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) {
constructor(window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig;
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = await this.api.getWidgets();
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
this.customWidgets.set(widgets);
return widgets;
});
@ -180,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
this.plugins = this._gristConfig?.plugins || [];
this.fetchUsersAndOrgs().catch(reportError);
if (this.options.useApi !== false) {
this.fetchUsersAndOrgs().catch(reportError);
}
}
public initialize(): void {
@ -237,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
private async _doInitialize() {
this.appObs.set(null);
if (this.options.useApi === false) {
AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500});
return;
}
try {
const {user, org, orgError} = await this.api.getSessionActive();
if (this.isDisposed()) { return; }

@ -1,6 +1,6 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/AppModel';
import {reportError, setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
@ -14,10 +14,12 @@ const G = getBrowserGlobals('document', 'window');
* Sets up the application model, error handling, and global styles, and replaces
* the DOM body with the result of calling `buildAppPage`.
*/
export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) {
export function createAppPage(
buildAppPage: (appModel: AppModel) => DomContents,
modelOptions: TopAppModelOptions = {}) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
addViewportTag();
attachCssRootVars(topAppModel.productFlavor);

@ -0,0 +1,22 @@
export type BootProbeIds =
'boot-page' |
'health-check' |
'reachable' |
'host-header' |
'system-user'
;
export interface BootProbeResult {
verdict?: string;
success?: boolean;
done?: boolean;
severity?: 'fault' | 'warning' | 'hmm';
details?: Record<string, any>;
}
export interface BootProbeInfo {
id: BootProbeIds;
name: string;
}

@ -0,0 +1,185 @@
import { ApiError } from 'app/common/ApiError';
import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
import { GristServer } from 'app/server/lib/GristServer';
import * as express from 'express';
import fetch from 'node-fetch';
/**
* Self-diagnostics useful when installing Grist.
*/
export class BootProbes {
// List of probes.
public _probes = new Array<Probe>();
// Probes indexed by id.
public _probeById = new Map<string, Probe>();
public constructor(private _app: express.Application,
private _server: GristServer,
private _base: string) {
this._addProbes();
}
public addEndpoints() {
// Return a list of available probes.
this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => {
res.json({
'probes': this._probes.map(probe => {
return { id: probe.id, name: probe.name };
}),
});
}));
// Return result of running an individual probe.
this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => {
const probe = this._probeById.get(req.params.probeId);
if (!probe) {
throw new ApiError('unknown probe', 400);
}
const result = await probe.apply(this._server, req);
res.json(result);
}));
// Fall-back for errors.
this._app.use(`${this._base}/probe`, jsonErrorHandler);
}
private _addProbes() {
this._probes.push(_homeUrlReachableProbe);
this._probes.push(_statusCheckProbe);
this._probes.push(_userProbe);
this._probes.push(_bootProbe);
this._probes.push(_hostHeaderProbe);
this._probeById = new Map(this._probes.map(p => [p.id, p]));
}
}
/**
* An individual probe has an id, a name, an optional description,
* and a method that returns a probe result.
*/
export interface Probe {
id: BootProbeIds;
name: string;
description?: string;
apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;
}
const _homeUrlReachableProbe: Probe = {
id: 'reachable',
name: 'Grist is reachable',
apply: async (server, req) => {
const url = server.getHomeUrl(req);
try {
const resp = await fetch(url);
if (resp.status !== 200) {
throw new ApiError(await resp.text(), resp.status);
}
return {
success: true,
};
} catch (e) {
return {
success: false,
details: {
error: String(e),
},
severity: 'fault',
};
}
}
};
const _statusCheckProbe: Probe = {
id: 'health-check',
name: 'Built-in Health check',
apply: async (server, req) => {
const baseUrl = server.getHomeUrl(req);
const url = new URL(baseUrl);
url.pathname = removeTrailingSlash(url.pathname) + '/status';
try {
const resp = await fetch(url);
if (resp.status !== 200) {
throw new Error(`Failed with status ${resp.status}`);
}
const txt = await resp.text();
if (!txt.includes('is alive')) {
throw new Error(`Failed, page has unexpected content`);
}
return {
success: true,
};
} catch (e) {
return {
success: false,
error: String(e),
severity: 'fault',
};
}
},
};
const _userProbe: Probe = {
id: 'system-user',
name: 'System user is sane',
apply: async () => {
if (process.getuid && process.getuid() === 0) {
return {
success: false,
verdict: 'User appears to be root (UID 0)',
severity: 'warning',
};
} else {
return {
success: true,
};
}
},
};
const _bootProbe: Probe = {
id: 'boot-page',
name: 'Boot page exposure',
apply: async (server) => {
if (!server.hasBoot) {
return { success: true };
}
const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10;
return {
success: maybeSecureEnough,
severity: 'hmm',
};
},
};
/**
* Based on:
* https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438
*
* When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need
* to have an accurate Host header.
*/
const _hostHeaderProbe: Probe = {
id: 'host-header',
name: 'Host header is sane',
apply: async (server, req) => {
const host = req.header('host');
const url = new URL(server.getHomeUrl(req));
if (url.hostname === 'localhost') {
return {
done: true,
};
}
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
return {
success: false,
severity: 'hmm',
};
}
return {
done: true,
};
},
};

@ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings';
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
import {BootProbes} from 'app/server/lib/BootProbes';
import {forceSessionChange} from 'app/server/lib/BrowserSession';
import {Comm} from 'app/server/lib/Comm';
import {create} from 'app/server/lib/create';
@ -175,6 +176,7 @@ export class FlexServer implements GristServer {
private _getLoginSystem?: () => Promise<GristLoginSystem>;
// Set once ready() is called
private _isReady: boolean = false;
private _probes: BootProbes;
constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) {
@ -481,6 +483,57 @@ export class FlexServer implements GristServer {
});
}
/**
*
* Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics.
* Accepts any /boot/... URL in order to let the front end
* give some guidance if the user is stumbling around trying
* to find the boot page, but won't actually provide diagnostics
* unless GRIST_BOOT_KEY is set in the environment, and is present
* in the URL.
*
* We take some steps to make the boot page available even when
* things are going wrong, and should take more in future.
*
* When rendering the page a hardcoded 'boot' tag is used, which
* is used to ensure that static assets are served locally and
* we aren't relying on APP_STATIC_URL being set correctly.
*
* We use a boot key so that it is more acceptable to have this
* boot page living outside of the authentication system, which
* could be broken.
*
* TODO: there are some configuration problems that currently
* result in Grist not running at all. ideally they would result in
* Grist running in a limited mode that is enough to bring up the boot
* page.
*
*/
public addBootPage() {
if (this._check('boot')) { return; }
const bootKey = appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
const base = `/boot/${bootKey}`;
this._probes = new BootProbes(this.app, this, base);
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
// a helpful message even if user gets KEY wrong or omits it.
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
const goodKey = bootKey && req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
} : {
errMessage: 'not-the-key',
}, tag: 'boot',
});
});
this._probes.addEndpoints();
}
public hasBoot(): boolean {
return Boolean(this._probes);
}
public denyRequestsIfNotReady() {
this.app.use((_req, res, next) => {
if (!this._isReady) {

@ -60,6 +60,7 @@ export interface GristServer {
getPlugins(): LocalPlugin[];
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
hasBoot(): boolean;
}
export interface GristLoginSystem {
@ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer {
servesPlugins() { return false; },
getPlugins() { return []; },
getBundledWidgets() { return []; },
hasBoot() { return false; },
};
}

@ -139,8 +139,11 @@ export function makeSendAppPage(opts: {
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
options.googleTagManager === true;
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`;
const staticTag = options.tag || tag;
// If boot tag is used, serve assets locally, otherwise respect
// APP_STATIC_URL.
const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || '');
const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`;
const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? "";
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
// Preload all languages that will be used or are requested by client.

@ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[],
}
server.addHealthCheck();
if (includeHome || includeApp) {
server.addBootPage();
}
server.denyRequestsIfNotReady();
if (includeHome || includeStatic || includeApp) {

@ -14,6 +14,7 @@ module.exports = {
main: "app/client/app",
errorPages: "app/client/errorMain",
apiconsole: "app/client/apiconsole",
boot: "app/client/boot",
billing: "app/client/billingMain",
form: "app/client/formMain",
// Include client test harness if it is present (it won't be in

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<!-- INSERT LOCALE -->
<!-- INSERT CONFIG -->
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<script crossorigin="anonymous" src="boot.bundle.js"></script>
</body>
</html>

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren.",
"Permission to edit document structure": "Berechtigung zur Bearbeitung der Dokumentenstruktur",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Erlauben Sie Editoren, Struktur zu bearbeiten (z.B. Tabellen, Spalten, Layouts zu ändern und zu löschen) und Formeln zu schreiben, die unabhängig von Leseeinschränkungen Zugriff auf alle Daten geben.",
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. "
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. ",
"Add Table-wide Rule": "Tabellenweite Regel hinzufügen"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "Dokument-ID in die Zwischenablage kopiert",
"Ok": "OK",
"Webhooks": "Webhaken",
"Manage Webhooks": "Webhaken verwalten"
"Manage Webhooks": "Webhaken verwalten",
"API Console": "API-Konsole"
},
"DocumentUsage": {
"Attachments Size": "Größe der Anhänge",
@ -464,7 +466,19 @@
"Add column": "Spalte hinzufügen",
"Last updated by": "Zuletzt aktualisiert von",
"Detect duplicates in...": "Erkennen Sie Duplikate in...",
"Last updated at": "Zuletzt aktualisiert am"
"Last updated at": "Zuletzt aktualisiert am",
"Reference List": "Referenzliste",
"Text": "Text",
"Date": "Datum",
"DateTime": "DatumUhrzeit",
"Choice": "Auswahl",
"Choice List": "Auswahlliste",
"Reference": "Referenz",
"Attachment": "Anhang",
"Any": "Jegliche",
"Numeric": "Numerisch",
"Integer": "Ganze Zahl",
"Toggle": "Umschalten"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Neuer verlinkter Abschnitt zur Ansicht hinzugefügt {{viewName}}",
@ -667,7 +681,24 @@
"Fields_one": "Feld",
"Fields_other": "Felder",
"Add referenced columns": "Referenzspalten hinzufügen",
"Reset form": "Formular zurücksetzen"
"Reset form": "Formular zurücksetzen",
"Enter text": "Text eingeben",
"Layout": "Layout",
"Submission": "Einreichung",
"Redirect automatically after submission": "Nach Eingabe automatisch umleiten",
"Redirection": "Umleitung",
"Submit another response": "Eine weitere Antwort einreichen",
"Required field": "Erforderliches Feld",
"Table column name": "Name der Spalte in der Tabelle",
"Enter redirect URL": "Weiterleitungs-URL eingeben",
"Display button": "Anzeigetaste",
"Field rules": "Feldregeln",
"Success text": "Erfolgstext",
"Configuration": "Konfiguration",
"Default field value": "Standard-Feldwert",
"Field title": "Feldtitel",
"Hidden field": "Verborgenes Feld",
"Submit button label": "Beschriftung der Schaltfläche Senden"
},
"RowContextMenu": {
"Copy anchor link": "Ankerlink kopieren",
@ -813,7 +844,8 @@
"Show raw data": "Rohdaten anzeigen",
"Widget options": "Widget Optionen",
"Add to page": "Zur Seite hinzufügen",
"Collapse widget": "Widget einklappen"
"Collapse widget": "Widget einklappen",
"Create a form": "Formular erstellen"
},
"ViewSectionMenu": {
"(customized)": "(angepasst)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Sie haben keinen Zugriff auf die Dokumente dieser Organisation.",
"Account deleted{{suffix}}": "Konto gelöscht{{suffix}}",
"Your account has been deleted.": "Ihr Konto wurde gelöscht.",
"Sign up": "Anmelden"
"Sign up": "Anmelden",
"An unknown error occurred.": "Ein unbekannter Fehler ist aufgetreten.",
"Powered by": "Angetrieben durch",
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Form not found": "Formular nicht gefunden"
},
"menus": {
"* Workspaces are available on team plans. ": "* Arbeitsbereiche sind in Teamplänen verfügbar. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Abbrechen",
"Ok": "OK",
"Save": "Speichern"
"Save": "Speichern",
"Delete": "Löschen",
"Are you sure you want to delete these records?": "Sind Sie sicher, dass Sie diese Datensätze löschen wollen?",
"Are you sure you want to delete this record?": "Sind Sie sicher, dass Sie diesen Datensatz löschen wollen?",
"Undo to restore": "Rückgängig machen zum Wiederherstellen",
"Don't show again": "Nicht mehr anzeigen",
"Got it": "Verstanden",
"Dismiss": "Ablehnen",
"Don't ask again.": "Frag nicht mehr.",
"Don't show again.": "Zeig nicht mehr.",
"Don't show tips": "Keine Tipps anzeigen"
},
"pages": {
"Duplicate Page": "Seite duplizieren",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "Lookups geben Daten aus Bezugstabellen zurück.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Sie können aus Widgets wählen, die Ihnen im Dropdown zur Verfügung stehen, oder Sie selbst einbetten, indem Sie seine volle URL angeben.",
"Use reference columns to relate data in different tables.": "Verwenden Sie Referenzspalten, um Daten in verschiedenen Tabellen zu beziehen.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten.",
"Learn more": "Mehr erfahren",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Erstellen Sie einfache Formulare direkt in Grist und teilen Sie sie mit einem Klick mit unserem neuen Widget. {{learnMoreButton}}",
"Forms are here!": "Die Formulare sind da!",
"These rules are applied after all column rules have been processed, if applicable.": "Diese Regeln werden angewendet, nachdem alle Spaltenregeln abgearbeitet wurden, falls zutreffend."
},
"DescriptionConfig": {
"DESCRIPTION": "BESCHREIBUNG"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Ausgeblendete Felder"
},
"FormView": {
"Publish your form?": "Ihr Formular veröffentlichen?",
"Unpublish": "Unveröffentlichen",
"Unpublish your form?": "Ihr Formular unveröffentlichen?",
"Publish": "Veröffentlichen"
},
"Editor": {
"Delete": "Löschen"
},
"Menu": {
"Building blocks": "Bausteine",
"Unmapped fields": "Nicht zugeordnete Felder",
"Separator": "Abscheider",
"Header": "Kopfzeile",
"Cut": "Schneiden",
"Insert question above": "Frage oben einfügen",
"Insert question below": "Frage unten einfügen",
"Paragraph": "Absatz",
"Paste": "Einfügen",
"Columns": "Spalten",
"Copy": "Kopieren"
},
"FormContainer": {
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Powered by": "Angetrieben durch"
},
"FormErrorPage": {
"Error": "Fehler"
},
"FormModel": {
"Oops! This form is no longer published.": "Huch! Dieses Formular wird nicht mehr veröffentlicht.",
"Oops! The form you're looking for doesn't exist.": "Huch! Das Formular, das Sie suchen, existiert nicht.",
"You don't have access to this form.": "Sie haben keinen Zugang zu diesem Formular.",
"There was a problem loading the form.": "Beim Laden des Formulars ist ein Problem aufgetreten."
},
"WelcomeCoachingCall": {
"free coaching call": "kostenloser Coaching-Anruf",
"Schedule Call": "Anruf planen",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Planen Sie Ihre {{freeCoachingCall}} mit einem Mitglied unseres Teams.",
"Maybe Later": "Vielleicht später",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Während des Gesprächs nehmen wir uns die Zeit, Ihre Bedürfnisse zu verstehen und das Gespräch auf Sie zuzuschneiden. Wir können Ihnen die Grundlagen von Grist zeigen oder sofort mit Ihren Daten arbeiten, um die von Ihnen benötigten Dashboards zu erstellen."
},
"UnmappedFieldsConfig": {
"Clear": "Leeren",
"Map fields": "Felder zuordnen",
"Unmap fields": "Felder freigeben",
"Unmapped": "Nicht zugeordnet",
"Mapped": "Zugeordnet",
"Select All": "Alle auswählen"
},
"FormConfig": {
"Field rules": "Feldregeln",
"Required field": "Erforderliches Feld"
},
"CustomView": {
"Some required columns aren't mapped": "Einige erforderliche Spalten sind nicht zugeordnet",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Um dieses Widget zu verwenden, ordnen Sie bitte alle nicht-optionalen Spalten im Ersteller-Panel auf der rechten Seite zu."
},
"FormSuccessPage": {
"Form Submitted": "Formular eingereicht",
"Thank you! Your response has been recorded.": "Danke! Ihre Antwort wurde aufgezeichnet."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
}
}

@ -37,7 +37,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO.",
"Permission to edit document structure": "Permiso para editar la estructura del documento",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permitir a los editores editar la estructura (por ejemplo, modificar y eliminar tablas, columnas, diseños), y escribir fórmulas, que dan acceso a todos los datos independientemente de las restricciones de lectura.",
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. "
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. ",
"Add Table-wide Rule": "Añadir regla a toda la tabla"
},
"AccountPage": {
"API": "API",
@ -1176,7 +1177,8 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Las fórmulas admiten muchas funciones de Excel, sintaxis completa de Python e incluyen un útil asistente de inteligencia artificial.",
"Forms are here!": "¡Los formularios están aquí!",
"Learn more": "Más información",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}"
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Estas reglas se aplican después de que se hayan procesado todas las reglas de columna, si procede."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
@ -1403,5 +1405,29 @@
"FormConfig": {
"Field rules": "Reglas del campo",
"Required field": "Campo obligatorio"
},
"CustomView": {
"Some required columns aren't mapped": "Algunas columnas obligatorias no están asignadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para utilizar este widget, asigne todas las columnas no opcionales desde el panel del creador de la derecha."
},
"FormContainer": {
"Build your own form": "Cree su propio formulario",
"Powered by": "Desarrollado por"
},
"FormErrorPage": {
"Error": "Error"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "¡Vaya! El formulario que busca no existe.",
"Oops! This form is no longer published.": "¡Vaya! Este formulario ya no se publica.",
"There was a problem loading the form.": "Hubo un problema al cargar el formulario.",
"You don't have access to this form.": "No tiene acceso a este formulario."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Se ha producido un error al enviar el formulario. Por favor, inténtelo de nuevo."
},
"FormSuccessPage": {
"Form Submitted": "Formulario enviado",
"Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada."
}
}

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"Permission to edit document structure": "Droits d'édition de la structure",
"This default should be changed if editors' access is to be limited. ": "Cette valeur par défaut doit être modifiée si l'on souhaite limiter l'accès des éditeurs. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page...) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page...) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture.",
"Add Table-wide Rule": "Ajouter une règle pour l'ensemble du tableau"
},
"AccountPage": {
"Account settings": "Paramètres du compte",
@ -414,11 +415,11 @@
"Hidden Columns": "Colonnes cachées",
"Lookups": "Champ rapporté",
"No reference columns.": "Pas de colonne de référence.",
"Apply on record changes": "Appliquer lors de changements d'enregistrements",
"Apply on record changes": "Appliquer lors de modification de la ligne",
"Duplicate in {{- label}}": "Duplica dans {{-label}}",
"Created By": "Créé(e) par",
"Last Updated At": "Dernière mise à jour le",
"Apply to new records": "Appliquer au nouveaux enregistrements",
"Apply to new records": "Appliquer aux nouvelles lignes",
"Search columns": "Chercher des colonnes",
"Timestamp": "Horodatage",
"no reference column": "pas de colonne de référence",
@ -730,7 +731,7 @@
},
"TriggerFormulas": {
"Any field": "N'importe quel champ",
"Apply to new records": "Nouveaux enregistrements",
"Apply to new records": "Appliquer sur les nouvelles lignes uniquement",
"Apply on changes to:": "Appliquer sur les modifications à :",
"Apply on record changes": "Réappliquer en cas de modification de la ligne",
"Current field ": "Champ actif ",
@ -1124,7 +1125,8 @@
"Use reference columns to relate data in different tables.": "Utilisez les colonnes de type Référence pour lier différentes tables entre elles.",
"Learn more": "En savoir plus",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}",
"Forms are here!": "Les formulaires sont là!"
"Forms are here!": "Les formulaires sont là!",
"These rules are applied after all column rules have been processed, if applicable.": "Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant."
},
"ColumnTitle": {
"Add description": "Ajouter une description",
@ -1351,5 +1353,29 @@
},
"Editor": {
"Delete": "Supprimer"
},
"CustomView": {
"Some required columns aren't mapped": "Certaines colonnes obligatoires ne sont pas mappées",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Pour utiliser cette vue, mappez toutes les colonnes non optionnelles à partir du panneau du créateur sur la droite."
},
"FormContainer": {
"Build your own form": "Créez votre propre formulaire",
"Powered by": "Créé avec"
},
"FormErrorPage": {
"Error": "Erreur"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Oups! Le formulaire que vous recherchez n'existe pas.",
"Oops! This form is no longer published.": "Oups! Ce formulaire n'est plus publié.",
"There was a problem loading the form.": "Il y a eu un problème de chargement du formulaire.",
"You don't have access to this form.": "Vous n'avez pas accès à ce formulaire."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Une erreur s'est produite lors de l'envoi de votre formulaire. Veuillez réessayer."
},
"FormSuccessPage": {
"Form Submitted": "Formulaire envoyé",
"Thank you! Your response has been recorded.": "Nous vous remercions. Votre réponse a été enregistrée."
}
}

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento",
"This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.",
"Add Table-wide Rule": "Adicionar regra para toda a tabela"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"API": "API",
"Manage Webhooks": "Gerenciar ganchos web",
"Webhooks": "Ganchos Web"
"Webhooks": "Ganchos Web",
"API Console": "Consola API"
},
"DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos",
@ -464,7 +466,19 @@
"Add column": "Adicionar coluna",
"Last updated by": "Última atualização por",
"Detect duplicates in...": "Detectar duplicações em...",
"Last updated at": "Última atualização em"
"Last updated at": "Última atualização em",
"Any": "Qualquer",
"Numeric": "Numérico",
"Text": "Texto",
"Date": "Data",
"DateTime": "DataHora",
"Choice List": "Lista de opções",
"Reference": "Referência",
"Reference List": "Lista de referências",
"Attachment": "Anexo",
"Integer": "Inteiro",
"Toggle": "Alternar",
"Choice": "Opção"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Adicionada nova seção vinculada para visualizar {{viewName}}}",
@ -667,7 +681,24 @@
"Fields_one": "Campo",
"Fields_other": "Campos",
"Add referenced columns": "Adicionar colunas referenciadas",
"Reset form": "Restaurar formulário"
"Reset form": "Restaurar formulário",
"Default field value": "Valor padrão do campo",
"Display button": "Botão de exibição",
"Enter text": "Digite texto",
"Field rules": "Regras de campo",
"Field title": "Título do campo",
"Hidden field": "Campo escondido",
"Redirection": "Redirecionamento",
"Submission": "Envio",
"Required field": "Campo necessário",
"Submit another response": "Enviar outra resposta",
"Submit button label": "Etiqueta do botão de envio",
"Table column name": "Nome da coluna da tabela",
"Enter redirect URL": "Insira URL de redirecionamento",
"Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"Configuration": "Configuração",
"Success text": "Texto de sucesso",
"Layout": "Leiaute"
},
"RowContextMenu": {
"Copy anchor link": "Copiar o link de ancoragem",
@ -813,7 +844,8 @@
"Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget",
"Collapse widget": "Colapsar widget",
"Add to page": "Adicionar à página"
"Add to page": "Adicionar à página",
"Create a form": "Criar um formulário"
},
"ViewSectionMenu": {
"(customized)": "(personalizado)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Você não tem acesso aos documentos desta organização.",
"Account deleted{{suffix}}": "Conta excluída{{suffix}}",
"Your account has been deleted.": "Sua conta foi excluída.",
"Sign up": "Cadastre-se"
"Sign up": "Cadastre-se",
"An unknown error occurred.": "Ocorreu um erro desconhecido.",
"Form not found": "Formulário não encontrado",
"Powered by": "Desenvolvido por",
"Build your own form": "Construa seu próprio formulário"
},
"menus": {
"* Workspaces are available on team plans. ": "* As áreas de trabalho estão disponíveis nos planos de equipe. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Cancelar",
"Ok": "OK",
"Save": "Salvar"
"Save": "Salvar",
"Are you sure you want to delete these records?": "Tem a certeza de que deseja apagar esses registros?",
"Undo to restore": "Desfazer para restaurar",
"Delete": "Eliminar",
"Are you sure you want to delete this record?": "Tem certeza de que deseja apagar este registro?",
"Dismiss": "Descartar",
"Don't ask again.": "Não perguntar novamente",
"Got it": "Entendido",
"Don't show again": "Não mostrar novamente",
"Don't show again.": "Não mostrar novamente.",
"Don't show tips": "Não mostrar dicas"
},
"pages": {
"Duplicate Page": "Duplicar a Página",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "As pesquisas retornam dados de tabelas relacionadas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Você pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.",
"Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}",
"Forms are here!": "Os formulários chegaram!",
"Learn more": "Saiba mais",
"These rules are applied after all column rules have been processed, if applicable.": "Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIÇÃO"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Campos ocultos"
},
"FormView": {
"Publish": "Publicar",
"Publish your form?": "Publicar o seu formulário?",
"Unpublish your form?": "Despublicar seu formulário?",
"Unpublish": "Cancelar publicação"
},
"Menu": {
"Columns": "Colunas",
"Cut": "Cortar",
"Building blocks": "Blocos de construção",
"Unmapped fields": "Campos não mapeados",
"Insert question above": "Insira a questão acima",
"Insert question below": "Inserir questão abaixo",
"Paragraph": "Parágrafo",
"Paste": "Colar",
"Separator": "Separador",
"Copy": "Copiar",
"Header": "Cabeçalho"
},
"UnmappedFieldsConfig": {
"Unmap fields": "Desmapear campos",
"Unmapped": "Desmapeado",
"Mapped": "Mapeado",
"Select All": "Selecionar Todos",
"Map fields": "Mapear campos",
"Clear": "Limpar"
},
"Editor": {
"Delete": "Eliminar"
},
"WelcomeCoachingCall": {
"Schedule your {{freeCoachingCall}} with a member of our team.": "Programe seu {{freeCoachingCall}} com um membro da nossa equipe.",
"free coaching call": "chamada gratuita de treinamento",
"Maybe Later": "Talvez mais tarde",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Na chamada, vamos ter tempo para entender suas necessidades e adaptar a chamada para você. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que você precisa.",
"Schedule Call": "Agendar chamada"
},
"FormConfig": {
"Field rules": "Regras de campo",
"Required field": "Campo obrigatório"
},
"CustomView": {
"Some required columns aren't mapped": "Algumas colunas obrigatórias não estão mapeadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita."
},
"FormContainer": {
"Build your own form": "Crie seu próprio formulário",
"Powered by": "Desenvolvido por"
},
"FormErrorPage": {
"Error": "Erro"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ops! O formulário que você está procurando não existe.",
"Oops! This form is no longer published.": "Ops! Este formulário não está mais publicado.",
"There was a problem loading the form.": "Houve um problema ao carregar o formulário.",
"You don't have access to this form.": "Você não tem acesso a este formulário."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Houve um erro ao enviar seu formulário. Por favor, tente novamente."
},
"FormSuccessPage": {
"Form Submitted": "Formulário enviado",
"Thank you! Your response has been recorded.": "Obrigado! Sua resposta foi registrada."
}
}

@ -36,7 +36,8 @@
"Attribute to Look Up": "Atribut za iskanje",
"Lookup Table": "Preglednica za iskanje",
"This default should be changed if editors' access is to be limited. ": "To privzeto nastavitev je treba spremeniti, če je treba omejiti dostop urednikov. ",
"Seed rules": "Privzete pravice"
"Seed rules": "Privzete pravice",
"Add Table-wide Rule": "Dodaj pravilo za celotno tabelo"
},
"ACUserManager": {
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
@ -730,7 +731,8 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formule podpirajo številne Excelove funkcije, polno Pythonovo sintakso in vključujejo koristnega AI pomočnika.",
"Forms are here!": "Obrazci so tukaj!",
"Learn more": "Nauči se več",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}"
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Ta pravila se uporabijo, ko so obdelana vsa pravila stolpcev, če so na voljo."
},
"UserManager": {
"Anyone with link ": "Vsakdo s povezavo ",
@ -1349,5 +1351,29 @@
"FormConfig": {
"Required field": "Obvezno polje",
"Field rules": "Pravila polj"
},
"CustomView": {
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Če želite uporabiti ta pripomoček, preslikajte vse neobvezne stolpce na plošči za ustvarjanje na desni.",
"Some required columns aren't mapped": "Nekateri zahtevani stolpci niso preslikani"
},
"FormContainer": {
"Build your own form": "Ustvari svoj obrazec",
"Powered by": "Poganja ga"
},
"FormErrorPage": {
"Error": "Napaka"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ups! Obrazec, ki ga iščeš, ne obstaja.",
"Oops! This form is no longer published.": "Ups! Ta obrazec ni več objavljen.",
"There was a problem loading the form.": "Pri nalaganju obrazca je prišlo do težave.",
"You don't have access to this form.": "Nimaš dostopa do tega obrazca."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Pri pošiljanju obrazca je prišlo do napake. Prosim poskusi ponovno."
},
"FormSuccessPage": {
"Form Submitted": "Obrazec oddan",
"Thank you! Your response has been recorded.": "Hvala ti! Tvoj odgovor je bil zabeležen."
}
}

@ -0,0 +1,52 @@
import {assert, driver} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils';
describe('Boot', function() {
this.timeout(30000);
setupTestSuite();
let oldEnv: testUtils.EnvironmentSnapshot;
afterEach(() => gu.checkForErrors());
async function hasPrompt() {
assert.include(
await driver.findContentWait('p', /diagnostics page/, 2000).getText(),
'A diagnostics page can be made available');
}
it('gives prompt about how to enable boot page', async function() {
await driver.get(`${server.getHost()}/boot`);
await hasPrompt();
});
describe('with a GRIST_BOOT_KEY', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_BOOT_KEY = 'lala';
await server.restart();
});
after(async function() {
oldEnv.restore();
await server.restart();
});
it('gives prompt when key is missing', async function() {
await driver.get(`${server.getHost()}/boot`);
await hasPrompt();
});
it('gives prompt when key is wrong', async function() {
await driver.get(`${server.getHost()}/boot/bilbo`);
await hasPrompt();
});
it('gives page when key is right', async function() {
await driver.get(`${server.getHost()}/boot/lala`);
await driver.findContentWait('h2', /Grist is reachable/, 2000);
});
});
});
Loading…
Cancel
Save