2023-12-12 09:58:20 +00:00
|
|
|
import BaseView from 'app/client/components/BaseView';
|
|
|
|
import * as commands from 'app/client/components/commands';
|
|
|
|
import {Cursor} from 'app/client/components/Cursor';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer';
|
2023-12-12 09:58:20 +00:00
|
|
|
import * as components from 'app/client/components/Forms/elements';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {NewBox} from 'app/client/components/Forms/Menu';
|
2024-01-23 20:52:57 +00:00
|
|
|
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
|
2023-12-12 09:58:20 +00:00
|
|
|
import * as style from 'app/client/components/Forms/styles';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
2024-01-12 17:35:24 +00:00
|
|
|
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {Disposable} from 'app/client/lib/dispose';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
2024-01-12 17:35:24 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
|
|
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
2024-02-13 17:49:00 +00:00
|
|
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
2023-12-12 09:58:20 +00:00
|
|
|
import DataTableModel from 'app/client/models/DataTableModel';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {reportError} from 'app/client/models/errors';
|
2024-04-11 06:50:30 +00:00
|
|
|
import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil';
|
2024-01-12 17:35:24 +00:00
|
|
|
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
2024-02-21 19:22:01 +00:00
|
|
|
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {SortedRowSet} from 'app/client/models/rowset';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {cssButton} from 'app/client/ui2018/buttons';
|
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
|
|
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
2024-01-12 17:35:24 +00:00
|
|
|
import {confirmModal} from 'app/client/ui2018/modals';
|
2024-02-21 19:22:01 +00:00
|
|
|
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
|
2024-03-08 04:43:46 +00:00
|
|
|
import {isOwner} from 'app/common/roles';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {Events as BackboneEvents} from 'backbone';
|
2024-01-24 16:14:34 +00:00
|
|
|
import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
2023-12-12 09:58:20 +00:00
|
|
|
import defaults from 'lodash/defaults';
|
|
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
import {v4 as uuidv4} from 'uuid';
|
2024-01-18 17:23:50 +00:00
|
|
|
import * as ko from 'knockout';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
2023-12-12 09:58:20 +00:00
|
|
|
|
2024-01-12 17:35:24 +00:00
|
|
|
const t = makeT('FormView');
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
const testId = makeTestId('test-forms-');
|
|
|
|
|
|
|
|
export class FormView extends Disposable {
|
|
|
|
public viewPane: HTMLElement;
|
|
|
|
public gristDoc: GristDoc;
|
|
|
|
public viewSection: ViewSectionRec;
|
2024-01-24 16:14:34 +00:00
|
|
|
public selectedBox: Computed<BoxModel | null>;
|
2024-01-18 17:23:50 +00:00
|
|
|
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
|
2024-03-20 14:51:59 +00:00
|
|
|
public disableDeleteSection: Computed<boolean>;
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
protected sortedRows: SortedRowSet;
|
|
|
|
protected tableModel: DataTableModel;
|
|
|
|
protected cursor: Cursor;
|
|
|
|
protected menuHolder: Holder<any>;
|
|
|
|
protected bundle: (clb: () => Promise<void>) => Promise<void>;
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
private _formFields: Computed<ViewFieldRec[]>;
|
2024-04-11 06:50:30 +00:00
|
|
|
private _layoutSpec: SaveableObjObservable<FormLayoutNode>;
|
|
|
|
private _layout: Computed<FormLayoutNode>;
|
2023-12-12 09:58:20 +00:00
|
|
|
private _root: BoxModel;
|
|
|
|
private _savedLayout: any;
|
|
|
|
private _saving: boolean = false;
|
2024-03-20 14:51:59 +00:00
|
|
|
private _previewUrl: Computed<string>;
|
2024-01-12 17:35:24 +00:00
|
|
|
private _pageShare: Computed<ShareRec | null>;
|
|
|
|
private _remoteShare: AsyncComputed<{key: string}|null>;
|
2024-03-20 14:51:59 +00:00
|
|
|
private _isFork: Computed<boolean>;
|
2024-01-12 17:35:24 +00:00
|
|
|
private _published: Computed<boolean>;
|
|
|
|
private _showPublishedMessage: Observable<boolean>;
|
2024-03-08 04:43:46 +00:00
|
|
|
private _isOwner: boolean;
|
2024-03-20 14:51:59 +00:00
|
|
|
private _openingForm: Observable<boolean>;
|
2024-04-11 06:50:30 +00:00
|
|
|
private _formEditorBodyElement: HTMLElement;
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
|
|
|
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
|
|
|
this.menuHolder = Holder.create(this);
|
2024-01-24 16:14:34 +00:00
|
|
|
|
|
|
|
// We will store selected box here.
|
|
|
|
const selectedBox = Observable.create(this, null as BoxModel|null);
|
|
|
|
|
|
|
|
// But we will guard it with a computed, so that if box is disposed we will clear it.
|
|
|
|
this.selectedBox = Computed.create(this, use => use(selectedBox));
|
|
|
|
|
|
|
|
// Prepare scope for the method calls.
|
|
|
|
const holder = Holder.create(this);
|
|
|
|
|
|
|
|
this.selectedBox.onWrite((box) => {
|
|
|
|
// Create new scope and dispose the previous one (using holder).
|
|
|
|
const scope = MultiHolder.create(holder);
|
|
|
|
if (!box) {
|
|
|
|
selectedBox.set(null);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (box.isDisposed()) {
|
|
|
|
throw new Error('Box is disposed');
|
|
|
|
}
|
|
|
|
selectedBox.set(box);
|
|
|
|
|
|
|
|
// Now subscribe to the new box, if it is disposed, remove it from the selected box.
|
|
|
|
// Note that the dispose listener itself is disposed when the box is switched as we don't
|
|
|
|
// care anymore for this event if the box is switched.
|
|
|
|
scope.autoDispose(box.onDispose(() => {
|
|
|
|
if (selectedBox.get() === box) {
|
|
|
|
selectedBox.set(null);
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
});
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true});
|
|
|
|
|
|
|
|
|
|
|
|
this.selectedBox.addListener((v) => {
|
|
|
|
if (!v) { return; }
|
|
|
|
const colRef = Number(v.prop('leaf').get());
|
|
|
|
if (!colRef || typeof colRef !== 'number') { return; }
|
|
|
|
const fieldIndex = this.viewSection.viewFields().all().findIndex(f => f.getRowId() === colRef);
|
|
|
|
if (fieldIndex === -1) { return; }
|
|
|
|
this.cursor.setCursorPos({fieldIndex});
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
this.selectedColumns = this.autoDispose(ko.pureComputed(() => {
|
|
|
|
const result = this.viewSection.viewFields().all().filter((field, index) => {
|
|
|
|
// During column removal or restoring (with undo), some columns fields
|
|
|
|
// might be disposed.
|
|
|
|
if (field.isDisposed() || field.column().isDisposed()) { return false; }
|
|
|
|
return this.cursor.currentPosition().fieldIndex === index;
|
|
|
|
});
|
|
|
|
return result;
|
|
|
|
}));
|
|
|
|
|
|
|
|
// Wire up selected fields to the cursor.
|
|
|
|
this.autoDispose(this.selectedColumns.subscribe((columns) => {
|
|
|
|
this.viewSection.selectedFields(columns);
|
|
|
|
}));
|
|
|
|
this.viewSection.selectedFields(this.selectedColumns.peek());
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
this._formFields = Computed.create(this, use => {
|
|
|
|
const fields = use(use(this.viewSection.viewFields).getObservable());
|
2024-04-11 06:50:30 +00:00
|
|
|
return fields.filter(f => {
|
|
|
|
const column = use(f.column);
|
|
|
|
return (
|
|
|
|
use(column.pureType) !== 'Attachments' &&
|
|
|
|
!(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform'))
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
|
|
|
|
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
|
2024-03-20 14:51:59 +00:00
|
|
|
});
|
2024-01-18 17:23:50 +00:00
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
this._layout = Computed.create(this, use => {
|
2024-03-20 14:51:59 +00:00
|
|
|
const fields = use(this._formFields);
|
2024-04-11 06:50:30 +00:00
|
|
|
const layoutSpec = use(this._layoutSpec);
|
|
|
|
const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id())));
|
|
|
|
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
2024-03-20 14:51:59 +00:00
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
return patchedLayout;
|
2023-12-12 09:58:20 +00:00
|
|
|
});
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
await this.bundle(async () => {
|
|
|
|
if (clb) {
|
|
|
|
await clb();
|
|
|
|
}
|
|
|
|
await this.save();
|
|
|
|
});
|
2023-12-12 09:58:20 +00:00
|
|
|
}, this));
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
this._layout.addListener((v) => {
|
2023-12-12 09:58:20 +00:00
|
|
|
if (this._saving) {
|
2024-01-18 17:23:50 +00:00
|
|
|
console.warn('Layout changed while saving');
|
2023-12-12 09:58:20 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
// When the layout has changed, we will update the root, but only when it is not the same
|
|
|
|
// as the one we just saved.
|
|
|
|
if (isEqual(v, this._savedLayout)) { return; }
|
|
|
|
if (this._savedLayout) {
|
|
|
|
this._savedLayout = v;
|
|
|
|
}
|
|
|
|
this._root.update(v);
|
|
|
|
});
|
|
|
|
|
|
|
|
const keyboardActions = {
|
|
|
|
copy: () => {
|
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
2024-03-20 14:51:59 +00:00
|
|
|
selected.copySelf().catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
},
|
|
|
|
cut: () => {
|
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
|
|
|
selected.cutSelf().catch(reportError);
|
|
|
|
},
|
|
|
|
paste: () => {
|
2024-03-20 14:51:59 +00:00
|
|
|
const doPaste = async () => {
|
2023-12-12 09:58:20 +00:00
|
|
|
const boxInClipboard = parseBox(await navigator.clipboard.readText());
|
|
|
|
if (!boxInClipboard) { return; }
|
|
|
|
if (!this.selectedBox.get()) {
|
|
|
|
this.selectedBox.set(this._root.insert(boxInClipboard, 0));
|
|
|
|
} else {
|
|
|
|
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
|
|
|
|
}
|
2024-03-20 14:51:59 +00:00
|
|
|
const maybeCutBox = this._root.find(boxInClipboard.id);
|
|
|
|
if (maybeCutBox?.cut.get()) {
|
|
|
|
maybeCutBox.removeSelf();
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
await this._root.save();
|
|
|
|
await navigator.clipboard.writeText('');
|
|
|
|
};
|
2024-03-20 14:51:59 +00:00
|
|
|
doPaste().catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
},
|
|
|
|
nextField: () => {
|
|
|
|
const current = this.selectedBox.get();
|
2024-01-24 16:14:34 +00:00
|
|
|
const all = [...this._root.traverse()];
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!all.length) { return; }
|
|
|
|
if (!current) {
|
|
|
|
this.selectedBox.set(all[0]);
|
|
|
|
} else {
|
|
|
|
const next = all[all.indexOf(current) + 1];
|
|
|
|
if (next) {
|
|
|
|
this.selectedBox.set(next);
|
|
|
|
} else {
|
|
|
|
this.selectedBox.set(all[0]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
prevField: () => {
|
|
|
|
const current = this.selectedBox.get();
|
2024-01-24 16:14:34 +00:00
|
|
|
const all = [...this._root.traverse()];
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!all.length) { return; }
|
|
|
|
if (!current) {
|
|
|
|
this.selectedBox.set(all[all.length - 1]);
|
|
|
|
} else {
|
|
|
|
const next = all[all.indexOf(current) - 1];
|
|
|
|
if (next) {
|
|
|
|
this.selectedBox.set(next);
|
|
|
|
} else {
|
|
|
|
this.selectedBox.set(all[all.length - 1]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
lastField: () => {
|
2024-01-24 16:14:34 +00:00
|
|
|
const all = [...this._root.traverse()];
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!all.length) { return; }
|
|
|
|
this.selectedBox.set(all[all.length - 1]);
|
|
|
|
},
|
|
|
|
firstField: () => {
|
2024-01-24 16:14:34 +00:00
|
|
|
const all = [...this._root.traverse()];
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!all.length) { return; }
|
|
|
|
this.selectedBox.set(all[0]);
|
|
|
|
},
|
|
|
|
edit: () => {
|
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
|
|
|
(selected as any)?.edit?.set(true); // TODO: hacky way
|
|
|
|
},
|
|
|
|
clearValues: () => {
|
|
|
|
const selected = this.selectedBox.get();
|
2024-03-20 14:51:59 +00:00
|
|
|
if (!selected || selected.canRemove?.() === false) { return; }
|
2023-12-12 09:58:20 +00:00
|
|
|
keyboardActions.nextField();
|
|
|
|
this.bundle(async () => {
|
|
|
|
await selected.deleteSelf();
|
|
|
|
}).catch(reportError);
|
|
|
|
},
|
2024-01-18 17:23:50 +00:00
|
|
|
hideFields: (colId: [string]) => {
|
|
|
|
// Get the ref from colId.
|
|
|
|
const existing: Array<[number, string]> =
|
|
|
|
this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]);
|
|
|
|
const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r);
|
|
|
|
if (!ref.length) { return; }
|
|
|
|
const box = Array.from(this._root.filter(b => ref.includes(b.prop('leaf')?.get())));
|
|
|
|
box.forEach(b => b.removeSelf());
|
|
|
|
this._root.save(async () => {
|
|
|
|
await this.viewSection.removeField(ref);
|
|
|
|
}).catch(reportError);
|
|
|
|
},
|
|
|
|
insertFieldBefore: (what: NewBox) => {
|
2023-12-12 09:58:20 +00:00
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
2024-01-18 17:23:50 +00:00
|
|
|
if ('add' in what || 'show' in what) {
|
|
|
|
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
} else {
|
2024-01-18 17:23:50 +00:00
|
|
|
selected.insertBefore(components.defaultElement(what.structure));
|
2024-03-20 14:51:59 +00:00
|
|
|
this.save().catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
},
|
2024-01-18 17:23:50 +00:00
|
|
|
insertField: (what: NewBox) => {
|
2023-12-12 09:58:20 +00:00
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
2024-01-18 17:23:50 +00:00
|
|
|
const place = selected.placeAfterListChild();
|
|
|
|
if ('add' in what || 'show' in what) {
|
|
|
|
this.addNewQuestion(place, what).catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
} else {
|
2024-01-18 17:23:50 +00:00
|
|
|
place(components.defaultElement(what.structure));
|
|
|
|
this.save().catch(reportError);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
insertFieldAfter: (what: NewBox) => {
|
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (!selected) { return; }
|
|
|
|
if ('add' in what || 'show' in what) {
|
|
|
|
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
|
|
|
|
} else {
|
|
|
|
selected.insertAfter(components.defaultElement(what.structure));
|
2024-03-20 14:51:59 +00:00
|
|
|
this.save().catch(reportError);
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
},
|
|
|
|
showColumns: (colIds: string[]) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
// Sanity check that type is correct.
|
|
|
|
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
|
|
|
|
this._root.save(async () => {
|
2024-02-21 19:22:01 +00:00
|
|
|
const boxes: FormLayoutNode[] = [];
|
2023-12-12 09:58:20 +00:00
|
|
|
for (const colId of colIds) {
|
|
|
|
const fieldRef = await this.viewSection.showColumn(colId);
|
|
|
|
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
|
|
|
|
if (!field) { continue; }
|
|
|
|
const box = {
|
2024-03-20 14:51:59 +00:00
|
|
|
id: uuidv4(),
|
2023-12-12 09:58:20 +00:00
|
|
|
leaf: fieldRef,
|
2024-02-21 19:22:01 +00:00
|
|
|
type: 'Field' as FormLayoutNodeType,
|
2023-12-12 09:58:20 +00:00
|
|
|
};
|
|
|
|
boxes.push(box);
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
// Add to selected or last section, or root.
|
|
|
|
const selected = this.selectedBox.get();
|
|
|
|
if (selected instanceof components.SectionModel) {
|
|
|
|
boxes.forEach(b => selected.append(b));
|
|
|
|
} else {
|
|
|
|
const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel);
|
|
|
|
if (topLevel) {
|
|
|
|
boxes.forEach(b => topLevel.append(b));
|
|
|
|
} else {
|
|
|
|
boxes.forEach(b => this._root.append(b));
|
|
|
|
}
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}).catch(reportError);
|
|
|
|
},
|
|
|
|
};
|
|
|
|
this.autoDispose(commands.createGroup({
|
|
|
|
...keyboardActions,
|
|
|
|
cursorDown: keyboardActions.nextField,
|
|
|
|
cursorUp: keyboardActions.prevField,
|
|
|
|
cursorLeft: keyboardActions.prevField,
|
|
|
|
cursorRight: keyboardActions.nextField,
|
|
|
|
shiftDown: keyboardActions.lastField,
|
|
|
|
shiftUp: keyboardActions.firstField,
|
|
|
|
editField: keyboardActions.edit,
|
|
|
|
deleteFields: keyboardActions.clearValues,
|
2024-01-18 17:23:50 +00:00
|
|
|
hideFields: keyboardActions.hideFields,
|
2023-12-12 09:58:20 +00:00
|
|
|
}, this, this.viewSection.hasFocus));
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
this._previewUrl = Computed.create(this, use => {
|
2023-12-12 09:58:20 +00:00
|
|
|
const doc = use(this.gristDoc.docPageModel.currentDoc);
|
|
|
|
if (!doc) { return ''; }
|
2024-01-23 21:24:57 +00:00
|
|
|
const url = urlState().makeUrl({
|
2024-02-21 19:22:01 +00:00
|
|
|
...docUrl(doc),
|
2024-01-23 21:24:57 +00:00
|
|
|
form: {
|
|
|
|
vsId: use(this.viewSection.id),
|
|
|
|
},
|
2024-01-12 17:35:24 +00:00
|
|
|
});
|
2023-12-12 09:58:20 +00:00
|
|
|
return url;
|
|
|
|
});
|
|
|
|
|
2024-01-12 17:35:24 +00:00
|
|
|
this._pageShare = Computed.create(this, use => {
|
|
|
|
const page = use(use(this.viewSection.view).page);
|
|
|
|
if (!page) { return null; }
|
|
|
|
return use(page.share);
|
|
|
|
});
|
|
|
|
|
|
|
|
this._remoteShare = AsyncComputed.create(this, async (use) => {
|
|
|
|
const share = use(this._pageShare);
|
|
|
|
if (!share) { return null; }
|
2024-01-18 17:23:50 +00:00
|
|
|
try {
|
|
|
|
const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));
|
|
|
|
return remoteShare ?? null;
|
|
|
|
} catch(ex) {
|
|
|
|
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
|
|
|
// for non owners.
|
|
|
|
if (ex.code === 'AUTH_NO_OWNER') { return null; }
|
|
|
|
throw ex;
|
|
|
|
}
|
2024-01-12 17:35:24 +00:00
|
|
|
});
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
this._isFork = Computed.create(this, use => {
|
|
|
|
const {docPageModel} = this.gristDoc;
|
|
|
|
return use(docPageModel.isFork) || use(docPageModel.isPrefork);
|
|
|
|
});
|
|
|
|
|
2024-01-12 17:35:24 +00:00
|
|
|
this._published = Computed.create(this, use => {
|
2024-03-20 14:51:59 +00:00
|
|
|
const isFork = use(this._isFork);
|
|
|
|
if (isFork) { return false; }
|
|
|
|
|
2024-01-12 17:35:24 +00:00
|
|
|
const pageShare = use(this._pageShare);
|
|
|
|
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
|
|
|
|
const validShare = pageShare && remoteShare;
|
|
|
|
if (!validShare) { return false; }
|
|
|
|
|
|
|
|
return use(pageShare.optionsObj.prop('publish')) &&
|
|
|
|
use(this.viewSection.shareOptionsObj.prop('publish'));
|
|
|
|
});
|
|
|
|
|
|
|
|
const userId = this.gristDoc.app.topAppModel.appObs.get()?.currentUser?.id || 0;
|
|
|
|
this._showPublishedMessage = this.autoDispose(localStorageBoolObs(
|
|
|
|
`u:${userId};d:${this.gristDoc.docId()};vs:${this.viewSection.id()};formShowPublishedMessage`,
|
|
|
|
true
|
|
|
|
));
|
|
|
|
|
2024-03-08 04:43:46 +00:00
|
|
|
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
this._openingForm = Observable.create(this, false);
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
// Last line, build the dom.
|
|
|
|
this.viewPane = this.autoDispose(this.buildDom());
|
|
|
|
}
|
|
|
|
|
|
|
|
public insertColumn(colId?: string | null, options?: InsertColOptions) {
|
|
|
|
return this.viewSection.insertColumn(colId, {...options, nestInActiveBundle: true});
|
|
|
|
}
|
|
|
|
|
|
|
|
public showColumn(colRef: number|string, index?: number) {
|
|
|
|
return this.viewSection.showColumn(colRef, index);
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom() {
|
2024-01-24 09:58:19 +00:00
|
|
|
return style.cssFormView(
|
2024-01-18 17:23:50 +00:00
|
|
|
testId('editor'),
|
2024-04-11 06:50:30 +00:00
|
|
|
this._formEditorBodyElement = style.cssFormEditBody(
|
2023-12-12 09:58:20 +00:00
|
|
|
style.cssFormContainer(
|
2024-04-11 06:50:30 +00:00
|
|
|
dom('div', dom.forEach(this._root.children, (child) => {
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!child) {
|
|
|
|
return dom('div', 'Empty node');
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
const element = child.render();
|
|
|
|
if (!(element instanceof Node)) {
|
2023-12-12 09:58:20 +00:00
|
|
|
throw new Error('Element is not an HTMLElement');
|
|
|
|
}
|
|
|
|
return element;
|
2024-03-20 14:51:59 +00:00
|
|
|
})),
|
2023-12-12 09:58:20 +00:00
|
|
|
),
|
|
|
|
),
|
2024-04-11 06:50:30 +00:00
|
|
|
this._buildPublisher(),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.on('click', () => this.selectedBox.set(null)),
|
|
|
|
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public buildOverlay(...args: IDomArgs) {
|
2023-12-12 09:58:20 +00:00
|
|
|
return style.cssSelectedOverlay(
|
2024-01-18 17:23:50 +00:00
|
|
|
...args,
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public async addNewQuestion(insert: Place, action: {add: string}|{show: string}) {
|
2023-12-12 09:58:20 +00:00
|
|
|
await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {
|
2024-01-18 17:23:50 +00:00
|
|
|
// First save the layout, so that we don't have autogenerated layout.
|
|
|
|
await this.save();
|
|
|
|
// Now that the layout is saved, we won't be bothered with autogenerated layout,
|
2023-12-12 09:58:20 +00:00
|
|
|
// and we can safely insert to column.
|
2024-01-18 17:23:50 +00:00
|
|
|
let fieldRef = 0;
|
|
|
|
if ('show' in action) {
|
|
|
|
fieldRef = await this.showColumn(action.show);
|
|
|
|
} else {
|
|
|
|
const result = await this.insertColumn(null, {
|
|
|
|
colInfo: {
|
|
|
|
type: action.add,
|
|
|
|
}
|
|
|
|
});
|
|
|
|
fieldRef = result.fieldRef;
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
// And add it into the layout.
|
|
|
|
this.selectedBox.set(insert({
|
2024-03-20 14:51:59 +00:00
|
|
|
id: uuidv4(),
|
2023-12-12 09:58:20 +00:00
|
|
|
leaf: fieldRef,
|
|
|
|
type: 'Field'
|
|
|
|
}));
|
|
|
|
await this._root.save();
|
|
|
|
}, {nestInActiveBundle: true});
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public async save() {
|
2023-12-12 09:58:20 +00:00
|
|
|
try {
|
|
|
|
this._saving = true;
|
|
|
|
const newVersion = {...this._root.toJSON()};
|
|
|
|
// If nothing has changed, don't bother.
|
|
|
|
if (isEqual(newVersion, this._savedLayout)) { return; }
|
|
|
|
this._savedLayout = newVersion;
|
2024-04-11 06:50:30 +00:00
|
|
|
await this._layoutSpec.setAndSave(newVersion);
|
2023-12-12 09:58:20 +00:00
|
|
|
} finally {
|
|
|
|
this._saving = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-24 09:58:19 +00:00
|
|
|
private async _handleClickPublish() {
|
|
|
|
if (this.gristDoc.appModel.dismissedPopups.get().includes('publishForm')) {
|
|
|
|
await this._publishForm();
|
|
|
|
} else {
|
|
|
|
confirmModal(t('Publish your form?'),
|
|
|
|
t('Publish'),
|
|
|
|
async (dontShowAgain) => {
|
|
|
|
await this._publishForm();
|
|
|
|
if (dontShowAgain) {
|
2024-03-23 17:11:06 +00:00
|
|
|
this.gristDoc.appModel.dismissPopup('publishForm', true);
|
2024-01-12 17:35:24 +00:00
|
|
|
}
|
2024-01-24 09:58:19 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
explanation: (
|
|
|
|
dom('div',
|
|
|
|
style.cssParagraph(
|
|
|
|
t(
|
|
|
|
'Publishing your form will generate a share link. Anyone with the link can ' +
|
|
|
|
'see the empty form and submit a response.'
|
|
|
|
),
|
|
|
|
),
|
|
|
|
style.cssParagraph(
|
|
|
|
t(
|
|
|
|
'Users are limited to submitting ' +
|
|
|
|
'entries (records in your table) and reading pre-set values in designated ' +
|
|
|
|
'fields, such as reference and choice columns.'
|
|
|
|
),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
),
|
|
|
|
hideDontShowAgain: false,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _publishForm() {
|
|
|
|
const page = this.viewSection.view().page();
|
|
|
|
if (!page) {
|
|
|
|
throw new Error('Unable to publish form: undefined page');
|
|
|
|
}
|
|
|
|
let validShare = page.shareRef() !== 0;
|
|
|
|
// If page is shared, make sure home server is aware of it.
|
|
|
|
if (validShare) {
|
|
|
|
try {
|
|
|
|
const pageShare = page.share();
|
|
|
|
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
|
|
|
|
validShare = !!serverShare;
|
|
|
|
} catch(ex) {
|
|
|
|
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
|
|
|
if (ex.code === 'AUTH_NO_OWNER') {
|
|
|
|
return;
|
2024-01-18 17:23:50 +00:00
|
|
|
}
|
2024-01-24 09:58:19 +00:00
|
|
|
throw ex;
|
|
|
|
}
|
|
|
|
}
|
2024-02-13 17:49:00 +00:00
|
|
|
|
|
|
|
logTelemetryEvent('publishedForm', {
|
|
|
|
full: {
|
|
|
|
docIdDigest: this.gristDoc.docId(),
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2024-01-24 09:58:19 +00:00
|
|
|
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
|
|
|
|
if (!validShare) {
|
|
|
|
const shareRef = await this.gristDoc.docModel.docData.sendAction([
|
|
|
|
'AddRecord',
|
|
|
|
'_grist_Shares',
|
|
|
|
null,
|
|
|
|
{
|
|
|
|
linkId: uuidv4(),
|
|
|
|
options: JSON.stringify({
|
|
|
|
publish: true,
|
|
|
|
}),
|
2024-01-12 17:35:24 +00:00
|
|
|
}
|
2024-01-24 09:58:19 +00:00
|
|
|
]);
|
|
|
|
await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]);
|
|
|
|
} else {
|
|
|
|
const share = page.share();
|
|
|
|
share.optionsObj.update({publish: true});
|
|
|
|
await share.optionsObj.save();
|
|
|
|
}
|
2024-01-12 17:35:24 +00:00
|
|
|
|
2024-01-24 09:58:19 +00:00
|
|
|
await this.save();
|
|
|
|
this.viewSection.shareOptionsObj.update({
|
|
|
|
form: true,
|
|
|
|
publish: true,
|
|
|
|
});
|
|
|
|
await this.viewSection.shareOptionsObj.save();
|
|
|
|
});
|
2024-01-12 17:35:24 +00:00
|
|
|
}
|
|
|
|
|
2024-01-24 09:58:19 +00:00
|
|
|
private async _handleClickUnpublish() {
|
|
|
|
if (this.gristDoc.appModel.dismissedPopups.get().includes('unpublishForm')) {
|
|
|
|
await this._unpublishForm();
|
|
|
|
} else {
|
|
|
|
confirmModal(t('Unpublish your form?'),
|
|
|
|
t('Unpublish'),
|
|
|
|
async (dontShowAgain) => {
|
|
|
|
await this._unpublishForm();
|
|
|
|
if (dontShowAgain) {
|
2024-03-23 17:11:06 +00:00
|
|
|
this.gristDoc.appModel.dismissPopup('unpublishForm', true);
|
2024-01-12 17:35:24 +00:00
|
|
|
}
|
2024-01-24 09:58:19 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
explanation: (
|
|
|
|
dom('div',
|
|
|
|
style.cssParagraph(
|
|
|
|
t(
|
|
|
|
'Unpublishing the form will disable the share link so that users accessing ' +
|
|
|
|
'your form via that link will see an error.'
|
|
|
|
),
|
2024-01-12 17:35:24 +00:00
|
|
|
),
|
2024-01-24 09:58:19 +00:00
|
|
|
)
|
|
|
|
),
|
|
|
|
hideDontShowAgain: false,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _unpublishForm() {
|
2024-02-13 17:49:00 +00:00
|
|
|
logTelemetryEvent('unpublishedForm', {
|
|
|
|
full: {
|
|
|
|
docIdDigest: this.gristDoc.docId(),
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2024-01-24 09:58:19 +00:00
|
|
|
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
|
|
|
|
this.viewSection.shareOptionsObj.update({
|
|
|
|
publish: false,
|
|
|
|
});
|
|
|
|
await this.viewSection.shareOptionsObj.save();
|
|
|
|
|
|
|
|
const view = this.viewSection.view();
|
|
|
|
if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) {
|
|
|
|
const share = this._pageShare.get();
|
|
|
|
if (!share) { return; }
|
|
|
|
|
|
|
|
share.optionsObj.update({
|
|
|
|
publish: false,
|
|
|
|
});
|
|
|
|
await share.optionsObj.save();
|
|
|
|
}
|
|
|
|
});
|
2024-01-12 17:35:24 +00:00
|
|
|
}
|
2024-01-24 09:58:19 +00:00
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
private _buildPublisher() {
|
2024-01-12 17:35:24 +00:00
|
|
|
return style.cssSwitcher(
|
2024-03-20 14:51:59 +00:00
|
|
|
this._buildNotifications(),
|
2024-01-12 17:35:24 +00:00
|
|
|
style.cssButtonGroup(
|
2024-03-20 14:51:59 +00:00
|
|
|
style.cssSmallButton(
|
|
|
|
style.cssSmallButton.cls('-frameless'),
|
2024-01-18 17:23:50 +00:00
|
|
|
icon('Revert'),
|
|
|
|
testId('reset'),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom('div', t('Reset form')),
|
|
|
|
dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'),
|
2024-01-18 17:23:50 +00:00
|
|
|
dom.style('margin-right', 'auto'), // move it to the left
|
|
|
|
dom.on('click', () => {
|
2024-03-20 14:51:59 +00:00
|
|
|
return confirmModal(t('Are you sure you want to reset your form?'),
|
|
|
|
t('Reset'),
|
|
|
|
() => this._resetForm(),
|
|
|
|
);
|
2024-01-18 17:23:50 +00:00
|
|
|
})
|
2024-01-12 17:35:24 +00:00
|
|
|
),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.domComputed(this._published, published => {
|
|
|
|
if (published) {
|
|
|
|
return style.cssSmallButton(
|
|
|
|
testId('view'),
|
|
|
|
icon('EyeShow'),
|
|
|
|
t('View'),
|
|
|
|
dom.boolAttr('disabled', this._openingForm),
|
|
|
|
dom.on('click', async (ev) => {
|
|
|
|
// If this form is not yet saved, we will save it first.
|
|
|
|
if (!this._savedLayout) {
|
|
|
|
await this.save();
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
this._openingForm.set(true);
|
|
|
|
window.open(await this._getFormUrl());
|
|
|
|
} finally {
|
|
|
|
this._openingForm.set(false);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
return style.cssSmallLinkButton(
|
|
|
|
testId('preview'),
|
|
|
|
icon('EyeShow'),
|
|
|
|
t('Preview'),
|
|
|
|
dom.attr('href', this._previewUrl),
|
|
|
|
dom.prop('target', '_blank'),
|
|
|
|
dom.on('click', async (ev) => {
|
|
|
|
// If this form is not yet saved, we will save it first.
|
|
|
|
if (!this._savedLayout) {
|
|
|
|
stopEvent(ev);
|
|
|
|
await this.save();
|
|
|
|
window.open(this._previewUrl.get());
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
style.cssSmallButton(
|
|
|
|
icon('Share'),
|
|
|
|
testId('share'),
|
|
|
|
dom('div', t('Share')),
|
2024-03-08 04:43:46 +00:00
|
|
|
dom.show(use => this._isOwner && use(this._published)),
|
2024-03-20 14:51:59 +00:00
|
|
|
elem => {
|
|
|
|
setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
|
|
|
|
...defaultMenuOptions,
|
|
|
|
placement: 'top-end',
|
|
|
|
});
|
|
|
|
},
|
2024-01-12 17:35:24 +00:00
|
|
|
),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.domComputed(use => {
|
|
|
|
const isFork = use(this._isFork);
|
|
|
|
const published = use(this._published);
|
2024-01-12 17:35:24 +00:00
|
|
|
return published
|
2024-03-20 14:51:59 +00:00
|
|
|
? style.cssSmallButton(
|
|
|
|
dom('div', t('Unpublish')),
|
2024-03-08 04:43:46 +00:00
|
|
|
dom.show(this._isOwner),
|
2024-03-20 14:51:59 +00:00
|
|
|
style.cssSmallButton.cls('-warning'),
|
2024-01-24 09:58:19 +00:00
|
|
|
dom.on('click', () => this._handleClickUnpublish()),
|
2024-01-12 17:35:24 +00:00
|
|
|
testId('unpublish'),
|
|
|
|
)
|
2024-03-20 14:51:59 +00:00
|
|
|
: style.cssSmallButton(
|
|
|
|
dom('div', t('Publish')),
|
|
|
|
dom.boolAttr('disabled', isFork),
|
|
|
|
!isFork ? null : hoverTooltip(t('Save your document to publish this form.'), {
|
|
|
|
placement: 'top',
|
|
|
|
}),
|
2024-03-08 04:43:46 +00:00
|
|
|
dom.show(this._isOwner),
|
2024-01-12 17:35:24 +00:00
|
|
|
cssButton.cls('-primary'),
|
2024-01-24 09:58:19 +00:00
|
|
|
dom.on('click', () => this._handleClickPublish()),
|
2024-01-12 17:35:24 +00:00
|
|
|
testId('publish'),
|
|
|
|
);
|
|
|
|
}),
|
2023-12-12 09:58:20 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2024-01-12 17:35:24 +00:00
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
private async _getFormUrl() {
|
2024-02-14 21:18:09 +00:00
|
|
|
const share = this._pageShare.get();
|
|
|
|
if (!share) {
|
|
|
|
throw new Error('Unable to get form link: form is not published');
|
|
|
|
}
|
|
|
|
|
|
|
|
const remoteShare = await this.gristDoc.docComm.getShare(share.linkId());
|
|
|
|
if (!remoteShare) {
|
|
|
|
throw new Error('Unable to get form link: form is not published');
|
|
|
|
}
|
|
|
|
|
|
|
|
return urlState().makeUrl({
|
|
|
|
doc: undefined,
|
|
|
|
form: {
|
|
|
|
shareKey: remoteShare.key,
|
|
|
|
vsId: this.viewSection.id(),
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
private _buildShareMenu(ctl: IOpenController) {
|
|
|
|
const formUrl = Observable.create<string | null>(ctl, null);
|
|
|
|
const showEmbedCode = Observable.create(this, false);
|
|
|
|
const embedCode = Computed.create(ctl, formUrl, (_use, url) => {
|
|
|
|
if (!url) { return null; }
|
|
|
|
|
|
|
|
return '<iframe style="border: none; width: 640px; ' +
|
|
|
|
`height: ${this._getEstimatedFormHeightPx()}px" src="${url}"></iframe>`;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Reposition the popup when its height changes.
|
|
|
|
ctl.autoDispose(formUrl.addListener(() => ctl.update()));
|
|
|
|
ctl.autoDispose(showEmbedCode.addListener(() => ctl.update()));
|
|
|
|
|
|
|
|
this._getFormUrl()
|
|
|
|
.then((url) => {
|
|
|
|
if (ctl.isDisposed()) { return; }
|
|
|
|
|
|
|
|
formUrl.set(url);
|
|
|
|
})
|
|
|
|
.catch((e) => {
|
|
|
|
ctl.close();
|
|
|
|
reportError(e);
|
|
|
|
});
|
|
|
|
|
|
|
|
return style.cssShareMenu(
|
|
|
|
dom.cls(menuCssClass),
|
|
|
|
style.cssShareMenuHeader(
|
|
|
|
style.cssShareMenuCloseButton(
|
|
|
|
icon('CrossBig'),
|
|
|
|
dom.on('click', () => ctl.close()),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
style.cssShareMenuBody(
|
|
|
|
dom.domComputed(use => {
|
|
|
|
const url = use(formUrl);
|
|
|
|
const code = use(embedCode);
|
|
|
|
if (!url || !code) {
|
|
|
|
return style.cssShareMenuSpinner(loadingSpinner());
|
|
|
|
}
|
|
|
|
|
|
|
|
return [
|
|
|
|
dom('div',
|
|
|
|
style.cssShareMenuSectionHeading(
|
|
|
|
t('Share this form'),
|
|
|
|
),
|
|
|
|
dom('div',
|
|
|
|
style.cssShareMenuHintText(
|
|
|
|
t('Anyone with the link below can see the empty form and submit a response.'),
|
|
|
|
),
|
|
|
|
style.cssShareMenuUrlBlock(
|
|
|
|
style.cssShareMenuUrl(
|
|
|
|
{readonly: true, value: url},
|
|
|
|
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
|
|
|
|
),
|
|
|
|
style.cssShareMenuCopyButton(
|
|
|
|
testId('link'),
|
|
|
|
t('Copy link'),
|
|
|
|
dom.on('click', async (_ev, el) => {
|
|
|
|
await copyToClipboard(url);
|
|
|
|
showTransientTooltip(
|
|
|
|
el,
|
|
|
|
t('Link copied to clipboard'),
|
|
|
|
{key: 'share-form-menu'}
|
|
|
|
);
|
|
|
|
})
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
dom.domComputed(showEmbedCode, (showCode) => {
|
|
|
|
if (!showCode) {
|
|
|
|
return dom('div',
|
|
|
|
style.cssShareMenuEmbedFormButton(
|
|
|
|
t('Embed this form'),
|
|
|
|
dom.on('click', () => showEmbedCode.set(true)),
|
|
|
|
)
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
return dom('div',
|
|
|
|
style.cssShareMenuSectionHeading(t('Embed this form')),
|
|
|
|
dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock(
|
|
|
|
style.cssShareMenuCode(
|
|
|
|
code,
|
|
|
|
{readonly: true, rows: '3'},
|
|
|
|
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
|
|
|
|
),
|
|
|
|
style.cssShareMenuCodeBlockButtons(
|
|
|
|
style.cssShareMenuCopyButton(
|
|
|
|
testId('code'),
|
|
|
|
t('Copy code'),
|
|
|
|
dom.on('click', async (_ev, el) => {
|
|
|
|
await copyToClipboard(code);
|
|
|
|
showTransientTooltip(
|
|
|
|
el,
|
|
|
|
t('Code copied to clipboard'),
|
|
|
|
{key: 'share-form-menu'}
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
)),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
];
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
private _getSectionCount() {
|
|
|
|
return [...this._root.filter(box => box.type === 'Section')].length;
|
|
|
|
}
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
private _getEstimatedFormHeightPx() {
|
|
|
|
return (
|
2024-04-11 06:50:30 +00:00
|
|
|
// Form height.
|
|
|
|
this._formEditorBodyElement.scrollHeight +
|
|
|
|
// Minus "+" button height in each section.
|
|
|
|
(-32 * this._getSectionCount()) +
|
|
|
|
// Plus form footer height (visible only in the preview and published form).
|
2024-03-20 14:51:59 +00:00
|
|
|
64
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildNotifications() {
|
|
|
|
return [
|
|
|
|
this._buildFormPublishedNotification(),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildFormPublishedNotification() {
|
2024-01-12 17:35:24 +00:00
|
|
|
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
|
|
|
|
return style.cssSwitcherMessage(
|
|
|
|
style.cssSwitcherMessageBody(
|
|
|
|
t(
|
|
|
|
'Your form is published. Every change is live and visible to users ' +
|
|
|
|
'with access to the form. If you want to make changes in draft, unpublish the form.'
|
|
|
|
),
|
|
|
|
),
|
|
|
|
style.cssSwitcherMessageDismissButton(
|
|
|
|
icon('CrossSmall'),
|
|
|
|
dom.on('click', () => {
|
|
|
|
this._showPublishedMessage.set(false);
|
|
|
|
}),
|
|
|
|
),
|
2024-03-08 04:43:46 +00:00
|
|
|
dom.show(this._isOwner),
|
2024-01-12 17:35:24 +00:00
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
private async _resetForm() {
|
|
|
|
this.selectedBox.set(null);
|
|
|
|
await this.gristDoc.docData.bundleActions('Reset form', async () => {
|
|
|
|
// First we will remove all fields from this section, and add top 9 back.
|
|
|
|
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
const toAdd = this.viewSection.table().columns().peek()
|
|
|
|
.filter(c => c.isFormCol())
|
|
|
|
.sort((a, b) => a.parentPos() - b.parentPos());
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
|
|
|
|
const parentId = colRef.map(() => this.viewSection.id());
|
|
|
|
const parentPos = colRef.map((_, i) => i + 1);
|
|
|
|
const ids = colRef.map(() => null);
|
|
|
|
|
|
|
|
await this.gristDoc.docData.sendActions([
|
|
|
|
['BulkRemoveRecord', '_grist_Views_section_field', toDelete],
|
|
|
|
['BulkAddRecord', '_grist_Views_section_field', ids, {
|
|
|
|
colRef,
|
|
|
|
parentId,
|
|
|
|
parentPos,
|
|
|
|
}],
|
|
|
|
]);
|
|
|
|
|
|
|
|
const fields = this.viewSection.viewFields().all().slice(0, 9);
|
2024-04-11 06:50:30 +00:00
|
|
|
await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields));
|
2024-01-18 17:23:50 +00:00
|
|
|
});
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
/**
|
|
|
|
* Generates a default form layout based on the fields in the view section.
|
|
|
|
*/
|
|
|
|
export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode {
|
|
|
|
const boxes: FormLayoutNode[] = fields.map(f => {
|
|
|
|
return {
|
|
|
|
id: uuidv4(),
|
|
|
|
type: 'Field',
|
|
|
|
leaf: f.id(),
|
|
|
|
};
|
|
|
|
});
|
|
|
|
const section = components.Section(...boxes);
|
|
|
|
return {
|
|
|
|
id: uuidv4(),
|
|
|
|
type: 'Layout',
|
|
|
|
children: [
|
|
|
|
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
|
|
|
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
|
|
|
section,
|
|
|
|
{id: uuidv4(), type: 'Submit'},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
|
|
|
|
defaults(FormView.prototype, BaseView.prototype);
|
|
|
|
Object.assign(FormView.prototype, BackboneEvents);
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
// Default values when form is reset.
|
2024-01-24 09:58:19 +00:00
|
|
|
const FORM_TITLE = "## **Form Title**";
|
|
|
|
const FORM_DESC = "Your form description goes here.";
|