mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) updates from grist-core
This commit is contained in:
362
app/client/ui/CreateTeamModal.ts
Normal file
362
app/client/ui/CreateTeamModal.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import {autoFocus} from 'app/client/lib/domUtils';
|
||||
import {ValidationGroup, Validator} from 'app/client/lib/Validator';
|
||||
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
|
||||
import {reportError, UserError} from 'app/client/models/errors';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {TEAM_PLAN} from 'app/common/Features';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
import {UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {
|
||||
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
|
||||
Observable, styled
|
||||
} from 'grainjs';
|
||||
import { makeT } from '../lib/localization';
|
||||
|
||||
const t = makeT('CreateTeamModal');
|
||||
const testId = makeTestId('test-create-team-');
|
||||
|
||||
export function buildNewSiteModal(context: Disposable, options: {
|
||||
planName: string,
|
||||
selectedPlan?: string,
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
const { onCreate } = options;
|
||||
|
||||
return showModal(
|
||||
context,
|
||||
(_owner: Disposable, ctrl: IModalControl) => dom.create(NewSiteModalContent, ctrl, onCreate),
|
||||
dom.cls(cssModalIndex.className),
|
||||
);
|
||||
}
|
||||
|
||||
class NewSiteModalContent extends Disposable {
|
||||
private _page = Observable.create(this, 'createTeam');
|
||||
private _team = Observable.create(this, '');
|
||||
private _domain = Observable.create(this, '');
|
||||
private _ctrl: IModalControl;
|
||||
|
||||
constructor(
|
||||
ctrl: IModalControl,
|
||||
private _onCreate?: (planName: string) => void) {
|
||||
super();
|
||||
this._ctrl = ctrl;
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const team = this._team;
|
||||
const domain = this._domain;
|
||||
const ctrl = this._ctrl;
|
||||
return dom.domComputed(this._page, pageValue => {
|
||||
|
||||
switch (pageValue) {
|
||||
case 'createTeam': return buildTeamPage({
|
||||
team,
|
||||
domain,
|
||||
create: () => this._createTeam(),
|
||||
ctrl
|
||||
});
|
||||
case 'teamSuccess': return buildConfirm({domain: domain.get()});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _createTeam() {
|
||||
const api = new UserAPIImpl(getHomeUrl());
|
||||
try {
|
||||
await api.newOrg({name: this._team.get(), domain: this._domain.get()});
|
||||
this._page.set('teamSuccess');
|
||||
if (this._onCreate) {
|
||||
this._onCreate(TEAM_PLAN);
|
||||
}
|
||||
} catch (err) {
|
||||
reportError(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpgradeModal(owner: Disposable, planName: string): void {
|
||||
throw new UserError(t(`Billing is not supported in grist-core`));
|
||||
}
|
||||
|
||||
export interface UpgradeButton {
|
||||
showUpgradeCard(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
showUpgradeButton(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
}
|
||||
|
||||
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton {
|
||||
return {
|
||||
showUpgradeCard: () => null,
|
||||
showUpgradeButton: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfirm({
|
||||
domain,
|
||||
}: {
|
||||
domain: string;
|
||||
}) {
|
||||
return cssConfirmWrapper(
|
||||
cssSparks(),
|
||||
hspace('1.5em'),
|
||||
cssHeaderLine(t('Team site created'), testId("confirmation")),
|
||||
hspace('2em'),
|
||||
bigPrimaryButtonLink(
|
||||
urlState().setLinkUrl({org: domain || undefined}), t('Go to your site'), testId("confirmation-link")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildTeamPage({
|
||||
team,
|
||||
domain,
|
||||
create,
|
||||
ctrl
|
||||
}: {
|
||||
team: Observable<string>;
|
||||
domain: Observable<string>;
|
||||
create: () => any;
|
||||
ctrl: IModalControl;
|
||||
}) {
|
||||
const disabled = Observable.create(null, false);
|
||||
const group = new ValidationGroup();
|
||||
async function click() {
|
||||
disabled.set(true);
|
||||
try {
|
||||
if (!await group.validate()) {
|
||||
return;
|
||||
}
|
||||
await create();
|
||||
} finally {
|
||||
disabled.set(false);
|
||||
}
|
||||
}
|
||||
const clickOnEnter = dom.onKeyPress({
|
||||
Enter: () => click(),
|
||||
});
|
||||
return cssWide(
|
||||
dom.autoDispose(disabled),
|
||||
cssHeaderLine(t("Work as a Team"), testId("creation-title")),
|
||||
cssSubHeaderLine(t("Choose a name and url for your team site")),
|
||||
hspace('1.5em'),
|
||||
cssColumns(
|
||||
cssSetup(
|
||||
cssLabel(t('Team name')),
|
||||
cssRow(cssField(cssInput(
|
||||
team,
|
||||
{onInput: true},
|
||||
autoFocus(),
|
||||
group.inputReset(),
|
||||
clickOnEnter,
|
||||
testId('name')))),
|
||||
dom.create(Validator, group, t("Team name is required"), () => !!team.get()),
|
||||
hspace('2em'),
|
||||
cssLabel(t('Team url')),
|
||||
cssRow(
|
||||
{style: 'align-items: baseline'},
|
||||
cssField(
|
||||
{style: 'flex: 0 1 0; min-width: auto; margin-right: 5px'},
|
||||
dom.text(`${window.location.origin}/o/`)),
|
||||
cssField(cssInput(
|
||||
domain, {onInput: true}, clickOnEnter, group.inputReset(), testId('domain')
|
||||
)),
|
||||
),
|
||||
dom.create(Validator, group, t("Domain name is required"), () => !!domain.get()),
|
||||
dom.create(Validator, group, t("Domain name is invalid"), () => checkSubdomainValidity(domain.get())),
|
||||
cssButtonsRow(
|
||||
bigBasicButton(
|
||||
t('Cancel'),
|
||||
dom.on('click', () => ctrl.close()),
|
||||
testId('cancel')),
|
||||
bigPrimaryButton(t("Create site"),
|
||||
dom.on('click', click),
|
||||
dom.prop('disabled', disabled),
|
||||
testId('confirm')
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function showModal(
|
||||
context: Disposable,
|
||||
content: (owner: Disposable, ctrl: IModalControl) => DomContents,
|
||||
...args: DomElementArg[]
|
||||
) {
|
||||
let control!: IModalControl;
|
||||
modal((ctrl, modalScope) => {
|
||||
control = ctrl;
|
||||
// When parent is being disposed and we are still visible, close the modal.
|
||||
context.onDispose(() => {
|
||||
// If the modal is already closed (disposed, do nothing)
|
||||
if (modalScope.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
// If not, and parent is going away, close the modal.
|
||||
ctrl.close();
|
||||
});
|
||||
return [
|
||||
cssCreateTeamModal.cls(''),
|
||||
cssCloseButton(testId("close-modal"), cssBigIcon('CrossBig'), dom.on('click', () => ctrl.close())),
|
||||
content(modalScope, ctrl)
|
||||
];
|
||||
}, {backerDomArgs: args});
|
||||
return control;
|
||||
}
|
||||
|
||||
function hspace(height: string) {
|
||||
return dom('div', {style: `height: ${height}`});
|
||||
}
|
||||
|
||||
export const cssCreateTeamModal = styled('div', `
|
||||
position: relative;
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssConfirmWrapper = styled('div', `
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssSparks = styled('div', `
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
background-image: var(--icon-Sparks);
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
&-small {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-size: cover;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssColumns = styled('div', `
|
||||
display: flex;
|
||||
gap: 60px;
|
||||
flex-wrap: wrap;
|
||||
`);
|
||||
|
||||
const cssSetup = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
const cssHeaderLine = styled('div', `
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
const cssSubHeaderLine = styled('div', `
|
||||
text-align: center;
|
||||
margin-bottom: 7px;
|
||||
`);
|
||||
|
||||
const cssLabel = styled('label', `
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
font-size: ${vars.mediumFontSize};
|
||||
color: ${theme.text};
|
||||
line-height: 1.5em;
|
||||
margin: 0px;
|
||||
margin-bottom: 0.3em;
|
||||
`);
|
||||
|
||||
|
||||
const cssWide = styled('div', `
|
||||
min-width: 760px;
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssField = styled('div', `
|
||||
display: block;
|
||||
flex: 1 1 0;
|
||||
margin: 4px 0;
|
||||
min-width: 120px;
|
||||
`);
|
||||
|
||||
|
||||
const cssButtonsRow = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
min-width: 250px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
margin-top: 60px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCloseButton = styled('div', `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.modalCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssBigIcon = styled(icon, `
|
||||
padding: 12px;
|
||||
`);
|
||||
|
||||
const cssModalIndex = styled('div', `
|
||||
z-index: ${vars.pricingModalZIndex}
|
||||
`);
|
||||
|
||||
const cssInput = styled(input, `
|
||||
color: ${theme.inputFg};
|
||||
background-color: ${theme.inputBg};
|
||||
font-size: ${vars.mediumFontSize};
|
||||
height: 42px;
|
||||
line-height: 16px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
|
||||
&-invalid {
|
||||
color: ${theme.inputInvalid};
|
||||
}
|
||||
|
||||
&[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
&[type=number]::-webkit-inner-spin-button,
|
||||
&[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
}
|
||||
`);
|
||||
@@ -93,7 +93,10 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")),
|
||||
(!isFeatureEnabled('helpCenter') ? null :
|
||||
cssIntroLine(
|
||||
'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n
|
||||
t(
|
||||
'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.',
|
||||
{helpCenterLink: helpCenterLink(), sproutsProgram}
|
||||
),
|
||||
testId('welcome-text')
|
||||
)
|
||||
),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type {AppModel} from 'app/client/models/AppModel';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {Disposable, DomArg, DomContents, IDisposableOwner} from 'grainjs';
|
||||
|
||||
export function buildNewSiteModal(context: Disposable, options: {
|
||||
planName: string,
|
||||
selectedPlan?: string,
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
window.location.href = commonUrls.plans;
|
||||
}
|
||||
|
||||
export function buildUpgradeModal(owner: Disposable, planName: string) {
|
||||
window.location.href = commonUrls.plans;
|
||||
}
|
||||
|
||||
export function showTeamUpgradeConfirmation(owner: Disposable) {
|
||||
}
|
||||
|
||||
export interface UpgradeButton {
|
||||
showUpgradeCard(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
showUpgradeButton(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
}
|
||||
|
||||
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton {
|
||||
return {
|
||||
showUpgradeCard : () => null,
|
||||
showUpgradeButton : () => null,
|
||||
};
|
||||
}
|
||||
@@ -149,8 +149,8 @@ export class SupportGristNudge extends Disposable {
|
||||
cssCenterAlignedHeader(t('Opted In')),
|
||||
cssParagraph(
|
||||
t(
|
||||
'Thank you! Your trust and support is greatly appreciated. ' +
|
||||
'Opt out any time from the {{link}} in the user menu.',
|
||||
'Thank you! Your trust and support is greatly appreciated.\
|
||||
Opt out any time from the {{link}} in the user menu.',
|
||||
{link: adminPanelLink()},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,7 +37,7 @@ const WEBHOOK_COLUMNS = [
|
||||
id: 'vt_webhook_fc1',
|
||||
colId: 'tableId',
|
||||
type: 'Choice',
|
||||
label: 'Table',
|
||||
label: t('Table'),
|
||||
// widgetOptions are configured later, since the choices depend
|
||||
// on the user tables in the document.
|
||||
},
|
||||
@@ -45,13 +45,13 @@ const WEBHOOK_COLUMNS = [
|
||||
id: 'vt_webhook_fc2',
|
||||
colId: 'url',
|
||||
type: 'Text',
|
||||
label: 'URL',
|
||||
label: t('URL'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc3',
|
||||
colId: 'eventTypes',
|
||||
type: 'ChoiceList',
|
||||
label: 'Event Types',
|
||||
label: t('Event Types'),
|
||||
widgetOptions: JSON.stringify({
|
||||
widget: 'TextBox',
|
||||
alignment: 'left',
|
||||
@@ -59,11 +59,17 @@ const WEBHOOK_COLUMNS = [
|
||||
choiceOptions: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc10',
|
||||
colId: 'watchedColIdsText',
|
||||
type: 'Text',
|
||||
label: t('Filter for changes in these columns (semicolon-separated ids)'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc4',
|
||||
colId: 'enabled',
|
||||
type: 'Bool',
|
||||
label: 'Enabled',
|
||||
label: t('Enabled'),
|
||||
widgetOptions: JSON.stringify({
|
||||
widget: 'Switch',
|
||||
}),
|
||||
@@ -72,31 +78,31 @@ const WEBHOOK_COLUMNS = [
|
||||
id: 'vt_webhook_fc5',
|
||||
colId: 'isReadyColumn',
|
||||
type: 'Text',
|
||||
label: 'Ready Column',
|
||||
label: t('Ready Column'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc6',
|
||||
colId: 'webhookId',
|
||||
type: 'Text',
|
||||
label: 'Webhook Id',
|
||||
label: t('Webhook Id'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc7',
|
||||
colId: 'name',
|
||||
type: 'Text',
|
||||
label: 'Name',
|
||||
label: t('Name'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc8',
|
||||
colId: 'memo',
|
||||
type: 'Text',
|
||||
label: 'Memo',
|
||||
label: t('Memo'),
|
||||
},
|
||||
{
|
||||
id: 'vt_webhook_fc9',
|
||||
colId: 'status',
|
||||
type: 'Text',
|
||||
label: 'Status',
|
||||
label: t('Status'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -107,8 +113,8 @@ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
|
||||
'name', 'memo',
|
||||
'eventTypes', 'url',
|
||||
'tableId', 'isReadyColumn',
|
||||
'webhookId', 'enabled',
|
||||
'status'
|
||||
'watchedColIdsText', 'webhookId',
|
||||
'enabled', 'status'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -127,9 +133,9 @@ class WebhookExternalTable implements IExternalTable {
|
||||
public name = 'GristHidden_WebhookTable';
|
||||
public initialActions = _prepareWebhookInitialActions(this.name);
|
||||
public saveableFields = [
|
||||
'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
|
||||
'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
|
||||
];
|
||||
public webhooks: ObservableArray<WebhookSummary> = observableArray<WebhookSummary>([]);
|
||||
public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
|
||||
|
||||
public constructor(private _docApi: DocAPI) {
|
||||
}
|
||||
@@ -151,7 +157,7 @@ class WebhookExternalTable implements IExternalTable {
|
||||
}
|
||||
const colIds = new Set(getColIdsFromDocAction(d) || []);
|
||||
if (colIds.has('webhookId') || colIds.has('status')) {
|
||||
throw new Error(`Sorry, not all fields can be edited.`);
|
||||
throw new Error(t(`Sorry, not all fields can be edited.`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +168,7 @@ class WebhookExternalTable implements IExternalTable {
|
||||
continue;
|
||||
}
|
||||
await this._removeWebhook(rec);
|
||||
reportMessage(`Removed webhook.`);
|
||||
reportMessage(t(`Removed webhook.`));
|
||||
}
|
||||
const updates = new Set(delta.updateRows);
|
||||
const t2 = editor;
|
||||
@@ -227,6 +233,7 @@ class WebhookExternalTable implements IExternalTable {
|
||||
for (const webhook of webhooks) {
|
||||
const values = _mapWebhookValues(webhook);
|
||||
const rowId = rowMap.get(webhook.id);
|
||||
|
||||
if (rowId) {
|
||||
toRemove.delete(rowId);
|
||||
actions.push(
|
||||
@@ -269,7 +276,12 @@ class WebhookExternalTable implements IExternalTable {
|
||||
private _initalizeWebhookList(webhooks: WebhookSummary[]){
|
||||
|
||||
this.webhooks.removeAll();
|
||||
this.webhooks.push(...webhooks);
|
||||
this.webhooks.push(
|
||||
...webhooks.map(webhook => {
|
||||
const uiWebhook: UIWebhookSummary = {...webhook};
|
||||
uiWebhook.fields.watchedColIdsText = webhook.fields.watchedColIds ? webhook.fields.watchedColIds.join(";") : "";
|
||||
return uiWebhook;
|
||||
}));
|
||||
}
|
||||
|
||||
private _getErrorString(e: ApiError): string {
|
||||
@@ -308,6 +320,9 @@ class WebhookExternalTable implements IExternalTable {
|
||||
if (fields.eventTypes) {
|
||||
fields.eventTypes = without(fields.eventTypes, 'L');
|
||||
}
|
||||
fields.watchedColIds = fields.watchedColIdsText
|
||||
? fields.watchedColIdsText.split(";").filter((colId: string) => colId.trim() !== "")
|
||||
: [];
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -355,12 +370,12 @@ export class WebhookPage extends DisposableWithEvents {
|
||||
|
||||
public async reset() {
|
||||
await this.docApi.flushWebhooks();
|
||||
reportSuccess('Cleared webhook queue.');
|
||||
reportSuccess(t('Cleared webhook queue.'));
|
||||
}
|
||||
|
||||
public async resetSelected(id: string) {
|
||||
await this.docApi.flushWebhook(id);
|
||||
reportSuccess(`Cleared webhook ${id} queue.`);
|
||||
reportSuccess(t(`Cleared webhook ${id} queue.`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,16 +455,21 @@ function _prepareWebhookInitialActions(tableId: string): DocAction[] {
|
||||
/**
|
||||
* Map a webhook summary to a webhook table raw record. The main
|
||||
* difference is that `eventTypes` is tweaked to be in a cell format,
|
||||
* and `status` is converted to a string.
|
||||
* `status` is converted to a string,
|
||||
* and `watchedColIdsText` is converted to list in a cell format.
|
||||
*/
|
||||
function _mapWebhookValues(webhookSummary: WebhookSummary): Partial<WebhookSchemaType> {
|
||||
function _mapWebhookValues(webhookSummary: UIWebhookSummary): Partial<WebhookSchemaType> {
|
||||
const fields = webhookSummary.fields;
|
||||
const {eventTypes} = fields;
|
||||
const {eventTypes, watchedColIdsText} = fields;
|
||||
const watchedColIds = watchedColIdsText
|
||||
? watchedColIdsText.split(";").filter(colId => colId.trim() !== "")
|
||||
: [];
|
||||
return {
|
||||
...fields,
|
||||
webhookId: webhookSummary.id,
|
||||
status: JSON.stringify(webhookSummary.usage),
|
||||
eventTypes: [GristObjCode.List, ...eventTypes],
|
||||
watchedColIds: [GristObjCode.List, ...watchedColIds],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -457,6 +477,11 @@ type WebhookSchemaType = {
|
||||
[prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop]
|
||||
} & {
|
||||
eventTypes: [GristObjCode, ...unknown[]];
|
||||
watchedColIds: [GristObjCode, ...unknown[]];
|
||||
status: string;
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
type UIWebhookSummary = WebhookSummary & {
|
||||
fields: {watchedColIdsText?: string;}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const WebhookFields = t.iface([], {
|
||||
"url": "string",
|
||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||
"tableId": "string",
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
"enabled": t.opt("boolean"),
|
||||
"isReadyColumn": t.opt(t.union("string", "null")),
|
||||
"name": t.opt("string"),
|
||||
@@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
|
||||
export const WebhookSubscribe = t.iface([], {
|
||||
"url": "string",
|
||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
"enabled": t.opt("boolean"),
|
||||
"isReadyColumn": t.opt(t.union("string", "null")),
|
||||
"name": t.opt("string"),
|
||||
@@ -47,6 +49,7 @@ export const WebhookSummary = t.iface([], {
|
||||
"eventTypes": t.array("string"),
|
||||
"isReadyColumn": t.union("string", "null"),
|
||||
"tableId": "string",
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
"enabled": "boolean",
|
||||
"name": "string",
|
||||
"memo": "string",
|
||||
@@ -63,6 +66,7 @@ export const WebhookPatch = t.iface([], {
|
||||
"url": t.opt("string"),
|
||||
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
|
||||
"tableId": t.opt("string"),
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
"enabled": t.opt("boolean"),
|
||||
"isReadyColumn": t.opt(t.union("string", "null")),
|
||||
"name": t.opt("string"),
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface WebhookFields {
|
||||
url: string;
|
||||
eventTypes: Array<"add"|"update">;
|
||||
tableId: string;
|
||||
watchedColIds?: string[];
|
||||
enabled?: boolean;
|
||||
isReadyColumn?: string|null;
|
||||
name?: string;
|
||||
@@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
|
||||
export interface WebhookSubscribe {
|
||||
url: string;
|
||||
eventTypes: Array<"add"|"update">;
|
||||
watchedColIds?: string[];
|
||||
enabled?: boolean;
|
||||
isReadyColumn?: string|null;
|
||||
name?: string;
|
||||
@@ -44,6 +46,7 @@ export interface WebhookSummary {
|
||||
eventTypes: string[];
|
||||
isReadyColumn: string|null;
|
||||
tableId: string;
|
||||
watchedColIds?: string[];
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
memo: string;
|
||||
@@ -63,6 +66,7 @@ export interface WebhookPatch {
|
||||
url?: string;
|
||||
eventTypes?: Array<"add"|"update">;
|
||||
tableId?: string;
|
||||
watchedColIds?: string[];
|
||||
enabled?: boolean;
|
||||
isReadyColumn?: string|null;
|
||||
name?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
|
||||
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
export const SCHEMA_VERSION = 41;
|
||||
export const SCHEMA_VERSION = 42;
|
||||
|
||||
export const schema = {
|
||||
|
||||
@@ -167,6 +167,8 @@ export const schema = {
|
||||
label : "Text",
|
||||
memo : "Text",
|
||||
enabled : "Bool",
|
||||
watchedColRefList : "RefList:_grist_Tables_column",
|
||||
options : "Text",
|
||||
},
|
||||
|
||||
"_grist_ACLRules": {
|
||||
@@ -388,6 +390,8 @@ export interface SchemaTypes {
|
||||
label: string;
|
||||
memo: string;
|
||||
enabled: boolean;
|
||||
watchedColRefList: [GristObjCode.List, ...number[]]|null;
|
||||
options: string;
|
||||
};
|
||||
|
||||
"_grist_ACLRules": {
|
||||
|
||||
@@ -24,6 +24,9 @@ const CHECKSUM_TTL_MSEC = 24 * 60 * 60 * 1000; // 24 hours
|
||||
// How long do permits stored in redis last, in milliseconds.
|
||||
const PERMIT_TTL_MSEC = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
// Default doc worker group.
|
||||
const DEFAULT_GROUP = 'default';
|
||||
|
||||
class DummyDocWorkerMap implements IDocWorkerMap {
|
||||
private _worker?: DocWorkerInfo;
|
||||
private _available: boolean = false;
|
||||
@@ -62,6 +65,10 @@ class DummyDocWorkerMap implements IDocWorkerMap {
|
||||
this._available = available;
|
||||
}
|
||||
|
||||
public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
public async releaseAssignment(workerId: string, docId: string): Promise<void> {
|
||||
// nothing to do
|
||||
}
|
||||
@@ -241,7 +248,7 @@ export class DocWorkerMap implements IDocWorkerMap {
|
||||
try {
|
||||
// Drop out of available set first.
|
||||
await this._client.sremAsync('workers-available', workerId);
|
||||
const group = await this._client.getAsync(`worker-${workerId}-group`) || 'default';
|
||||
const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP;
|
||||
await this._client.sremAsync(`workers-available-${group}`, workerId);
|
||||
// At this point, this worker should no longer be receiving new doc assignments, though
|
||||
// clients may still be directed to the worker.
|
||||
@@ -290,7 +297,7 @@ export class DocWorkerMap implements IDocWorkerMap {
|
||||
|
||||
public async setWorkerAvailability(workerId: string, available: boolean): Promise<void> {
|
||||
log.info(`DocWorkerMap.setWorkerAvailability ${workerId} ${available}`);
|
||||
const group = await this._client.getAsync(`worker-${workerId}-group`) || 'default';
|
||||
const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP;
|
||||
if (available) {
|
||||
const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo|null;
|
||||
if (!docWorker) { throw new Error('no doc worker contact info available'); }
|
||||
@@ -306,6 +313,11 @@ export class DocWorkerMap implements IDocWorkerMap {
|
||||
}
|
||||
}
|
||||
|
||||
public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean> {
|
||||
const group = workerInfo.group || DEFAULT_GROUP;
|
||||
return Boolean(await this._client.sismemberAsync(`workers-available-${group}`, workerInfo.id));
|
||||
}
|
||||
|
||||
public async releaseAssignment(workerId: string, docId: string): Promise<void> {
|
||||
const op = this._client.multi();
|
||||
op.del(`doc-${docId}`);
|
||||
@@ -352,7 +364,7 @@ export class DocWorkerMap implements IDocWorkerMap {
|
||||
if (docId === 'import') {
|
||||
const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT);
|
||||
try {
|
||||
const _workerId = await this._client.srandmemberAsync(`workers-available-default`);
|
||||
const _workerId = await this._client.srandmemberAsync(`workers-available-${DEFAULT_GROUP}`);
|
||||
if (!_workerId) { throw new Error('no doc worker available'); }
|
||||
const docWorker = await this._client.hgetallAsync(`worker-${_workerId}`) as DocWorkerInfo|null;
|
||||
if (!docWorker) { throw new Error('no doc worker contact info available'); }
|
||||
@@ -383,7 +395,7 @@ export class DocWorkerMap implements IDocWorkerMap {
|
||||
|
||||
if (!workerId) {
|
||||
// Check if document has a preferred worker group set.
|
||||
const group = await this._client.getAsync(`doc-${docId}-group`) || 'default';
|
||||
const group = await this._client.getAsync(`doc-${docId}-group`) || DEFAULT_GROUP;
|
||||
|
||||
// Let's start off by assigning documents to available workers randomly.
|
||||
// TODO: use a smarter algorithm.
|
||||
|
||||
@@ -392,7 +392,7 @@ export class DocWorkerApi {
|
||||
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
|
||||
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
|
||||
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
|
||||
const {url, eventTypes, isReadyColumn, name} = webhook;
|
||||
const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
|
||||
const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
|
||||
|
||||
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
|
||||
@@ -409,6 +409,23 @@ export class DocWorkerApi {
|
||||
}
|
||||
|
||||
if (tableId !== undefined) {
|
||||
if (watchedColIds) {
|
||||
if (tableId !== currentTableId && currentTableId) {
|
||||
// if the tableId changed, we need to reset the watchedColIds
|
||||
fields.watchedColRefList = [GristObjCode.List];
|
||||
} else {
|
||||
if (!tableId) {
|
||||
throw new ApiError(`Cannot find columns "${watchedColIds}" because table is not known`, 404);
|
||||
}
|
||||
fields.watchedColRefList = [GristObjCode.List, ...watchedColIds
|
||||
.filter(colId => colId.trim() !== "")
|
||||
.map(
|
||||
colId => { return colIdToReference(metaTables, tableId, colId.trim().replace(/^\$/, '')); }
|
||||
)];
|
||||
}
|
||||
} else {
|
||||
fields.watchedColRefList = [GristObjCode.List];
|
||||
}
|
||||
fields.tableRef = tableIdToRef(metaTables, tableId);
|
||||
currentTableId = tableId;
|
||||
}
|
||||
@@ -910,7 +927,6 @@ export class DocWorkerApi {
|
||||
const docId = activeDoc.docName;
|
||||
const webhookId = req.params.webhookId;
|
||||
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
|
||||
|
||||
if (fields.enabled === false) {
|
||||
await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { IChecksumStore } from 'app/server/lib/IChecksumStore';
|
||||
import { IElectionStore } from 'app/server/lib/IElectionStore';
|
||||
import { IPermitStores } from 'app/server/lib/Permit';
|
||||
import {RedisClient} from 'redis';
|
||||
import { RedisClient } from 'redis';
|
||||
|
||||
export interface DocWorkerInfo {
|
||||
id: string;
|
||||
@@ -57,6 +57,8 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS
|
||||
// release existing assignments.
|
||||
setWorkerAvailability(workerId: string, available: boolean): Promise<void>;
|
||||
|
||||
isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean>;
|
||||
|
||||
// Releases doc from worker, freeing it to be assigned elsewhere.
|
||||
// Assignments should only be released for workers that are now unavailable.
|
||||
releaseAssignment(workerId: string, docId: string): Promise<void>;
|
||||
|
||||
@@ -452,7 +452,8 @@ export class FlexServer implements GristServer {
|
||||
// /status/hooks allows the tests to wait for them to be ready.
|
||||
// If db=1 query parameter is included, status will include the status of DB connection.
|
||||
// If redis=1 query parameter is included, status will include the status of the Redis connection.
|
||||
// If ready=1 query parameter is included, status will include whether the server is fully ready.
|
||||
// If docWorkerRegistered=1 query parameter is included, status will include the status of the
|
||||
// doc worker registration in Redis.
|
||||
this.app.get('/status(/hooks)?', async (req, res) => {
|
||||
const checks = new Map<string, Promise<boolean>|boolean>();
|
||||
const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000;
|
||||
@@ -474,6 +475,20 @@ export class FlexServer implements GristServer {
|
||||
if (isParameterOn(req.query.redis)) {
|
||||
checks.set('redis', asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync()));
|
||||
}
|
||||
if (isParameterOn(req.query.docWorkerRegistered) && this.worker) {
|
||||
// Only check whether the doc worker is registered if we have a worker.
|
||||
// The Redis client may not be connected, but in this case this has to
|
||||
// be checked with the 'redis' parameter (the user may want to avoid
|
||||
// removing workers when connection is unstable).
|
||||
if (this._docWorkerMap.getRedisClient()?.connected) {
|
||||
checks.set('docWorkerRegistered', asyncCheck(
|
||||
this._docWorkerMap.isWorkerRegistered(this.worker).then(isRegistered => {
|
||||
if (!isRegistered) { throw new Error('doc worker not registered'); }
|
||||
return isRegistered;
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
if (isParameterOn(req.query.ready)) {
|
||||
checks.set('ready', this._isReady);
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ export class DocTriggers {
|
||||
// Webhook might have been deleted in the mean time.
|
||||
continue;
|
||||
}
|
||||
const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || [];
|
||||
// Report some basic info and usage stats.
|
||||
const entry: WebhookSummary = {
|
||||
// Id of the webhook
|
||||
@@ -288,6 +289,7 @@ export class DocTriggers {
|
||||
// Other fields used to register this webhook.
|
||||
eventTypes: decodeObject(t.eventTypes) as string[],
|
||||
isReadyColumn: getColId(t.isReadyColRef) ?? null,
|
||||
watchedColIds: decodedWatchedColRefList.map((columnRef) => getColId(columnRef)),
|
||||
tableId: getTableId(t.tableRef) ?? null,
|
||||
// For future use - for now every webhook is enabled.
|
||||
enabled: t.enabled,
|
||||
@@ -509,6 +511,21 @@ export class DocTriggers {
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.watchedColRefList) {
|
||||
for (const colRef of trigger.watchedColRefList.slice(1)) {
|
||||
if (!this._validateColId(colRef as number, trigger.tableRef)) {
|
||||
// column does not belong to table, let's ignore trigger and log stats
|
||||
for (const action of webhookActions) {
|
||||
const colId = this._getColId(colRef as number); // no validation
|
||||
const tableId = this._getTableId(trigger.tableRef);
|
||||
const error = `column is not valid: colId ${colId} does not belong to ${tableId}`;
|
||||
this._stats.logInvalid(action.id, error).catch(e => log.error("Webhook stats failed to log", e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url,
|
||||
// ...) as there's no guarantee that they are.
|
||||
|
||||
@@ -585,9 +602,21 @@ export class DocTriggers {
|
||||
}
|
||||
}
|
||||
|
||||
const colIdsToCheck: Array<string> = [];
|
||||
if (trigger.watchedColRefList) {
|
||||
for (const colRef of trigger.watchedColRefList.slice(1)) {
|
||||
colIdsToCheck.push(this._getColId(colRef as number)!);
|
||||
}
|
||||
}
|
||||
|
||||
let eventType: EventType;
|
||||
if (readyBefore) {
|
||||
eventType = "update";
|
||||
// check if any of the columns to check were changed to consider this an update
|
||||
if (colIdsToCheck.length === 0 || colIdsToCheck.some(colId => tableDelta.columnDeltas[colId]?.[rowId])) {
|
||||
eventType = "update";
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// If we allow subscribing to deletion in the future
|
||||
// if (recordDelta.existedAfter) {
|
||||
// eventType = "update";
|
||||
|
||||
@@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
|
||||
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
|
||||
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY,
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
|
||||
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
|
||||
@@ -44,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
|
||||
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
|
||||
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
|
||||
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
|
||||
@@ -80,7 +80,7 @@ INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
|
||||
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
|
||||
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
|
||||
|
||||
@@ -103,6 +103,12 @@ export function allowHost(req: IncomingMessage, allowedHost: string|URL) {
|
||||
const proto = getEndUserProtocol(req);
|
||||
const actualUrl = new URL(getOriginUrl(req));
|
||||
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost;
|
||||
log.rawDebug('allowHost: ', {
|
||||
req: (new URL(req.url!, `http://${req.headers.host}`).href),
|
||||
origin: req.headers.origin,
|
||||
actualUrl: actualUrl.hostname,
|
||||
allowedUrl: allowedUrl.hostname,
|
||||
});
|
||||
if ((req as RequestWithOrg).isCustomHost) {
|
||||
// For a request to a custom domain, the full hostname must match.
|
||||
return actualUrl.hostname === allowedUrl.hostname;
|
||||
|
||||
@@ -409,6 +409,12 @@ export async function fetchDoc(server: GristServer, docId: string, req: Request,
|
||||
// Prepare headers that preserve credentials of current user.
|
||||
const headers = getTransitiveHeaders(req);
|
||||
|
||||
// Passing the Origin header would serve no purpose here, as we are
|
||||
// constructing an internal request to fetch from our own doc worker
|
||||
// URL. Indeed, it may interfere, as it could incur a CORS check in
|
||||
// `trustOrigin`, which we do not need.
|
||||
delete headers.Origin;
|
||||
|
||||
// Find the doc worker responsible for the document we wish to copy.
|
||||
// The backend needs to be well configured for this to work.
|
||||
const homeUrl = server.getHomeUrl(req);
|
||||
|
||||
Reference in New Issue
Block a user