Merge pull request #384 from gristlabs/eslint

Adding basic eslint support.
This commit is contained in:
jarek 2023-01-04 16:36:55 +01:00 committed by GitHub
commit 8c6f82fce3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1080 additions and 102 deletions

11
.eslintignore Normal file
View File

@ -0,0 +1,11 @@
# Exclude everything, and use an allowlist to include just the folders we want checked.
/*
!/app
!/test
!/plugins
!/buildtools
!/stubs
!/.eslintrc.js
# And exclude some things within those (generated files)
/plugins/**/dist

122
.eslintrc.js Normal file
View File

@ -0,0 +1,122 @@
module.exports = {
// Basic settings for JS files.
extends: ['eslint:recommended'],
env: {
node: true,
es6: true,
},
// Set parser to support, e.g. import() function for dynamic imports (see
// https://stackoverflow.com/a/47833471/328565 and https://stackoverflow.com/a/69557309/328565).
parser: '@babel/eslint-parser',
parserOptions: {
ecmaVersion: 2018,
requireConfigFile: false,
},
ignorePatterns: [
"/static/**/*.js", // Built JS bundles
"*-ti.ts", // Files generated by ts-interface-builder
],
rules: {
'no-unused-vars': ["error", {args: "none"}],
'no-prototype-builtins': 'off',
'no-trailing-spaces': 'warn',
'comma-spacing': 'warn',
'semi-spacing': 'warn',
},
// The Typescript-specific settings apply only to TS files. ESLint is expensive here, and has to
// analyze dependencies for type-checking. (In an editor, it's much faster when used with
// tsserver.)
overrides: [{
files: "**/*.ts",
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json'],
sourceType: 'module',
ecmaVersion: 2018,
},
env: {
node: true,
browser: true,
es6: true,
mocha: true,
},
globals: {
Promise: true
},
rules: {
// A lot of the options below would be helpful to keep on, but would need a lot of fix-ups.
"@typescript-eslint/ban-types": 'off',
"@typescript-eslint/explicit-member-accessibility": ["error", {overrides: {constructors: 'off'}}],
"@typescript-eslint/explicit-module-boundary-types": 'off',
// These settings mimic what we had before with tslint.
"@typescript-eslint/member-ordering": ["warn", {default: [
'public-static-field',
'public-static-method',
'protected-static-field', 'private-static-field', 'static-field',
'protected-static-method', 'private-static-method', 'static-method',
'public-field', 'protected-field', 'private-field', 'field',
'public-constructor', 'protected-constructor', 'private-constructor', 'constructor',
'public-method', 'protected-method', 'private-method', 'method',
]}],
"@typescript-eslint/naming-convention": ["warn", {
selector: "memberLike", filter: { match: false, regex: '(listenTo)' },
modifiers: ["private"], format: ["camelCase"], leadingUnderscore: "require"
}],
"@typescript-eslint/no-empty-function": 'off',
"@typescript-eslint/no-explicit-any": 'off',
"@typescript-eslint/no-inferrable-types": 'off',
"@typescript-eslint/no-misused-promises": ["error", {"checksVoidReturn": false}],
"@typescript-eslint/no-namespace": 'off',
"@typescript-eslint/no-non-null-assertion": 'off',
"@typescript-eslint/no-shadow": ["warn", { ignoreTypeValueShadow: true }],
"@typescript-eslint/no-this-alias": 'off',
"@typescript-eslint/no-type-alias": ["warn", {
"allowAliases": "always",
"allowCallbacks": "always",
"allowConditionalTypes": "always",
"allowConstructors": "always",
"allowLiterals": "in-unions-and-intersections",
"allowMappedTypes": "always",
"allowTupleTypes": "always",
"allowGenerics": "always",
}],
"@typescript-eslint/no-unsafe-assignment": 'off',
"@typescript-eslint/no-unsafe-argument": 'off',
"@typescript-eslint/no-unsafe-call": 'off',
"@typescript-eslint/no-unsafe-member-access": 'off',
"@typescript-eslint/no-unsafe-return": 'off',
"@typescript-eslint/no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": false }],
"@typescript-eslint/no-var-requires": 'off',
"@typescript-eslint/prefer-regexp-exec": 'off',
"@typescript-eslint/require-await": 'off',
"@typescript-eslint/restrict-plus-operands": 'off',
"@typescript-eslint/restrict-template-expressions": 'off',
"@typescript-eslint/type-annotation-spacing": 'warn',
"@typescript-eslint/unbound-method": 'off',
'no-undef': 'off',
'no-prototype-builtins': 'off',
'prefer-rest-params': 'off',
'no-console': 'off',
'no-shadow': 'off',
'no-inner-declarations': 'off',
'max-len': ['warn', {code: 120, ignoreUrls: true}],
'sort-imports': ['warn', {ignoreDeclarationSort: true, ignoreCase: true, allowSeparatedGroups: true}],
'no-trailing-spaces': 'warn',
'no-unused-expressions': ["error", {allowShortCircuit: true, allowTernary: true}],
'block-spacing': ['warn', 'always'],
'comma-spacing': 'warn',
'curly': ['warn', 'all'],
'semi': ['warn', 'always'],
'semi-spacing': 'warn',
},
}]
}

View File

@ -37,6 +37,9 @@ jobs:
- name: Install Node.js packages - name: Install Node.js packages
run: yarn install run: yarn install
- name: Run eslint
run: yarn run lint:ci
- name: Make sure bucket is versioned - name: Make sure bucket is versioned
env: env:
AWS_ACCESS_KEY_ID: administrator AWS_ACCESS_KEY_ID: administrator
@ -46,6 +49,7 @@ jobs:
- name: Build Node.js code - name: Build Node.js code
run: yarn run build:prod run: yarn run build:prod
- name: Run smoke test - name: Run smoke test
run: VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:smoke run: VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:smoke

View File

@ -134,7 +134,7 @@ export class AccessRules extends Disposable {
// there were only removals, then length will be reduced. // there were only removals, then length will be reduced.
getChangedStatus(tableRules.length < this._ruleCollection.getAllTableIds().length), getChangedStatus(tableRules.length < this._ruleCollection.getAllTableIds().length),
getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size), getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),
...tableRules.map(t => use(t.ruleStatus)), ...tableRules.map(tr => use(tr.ruleStatus)),
...userAttr.map(u => use(u.ruleStatus)), ...userAttr.map(u => use(u.ruleStatus)),
specialRules ? use(specialRules.ruleStatus) : RuleStatus.Unchanged, specialRules ? use(specialRules.ruleStatus) : RuleStatus.Unchanged,
); );
@ -239,7 +239,7 @@ export class AccessRules extends Disposable {
const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten( const newResources: MetaRowRecord<'_grist_ACLResources'>[] = flatten(
[{tableId: '*', colIds: '*'}], [{tableId: '*', colIds: '*'}],
this._specialRules.get()?.getResources() || [], this._specialRules.get()?.getResources() || [],
...this._tableRules.get().map(t => t.getResources())) ...this._tableRules.get().map(tr => tr.getResources()))
.map(r => ({id: -1, ...r})); .map(r => ({id: -1, ...r}));
// Prepare userActions and a mapping of serializedResource to rowIds. // Prepare userActions and a mapping of serializedResource to rowIds.
@ -350,7 +350,7 @@ export class AccessRules extends Disposable {
// synchronously, which prevents the menu from closing on click. // synchronously, which prevents the menu from closing on click.
menuItemAsync(() => this._addTableRules(tableId), menuItemAsync(() => this._addTableRules(tableId),
this.getTableTitle(tableId), this.getTableTitle(tableId),
dom.cls('disabled', (use) => use(this._tableRules).some(t => t.tableId === tableId)), dom.cls('disabled', (use) => use(this._tableRules).some(tr => tr.tableId === tableId)),
) )
), ),
), ),
@ -442,7 +442,7 @@ export class AccessRules extends Disposable {
*/ */
public getRules(): RuleRec[] { public getRules(): RuleRec[] {
return flatten( return flatten(
...this._tableRules.get().map(t => t.getRules()), ...this._tableRules.get().map(tr => tr.getRules()),
this._specialRules.get()?.getRules() || [], this._specialRules.get()?.getRules() || [],
this._docDefaultRuleSet.get()?.getRules('*') || [] this._docDefaultRuleSet.get()?.getRules('*') || []
); );
@ -484,7 +484,7 @@ export class AccessRules extends Disposable {
} }
private _addTableRules(tableId: string) { private _addTableRules(tableId: string) {
if (this._tableRules.get().some(t => t.tableId === tableId)) { if (this._tableRules.get().some(tr => tr.tableId === tableId)) {
throw new Error(`Trying to add TableRules for existing table ${tableId}`); throw new Error(`Trying to add TableRules for existing table ${tableId}`);
} }
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []}; const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};

View File

@ -395,7 +395,10 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
const newName = tableRename[1]; const newName = tableRename[1];
if (!newName) { if (!newName) {
// TODO - find a better way to send informative notifications. // TODO - find a better way to send informative notifications.
gristNotify(t("Table {{tableId}} was subsequently removed in action #{{actionNum}}", {tableId:tableId, actionNum: action.actionNum})); gristNotify(t(
"Table {{tableId}} was subsequently removed in action #{{actionNum}}",
{tableId:tableId, actionNum: action.actionNum}
));
return; return;
} }
tableId = newName; tableId = newName;
@ -416,7 +419,10 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
const newName = columnRename[1]; const newName = columnRename[1];
if (!newName) { if (!newName) {
// TODO - find a better way to send informative notifications. // TODO - find a better way to send informative notifications.
gristNotify(t("Column {{colId}} was subsequently removed in action #{{action.actionNum}}", {colId, actionNum: action.actionNum})); gristNotify(t(
"Column {{colId}} was subsequently removed in action #{{action.actionNum}}",
{colId, actionNum: action.actionNum}
));
return; return;
} }
colId = newName; colId = newName;

View File

@ -656,8 +656,9 @@ export class ChartConfig extends GrainJSDisposable {
), ),
dom.domComputed(this._optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) => dom.domComputed(this._optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) =>
value === 'symmetric' ? cssRowHelp(t("Each Y series is followed by a series for the length of error bars.")) : value === 'symmetric' ? cssRowHelp(t("Each Y series is followed by a series for the length of error bars.")) :
value === 'separate' ? cssRowHelp(t("Each Y series is followed by two series, for top and bottom error bars.")) : value === 'separate' ? cssRowHelp(t("Each Y series is followed by two series, for " +
null "top and bottom error bars."))
: null
), ),
]), ]),

View File

@ -160,7 +160,10 @@ export class DataTables extends Disposable {
function doRemove() { function doRemove() {
return docModel.docData.sendAction(['RemoveTable', r.tableId()]); return docModel.docData.sendAction(['RemoveTable', r.tableId()]);
} }
confirmModal(t("Delete {{formattedTableName}} data, and remove it from all pages?", {formattedTableName : r.formattedTableName()}), 'Delete', doRemove); confirmModal(t(
"Delete {{formattedTableName}} data, and remove it from all pages?",
{formattedTableName : r.formattedTableName()}
), 'Delete', doRemove);
} }
private _tableRows(table: TableRec) { private _tableRows(table: TableRec) {

View File

@ -149,7 +149,9 @@ export class DocumentUsage extends Disposable {
return dom.domComputed((use) => { return dom.domComputed((use) => {
const isAccessDenied = use(this._isAccessDenied); const isAccessDenied = use(this._isAccessDenied);
if (isAccessDenied === null) { return null; } if (isAccessDenied === null) { return null; }
if (isAccessDenied) { return buildMessage(t("Usage statistics are only available to users with full access to the document data.")); } if (isAccessDenied) {
return buildMessage(t("Usage statistics are only available to users with full access to the document data."));
}
const org = use(this._currentOrg); const org = use(this._currentOrg);
const product = use(this._currentProduct); const product = use(this._currentProduct);
@ -239,7 +241,7 @@ export function buildUpgradeMessage(
) { ) {
if (!canUpgrade) { return t("Contact the site owner to upgrade the plan to raise limits."); } if (!canUpgrade) { return t("Contact the site owner to upgrade the plan to raise limits."); }
const upgradeLinkText = t("start your 30-day free trial of the Pro plan.") const upgradeLinkText = t("start your 30-day free trial of the Pro plan.");
// TODO i18next // TODO i18next
return [ return [
variant === 'short' ? null : t("For higher limits, "), variant === 'short' ? null : t("For higher limits, "),

View File

@ -986,7 +986,7 @@ export class GristDoc extends DisposableWithEvents {
* Renames table. Method exposed primarily for tests. * Renames table. Method exposed primarily for tests.
*/ */
public async renameTable(tableId: string, newTableName: string) { public async renameTable(tableId: string, newTableName: string) {
const tableRec = this.docModel.visibleTables.all().find(t => t.tableId.peek() === tableId); const tableRec = this.docModel.visibleTables.all().find(tb => tb.tableId.peek() === tableId);
if (!tableRec) { if (!tableRec) {
throw new UserError(`No table with id ${tableId}`); throw new UserError(`No table with id ${tableId}`);
} }

View File

@ -380,7 +380,7 @@ export class Importer extends DisposableWithEvents {
} }
private _getHiddenTableIds(): string[] { private _getHiddenTableIds(): string[] {
return this._sourceInfoArray.get().map((t: SourceInfo) => t.hiddenTableId); return this._sourceInfoArray.get().map((si: SourceInfo) => si.hiddenTableId);
} }
private async _reImport(upload: UploadResult) { private async _reImport(upload: UploadResult) {

View File

@ -1,11 +1,12 @@
import {computed, Computed, dom, DomElementArg, IDisposableOwner, Observable, styled} from "grainjs"; import {computed, Computed, dom, DomElementArg, IDisposableOwner, Observable, styled} from "grainjs";
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal, cssAnimatedModal} from 'app/client/ui2018/modals'; import {cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle,
IModalControl, modal} from 'app/client/ui2018/modals';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {UserManagerModel, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel'; import {IOrgMemberSelectOption, UserManagerModel} from 'app/client/models/UserManagerModel';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {textarea} from "app/client/ui/inputs"; import {textarea} from "app/client/ui/inputs";
import {BasicRole, VIEWER, NonGuestRole, isBasicRole} from "app/common/roles"; import {BasicRole, isBasicRole, NonGuestRole, VIEWER} from "app/common/roles";
import {menu, menuItem} from 'app/client/ui2018/menus'; import {menu, menuItem} from 'app/client/ui2018/menus';
function parseEmailList(emailListRaw: string): Array<string> { function parseEmailList(emailListRaw: string): Array<string> {
@ -29,7 +30,9 @@ export function buildMultiUserManagerModal(
const rolesObs = Observable.create<BasicRole>(owner, VIEWER); const rolesObs = Observable.create<BasicRole>(owner, VIEWER);
const isValidObs = Observable.create(owner, true); const isValidObs = Observable.create(owner, true);
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(emailListObs) && use(rolesObs) && use(isValidObs))); const enableAdd: Computed<boolean> = computed(
(use) => Boolean(use(emailListObs) && use(rolesObs) && use(isValidObs))
);
const save = (ctl: IModalControl) => { const save = (ctl: IModalControl) => {
const emailList = parseEmailList(emailListObs.get()); const emailList = parseEmailList(emailListObs.get());
@ -40,7 +43,7 @@ export function buildMultiUserManagerModal(
emailList.forEach(email => onAdd(email, role)); emailList.forEach(email => onAdd(email, role));
ctl.close(); ctl.close();
} }
} };
return modal(ctl => [ return modal(ctl => [
{ style: 'padding: 0;' }, { style: 'padding: 0;' },
@ -63,7 +66,7 @@ export function buildMultiUserManagerModal(
{ style: 'margin: 32px 64px; display: flex;' }, { style: 'margin: 32px 64px; display: flex;' },
bigPrimaryButton('Confirm', bigPrimaryButton('Confirm',
dom.boolAttr('disabled', (use) => !use(enableAdd)), dom.boolAttr('disabled', (use) => !use(enableAdd)),
dom.on('click', () => {save(ctl)}), dom.on('click', () => save(ctl)),
testId('um-confirm') testId('um-confirm')
), ),
bigBasicButton( bigBasicButton(

View File

@ -101,7 +101,7 @@ function domT(key: string, args: any, tImpl: typeof i18next.t) {
// If there are any DomElements in args, handle it with missingInterpolationHandler. // If there are any DomElements in args, handle it with missingInterpolationHandler.
const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value));
if (!args || !domElements.length) { if (!args || !domElements.length) {
return tImpl(key, args || undefined) as any; return tImpl(key, args || undefined);
} else { } else {
// Make a copy of the arguments, and remove any dom elements from it. It will instruct // Make a copy of the arguments, and remove any dom elements from it. It will instruct
// i18next library to use `missingInterpolationHandler` handler. // i18next library to use `missingInterpolationHandler` handler.
@ -171,5 +171,5 @@ export function makeT(scope: string, instance?: typeof i18next) {
reportError(error); reportError(error);
} }
return domT(key, args, scopedResolver!); return domT(key, args, scopedResolver!);
} };
} }

View File

@ -239,14 +239,24 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
t("Error accessing document"), t("Error accessing document"),
t("Reload"), t("Reload"),
async () => window.location.reload(true), async () => window.location.reload(true),
isDocOwner ? t("You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]", {error: err.message}) : isDocOwner
isDenied ? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message}) : ? t("You can try reloading the document, or using recovery mode. " +
t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message}), "Recovery mode opens the document to be fully accessible to " +
{ hideCancel: true, "owners, and inaccessible to others. It also disables " +
extraButtons: (isDocOwner && !isDenied) ? bigBasicButton(t("Enter recovery mode"), dom.on('click', async () => { "formulas. [{{error}}]", {error: err.message})
: isDenied
? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message})
: t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message}),
{
hideCancel: true,
extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton(
t("Enter recovery mode"),
dom.on('click', async () => {
await this._api.getDocAPI(this.currentDocId.get()!).recover(true); await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
window.location.reload(true); window.location.reload(true);
}), testId('modal-recovery-mode')) : null, }),
testId('modal-recovery-mode')
)
}, },
); );
} }

View File

@ -113,7 +113,9 @@ export class AccountPage extends Disposable {
), ),
css.subHeader(t("Two-factor authentication")), css.subHeader(t("Two-factor authentication")),
css.description( css.description(
t("Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.") t("Two-factor authentication is an extra layer of security for your Grist account " +
"designed to ensure that you're the only person who can access your account, " +
"even if someone knows your password.")
), ),
dom.create(MFAConfig, user), dom.create(MFAConfig, user),
), ),

View File

@ -78,7 +78,8 @@ export class ApiKey extends Disposable {
dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [ dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [
basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'), basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'),
dom.boolAttr('disabled', this._loading)), dom.boolAttr('disabled', this._loading)),
description(t("By generating an API key, you will be able to make API calls for your own account."), testId('description')), description(t("By generating an API key, you will be able to " +
"make API calls for your own account."), testId('description')),
]), ]),
); );
} }
@ -112,7 +113,8 @@ export class ApiKey extends Disposable {
confirmModal( confirmModal(
t("Remove API Key"), t("Remove"), t("Remove API Key"), t("Remove"),
() => this._onDelete(), () => this._onDelete(),
t("You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?") t("You're about to delete an API key. This will cause all future requests " +
"using this API key to be rejected. Do you still want to delete?")
); );
} }
} }

View File

@ -73,7 +73,9 @@ class ColumnPicker extends Disposable {
properValue, properValue,
options, options,
{ {
defaultLabel: this._column.typeDesc != "any" ? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc}) : t("Pick a column") defaultLabel: this._column.typeDesc != "any"
? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc})
: t("Pick a column")
} }
), ),
testId('mapping-for-' + this._column.name), testId('mapping-for-' + this._column.name),
@ -117,7 +119,11 @@ class ColumnListPicker extends Disposable {
col.label.peek(), col.label.peek(),
)), )),
wrongTypeCount > 0 ? menuText( wrongTypeCount > 0 ? menuText(
t("{{wrongTypeCount}} non-{{columnType}} columns are not shown", {wrongTypeCount, columnType: this._column.type.toLowerCase(), count: wrongTypeCount}), t("{{wrongTypeCount}} non-{{columnType}} columns are not shown", {
wrongTypeCount,
columnType: this._column.type.toLowerCase(),
count: wrongTypeCount
}),
testId('map-message-' + this._column.name) testId('map-message-' + this._column.name)
) : null ) : null
]; ];
@ -371,8 +377,12 @@ export class CustomSectionConfig extends Disposable {
} }
switch(level) { switch(level) {
case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions.")); case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions."));
case AccessLevel.read_table: return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")})); // TODO i18next case AccessLevel.read_table:
case AccessLevel.full: return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {fullAccess: dom("b", "full access")})); // TODO i18next return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
case AccessLevel.full:
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
fullAccess: dom("b", "full access")
}));
default: throw new Error(`Unsupported ${level} access level`); default: throw new Error(`Unsupported ${level} access level`);
} }
} }

View File

@ -21,7 +21,8 @@ export async function startDocTour(docData: DocData, docComm: DocComm, onFinishC
const invalidDocTour: IOnBoardingMsg[] = [{ const invalidDocTour: IOnBoardingMsg[] = [{
title: t("No valid document tour"), title: t("No valid document tour"),
body: t("Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."), body: t("Cannot construct a document tour from the data in this document. " +
"Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."),
selector: 'document', selector: 'document',
showHasModal: true, showHasModal: true,
}]; }];

View File

@ -83,10 +83,9 @@ class DuplicateTableModal extends Disposable {
), ),
cssWarning( cssWarning(
cssWarningIcon('Warning'), cssWarningIcon('Warning'),
dom('div', t("Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}",
dom('div', {link: cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Read More.')}
t("Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}", {link: cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Read More.')}) )),
), //TODO: i18next
), ),
cssField( cssField(
cssCheckbox( cssCheckbox(

View File

@ -1,4 +1,4 @@
import {makeT} from 'app/client/lib/localization' import {makeT} from 'app/client/lib/localization';
const t = makeT('ExampleInfo'); const t = makeT('ExampleInfo');
@ -36,7 +36,8 @@ export const buildExamples = (): IExampleInfo[] => [{
tutorialUrl: 'https://support.getgrist.com/investment-research/', tutorialUrl: 'https://support.getgrist.com/investment-research/',
welcomeCard: { welcomeCard: {
title: t("Welcome to the Investment Research template"), title: t("Welcome to the Investment Research template"),
text: t("Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically."), text: t("Check out our related tutorial to learn how to create " +
"summary tables and charts, and to link charts dynamically."),
tutorialName: t("Tutorial: Analyze & Visualize"), tutorialName: t("Tutorial: Analyze & Visualize"),
}, },
}, { }, {

View File

@ -41,7 +41,8 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a
if (cmp.summary === 'left' || cmp.summary === 'both') { if (cmp.summary === 'left' || cmp.summary === 'both') {
titleText = t("Original Has Modifications"); titleText = t("Original Has Modifications");
buttonText = t("Overwrite"); buttonText = t("Overwrite");
warningText = `${warningText} ${t("Be careful, the original has changes not in this document. Those changes will be overwritten.")}`; warningText = `${warningText} ${t("Be careful, the original has changes " +
"not in this document. Those changes will be overwritten.")}`;
} else if (cmp.summary === 'unrelated') { } else if (cmp.summary === 'unrelated') {
titleText = t("Original Looks Unrelated"); titleText = t("Original Looks Unrelated");
buttonText = t("Overwrite"); buttonText = t("Overwrite");

View File

@ -268,8 +268,8 @@ export class PageWidgetSelect extends Disposable {
}) : }) :
null; null;
private _isNewTableDisabled = Computed.create(this, this._value.type, (use, t) => !isValidSelection( private _isNewTableDisabled = Computed.create(this, this._value.type, (use, type) => !isValidSelection(
'New Table', t, this._options.isNewPage)); 'New Table', type, this._options.isNewPage));
constructor( constructor(
private _value: IWidgetValueObs, private _value: IWidgetValueObs,
@ -399,8 +399,8 @@ export class PageWidgetSelect extends Disposable {
this._value.summarize.set(true); this._value.summarize.set(true);
} }
private _selectType(t: IWidgetType) { private _selectType(type: IWidgetType) {
this._value.type.set(t); this._value.type.set(type);
} }
private _selectTable(tid: TableId) { private _selectTable(tid: TableId) {

View File

@ -100,11 +100,11 @@ function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const removePage = () => [['RemoveRecord', '_grist_Views', viewId]]; const removePage = () => [['RemoveRecord', '_grist_Views', viewId]];
const removeAll = () => [ const removeAll = () => [
...removePage(), ...removePage(),
...notVisibleTables.map(t => ['RemoveTable', t.tableId.peek()]) ...notVisibleTables.map(tb => ['RemoveTable', tb.tableId.peek()])
]; ];
if (notVisibleTables.length) { if (notVisibleTables.length) {
const tableNames = notVisibleTables.map(t => t.tableNameDef.peek()); const tableNames = notVisibleTables.map(tb => tb.tableNameDef.peek());
buildPrompt(tableNames, async (option) => { buildPrompt(tableNames, async (option) => {
// Errors are handled in the dialog. // Errors are handled in the dialog.
if (option === 'data') { if (option === 'data') {
@ -174,7 +174,7 @@ function buildOption(value: Observable<RemoveOption>, id: RemoveOption, content:
function buildWarning(tables: string[]) { function buildWarning(tables: string[]) {
return cssWarning( return cssWarning(
dom.forEach(tables, (t) => cssTableName(t, testId('table'))) dom.forEach(tables, (tb) => cssTableName(tb, testId('table')))
); );
} }

View File

@ -39,8 +39,8 @@ import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl, import {confirmModal, cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle,
modal, cssAnimatedModal} from 'app/client/ui2018/modals'; IModalControl, modal} from 'app/client/ui2018/modals';
export interface IUserManagerOptions { export interface IUserManagerOptions {
permissionData: Promise<PermissionData>; permissionData: Promise<PermissionData>;

View File

@ -35,7 +35,8 @@ export function createForbiddenPage(appModel: AppModel, message?: string) {
return pagePanelsError(appModel, t("Access denied{{suffix}}", {suffix: ''}), [ return pagePanelsError(appModel, t("Access denied{{suffix}}", {suffix: ''}), [
dom.domComputed(appModel.currentValidUser, user => user ? [ dom.domComputed(appModel.currentValidUser, user => user ? [
cssErrorText(message || t("You do not have access to this organization's documents.")), cssErrorText(message || t("You do not have access to this organization's documents.")),
cssErrorText(t("You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.", {email: dom('b', user.email)})), // TODO: i18next cssErrorText(t("You are signed in as {{email}}. You can sign in with a different " +
"account, or ask an administrator for access.", {email: dom('b', user.email)})),
] : [ ] : [
// This page is not normally shown because a logged out user with no access will get // This page is not normally shown because a logged out user with no access will get
// redirected to log in. But it may be seen if a user logs out and returns to a cached // redirected to log in. But it may be seen if a user logs out and returns to a cached
@ -73,7 +74,10 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
document.title = t("Page not found{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())}); document.title = t("Page not found{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())});
return pagePanelsError(appModel, t("Page not found{{suffix}}", {suffix: ''}), [ return pagePanelsError(appModel, t("Page not found{{suffix}}", {suffix: ''}), [
cssErrorText(message || t("The requested page could not be found.{{separator}}Please check the URL and try again.", {separator: dom('br')})), // TODO: i18next cssErrorText(message ||
t("The requested page could not be found.{{separator}}Please check the URL and try again.", {
separator: dom('br')
})),
cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'), cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'),
urlState().setLinkUrl({}))), urlState().setLinkUrl({}))),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})), cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})),

View File

@ -1,5 +1,5 @@
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {dom, IDomArgs, DomElementArg, IInputOptions, Observable, styled, subscribe} from 'grainjs'; import {dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe} from 'grainjs';
export const cssInput = styled('input', ` export const cssInput = styled('input', `
font-size: ${vars.mediumFontSize}; font-size: ${vars.mediumFontSize};

View File

@ -1,4 +1,4 @@
import { ACResults, buildHighlightedDom, normalizeText, HighlightFunc } from 'app/client/lib/ACIndex'; import { ACResults, buildHighlightedDom, HighlightFunc, normalizeText } from 'app/client/lib/ACIndex';
import { Autocomplete } from 'app/client/lib/autocomplete'; import { Autocomplete } from 'app/client/lib/autocomplete';
import { ICellItem } from 'app/client/models/ColumnACIndexes'; import { ICellItem } from 'app/client/models/ColumnACIndexes';
import { reportError } from 'app/client/models/errors'; import { reportError } from 'app/client/models/errors';

View File

@ -1,5 +1,5 @@
import { createGroup } from 'app/client/components/commands'; import { createGroup } from 'app/client/components/commands';
import { ACItem, ACResults, normalizeText, HighlightFunc } from 'app/client/lib/ACIndex'; import { ACItem, ACResults, HighlightFunc, normalizeText } from 'app/client/lib/ACIndex';
import { IAutocompleteOptions } from 'app/client/lib/autocomplete'; import { IAutocompleteOptions } from 'app/client/lib/autocomplete';
import { IToken, TokenField, tokenFieldStyles } from 'app/client/lib/TokenField'; import { IToken, TokenField, tokenFieldStyles } from 'app/client/lib/TokenField';
import { reportError } from 'app/client/models/errors'; import { reportError } from 'app/client/models/errors';

View File

@ -9,5 +9,5 @@ export default function getCurrentTime(): moment.Moment {
if (typeof window === 'undefined' || !window) { return getDefault(); } if (typeof window === 'undefined' || !window) { return getDefault(); }
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
return searchParams.has('currentTime') ? moment(searchParams.get('currentTime')!) : getDefault(); return searchParams.has('currentTime') ? moment(searchParams.get('currentTime')) : getDefault();
} }

View File

@ -18,7 +18,7 @@ import {makeId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {IPermitStore, Permit} from 'app/server/lib/Permit'; import {IPermitStore, Permit} from 'app/server/lib/Permit';
import {AccessTokenInfo} from 'app/server/lib/AccessTokens'; import {AccessTokenInfo} from 'app/server/lib/AccessTokens';
import {allowHost, isEnvironmentAllowedHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils'; import {allowHost, getOriginUrl, isEnvironmentAllowedHost, optStringParam} from 'app/server/lib/requestUtils';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import {NextFunction, Request, RequestHandler, Response} from 'express'; import {NextFunction, Request, RequestHandler, Response} from 'express';
import {IncomingMessage} from 'http'; import {IncomingMessage} from 'http';

View File

@ -955,8 +955,8 @@ export class DocWorkerApi {
const options: DownloadOptions = { const options: DownloadOptions = {
...params, ...params,
filename: name + (params.tableId === name ? '' : '-' + params.tableId), filename: name + (params.tableId === name ? '' : '-' + params.tableId),
} };
return options return options;
} }
private _getActiveDoc(req: RequestWithLogin): Promise<ActiveDoc> { private _getActiveDoc(req: RequestWithLogin): Promise<ActiveDoc> {

View File

@ -1,7 +1,7 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {createFormatter} from 'app/common/ValueFormatter'; import {createFormatter} from 'app/common/ValueFormatter';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {ExportData, exportSection, exportTable, Filter, DownloadOptions} from 'app/server/lib/Export'; import {DownloadOptions, ExportData, exportSection, exportTable, Filter} from 'app/server/lib/Export';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import * as bluebird from 'bluebird'; import * as bluebird from 'bluebird';
import contentDisposition from 'content-disposition'; import contentDisposition from 'content-disposition';

View File

@ -1,6 +1,6 @@
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {createExcelFormatter} from 'app/server/lib/ExcelFormatter'; import {createExcelFormatter} from 'app/server/lib/ExcelFormatter';
import {ExportData, exportDoc, DownloadOptions, exportSection, exportTable, Filter} from 'app/server/lib/Export'; import {DownloadOptions, ExportData, exportDoc, exportSection, exportTable, Filter} from 'app/server/lib/Export';
import {Alignment, Border, Fill, Workbook} from 'exceljs'; import {Alignment, Border, Fill, Workbook} from 'exceljs';
import * as express from 'express'; import * as express from 'express';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';

View File

@ -376,7 +376,7 @@ export class DocTriggers {
private async _pushToRedisQueue(events: WebHookEvent[]) { private async _pushToRedisQueue(events: WebHookEvent[]) {
const strings = events.map(e => JSON.stringify(e)); const strings = events.map(e => JSON.stringify(e));
await this._redisClient!.rpushAsync(this._redisQueueKey, ...strings); await this._redisClient?.rpushAsync(this._redisQueueKey, ...strings);
} }
private async _getRedisQueue(redisClient: RedisClient) { private async _getRedisQueue(redisClient: RedisClient) {

View File

@ -108,7 +108,9 @@ export function allowHost(req: Request, allowedHost: string|URL) {
// For requests to a native subdomains, only the base domain needs to match. // For requests to a native subdomains, only the base domain needs to match.
const allowedDomain = parseSubdomain(allowedUrl.hostname); const allowedDomain = parseSubdomain(allowedUrl.hostname);
const actualDomain = parseSubdomain(actualUrl.hostname); const actualDomain = parseSubdomain(actualUrl.hostname);
return (!_.isEmpty(actualDomain) ? actualDomain.base === allowedDomain.base : allowedUrl.hostname === actualUrl.hostname); return (!_.isEmpty(actualDomain) ?
actualDomain.base === allowedDomain.base :
allowedUrl.hostname === actualUrl.hostname);
} }
} }

View File

@ -21,6 +21,9 @@
"test:docker": "./test/test_under_docker.sh", "test:docker": "./test/test_under_docker.sh",
"test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}", "test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}",
"cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js", "cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js",
"lint": "eslint --cache --cache-strategy content .",
"lint:fix": "eslint --cache --cache-strategy=content --fix .",
"lint:ci": "eslint --max-warnings=0 .",
"generate:translation": "NODE_PATH=_build:_build/stubs:_build/ext node buildtools/generate_translation_keys.js" "generate:translation": "NODE_PATH=_build:_build/stubs:_build/ext node buildtools/generate_translation_keys.js"
}, },
"keywords": [ "keywords": [
@ -34,6 +37,8 @@
}, },
"private": false, "private": false,
"devDependencies": { "devDependencies": {
"@babel/core": "7.18.5",
"@babel/eslint-parser": "7.18.2",
"@types/accept-language-parser": "1.5.2", "@types/accept-language-parser": "1.5.2",
"@types/backbone": "1.3.43", "@types/backbone": "1.3.43",
"@types/chai": "4.1.7", "@types/chai": "4.1.7",
@ -71,12 +76,15 @@
"@types/uuid": "3.4.4", "@types/uuid": "3.4.4",
"@types/which": "2.0.1", "@types/which": "2.0.1",
"@types/ws": "^6", "@types/ws": "^6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"app-module-path": "2.2.0", "app-module-path": "2.2.0",
"catw": "1.0.1", "catw": "1.0.1",
"chai": "4.2.0", "chai": "4.2.0",
"chai-as-promised": "7.1.1", "chai-as-promised": "7.1.1",
"chance": "1.0.16", "chance": "1.0.16",
"esbuild-loader": "2.19.0", "esbuild-loader": "2.19.0",
"eslint": "8.18.0",
"http-proxy": "1.18.1", "http-proxy": "1.18.1",
"i18next-scanner": "4.1.0", "i18next-scanner": "4.1.0",
"jsdom": "16.5.0", "jsdom": "16.5.0",

5
test/.eslintrc.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
rules: {
'@typescript-eslint/no-shadow': 'off',
},
}

View File

@ -2,14 +2,14 @@ import {makeT, t} from 'app/client/lib/localization';
import {assert} from 'chai'; import {assert} from 'chai';
import i18next, {i18n} from 'i18next'; import i18next, {i18n} from 'i18next';
import {Disposable, dom, DomContents, observable} from "grainjs"; import {Disposable, dom, DomContents, observable} from "grainjs";
import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; import {G, popGlobals, pushGlobals} from 'grainjs/dist/cjs/lib/browserGlobals';
import {JSDOM} from 'jsdom'; import {JSDOM} from 'jsdom';
describe('localization', function() { describe('localization', function() {
let instance: i18n; let instance: i18n;
before(() => { before(async () => {
instance = i18next.createInstance(); instance = i18next.createInstance();
instance.init({ await instance.init({
lng: 'en', lng: 'en',
resources: { resources: {
en: { en: {

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-shadow */
import {ActionSummary} from 'app/common/ActionSummary'; import {ActionSummary} from 'app/common/ActionSummary';
import {BulkColValues, UserAction} from 'app/common/DocActions'; import {BulkColValues, UserAction} from 'app/common/DocActions';
import {arrayRepeat} from 'app/common/gutil'; import {arrayRepeat} from 'app/common/gutil';
@ -2103,7 +2104,10 @@ function testDocApi() {
}); });
it("GET /docs/{did}/download/xlsx returns 404 if tableId is invalid", async function() { it("GET /docs/{did}/download/xlsx returns 404 if tableId is invalid", async function() {
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?tableId=MissingTableId`, chimpy); const resp = await axios.get(
`${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?tableId=MissingTableId`,
chimpy
);
assert.equal(resp.status, 404); assert.equal(resp.status, 404);
assert.deepEqual(resp.data, { error: 'Table MissingTableId not found.' }); assert.deepEqual(resp.data, { error: 'Table MissingTableId not found.' });
}); });

15
tsconfig.eslint.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "./buildtools/tsconfig-base.json",
"include": [
"app/**/*.js",
"app/**/*.ts",
"stubs/**/*.ts",
"stubs/**/*.ts",
"test/**/*.js",
"test/**/*.ts",
"plugins/**/*.js",
"plugins/**/*.ts",
"buildtools/**/*.js",
"buildtools/**/*.ts",
]
}

816
yarn.lock

File diff suppressed because it is too large Load Diff