mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Forms Improvements
Summary: - Forms now have a reset button. - Choice and Reference fields in forms now have an improved select menu. - Formula and attachments column types are no longer mappable or visible in forms. - Fields in a form widget are now removed if their column is deleted. - The preview button in a published form widget has been replaced with a view button. It now opens the published form in a new tab. - A new share menu for published form widgets, with options to copy a link or embed code. - Forms can now have multiple sections. - Form widgets now indicate when publishing is unavailable (e.g. in forks or unsaved documents). - General improvements to form styling. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4203
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
import * as css from 'app/client/components/FormRendererCss';
|
||||
import {FormField} from 'app/client/ui/FormAPI';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
|
||||
import {isXSmallScreenObs} from 'app/client/ui2018/cssVars';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {CellValue} from 'app/plugin/GristData';
|
||||
import {Disposable, dom, DomContents, Observable} from 'grainjs';
|
||||
import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import {marked} from 'marked';
|
||||
import {IPopupOptions, PopupControl} from 'popweasel';
|
||||
|
||||
export const CHOOSE_TEXT = '— Choose —';
|
||||
const testId = makeTestId('test-form-');
|
||||
|
||||
/**
|
||||
* A node in a recursive, tree-like hierarchy comprising the layout of a form.
|
||||
*/
|
||||
export interface FormLayoutNode {
|
||||
/** Unique ID of the node. Used by FormView. */
|
||||
id: string;
|
||||
type: FormLayoutNodeType;
|
||||
children?: Array<FormLayoutNode>;
|
||||
// Unique ID of the field. Used only in the Form widget.
|
||||
id?: string;
|
||||
// Used by Layout.
|
||||
submitText?: string;
|
||||
successURL?: string;
|
||||
@@ -55,6 +59,24 @@ export interface FormRendererContext {
|
||||
error: Observable<string|null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `layoutSpec` with any leaf nodes that don't exist
|
||||
* in `fieldIds` removed.
|
||||
*/
|
||||
export function patchLayoutSpec(
|
||||
layoutSpec: FormLayoutNode,
|
||||
fieldIds: Set<number>
|
||||
): FormLayoutNode | null {
|
||||
if (layoutSpec.leaf && !fieldIds.has(layoutSpec.leaf)) { return null; }
|
||||
|
||||
return {
|
||||
...layoutSpec,
|
||||
children: layoutSpec.children
|
||||
?.map(child => patchLayoutSpec(child, fieldIds))
|
||||
.filter((child): child is FormLayoutNode => child !== null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A renderer for a form layout.
|
||||
*
|
||||
@@ -68,20 +90,35 @@ export interface FormRendererContext {
|
||||
* TODO: merge the two implementations or factor out what's common.
|
||||
*/
|
||||
export abstract class FormRenderer extends Disposable {
|
||||
public static new(layoutNode: FormLayoutNode, context: FormRendererContext): FormRenderer {
|
||||
public static new(
|
||||
layoutNode: FormLayoutNode,
|
||||
context: FormRendererContext,
|
||||
parent?: FormRenderer
|
||||
): FormRenderer {
|
||||
const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
|
||||
return new Renderer(layoutNode, context);
|
||||
return new Renderer(layoutNode, context, parent);
|
||||
}
|
||||
|
||||
protected children: FormRenderer[];
|
||||
|
||||
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) {
|
||||
constructor(
|
||||
protected layoutNode: FormLayoutNode,
|
||||
protected context: FormRendererContext,
|
||||
protected parent?: FormRenderer
|
||||
) {
|
||||
super();
|
||||
this.children = (this.layoutNode.children ?? []).map((child) =>
|
||||
this.autoDispose(FormRenderer.new(child, this.context)));
|
||||
this.autoDispose(FormRenderer.new(child, this.context, this)));
|
||||
}
|
||||
|
||||
public abstract render(): DomContents;
|
||||
|
||||
/**
|
||||
* Reset the state of this layout node and all of its children.
|
||||
*/
|
||||
public reset() {
|
||||
this.children.forEach((child) => child.reset());
|
||||
}
|
||||
}
|
||||
|
||||
class LabelRenderer extends FormRenderer {
|
||||
@@ -122,30 +159,45 @@ class SubmitRenderer extends FormRenderer {
|
||||
public render() {
|
||||
return [
|
||||
css.error(dom.text(use => use(this.context.error) ?? '')),
|
||||
css.submit(
|
||||
dom('input',
|
||||
css.submitButtons(
|
||||
css.resetButton(
|
||||
'Reset',
|
||||
dom.boolAttr('disabled', this.context.disabled),
|
||||
{
|
||||
type: 'submit',
|
||||
value: this.context.rootLayoutNode.submitText || 'Submit'
|
||||
},
|
||||
{type: 'button'},
|
||||
dom.on('click', () => {
|
||||
// Make sure that all choice or reference lists that are required have at least one option selected.
|
||||
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
|
||||
Array.from(lists).forEach(function(list) {
|
||||
// If the form has at least one checkbox, make it required.
|
||||
const firstCheckbox = list.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// All other required choice or reference lists with at least one option selected are no longer required.
|
||||
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
|
||||
Array.from(checkedLists).forEach(function(list) {
|
||||
const firstCheckbox = list.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.removeAttribute('required');
|
||||
});
|
||||
return confirmModal(
|
||||
'Are you sure you want to reset your form?',
|
||||
'Reset',
|
||||
() => this.parent?.reset()
|
||||
);
|
||||
}),
|
||||
)
|
||||
testId('reset'),
|
||||
),
|
||||
css.submitButton(
|
||||
dom('input',
|
||||
dom.boolAttr('disabled', this.context.disabled),
|
||||
{
|
||||
type: 'submit',
|
||||
value: this.context.rootLayoutNode.submitText || 'Submit',
|
||||
},
|
||||
dom.on('click', () => {
|
||||
// Make sure that all choice or reference lists that are required have at least one option selected.
|
||||
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
|
||||
Array.from(lists).forEach(function(list) {
|
||||
// If the form has at least one checkbox, make it required.
|
||||
const firstCheckbox = list.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// All other required choice or reference lists with at least one option selected are no longer required.
|
||||
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
|
||||
Array.from(checkedLists).forEach(function(list) {
|
||||
const firstCheckbox = list.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.removeAttribute('required');
|
||||
});
|
||||
}),
|
||||
)
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -164,174 +216,380 @@ class LayoutRenderer extends FormRenderer {
|
||||
}
|
||||
|
||||
class FieldRenderer extends FormRenderer {
|
||||
public build(field: FormField) {
|
||||
public renderer: BaseFieldRenderer;
|
||||
|
||||
public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) {
|
||||
super(layoutNode, context);
|
||||
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
|
||||
if (!field) { throw new Error(); }
|
||||
|
||||
const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
|
||||
return new Renderer();
|
||||
this.renderer = this.autoDispose(new Renderer(field, context));
|
||||
}
|
||||
|
||||
public render() {
|
||||
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
|
||||
if (!field) { return null; }
|
||||
return css.field(this.renderer.render());
|
||||
}
|
||||
|
||||
const renderer = this.build(field);
|
||||
return css.field(renderer.render(field, this.context));
|
||||
public reset() {
|
||||
this.renderer.resetInput();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseFieldRenderer {
|
||||
public render(field: FormField, context: FormRendererContext) {
|
||||
abstract class BaseFieldRenderer extends Disposable {
|
||||
public constructor(protected field: FormField, protected context: FormRendererContext) {
|
||||
super();
|
||||
}
|
||||
|
||||
public render() {
|
||||
return css.field(
|
||||
this.label(field),
|
||||
dom('div', this.input(field, context)),
|
||||
this.label(),
|
||||
dom('div', this.input()),
|
||||
);
|
||||
}
|
||||
|
||||
public name(field: FormField) {
|
||||
return field.colId;
|
||||
public name() {
|
||||
return this.field.colId;
|
||||
}
|
||||
|
||||
public label(field: FormField) {
|
||||
public label() {
|
||||
return dom('label',
|
||||
css.label.cls(''),
|
||||
css.label.cls('-required', Boolean(field.options.formRequired)),
|
||||
{for: this.name(field)},
|
||||
field.question,
|
||||
css.label.cls('-required', Boolean(this.field.options.formRequired)),
|
||||
{for: this.name()},
|
||||
this.field.question,
|
||||
);
|
||||
}
|
||||
|
||||
public abstract input(field: FormField, context: FormRendererContext): DomContents;
|
||||
public abstract input(): DomContents;
|
||||
|
||||
public abstract resetInput(): void;
|
||||
}
|
||||
|
||||
class TextRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
return dom('input', {
|
||||
type: 'text',
|
||||
name: this.name(field),
|
||||
required: field.options.formRequired,
|
||||
});
|
||||
protected type = 'text';
|
||||
private _value = Observable.create(this, '');
|
||||
|
||||
public input() {
|
||||
return dom('input',
|
||||
{
|
||||
type: this.type,
|
||||
name: this.name(),
|
||||
required: this.field.options.formRequired,
|
||||
},
|
||||
dom.prop('value', this._value),
|
||||
dom.on('input', (_e, elem) => this._value.set(elem.value)),
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this._value.set('');
|
||||
}
|
||||
}
|
||||
|
||||
class DateRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
return dom('input', {
|
||||
type: 'date',
|
||||
name: this.name(field),
|
||||
required: field.options.formRequired,
|
||||
});
|
||||
}
|
||||
class DateRenderer extends TextRenderer {
|
||||
protected type = 'date';
|
||||
}
|
||||
|
||||
class DateTimeRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
return dom('input', {
|
||||
type: 'datetime-local',
|
||||
name: this.name(field),
|
||||
required: field.options.formRequired,
|
||||
});
|
||||
}
|
||||
class DateTimeRenderer extends TextRenderer {
|
||||
protected type = 'datetime-local';
|
||||
}
|
||||
|
||||
export const SELECT_PLACEHOLDER = 'Select...';
|
||||
|
||||
class ChoiceRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
const choices: Array<string|null> = field.options.choices || [];
|
||||
// Insert empty option.
|
||||
choices.unshift(null);
|
||||
return css.select(
|
||||
{name: this.name(field), required: field.options.formRequired},
|
||||
choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT))
|
||||
protected value = Observable.create<string>(this, '');
|
||||
private _choices: string[];
|
||||
private _selectElement: HTMLElement;
|
||||
private _ctl?: PopupControl<IPopupOptions>;
|
||||
|
||||
public constructor(field: FormField, context: FormRendererContext) {
|
||||
super(field, context);
|
||||
|
||||
const choices = this.field.options.choices;
|
||||
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
|
||||
this._choices = [];
|
||||
} else {
|
||||
// Support for 1000 choices. TODO: make limit dynamic.
|
||||
this._choices = choices.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public input() {
|
||||
return css.hybridSelect(
|
||||
this._selectElement = css.select(
|
||||
{name: this.name(), required: this.field.options.formRequired},
|
||||
dom.prop('value', this.value),
|
||||
dom.on('input', (_e, elem) => this.value.set(elem.value)),
|
||||
dom('option', {value: ''}, SELECT_PLACEHOLDER),
|
||||
this._choices.map((choice) => dom('option', {value: choice}, choice)),
|
||||
dom.onKeyDown({
|
||||
' $': (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
}),
|
||||
),
|
||||
dom.maybe(use => !use(isXSmallScreenObs()), () =>
|
||||
css.searchSelect(
|
||||
dom('div', dom.text(use => use(this.value) || SELECT_PLACEHOLDER)),
|
||||
dropdownWithSearch<string>({
|
||||
action: (value) => this.value.set(value),
|
||||
options: () => [
|
||||
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
|
||||
...this._choices.map((choice) => ({
|
||||
label: choice,
|
||||
value: choice,
|
||||
}),
|
||||
)],
|
||||
onClose: () => { setTimeout(() => this._selectElement.focus()); },
|
||||
placeholder: 'Search',
|
||||
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
|
||||
popupOptions: {
|
||||
trigger: [
|
||||
'click',
|
||||
(_el, ctl) => { this._ctl = ctl; },
|
||||
],
|
||||
},
|
||||
matchTriggerElemWidth: true,
|
||||
}),
|
||||
css.searchSelectIcon('Collapse'),
|
||||
testId('search-select'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.value.set('');
|
||||
}
|
||||
|
||||
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
|
||||
if (isXSmallScreenObs().get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._ctl?.open();
|
||||
}
|
||||
}
|
||||
|
||||
class BoolRenderer extends BaseFieldRenderer {
|
||||
public render(field: FormField) {
|
||||
protected checked = Observable.create<boolean>(this, false);
|
||||
|
||||
public render() {
|
||||
return css.field(
|
||||
dom('div', this.input(field)),
|
||||
dom('div', this.input()),
|
||||
);
|
||||
}
|
||||
|
||||
public input(field: FormField) {
|
||||
public input() {
|
||||
return css.toggle(
|
||||
css.label.cls('-required', Boolean(field.options.formRequired)),
|
||||
dom('input', {
|
||||
type: 'checkbox',
|
||||
name: this.name(field),
|
||||
value: '1',
|
||||
required: field.options.formRequired,
|
||||
}),
|
||||
dom('input',
|
||||
dom.prop('checked', this.checked),
|
||||
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: this.name(),
|
||||
value: '1',
|
||||
required: this.field.options.formRequired,
|
||||
},
|
||||
),
|
||||
css.gristSwitch(
|
||||
css.gristSwitchSlider(),
|
||||
css.gristSwitchCircle(),
|
||||
),
|
||||
dom('span', field.question || field.colId)
|
||||
css.toggleLabel(
|
||||
css.label.cls('-required', Boolean(this.field.options.formRequired)),
|
||||
this.field.question,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.checked.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceListRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
const choices: string[] = field.options.choices ?? [];
|
||||
const required = field.options.formRequired;
|
||||
protected checkboxes: MutableObsArray<{
|
||||
label: string;
|
||||
checked: Observable<string|null>
|
||||
}> = this.autoDispose(obsArray());
|
||||
|
||||
public constructor(field: FormField, context: FormRendererContext) {
|
||||
super(field, context);
|
||||
|
||||
let choices = this.field.options.choices;
|
||||
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
|
||||
choices = [];
|
||||
} else {
|
||||
// Support for 30 choices. TODO: make limit dynamic.
|
||||
choices = choices.slice(0, 30);
|
||||
}
|
||||
|
||||
this.checkboxes.set(choices.map(choice => ({
|
||||
label: choice,
|
||||
checked: Observable.create(this, null),
|
||||
})));
|
||||
}
|
||||
|
||||
public input() {
|
||||
const required = this.field.options.formRequired;
|
||||
return css.checkboxList(
|
||||
dom.cls('grist-checkbox-list'),
|
||||
dom.cls('required', Boolean(required)),
|
||||
{name: this.name(field), required},
|
||||
choices.map(choice => css.checkbox(
|
||||
dom('input', {
|
||||
type: 'checkbox',
|
||||
name: `${this.name(field)}[]`,
|
||||
value: choice,
|
||||
}),
|
||||
dom('span', choice),
|
||||
)),
|
||||
{name: this.name(), required},
|
||||
dom.forEach(this.checkboxes, (checkbox) =>
|
||||
css.checkbox(
|
||||
dom('input',
|
||||
dom.prop('checked', checkbox.checked),
|
||||
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: `${this.name()}[]`,
|
||||
value: checkbox.label,
|
||||
}
|
||||
),
|
||||
dom('span', checkbox.label),
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.checkboxes.get().forEach(checkbox => {
|
||||
checkbox.checked.set(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RefListRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
const choices: [number, CellValue][] = field.refValues ?? [];
|
||||
protected checkboxes: MutableObsArray<{
|
||||
label: string;
|
||||
value: string;
|
||||
checked: Observable<string|null>
|
||||
}> = this.autoDispose(obsArray());
|
||||
|
||||
public constructor(field: FormField, context: FormRendererContext) {
|
||||
super(field, context);
|
||||
|
||||
const references = this.field.refValues ?? [];
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
// Support for 30 choices. TODO: make limit dynamic.
|
||||
choices.splice(30);
|
||||
const required = field.options.formRequired;
|
||||
references.splice(30);
|
||||
this.checkboxes.set(references.map(reference => ({
|
||||
label: String(reference[1]),
|
||||
value: String(reference[0]),
|
||||
checked: Observable.create(this, null),
|
||||
})));
|
||||
}
|
||||
public input() {
|
||||
const required = this.field.options.formRequired;
|
||||
return css.checkboxList(
|
||||
dom.cls('grist-checkbox-list'),
|
||||
dom.cls('required', Boolean(required)),
|
||||
{name: this.name(field), required},
|
||||
choices.map(choice => css.checkbox(
|
||||
dom('input', {
|
||||
type: 'checkbox',
|
||||
'data-grist-type': field.type,
|
||||
name: `${this.name(field)}[]`,
|
||||
value: String(choice[0]),
|
||||
}),
|
||||
dom('span', String(choice[1] ?? '')),
|
||||
)),
|
||||
{name: this.name(), required},
|
||||
dom.forEach(this.checkboxes, (checkbox) =>
|
||||
css.checkbox(
|
||||
dom('input',
|
||||
dom.prop('checked', checkbox.checked),
|
||||
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
|
||||
{
|
||||
type: 'checkbox',
|
||||
'data-grist-type': this.field.type,
|
||||
name: `${this.name()}[]`,
|
||||
value: checkbox.value,
|
||||
}
|
||||
),
|
||||
dom('span', checkbox.label),
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.checkboxes.get().forEach(checkbox => {
|
||||
checkbox.checked.set(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RefRenderer extends BaseFieldRenderer {
|
||||
public input(field: FormField) {
|
||||
const choices: [number|string, CellValue][] = field.refValues ?? [];
|
||||
protected value = Observable.create(this, '');
|
||||
private _selectElement: HTMLElement;
|
||||
private _ctl?: PopupControl<IPopupOptions>;
|
||||
|
||||
public input() {
|
||||
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
// Support for 1000 choices. TODO: make limit dynamic.
|
||||
choices.splice(1000);
|
||||
// Insert empty option.
|
||||
choices.unshift(['', CHOOSE_TEXT]);
|
||||
return css.select(
|
||||
{
|
||||
name: this.name(field),
|
||||
'data-grist-type': field.type,
|
||||
required: field.options.formRequired,
|
||||
},
|
||||
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))),
|
||||
return css.hybridSelect(
|
||||
this._selectElement = css.select(
|
||||
{
|
||||
name: this.name(),
|
||||
'data-grist-type': this.field.type,
|
||||
required: this.field.options.formRequired,
|
||||
},
|
||||
dom.prop('value', this.value),
|
||||
dom.on('input', (_e, elem) => this.value.set(elem.value)),
|
||||
dom('option', {value: ''}, SELECT_PLACEHOLDER),
|
||||
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
|
||||
dom.onKeyDown({
|
||||
' $': (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
|
||||
}),
|
||||
),
|
||||
dom.maybe(use => !use(isXSmallScreenObs()), () =>
|
||||
css.searchSelect(
|
||||
dom('div', dom.text(use => {
|
||||
const choice = choices.find((c) => String(c[0]) === use(this.value));
|
||||
return String(choice?.[1] || SELECT_PLACEHOLDER);
|
||||
})),
|
||||
dropdownWithSearch<string>({
|
||||
action: (value) => this.value.set(value),
|
||||
options: () => [
|
||||
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
|
||||
...choices.map((choice) => ({
|
||||
label: String(choice[1]),
|
||||
value: String(choice[0]),
|
||||
}),
|
||||
)],
|
||||
onClose: () => { setTimeout(() => this._selectElement.focus()); },
|
||||
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
|
||||
placeholder: 'Search',
|
||||
popupOptions: {
|
||||
trigger: [
|
||||
'click',
|
||||
(_el, ctl) => { this._ctl = ctl; },
|
||||
],
|
||||
},
|
||||
matchTriggerElemWidth: true,
|
||||
}),
|
||||
css.searchSelectIcon('Collapse'),
|
||||
testId('search-select'),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public resetInput(): void {
|
||||
this.value.set('');
|
||||
}
|
||||
|
||||
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
|
||||
if (isXSmallScreenObs().get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._ctl?.open();
|
||||
}
|
||||
}
|
||||
|
||||
const FieldRenderers = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const label = styled('div', `
|
||||
@@ -38,7 +39,36 @@ export const columns = styled('div', `
|
||||
gap: 4px;
|
||||
`);
|
||||
|
||||
export const submit = styled('div', `
|
||||
export const submitButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
column-gap: 8px;
|
||||
`);
|
||||
|
||||
export const resetButton = styled('button', `
|
||||
line-height: inherit;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
padding: 10px 24px;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
color: ${vars.primaryBg};
|
||||
border: 1px solid ${vars.primaryBg};
|
||||
border-radius: 4px;
|
||||
outline-color: ${vars.primaryBgHover};
|
||||
|
||||
&:hover {
|
||||
color: ${vars.primaryBgHover};
|
||||
border-color: ${vars.primaryBgHover};
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: ${colors.light};
|
||||
background-color: ${colors.slate};
|
||||
border-color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
export const submitButton = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -52,11 +82,18 @@ export const submit = styled('div', `
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
outline-color: ${vars.primaryBgHover};
|
||||
}
|
||||
& input[type="submit"]:hover {
|
||||
border-color: ${vars.primaryBgHover};
|
||||
background-color: ${vars.primaryBgHover};
|
||||
}
|
||||
& input[type="submit"]:disabled {
|
||||
cursor: not-allowed;
|
||||
color: ${colors.light};
|
||||
background-color: ${colors.slate};
|
||||
border-color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
// TODO: break up into multiple variables, one for each field type.
|
||||
@@ -72,12 +109,10 @@ export const field = styled('div', `
|
||||
padding: 4px 8px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
outline-color: ${vars.primaryBgHover};
|
||||
}
|
||||
& input[type="text"] {
|
||||
font-size: 13px;
|
||||
outline-color: ${vars.primaryBg};
|
||||
outline-width: 1px;
|
||||
line-height: inherit;
|
||||
width: 100%;
|
||||
color: ${colors.dark};
|
||||
@@ -101,6 +136,9 @@ export const field = styled('div', `
|
||||
margin-right: 8px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
& input[type="checkbox"]:focus {
|
||||
outline-color: ${vars.primaryBgHover};
|
||||
}
|
||||
& input[type="checkbox"]:checked:enabled,
|
||||
& input[type="checkbox"]:indeterminate:enabled {
|
||||
--color: ${vars.primaryBg};
|
||||
@@ -171,11 +209,19 @@ export const toggle = styled('label', `
|
||||
& input[type='checkbox'] {
|
||||
position: absolute;
|
||||
}
|
||||
& input[type='checkbox']:focus {
|
||||
outline: none;
|
||||
}
|
||||
& > span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const toggleLabel = styled('span', `
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`);
|
||||
|
||||
export const gristSwitchSlider = styled('div', `
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
@@ -185,8 +231,8 @@ export const gristSwitchSlider = styled('div', `
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
border-radius: 17px;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
-webkit-transition: background-color .4s;
|
||||
transition: background-color .4s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
@@ -203,8 +249,8 @@ export const gristSwitchCircle = styled('div', `
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
border-radius: 17px;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
-webkit-transition: transform .4s;
|
||||
transition: transform .4s;
|
||||
`);
|
||||
|
||||
export const gristSwitch = styled('div', `
|
||||
@@ -214,6 +260,11 @@ export const gristSwitch = styled('div', `
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
|
||||
input:focus + & > .${gristSwitchSlider.className} {
|
||||
outline: 2px solid ${vars.primaryBgHover};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input:checked + & > .${gristSwitchSlider.className} {
|
||||
background-color: ${vars.primaryBg};
|
||||
}
|
||||
@@ -239,16 +290,52 @@ export const checkbox = styled('label', `
|
||||
}
|
||||
`);
|
||||
|
||||
export const hybridSelect = styled('div', `
|
||||
position: relative;
|
||||
`);
|
||||
|
||||
export const select = styled('select', `
|
||||
position: absolute;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
font-size: 13px;
|
||||
outline-color: ${vars.primaryBg};
|
||||
outline-width: 1px;
|
||||
outline: none;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
height: 27px;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
|
||||
@media ${mediaXSmall} {
|
||||
& {
|
||||
outline: revert;
|
||||
outline-color: ${vars.primaryBgHover};
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const searchSelect = styled('div', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
height: 27px;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
|
||||
select:focus + & {
|
||||
outline: 2px solid ${vars.primaryBgHover};
|
||||
}
|
||||
`);
|
||||
|
||||
export const searchSelectIcon = styled(icon, `
|
||||
flex-shrink: 0;
|
||||
`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {icon} from 'app/client/ui2018/icons';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {inlineStyle, not} from 'app/common/gutil';
|
||||
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
@@ -93,17 +94,23 @@ export class ColumnsModel extends BoxModel {
|
||||
const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
|
||||
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
|
||||
|
||||
// Remove each child of this column from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this column from the layout.
|
||||
this.removeSelf();
|
||||
|
||||
// Finally, remove the fields and save the changes to the layout.
|
||||
await this.parent?.save(async () => {
|
||||
// FormView is particularly sensitive to the order that view fields and
|
||||
// the form layout are modified. Specifically, if the layout is
|
||||
// modified before view fields are removed, deleting a column with
|
||||
// mapped fields inside seems to break. The same issue affects sections
|
||||
// containing mapped fields. Reversing the order causes no such issues.
|
||||
//
|
||||
// TODO: narrow down why this happens and see if it's worth fixing.
|
||||
if (fieldIdsToRemove.length > 0) {
|
||||
await this.view.viewSection.removeField(fieldIdsToRemove);
|
||||
}
|
||||
|
||||
// Remove each child of this column from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this column from the layout.
|
||||
this.removeSelf();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,16 +225,12 @@ export class PlaceholderModel extends BoxModel {
|
||||
}
|
||||
}
|
||||
|
||||
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
|
||||
return {type: 'Paragraph', text, alignment};
|
||||
}
|
||||
|
||||
export function Placeholder(): FormLayoutNode {
|
||||
return {type: 'Placeholder'};
|
||||
return {id: uuidv4(), type: 'Placeholder'};
|
||||
}
|
||||
|
||||
export function Columns(): FormLayoutNode {
|
||||
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
|
||||
return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]};
|
||||
}
|
||||
|
||||
const cssPlaceholder = styled('div', `
|
||||
|
||||
@@ -7,7 +7,7 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
||||
import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
const t = makeT('FormView.Editor');
|
||||
@@ -27,9 +27,13 @@ interface Props {
|
||||
*/
|
||||
click?: (ev: MouseEvent, box: BoxModel) => void,
|
||||
/**
|
||||
* Custom remove icon. If null, then no drop icon is shown.
|
||||
* Whether to show the remove button. Defaults to true.
|
||||
*/
|
||||
removeIcon?: IconName|null,
|
||||
showRemoveButton?: BindableValue<boolean>,
|
||||
/**
|
||||
* Custom remove icon.
|
||||
*/
|
||||
removeIcon?: IconName,
|
||||
/**
|
||||
* Custom remove button rendered atop overlay.
|
||||
*/
|
||||
@@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
||||
}
|
||||
|
||||
await box.save(async () => {
|
||||
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
|
||||
// When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no
|
||||
// field yet). In this case, we need to create a field first.
|
||||
if (dropped.type === 'Field' && typeof dropped.leaf === 'string') {
|
||||
dropped.leaf = await view.showColumn(dropped.leaf);
|
||||
}
|
||||
box.accept(dropped, wasBelow ? 'below' : 'above');
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -225,10 +234,9 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
||||
testId('element'),
|
||||
dom.attr('data-box-model', String(box.type)),
|
||||
dom.maybe(overlay, () => style.cssSelectedOverlay()),
|
||||
// Custom icons for removing.
|
||||
props.removeIcon === null || props.removeButton ? null :
|
||||
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
||||
props.removeButton ?? null,
|
||||
dom.maybe(props.showRemoveButton ?? true, () => [
|
||||
props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
||||
]),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
|
||||
@@ -8,6 +8,7 @@ import {refRecord} from 'app/client/models/DocModel';
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {isBlankValue} from 'app/common/gristTypes';
|
||||
import {Constructor, not} from 'app/common/gutil';
|
||||
import {
|
||||
BindableValue,
|
||||
@@ -102,18 +103,6 @@ export class FieldModel extends BoxModel {
|
||||
);
|
||||
}
|
||||
|
||||
public async afterDrop() {
|
||||
// Base class does good job of handling drop.
|
||||
await super.afterDrop();
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
// Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
|
||||
// field yet). In this case, we need to create a field.
|
||||
if (typeof this.leaf.get() === 'string') {
|
||||
this.leaf.set(await this.view.showColumn(this.leaf.get()));
|
||||
}
|
||||
}
|
||||
|
||||
public override render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
||||
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
|
||||
const save = (value: string) => {
|
||||
@@ -287,20 +276,14 @@ class TextModel extends Question {
|
||||
class ChoiceModel extends Question {
|
||||
protected choices: Computed<string[]> = Computed.create(this, use => {
|
||||
// Read choices from field.
|
||||
const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || [];
|
||||
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
|
||||
|
||||
// Make sure it is array of strings.
|
||||
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
|
||||
// Make sure it is an array of strings.
|
||||
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
|
||||
return [];
|
||||
} else {
|
||||
return choices;
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
protected choicesWithEmpty = Computed.create(this, use => {
|
||||
const list: Array<string|null> = Array.from(use(this.choices));
|
||||
// Add empty choice if not present.
|
||||
list.unshift(null);
|
||||
return list;
|
||||
});
|
||||
|
||||
public renderInput(): HTMLElement {
|
||||
@@ -309,21 +292,27 @@ class ChoiceModel extends Question {
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice ?? CHOOSE_TEXT, {value: choice ?? ''})),
|
||||
dom('option', SELECT_PLACEHOLDER, {value: ''}),
|
||||
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceListModel extends ChoiceModel {
|
||||
private _choices = Computed.create(this, use => {
|
||||
// Support for 30 choices. TODO: make limit dynamic.
|
||||
return use(this.choices).slice(0, 30);
|
||||
});
|
||||
|
||||
public renderInput() {
|
||||
const field = this.model.field;
|
||||
return dom('div',
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
|
||||
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
choice
|
||||
)),
|
||||
dom.maybe(use => use(this.choices).length === 0, () => [
|
||||
dom.maybe(use => use(this._choices).length === 0, () => [
|
||||
dom('div', 'No choices defined'),
|
||||
]),
|
||||
);
|
||||
@@ -382,22 +371,22 @@ class DateTimeModel extends Question {
|
||||
}
|
||||
|
||||
class RefListModel extends Question {
|
||||
protected choices = this._subscribeForChoices();
|
||||
protected options = this._getOptions();
|
||||
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
|
||||
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
String(choice[1] ?? '')
|
||||
option.label,
|
||||
)),
|
||||
dom.maybe(use => use(this.choices).length === 0, () => [
|
||||
dom('div', 'No choices defined'),
|
||||
dom.maybe(use => use(this.options).length === 0, () => [
|
||||
dom('div', 'No values in show column of referenced table'),
|
||||
]),
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
private _subscribeForChoices() {
|
||||
private _getOptions() {
|
||||
const tableId = Computed.create(this, use => {
|
||||
const refTable = use(use(this.model.column).refTable);
|
||||
return refTable ? use(refTable.tableId) : '';
|
||||
@@ -411,27 +400,23 @@ class RefListModel extends Question {
|
||||
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
|
||||
|
||||
return Computed.create(this, use => {
|
||||
const unsorted = use(observer);
|
||||
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
return unsorted.slice(0, 50); // TODO: pagination or a waning
|
||||
return use(observer)
|
||||
.filter(([_id, value]) => !isBlankValue(value))
|
||||
.map(([id, value]) => ({label: String(value), value: String(id)}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.slice(0, 30); // TODO: make limit dynamic.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RefModel extends RefListModel {
|
||||
protected withEmpty = Computed.create(this, use => {
|
||||
const list = Array.from(use(this.choices));
|
||||
// Add empty choice if not present.
|
||||
list.unshift(['', CHOOSE_TEXT]);
|
||||
return list;
|
||||
});
|
||||
|
||||
public renderInput() {
|
||||
return css.cssSelect(
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
|
||||
dom('option', SELECT_PLACEHOLDER, {value: ''}),
|
||||
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseView from 'app/client/components/BaseView';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {Cursor} from 'app/client/components/Cursor';
|
||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {NewBox} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
|
||||
@@ -15,13 +15,16 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {SortedRowSet} from 'app/client/models/rowset';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
|
||||
import {isOwner} from 'app/common/roles';
|
||||
@@ -31,6 +34,7 @@ import defaults from 'lodash/defaults';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import * as ko from 'knockout';
|
||||
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||
|
||||
const t = makeT('FormView');
|
||||
|
||||
@@ -42,6 +46,7 @@ export class FormView extends Disposable {
|
||||
public viewSection: ViewSectionRec;
|
||||
public selectedBox: Computed<BoxModel | null>;
|
||||
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
|
||||
public disableDeleteSection: Computed<boolean>;
|
||||
|
||||
protected sortedRows: SortedRowSet;
|
||||
protected tableModel: DataTableModel;
|
||||
@@ -49,17 +54,20 @@ export class FormView extends Disposable {
|
||||
protected menuHolder: Holder<any>;
|
||||
protected bundle: (clb: () => Promise<void>) => Promise<void>;
|
||||
|
||||
private _formFields: Computed<ViewFieldRec[]>;
|
||||
private _autoLayout: Computed<FormLayoutNode>;
|
||||
private _root: BoxModel;
|
||||
private _savedLayout: any;
|
||||
private _saving: boolean = false;
|
||||
private _url: Computed<string>;
|
||||
private _copyingLink: Observable<boolean>;
|
||||
private _previewUrl: Computed<string>;
|
||||
private _pageShare: Computed<ShareRec | null>;
|
||||
private _remoteShare: AsyncComputed<{key: string}|null>;
|
||||
private _isFork: Computed<boolean>;
|
||||
private _published: Computed<boolean>;
|
||||
private _showPublishedMessage: Observable<boolean>;
|
||||
private _isOwner: boolean;
|
||||
private _openingForm: Observable<boolean>;
|
||||
private _formElement: HTMLElement;
|
||||
|
||||
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
||||
@@ -124,15 +132,22 @@ export class FormView extends Disposable {
|
||||
}));
|
||||
this.viewSection.selectedFields(this.selectedColumns.peek());
|
||||
|
||||
this._formFields = Computed.create(this, use => {
|
||||
const fields = use(use(this.viewSection.viewFields).getObservable());
|
||||
return fields.filter(f => use(use(f.column).isFormCol));
|
||||
});
|
||||
|
||||
this._autoLayout = Computed.create(this, use => {
|
||||
// If the layout is already there, don't do anything.
|
||||
const existing = use(this.viewSection.layoutSpecObj);
|
||||
if (!existing || !existing.id) {
|
||||
const fields = use(use(this.viewSection.viewFields).getObservable());
|
||||
const fields = use(this._formFields);
|
||||
const layout = use(this.viewSection.layoutSpecObj);
|
||||
if (!layout || !layout.id) {
|
||||
return this._formTemplate(fields);
|
||||
} else {
|
||||
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
|
||||
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
||||
|
||||
return patchedLayout;
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
|
||||
@@ -166,12 +181,7 @@ export class FormView extends Disposable {
|
||||
copy: () => {
|
||||
const selected = this.selectedBox.get();
|
||||
if (!selected) { return; }
|
||||
// Add this box as a json to clipboard.
|
||||
const json = selected.toJSON();
|
||||
navigator.clipboard.writeText(JSON.stringify({
|
||||
...json,
|
||||
id: uuidv4(),
|
||||
})).catch(reportError);
|
||||
selected.copySelf().catch(reportError);
|
||||
},
|
||||
cut: () => {
|
||||
const selected = this.selectedBox.get();
|
||||
@@ -179,7 +189,7 @@ export class FormView extends Disposable {
|
||||
selected.cutSelf().catch(reportError);
|
||||
},
|
||||
paste: () => {
|
||||
const doPast = async () => {
|
||||
const doPaste = async () => {
|
||||
const boxInClipboard = parseBox(await navigator.clipboard.readText());
|
||||
if (!boxInClipboard) { return; }
|
||||
if (!this.selectedBox.get()) {
|
||||
@@ -187,13 +197,14 @@ export class FormView extends Disposable {
|
||||
} else {
|
||||
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
|
||||
}
|
||||
// Remove the original box from the clipboard.
|
||||
const cut = this._root.find(boxInClipboard.id);
|
||||
cut?.removeSelf();
|
||||
const maybeCutBox = this._root.find(boxInClipboard.id);
|
||||
if (maybeCutBox?.cut.get()) {
|
||||
maybeCutBox.removeSelf();
|
||||
}
|
||||
await this._root.save();
|
||||
await navigator.clipboard.writeText('');
|
||||
};
|
||||
doPast().catch(reportError);
|
||||
doPaste().catch(reportError);
|
||||
},
|
||||
nextField: () => {
|
||||
const current = this.selectedBox.get();
|
||||
@@ -242,7 +253,7 @@ export class FormView extends Disposable {
|
||||
},
|
||||
clearValues: () => {
|
||||
const selected = this.selectedBox.get();
|
||||
if (!selected) { return; }
|
||||
if (!selected || selected.canRemove?.() === false) { return; }
|
||||
keyboardActions.nextField();
|
||||
this.bundle(async () => {
|
||||
await selected.deleteSelf();
|
||||
@@ -267,6 +278,7 @@ export class FormView extends Disposable {
|
||||
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
|
||||
} else {
|
||||
selected.insertBefore(components.defaultElement(what.structure));
|
||||
this.save().catch(reportError);
|
||||
}
|
||||
},
|
||||
insertField: (what: NewBox) => {
|
||||
@@ -287,6 +299,7 @@ export class FormView extends Disposable {
|
||||
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
|
||||
} else {
|
||||
selected.insertAfter(components.defaultElement(what.structure));
|
||||
this.save().catch(reportError);
|
||||
}
|
||||
},
|
||||
showColumns: (colIds: string[]) => {
|
||||
@@ -299,6 +312,7 @@ export class FormView extends Disposable {
|
||||
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
|
||||
if (!field) { continue; }
|
||||
const box = {
|
||||
id: uuidv4(),
|
||||
leaf: fieldRef,
|
||||
type: 'Field' as FormLayoutNodeType,
|
||||
};
|
||||
@@ -332,7 +346,7 @@ export class FormView extends Disposable {
|
||||
hideFields: keyboardActions.hideFields,
|
||||
}, this, this.viewSection.hasFocus));
|
||||
|
||||
this._url = Computed.create(this, use => {
|
||||
this._previewUrl = Computed.create(this, use => {
|
||||
const doc = use(this.gristDoc.docPageModel.currentDoc);
|
||||
if (!doc) { return ''; }
|
||||
const url = urlState().makeUrl({
|
||||
@@ -344,8 +358,6 @@ export class FormView extends Disposable {
|
||||
return url;
|
||||
});
|
||||
|
||||
this._copyingLink = Observable.create(this, false);
|
||||
|
||||
this._pageShare = Computed.create(this, use => {
|
||||
const page = use(use(this.viewSection.view).page);
|
||||
if (!page) { return null; }
|
||||
@@ -366,7 +378,15 @@ export class FormView extends Disposable {
|
||||
}
|
||||
});
|
||||
|
||||
this._isFork = Computed.create(this, use => {
|
||||
const {docPageModel} = this.gristDoc;
|
||||
return use(docPageModel.isFork) || use(docPageModel.isPrefork);
|
||||
});
|
||||
|
||||
this._published = Computed.create(this, use => {
|
||||
const isFork = use(this._isFork);
|
||||
if (isFork) { return false; }
|
||||
|
||||
const pageShare = use(this._pageShare);
|
||||
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
|
||||
const validShare = pageShare && remoteShare;
|
||||
@@ -384,6 +404,8 @@ export class FormView extends Disposable {
|
||||
|
||||
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
|
||||
|
||||
this._openingForm = Observable.create(this, false);
|
||||
|
||||
// Last line, build the dom.
|
||||
this.viewPane = this.autoDispose(this.buildDom());
|
||||
}
|
||||
@@ -401,7 +423,7 @@ export class FormView extends Disposable {
|
||||
testId('editor'),
|
||||
style.cssFormEditBody(
|
||||
style.cssFormContainer(
|
||||
dom.forEach(this._root.children, (child) => {
|
||||
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
|
||||
if (!child) {
|
||||
return dom('div', 'Empty node');
|
||||
}
|
||||
@@ -410,11 +432,12 @@ export class FormView extends Disposable {
|
||||
throw new Error('Element is not an HTMLElement');
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
})),
|
||||
this._buildPublisher(),
|
||||
),
|
||||
),
|
||||
dom.on('click', () => this.selectedBox.set(null))
|
||||
dom.on('click', () => this.selectedBox.set(null)),
|
||||
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +466,7 @@ export class FormView extends Disposable {
|
||||
}
|
||||
// And add it into the layout.
|
||||
this.selectedBox.set(insert({
|
||||
id: uuidv4(),
|
||||
leaf: fieldRef,
|
||||
type: 'Field'
|
||||
}));
|
||||
@@ -612,67 +636,90 @@ export class FormView extends Disposable {
|
||||
|
||||
private _buildPublisher() {
|
||||
return style.cssSwitcher(
|
||||
this._buildSwitcherMessage(),
|
||||
this._buildNotifications(),
|
||||
style.cssButtonGroup(
|
||||
style.cssSmallIconButton(
|
||||
style.cssIconButton.cls('-frameless'),
|
||||
style.cssSmallButton(
|
||||
style.cssSmallButton.cls('-frameless'),
|
||||
icon('Revert'),
|
||||
testId('reset'),
|
||||
dom('div', 'Reset form'),
|
||||
dom('div', t('Reset form')),
|
||||
dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'),
|
||||
dom.style('margin-right', 'auto'), // move it to the left
|
||||
dom.on('click', () => {
|
||||
this._resetForm().catch(reportError);
|
||||
return confirmModal(t('Are you sure you want to reset your form?'),
|
||||
t('Reset'),
|
||||
() => this._resetForm(),
|
||||
);
|
||||
})
|
||||
),
|
||||
style.cssIconLink(
|
||||
testId('preview'),
|
||||
icon('EyeShow'),
|
||||
dom.text('Preview'),
|
||||
dom.prop('href', this._url),
|
||||
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._url.get());
|
||||
}
|
||||
})
|
||||
),
|
||||
style.cssIconButton(
|
||||
icon('FieldAttachment'),
|
||||
testId('link'),
|
||||
dom('div', 'Copy Link'),
|
||||
dom.prop('disabled', this._copyingLink),
|
||||
dom.show(use => this._isOwner && use(this._published)),
|
||||
dom.on('click', async (_event, element) => {
|
||||
try {
|
||||
this._copyingLink.set(true);
|
||||
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
|
||||
"text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
|
||||
});
|
||||
await copyToClipboard(data);
|
||||
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
|
||||
} catch (ex) {
|
||||
if (ex.code === 'AUTH_NO_OWNER') {
|
||||
throw new Error('Sharing a form is only available to owners');
|
||||
}
|
||||
} finally {
|
||||
this._copyingLink.set(false);
|
||||
}
|
||||
}),
|
||||
),
|
||||
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')),
|
||||
dom.show(use => this._isOwner && use(this._published)),
|
||||
elem => {
|
||||
setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
|
||||
...defaultMenuOptions,
|
||||
placement: 'top-end',
|
||||
});
|
||||
},
|
||||
),
|
||||
dom.domComputed(use => {
|
||||
const isFork = use(this._isFork);
|
||||
const published = use(this._published);
|
||||
return published
|
||||
? style.cssIconButton(
|
||||
dom('div', 'Unpublish'),
|
||||
? style.cssSmallButton(
|
||||
dom('div', t('Unpublish')),
|
||||
dom.show(this._isOwner),
|
||||
style.cssIconButton.cls('-warning'),
|
||||
style.cssSmallButton.cls('-warning'),
|
||||
dom.on('click', () => this._handleClickUnpublish()),
|
||||
testId('unpublish'),
|
||||
)
|
||||
: style.cssIconButton(
|
||||
dom('div', 'Publish'),
|
||||
: style.cssSmallButton(
|
||||
dom('div', t('Publish')),
|
||||
dom.boolAttr('disabled', isFork),
|
||||
!isFork ? null : hoverTooltip(t('Save your document to publish this form.'), {
|
||||
placement: 'top',
|
||||
}),
|
||||
dom.show(this._isOwner),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => this._handleClickPublish()),
|
||||
@@ -683,7 +730,7 @@ export class FormView extends Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
private async _getFormLink() {
|
||||
private async _getFormUrl() {
|
||||
const share = this._pageShare.get();
|
||||
if (!share) {
|
||||
throw new Error('Unable to get form link: form is not published');
|
||||
@@ -703,7 +750,139 @@ export class FormView extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
private _buildSwitcherMessage() {
|
||||
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'}
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _getEstimatedFormHeightPx() {
|
||||
return (
|
||||
// Form content height.
|
||||
this._formElement.scrollHeight +
|
||||
// Plus top/bottom page padding.
|
||||
(2 * 52) +
|
||||
// Plus top/bottom form padding.
|
||||
(2 * 20) +
|
||||
// Plus minimum form error height.
|
||||
38 +
|
||||
// Plus form footer height.
|
||||
64
|
||||
);
|
||||
}
|
||||
|
||||
private _buildNotifications() {
|
||||
return [
|
||||
this._buildFormPublishedNotification(),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildFormPublishedNotification() {
|
||||
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
|
||||
return style.cssSwitcherMessage(
|
||||
style.cssSwitcherMessageBody(
|
||||
@@ -726,29 +905,24 @@ export class FormView extends Disposable {
|
||||
/**
|
||||
* Generates a form template based on the fields in the view section.
|
||||
*/
|
||||
private _formTemplate(fields: ViewFieldRec[]) {
|
||||
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
|
||||
const boxes: FormLayoutNode[] = fields.map(f => {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Field',
|
||||
leaf: f.id()
|
||||
} as FormLayoutNode;
|
||||
leaf: f.id(),
|
||||
};
|
||||
});
|
||||
const section = {
|
||||
type: 'Section',
|
||||
children: [
|
||||
{type: 'Paragraph', text: SECTION_TITLE},
|
||||
{type: 'Paragraph', text: SECTION_DESC},
|
||||
...boxes,
|
||||
],
|
||||
};
|
||||
const section = components.Section(...boxes);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Layout',
|
||||
children: [
|
||||
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
||||
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
||||
section,
|
||||
{type: 'Submit'}
|
||||
]
|
||||
{id: uuidv4(), type: 'Submit'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -758,19 +932,9 @@ export class FormView extends Disposable {
|
||||
// First we will remove all fields from this section, and add top 9 back.
|
||||
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
|
||||
|
||||
const toAdd = this.viewSection.table().columns().peek().filter(c => {
|
||||
// If hidden than no.
|
||||
if (c.isHiddenCol()) { return false; }
|
||||
|
||||
// If formula column, no.
|
||||
if (c.isFormula() && c.formula()) { return false; }
|
||||
|
||||
// Attachments are currently unsupported in forms.
|
||||
if (c.pureType() === 'Attachments') { return false; }
|
||||
|
||||
return true;
|
||||
});
|
||||
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
|
||||
const toAdd = this.viewSection.table().columns().peek()
|
||||
.filter(c => c.isFormCol())
|
||||
.sort((a, b) => a.parentPos() - b.parentPos());
|
||||
|
||||
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
|
||||
const parentId = colRef.map(() => this.viewSection.id());
|
||||
@@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents);
|
||||
// Default values when form is reset.
|
||||
const FORM_TITLE = "## **Form Title**";
|
||||
const FORM_DESC = "Your form description goes here.";
|
||||
|
||||
const SECTION_TITLE = '### **Header**';
|
||||
const SECTION_DESC = 'Description';
|
||||
|
||||
@@ -16,7 +16,7 @@ const t = makeT('VisibleFieldsConfig');
|
||||
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
|
||||
* the ability to drag and drop fields onto the form.
|
||||
*/
|
||||
export class UnmappedFieldsConfig extends Disposable {
|
||||
export class MappedFieldsConfig extends Disposable {
|
||||
|
||||
constructor(private _section: ViewSectionRec) {
|
||||
super();
|
||||
@@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
|
||||
return [];
|
||||
}
|
||||
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
|
||||
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
|
||||
const cols = this._section.table().visibleColumns()
|
||||
.filter(c => c.isFormCol() && !fields.has(c.colId()));
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
@@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
|
||||
if (this._section.isDisposed()) {
|
||||
return [];
|
||||
}
|
||||
const cols = this._section.viewFields().map(f => f.column());
|
||||
const cols = this._section.viewFields().map(f => f.column()).all()
|
||||
.filter(c => c.isFormCol());
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
})).all();
|
||||
}));
|
||||
})));
|
||||
|
||||
const anyUnmappedSelected = Computed.create(this, use => {
|
||||
@@ -64,36 +66,6 @@ export class UnmappedFieldsConfig extends Disposable {
|
||||
};
|
||||
|
||||
return [
|
||||
cssHeader(
|
||||
cssFieldListHeader(t("Unmapped")),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
unmappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(unmappedColumns, (field) => {
|
||||
return this._buildUnmappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyUnmappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Map fields")),
|
||||
dom.on('click', mapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
cssHeader(
|
||||
cssFieldListHeader(dom.text(t("Mapped"))),
|
||||
selectAllLabel(
|
||||
@@ -124,6 +96,36 @@ export class UnmappedFieldsConfig extends Disposable {
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
cssHeader(
|
||||
cssFieldListHeader(t("Unmapped")),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
unmappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(unmappedColumns, (field) => {
|
||||
return this._buildUnmappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyUnmappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Map fields")),
|
||||
dom.on('click', mapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
|
||||
|
||||
const unmapped = Computed.create(owner, (use) => {
|
||||
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
|
||||
const normalCols = use(viewSection.hiddenColumns).filter(col => {
|
||||
if (use(col.isHiddenCol)) { return false; }
|
||||
if (use(col.isFormula) && use(col.formula)) { return false; }
|
||||
if (use(col.pureType) === 'Attachments') { return false; }
|
||||
return true;
|
||||
});
|
||||
const normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));
|
||||
const list = normalCols.map(col => {
|
||||
return {
|
||||
label: use(col.label),
|
||||
|
||||
@@ -2,7 +2,6 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRend
|
||||
import * as elements from 'app/client/components/Forms/elements';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
type Callback = () => Promise<void>;
|
||||
|
||||
@@ -33,9 +32,7 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
|
||||
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
|
||||
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
|
||||
* The unique id of the box.
|
||||
*/
|
||||
public id: string;
|
||||
/**
|
||||
@@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable {
|
||||
parent.children.autoDispose(this);
|
||||
}
|
||||
|
||||
// Store "pointer" to this element.
|
||||
this.id = uuidv4();
|
||||
this.id = box.id;
|
||||
|
||||
// Create observables for all properties.
|
||||
this.type = box.type;
|
||||
@@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable {
|
||||
this.onCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method that should be called when this box is dropped somewhere. In derived classes
|
||||
* this method can send some actions to the server, or do some other work. In particular Field
|
||||
* will insert or reveal a column.
|
||||
*/
|
||||
public async afterDrop() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* The only method that derived classes need to implement. It should return a DOM element that
|
||||
* represents this box.
|
||||
@@ -134,12 +121,19 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts self and puts it into clipboard.
|
||||
* Copies self and puts it into clipboard.
|
||||
*/
|
||||
public async cutSelf() {
|
||||
public async copySelf() {
|
||||
[...this.root().traverse()].forEach(box => box?.cut.set(false));
|
||||
// Add this box as a json to clipboard.
|
||||
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts self and puts it into clipboard.
|
||||
*/
|
||||
public async cutSelf() {
|
||||
await this.copySelf();
|
||||
this.cut.set(true);
|
||||
}
|
||||
|
||||
@@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable {
|
||||
this.prop(key).set(boxDef[key]);
|
||||
}
|
||||
|
||||
// Add or delete any children that were removed or added.
|
||||
const myLength = this.children.get().length;
|
||||
const newLength = boxDef.children ? boxDef.children.length : 0;
|
||||
if (myLength > newLength) {
|
||||
this.children.splice(newLength, myLength - newLength);
|
||||
} else if (myLength < newLength) {
|
||||
for (let i = myLength; i < newLength; i++) {
|
||||
const toPush = boxDef.children![i];
|
||||
this.children.push(toPush && BoxModel.new(toPush, this));
|
||||
// First remove any children from the model that aren't in `boxDef`.
|
||||
const boxDefChildren = boxDef.children ?? [];
|
||||
const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));
|
||||
for (const child of this.children.get()) {
|
||||
if (!boxDefChildrenIds.has(child.id)) {
|
||||
child.removeSelf();
|
||||
}
|
||||
}
|
||||
|
||||
if (!boxDef.children) { return; }
|
||||
|
||||
// Update those that indices are the same.
|
||||
const min = Math.min(myLength, newLength);
|
||||
for (let i = 0; i < min; i++) {
|
||||
const atIndex = this.children.get()[i];
|
||||
const atIndexDef = boxDef.children[i];
|
||||
atIndex.update(atIndexDef);
|
||||
// Then add or update the children from `boxDef` to the model.
|
||||
const newChildren: BoxModel[] = [];
|
||||
const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));
|
||||
for (const boxDefChild of boxDefChildren) {
|
||||
if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {
|
||||
newChildren.push(BoxModel.new(boxDefChild, this));
|
||||
} else {
|
||||
const existingChild = modelChildrenById.get(boxDefChild.id)!;
|
||||
existingChild.update(boxDefChild);
|
||||
newChildren.push(existingChild);
|
||||
}
|
||||
}
|
||||
this.children.set(newChildren);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,12 +376,18 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
public canRemove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected onCreate() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export class LayoutModel extends BoxModel {
|
||||
public disableDeleteSection: Computed<boolean>;
|
||||
|
||||
constructor(
|
||||
box: FormLayoutNode,
|
||||
public parent: BoxModel | null,
|
||||
@@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel {
|
||||
public view: FormView
|
||||
) {
|
||||
super(box, parent, view);
|
||||
this.disableDeleteSection = Computed.create(this, use => {
|
||||
return use(this.children).filter(c => c.type === 'Section').length === 1;
|
||||
});
|
||||
}
|
||||
|
||||
public async save(clb?: Callback) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as css from './styles';
|
||||
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import * as css from 'app/client/components/Forms/styles';
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {not} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable, styled} from 'grainjs';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
export class ParagraphModel extends BoxModel {
|
||||
public edit = Observable.create(this, false);
|
||||
@@ -60,6 +62,10 @@ export class ParagraphModel extends BoxModel {
|
||||
}
|
||||
}
|
||||
|
||||
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
|
||||
return {id: uuidv4(), type: 'Paragraph', text, alignment};
|
||||
}
|
||||
|
||||
const cssTextArea = styled(textarea, `
|
||||
color: ${theme.inputFg};
|
||||
background-color: ${theme.mainPanelBg};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {BoxModel, LayoutModel} from 'app/client/components/Forms/Model';
|
||||
import {Paragraph} from 'app/client/components/Forms/Paragraph';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
const t = makeT('FormView');
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
@@ -13,14 +21,17 @@ const testId = makeTestId('test-forms-');
|
||||
* Component that renders a section of the form.
|
||||
*/
|
||||
export class SectionModel extends BoxModel {
|
||||
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
|
||||
super(box, parent, view);
|
||||
}
|
||||
|
||||
public override render(): HTMLElement {
|
||||
const children = this.children;
|
||||
return buildEditor({
|
||||
box: this,
|
||||
// Custom drag element that is little bigger and at the top of the section.
|
||||
drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))),
|
||||
// No way to remove section now.
|
||||
removeIcon: null,
|
||||
showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection),
|
||||
// Content is just a list of children.
|
||||
content: style.cssSection(
|
||||
// Wrap them in a div that mutes hover events.
|
||||
@@ -35,6 +46,18 @@ export class SectionModel extends BoxModel {
|
||||
style.cssPlusIcon('Plus'),
|
||||
buildMenu({
|
||||
box: this,
|
||||
customItems: [
|
||||
menus.menuItem(
|
||||
() => allCommands.insertFieldBefore.run({structure: 'Section'}),
|
||||
menus.menuIcon('Section'),
|
||||
t('Insert section above'),
|
||||
),
|
||||
menus.menuItem(
|
||||
() => allCommands.insertFieldAfter.run({structure: 'Section'}),
|
||||
menus.menuIcon('Section'),
|
||||
t('Insert section below'),
|
||||
),
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
@@ -79,19 +102,35 @@ export class SectionModel extends BoxModel {
|
||||
const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];
|
||||
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
|
||||
|
||||
// Remove each child of this section from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this section from the layout.
|
||||
this.removeSelf();
|
||||
|
||||
// Finally, remove the fields and save the changes to the layout.
|
||||
await this.parent?.save(async () => {
|
||||
// Remove the fields.
|
||||
if (fieldIdsToRemove.length > 0) {
|
||||
await this.view.viewSection.removeField(fieldIdsToRemove);
|
||||
}
|
||||
|
||||
// Remove each child of this section from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this section from the layout.
|
||||
this.removeSelf();
|
||||
});
|
||||
}
|
||||
|
||||
public canRemove() {
|
||||
return !((this.parent as LayoutModel).disableDeleteSection.get());
|
||||
}
|
||||
}
|
||||
|
||||
export function Section(...children: FormLayoutNode[]): FormLayoutNode {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Section',
|
||||
children: [
|
||||
Paragraph('### **Header**'),
|
||||
Paragraph('Description'),
|
||||
...children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const cssSectionItems = styled('div.hover_border', `
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
|
||||
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
|
||||
import {Paragraph} from 'app/client/components/Forms/Paragraph';
|
||||
import {Section} from 'app/client/components/Forms/Section';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
/**
|
||||
* Add any other element you whish to use in the form here.
|
||||
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
|
||||
@@ -18,6 +21,7 @@ export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
|
||||
case 'Placeholder': return Placeholder();
|
||||
case 'Separator': return Paragraph('---');
|
||||
case 'Header': return Paragraph('## **Header**', 'center');
|
||||
default: return {type};
|
||||
case 'Section': return Section();
|
||||
default: return {id: uuidv4(), type};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
|
||||
@@ -239,14 +239,6 @@ export const cssSelect = styled('select', `
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
pointer-events: none;
|
||||
|
||||
&-invalid {
|
||||
color: ${theme.inputInvalid};
|
||||
}
|
||||
&:has(option[value='']:checked) {
|
||||
font-style: italic;
|
||||
color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssFieldEditorContent = styled('div', `
|
||||
@@ -373,13 +365,22 @@ export const cssButtonGroup = styled('div', `
|
||||
`);
|
||||
|
||||
|
||||
export const cssIconLink = styled(bigBasicButtonLink, `
|
||||
export const cssSmallLinkButton = styled(basicButtonLink, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 26px;
|
||||
`);
|
||||
|
||||
&-standard {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
export const cssSmallButton = styled(basicButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 26px;
|
||||
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
&-warning {
|
||||
color: ${theme.controlPrimaryFg};
|
||||
@@ -391,45 +392,6 @@ export const cssIconLink = styled(bigBasicButtonLink, `
|
||||
background-color: #B8791B;
|
||||
border: none;
|
||||
}
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssSmallIconButton = styled(basicButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssIconButton = styled(bigBasicButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&-standard {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
}
|
||||
&-warning {
|
||||
color: ${theme.controlPrimaryFg};
|
||||
background-color: ${theme.toastWarningBg};
|
||||
border: none;
|
||||
}
|
||||
&-warning:hover {
|
||||
color: ${theme.controlPrimaryFg};
|
||||
background-color: #B8791B;
|
||||
border: none;
|
||||
}
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssMarkdownRendered = styled('div', `
|
||||
@@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', `
|
||||
cursor: pointer;
|
||||
}
|
||||
.${cssFieldEditor.className}-selected > &,
|
||||
.${cssFieldEditor.className}:hover > & {
|
||||
.${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {
|
||||
display: flex;
|
||||
}
|
||||
&-right {
|
||||
@@ -623,6 +585,124 @@ export const cssRemoveButton = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssShareMenu = styled('div', `
|
||||
color: ${theme.text};
|
||||
background-color: ${theme.popupBg};
|
||||
width: min(calc(100% - 16px), 400px);
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuHeader = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`);
|
||||
|
||||
export const cssShareMenuBody = styled('div', `
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 32px;
|
||||
padding: 0px 16px 24px 16px;
|
||||
min-height: 160px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuCloseButton = styled('div', `
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
--icon-color: ${theme.popupCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssShareMenuSectionHeading = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuHintText = styled('div', `
|
||||
color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
export const cssShareMenuSpinner = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: inherit;
|
||||
`);
|
||||
|
||||
export const cssShareMenuSectionButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuUrlBlock = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.inputReadonlyBg};
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuUrl = styled('input', `
|
||||
background: transparent;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
outline: none;
|
||||
`);
|
||||
|
||||
export const cssShareMenuCopyButton = styled(textButton, `
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
`);
|
||||
|
||||
export const cssShareMenuEmbedFormButton = styled(textButton, `
|
||||
font-weight: 500;
|
||||
`);
|
||||
|
||||
export const cssShareMenuCodeBlock = styled('div', `
|
||||
border-radius: 3px;
|
||||
background-color: ${theme.inputReadonlyBg};
|
||||
padding: 8px;
|
||||
`);
|
||||
|
||||
export const cssShareMenuCodeBlockButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`);
|
||||
|
||||
export const cssShareMenuCode = styled('textarea', `
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
resize: none;
|
||||
`);
|
||||
|
||||
export const cssFormDisabledOverlay = styled('div', `
|
||||
background-color: ${theme.widgetBg};
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
`);
|
||||
|
||||
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
|
||||
return [
|
||||
dom.onKeyDown({
|
||||
|
||||
@@ -129,6 +129,7 @@ async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSect
|
||||
...record,
|
||||
layoutSpec: JSON.stringify(viewSectionLayoutSpec),
|
||||
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()],
|
||||
shareOptions: '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ function newViewSectionAction(widget: IPageWidget, viewId: number) {
|
||||
*/
|
||||
export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
|
||||
return cloneDeepWith(layoutSpec, (val) => {
|
||||
if (typeof val === 'object') {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
if (mapIds[val.leaf]) {
|
||||
return {...val, leaf: mapIds[val.leaf]};
|
||||
}
|
||||
|
||||
@@ -321,10 +321,12 @@ const cssArrowContainer = styled('div', `
|
||||
|
||||
${sideSelectorChunk('top')} > & {
|
||||
bottom: -17px;
|
||||
margin: 0px 16px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('bottom')} > & {
|
||||
top: -14px;
|
||||
margin: 0px 16px;
|
||||
}
|
||||
|
||||
${sideSelectorChunk('right')} > & {
|
||||
|
||||
@@ -15,7 +15,6 @@ import split = require("lodash/split");
|
||||
|
||||
export interface ACItem {
|
||||
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
|
||||
// Note that items with empty cleanText are never suggested.
|
||||
cleanText: string;
|
||||
}
|
||||
|
||||
@@ -65,6 +64,19 @@ interface Word {
|
||||
pos: number; // Position of the word within the item where it occurred.
|
||||
}
|
||||
|
||||
export interface ACIndexOptions {
|
||||
/** The max number of items to suggest. Defaults to 50. */
|
||||
maxResults?: number;
|
||||
/**
|
||||
* Suggested matches in the same relative order as items, rather than by score.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
keepOrder?: boolean;
|
||||
/** Show items with an empty `cleanText`. Defaults to false. */
|
||||
showEmptyItems?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a search index. It doesn't currently support updates; when any values change, the
|
||||
* index needs to be rebuilt from scratch.
|
||||
@@ -75,11 +87,12 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
|
||||
// All words from _allItems, sorted.
|
||||
private _words: Word[];
|
||||
|
||||
private _maxResults = this._options.maxResults ?? 50;
|
||||
private _keepOrder = this._options.keepOrder ?? false;
|
||||
private _showEmptyItems = this._options.showEmptyItems ?? false;
|
||||
|
||||
// Creates an index for the given list of items.
|
||||
// The max number of items to suggest may be set using _maxResults (default is 50).
|
||||
// If _keepOrder is true, best matches will be suggested in the order they occur in items,
|
||||
// rather than order by best score.
|
||||
constructor(items: Item[], private _maxResults: number = 50, private _keepOrder = false) {
|
||||
constructor(items: Item[], private _options: ACIndexOptions = {}) {
|
||||
this._allItems = items.slice(0);
|
||||
|
||||
// Collects [word, occurrence, position] tuples for all words in _allItems.
|
||||
@@ -132,7 +145,9 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
|
||||
|
||||
// Append enough non-matching indices to reach maxResults.
|
||||
for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) {
|
||||
if (this._allItems[i].cleanText && !myMatches.has(i)) {
|
||||
if (myMatches.has(i)) { continue; }
|
||||
|
||||
if (this._allItems[i].cleanText || this._showEmptyItems) {
|
||||
itemIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type { IOption, IOptionFull } from 'popweasel';
|
||||
export { getOptionFull } from 'popweasel';
|
||||
|
||||
export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {
|
||||
matchTriggerElemWidth?: boolean;
|
||||
headerDom?(): DomArg<HTMLElement>;
|
||||
renderItem?(item: U): DomArg<HTMLElement>;
|
||||
}
|
||||
@@ -42,6 +43,14 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
|
||||
const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
|
||||
this.content = cssMenuWrap(
|
||||
dom('div',
|
||||
elem => {
|
||||
if (opt.matchTriggerElemWidth) {
|
||||
const style = elem.style;
|
||||
style.minWidth = _ctl.getTriggerElem().getBoundingClientRect().width + 'px';
|
||||
style.marginLeft = '0px';
|
||||
style.marginRight = '0px';
|
||||
}
|
||||
},
|
||||
{class: menuCssClass + ' grist-floating-menu'},
|
||||
cssMenu.cls(''),
|
||||
cssMenuExt.cls(''),
|
||||
@@ -113,7 +122,7 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
|
||||
private _doAction(value: T | null) {
|
||||
// If value is null, simply close the menu. This happens when pressing enter with no element
|
||||
// selected.
|
||||
if (value) { this._action(value); }
|
||||
if (value !== null) { this._action(value); }
|
||||
this._ctl.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {FormLayoutNode, patchLayoutSpec} from 'app/client/components/FormRenderer';
|
||||
import {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getHomeUrl} from 'app/client/models/AppModel';
|
||||
@@ -25,7 +25,13 @@ export class FormModelImpl extends Disposable implements FormModel {
|
||||
public readonly formLayout = Computed.create(this, this.form, (_use, form) => {
|
||||
if (!form) { return null; }
|
||||
|
||||
return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode;
|
||||
const layout = safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode | null;
|
||||
if (!layout) { throw new Error('invalid formLayoutSpec'); }
|
||||
|
||||
const patchedLayout = patchLayoutSpec(layout, new Set(Object.keys(form.formFieldsById).map(Number)));
|
||||
if (!patchedLayout) { throw new Error('invalid formLayoutSpec'); }
|
||||
|
||||
return patchedLayout;
|
||||
});
|
||||
public readonly submitting = Observable.create<boolean>(this, false);
|
||||
public readonly submitted = Observable.create<boolean>(this, false);
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
||||
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
|
||||
|
||||
isHiddenCol: ko.Computed<boolean>;
|
||||
isFormCol: ko.Computed<boolean>;
|
||||
|
||||
// Returns the rowModel for the referenced table, or null, if is not a reference column.
|
||||
refTable: ko.Computed<TableRec|null>;
|
||||
@@ -144,6 +145,11 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
|
||||
this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
|
||||
|
||||
this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId()));
|
||||
this.isFormCol = ko.pureComputed(() => (
|
||||
!this.isHiddenCol() &&
|
||||
this.pureType() !== 'Attachments' &&
|
||||
!this.isRealFormula()
|
||||
));
|
||||
|
||||
// Returns the rowModel for the referenced table, or null, if this is not a reference column.
|
||||
this.refTable = ko.pureComputed(() => {
|
||||
|
||||
@@ -130,7 +130,7 @@ function buildLocaleSelect(
|
||||
locale: l.code,
|
||||
cleanText: l.name.trim().toLowerCase(),
|
||||
})).sort(propertyCompare("label"));
|
||||
const acIndex = new ACIndexImpl<LocaleItem>(localeList, 200, true);
|
||||
const acIndex = new ACIndexImpl<LocaleItem>(localeList, {maxResults: 200, keepOrder: true});
|
||||
// AC select will show the value (in this case locale) not a label when something is selected.
|
||||
// To show the label - create another observable that will be in sync with the value, but
|
||||
// will contain text.
|
||||
|
||||
@@ -106,7 +106,9 @@ export class FormAPIImpl extends BaseAPI implements FormAPI {
|
||||
});
|
||||
} else {
|
||||
const {shareKey, tableId, colValues} = options;
|
||||
return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, {
|
||||
const url = new URL(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`);
|
||||
url.searchParams.set('utm_source', 'grist-forms');
|
||||
return this.requestJson(url.href, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({records: [{fields: colValues}]}),
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', {
|
||||
popupOptions: {
|
||||
attach: null,
|
||||
placement: 'bottom',
|
||||
placement: 'bottom-start',
|
||||
}
|
||||
}),
|
||||
]},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
|
||||
import {MappedFieldsConfig} from 'app/client/components/Forms/MappedFieldsConfig';
|
||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
||||
import {EmptyFilterState} from "app/client/components/LinkingState";
|
||||
import {RefSelect} from 'app/client/components/RefSelect';
|
||||
@@ -559,7 +559,7 @@ export class RightPanel extends Disposable {
|
||||
|
||||
dom.maybe(this._isForm, () => [
|
||||
cssSeparator(),
|
||||
dom.create(UnmappedFieldsConfig, activeSection),
|
||||
dom.create(MappedFieldsConfig, activeSection),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -996,19 +996,11 @@ export class RightPanel extends Disposable {
|
||||
const fieldBox = box as FieldModel;
|
||||
return use(fieldBox.field);
|
||||
});
|
||||
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
|
||||
|
||||
const hasText = Computed.create(owner, (use) => {
|
||||
const selectedBoxWithOptions = Computed.create(owner, (use) => {
|
||||
const box = use(selectedBox);
|
||||
if (!box) { return false; }
|
||||
switch (box.type) {
|
||||
case 'Submit':
|
||||
case 'Paragraph':
|
||||
case 'Label':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; }
|
||||
|
||||
return box;
|
||||
});
|
||||
|
||||
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
|
||||
@@ -1036,24 +1028,12 @@ export class RightPanel extends Disposable {
|
||||
testId('field-label'),
|
||||
),
|
||||
),
|
||||
// TODO: this is for V1 as it requires full cell editor here.
|
||||
// cssLabel(t("Default field value")),
|
||||
// cssRow(
|
||||
// cssTextInput(
|
||||
// fromKo(defaultField),
|
||||
// (val) => defaultField.setAndSave(val),
|
||||
// ),
|
||||
// ),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||
cssSeparator(),
|
||||
cssLabel(t("COLUMN TYPE")),
|
||||
cssSection(
|
||||
builder.buildSelectTypeDom(),
|
||||
),
|
||||
// V2 thing
|
||||
// cssSection(
|
||||
// builder.buildSelectWidgetDom(),
|
||||
// ),
|
||||
cssSection(
|
||||
builder.buildFormConfigDom(),
|
||||
),
|
||||
@@ -1062,36 +1042,44 @@ export class RightPanel extends Disposable {
|
||||
}),
|
||||
|
||||
// Box config
|
||||
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
|
||||
dom.maybe(selectedBoxWithOptions, (box) => [
|
||||
cssLabel(dom.text(box.type)),
|
||||
dom.maybe(hasText, () => [
|
||||
cssRow(
|
||||
cssTextArea(
|
||||
box.prop('text'),
|
||||
{onInput: true, autoGrow: true},
|
||||
dom.on('blur', () => box.save().catch(reportError)),
|
||||
{placeholder: t('Enter text')},
|
||||
),
|
||||
cssRow(
|
||||
cssTextArea(
|
||||
box.prop('text'),
|
||||
{onInput: true, autoGrow: true},
|
||||
dom.on('blur', () => box.save().catch(reportError)),
|
||||
{placeholder: t('Enter text')},
|
||||
),
|
||||
cssRow(
|
||||
buttonSelect(box.prop('alignment'), [
|
||||
{value: 'left', icon: 'LeftAlign'},
|
||||
{value: 'center', icon: 'CenterAlign'},
|
||||
{value: 'right', icon: 'RightAlign'}
|
||||
]),
|
||||
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
|
||||
)
|
||||
]),
|
||||
),
|
||||
cssRow(
|
||||
buttonSelect(box.prop('alignment'), [
|
||||
{value: 'left', icon: 'LeftAlign'},
|
||||
{value: 'center', icon: 'CenterAlign'},
|
||||
{value: 'right', icon: 'RightAlign'}
|
||||
]),
|
||||
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
|
||||
)
|
||||
]),
|
||||
|
||||
// Default.
|
||||
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
|
||||
cssLabel(t('Layout')),
|
||||
dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [
|
||||
buildFormConfigPlaceholder(),
|
||||
])
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
function buildFormConfigPlaceholder() {
|
||||
return cssFormConfigPlaceholder(
|
||||
cssFormConfigImg(),
|
||||
cssFormConfigMessage(
|
||||
cssFormConfigMessageTitle(t('No field selected')),
|
||||
dom('div', t('Select a field in the form widget to configure.')),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function disabledSection() {
|
||||
return cssOverlay(
|
||||
testId('panel-disabled-section'),
|
||||
@@ -1429,3 +1417,33 @@ const cssLinkInfoPre = styled("pre", `
|
||||
font-size: ${vars.smallFontSize};
|
||||
line-height: 1.2;
|
||||
`);
|
||||
|
||||
const cssFormConfigPlaceholder = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
margin-top: 32px;
|
||||
padding: 8px;
|
||||
`);
|
||||
|
||||
const cssFormConfigImg = styled('div', `
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-image: var(--icon-FormConfig);
|
||||
`);
|
||||
|
||||
const cssFormConfigMessage = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 8px;
|
||||
color: ${theme.text};
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssFormConfigMessageTitle = styled('div', `
|
||||
font-size: ${vars.largeFontSize};
|
||||
font-weight: 600;
|
||||
`);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
|
||||
import { theme, vars } from 'app/client/ui2018/cssVars';
|
||||
import { ACIndexImpl, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex";
|
||||
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
|
||||
normalizeText } from "app/client/lib/ACIndex";
|
||||
import { menuDivider } from "app/client/ui2018/menus";
|
||||
import { icon } from "app/client/ui2018/icons";
|
||||
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
|
||||
@@ -28,20 +29,54 @@ export interface IDropdownWithSearchOptions<T> {
|
||||
// list of options
|
||||
options: () => Array<IOption<T>>,
|
||||
|
||||
/** Called when the dropdown menu is disposed. */
|
||||
onClose?: () => void;
|
||||
|
||||
// place holder for the search input. Default to 'Search'
|
||||
placeholder?: string;
|
||||
|
||||
// popup options
|
||||
popupOptions?: IPopupOptions;
|
||||
|
||||
/** ACIndexOptions to use for indexing and searching items. */
|
||||
acOptions?: ACIndexOptions;
|
||||
|
||||
/**
|
||||
* If set, the width of the dropdown menu will be equal to that of
|
||||
* the trigger element.
|
||||
*/
|
||||
matchTriggerElemWidth?: boolean;
|
||||
}
|
||||
|
||||
export interface OptionItemParams<T> {
|
||||
/** Item label. Normalized and used by ACIndex for indexing and searching. */
|
||||
label: string;
|
||||
/** Item value. */
|
||||
value: T;
|
||||
/** Defaults to false. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* If true, marks this item as the "placeholder" item.
|
||||
*
|
||||
* The placeholder item is excluded from indexing, so it's label doesn't
|
||||
* match search inputs. However, it's still shown when the search input is
|
||||
* empty.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
export class OptionItem<T> implements ACItem, IOptionFull<T> {
|
||||
public cleanText: string = normalizeText(this.label);
|
||||
constructor(
|
||||
public label: string,
|
||||
public value: T,
|
||||
public disabled?: boolean
|
||||
) {}
|
||||
public label = this._params.label;
|
||||
public value = this._params.value;
|
||||
public disabled = this._params.disabled;
|
||||
public placeholder = this._params.placeholder;
|
||||
public cleanText = this.placeholder ? '' : normalizeText(this.label);
|
||||
|
||||
constructor(private _params: OptionItemParams<T>) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {
|
||||
@@ -52,7 +87,7 @@ export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): D
|
||||
);
|
||||
setPopupToFunc(
|
||||
elem,
|
||||
(ctl) => DropdownWithSearch<T>.create(null, ctl, options),
|
||||
(ctl) => (DropdownWithSearch<T>).create(null, ctl, options),
|
||||
popupOptions
|
||||
);
|
||||
};
|
||||
@@ -68,8 +103,8 @@ class DropdownWithSearch<T> extends Disposable {
|
||||
|
||||
constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {
|
||||
super();
|
||||
const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled));
|
||||
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems);
|
||||
const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params));
|
||||
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);
|
||||
this._items = Observable.create<OptionItem<T>[]>(this, acItems);
|
||||
this._highlightFunc = () => [];
|
||||
this._simpleList = this._buildSimpleList();
|
||||
@@ -77,6 +112,7 @@ class DropdownWithSearch<T> extends Disposable {
|
||||
this._update();
|
||||
// auto-focus the search input
|
||||
setTimeout(() => this._inputElem.focus(), 1);
|
||||
this._ctl.onDispose(() => _options.onClose?.());
|
||||
}
|
||||
|
||||
public get content(): HTMLElement {
|
||||
@@ -87,7 +123,11 @@ class DropdownWithSearch<T> extends Disposable {
|
||||
const action = this._action.bind(this);
|
||||
const headerDom = this._buildHeader.bind(this);
|
||||
const renderItem = this._buildItem.bind(this);
|
||||
return SimpleList<T>.create(this, this._ctl, this._items, action, {headerDom, renderItem});
|
||||
return (SimpleList<T>).create(this, this._ctl, this._items, action, {
|
||||
matchTriggerElemWidth: this._options.matchTriggerElemWidth,
|
||||
headerDom,
|
||||
renderItem,
|
||||
});
|
||||
}
|
||||
|
||||
private _buildHeader() {
|
||||
@@ -110,7 +150,9 @@ class DropdownWithSearch<T> extends Disposable {
|
||||
|
||||
private _buildItem(item: OptionItem<T>) {
|
||||
return [
|
||||
buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
|
||||
item.placeholder
|
||||
? cssPlaceholderItem(item.label)
|
||||
: buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
|
||||
testId('searchable-list-item'),
|
||||
];
|
||||
}
|
||||
@@ -125,7 +167,7 @@ class DropdownWithSearch<T> extends Disposable {
|
||||
private _action(value: T | null) {
|
||||
// If value is null, simply close the menu. This happens when pressing enter with no element
|
||||
// selected.
|
||||
if (value) {
|
||||
if (value !== null) {
|
||||
this._options.action(value);
|
||||
}
|
||||
this._ctl.close();
|
||||
@@ -171,3 +213,10 @@ const cssMenuDivider = styled(menuDivider, `
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
`);
|
||||
const cssPlaceholderItem = styled('div', `
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
|
||||
.${cssMenuItem.className}-sel > & {
|
||||
color: ${theme.menuItemSelectedFg};
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -76,6 +76,7 @@ export type IconName = "ChartArea" |
|
||||
"FontItalic" |
|
||||
"FontStrikethrough" |
|
||||
"FontUnderline" |
|
||||
"FormConfig" |
|
||||
"FunctionResult" |
|
||||
"GreenArrow" |
|
||||
"Grow" |
|
||||
@@ -232,6 +233,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"FontItalic",
|
||||
"FontStrikethrough",
|
||||
"FontUnderline",
|
||||
"FormConfig",
|
||||
"FunctionResult",
|
||||
"GreenArrow",
|
||||
"Grow",
|
||||
|
||||
@@ -983,6 +983,22 @@ export function isNarrowScreenObs(): Observable<boolean> {
|
||||
return _isNarrowScreenObs;
|
||||
}
|
||||
|
||||
export function isXSmallScreen() {
|
||||
return window.innerWidth < smallScreenWidth;
|
||||
}
|
||||
|
||||
let _isXSmallScreenObs: Observable<boolean>|undefined;
|
||||
|
||||
// Returns a singleton observable for whether the screen is an extra small one.
|
||||
export function isXSmallScreenObs(): Observable<boolean> {
|
||||
if (!_isXSmallScreenObs) {
|
||||
const obs = Observable.create<boolean>(null, isXSmallScreen());
|
||||
window.addEventListener('resize', () => obs.set(isXSmallScreen()));
|
||||
_isXSmallScreenObs = obs;
|
||||
}
|
||||
return _isXSmallScreenObs;
|
||||
}
|
||||
|
||||
export const cssHideForNarrowScreen = styled('div', `
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {cssMenuElem} from 'app/client/ui2018/menus';
|
||||
import {waitGrainObs} from 'app/common/gutil';
|
||||
import {MaybePromise} from 'app/plugin/gutil';
|
||||
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
|
||||
MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
|
||||
@@ -356,7 +357,7 @@ export interface ConfirmModalOptions {
|
||||
export function confirmModal(
|
||||
title: DomElementArg,
|
||||
btnText: DomElementArg,
|
||||
onConfirm: (dontShowAgain?: boolean) => Promise<void>,
|
||||
onConfirm: (dontShowAgain?: boolean) => MaybePromise<void>,
|
||||
options: ConfirmModalOptions = {},
|
||||
): void {
|
||||
const {
|
||||
@@ -383,7 +384,7 @@ export function confirmModal(
|
||||
),
|
||||
],
|
||||
saveLabel: btnText,
|
||||
saveFunc: () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
|
||||
saveFunc: async () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
|
||||
hideCancel,
|
||||
width: width ?? 'normal',
|
||||
extraButtons,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function buildCurrencyPicker(
|
||||
// Create a computed that will display 'Local currency' as a value and label
|
||||
// when `currency` is undefined.
|
||||
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel);
|
||||
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, 200, true);
|
||||
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, {maxResults: 200, keepOrder: true});
|
||||
return buildACSelect(owner,
|
||||
{
|
||||
acIndex, valueObs,
|
||||
|
||||
@@ -34,6 +34,7 @@ import * as UserType from 'app/client/widgets/UserType';
|
||||
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
|
||||
import { WidgetType } from 'app/common/widgetTypes';
|
||||
import { CellValue } from 'app/plugin/GristData';
|
||||
import { bundleChanges, Computed, Disposable, fromKo,
|
||||
dom as grainjsDom, makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
|
||||
@@ -129,9 +130,15 @@ export class FieldBuilder extends Disposable {
|
||||
|
||||
// Observable with a list of available types.
|
||||
this._availableTypes = Computed.create(this, (use) => {
|
||||
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
|
||||
const isFormula = use(this.origColumn.isFormula);
|
||||
const types: Array<IOptionFull<string>> = [];
|
||||
_.each(UserType.typeDefs, (def: any, key: string|number) => {
|
||||
if (isForm && key === 'Attachments') {
|
||||
// Attachments in forms are currently unsupported.
|
||||
return;
|
||||
}
|
||||
|
||||
const o: IOptionFull<string> = {
|
||||
value: key as string,
|
||||
label: def.label,
|
||||
|
||||
@@ -52,7 +52,10 @@ export function buildTZAutocomplete(
|
||||
) {
|
||||
// Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
|
||||
// fewer than 1000 in practice).
|
||||
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), 1000, true);
|
||||
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), {
|
||||
maxResults: 1000,
|
||||
keepOrder: true,
|
||||
});
|
||||
|
||||
// Only save valid time zones. If there is no selected item, we'll auto-select and save only
|
||||
// when there is a good match.
|
||||
|
||||
@@ -1684,6 +1684,38 @@ export const TelemetryContracts: TelemetryContracts = {
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedForm: {
|
||||
category: 'WidgetUsage',
|
||||
description: 'Triggered when a published form is submitted.',
|
||||
minimumTelemetryLevel: Level.full,
|
||||
retentionPeriod: 'indefinitely',
|
||||
metadataContracts: {
|
||||
docIdDigest: {
|
||||
description: 'A hash of the doc id.',
|
||||
dataType: 'string',
|
||||
},
|
||||
siteId: {
|
||||
description: 'The site id.',
|
||||
dataType: 'number',
|
||||
},
|
||||
siteType: {
|
||||
description: 'The site type.',
|
||||
dataType: 'string',
|
||||
},
|
||||
altSessionId: {
|
||||
description: 'A random, session-based identifier for the user that triggered this event.',
|
||||
dataType: 'string',
|
||||
},
|
||||
access: {
|
||||
description: 'The document access level of the user that triggered this event.',
|
||||
dataType: 'string',
|
||||
},
|
||||
userId: {
|
||||
description: 'The id of the user that triggered this event.',
|
||||
dataType: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
changedAccessRules: {
|
||||
category: 'AccessRules',
|
||||
description: 'Triggered when a change to access rules is saved.',
|
||||
@@ -1776,6 +1808,7 @@ export const TelemetryEvents = StringUnion(
|
||||
'publishedForm',
|
||||
'unpublishedForm',
|
||||
'visitedForm',
|
||||
'submittedForm',
|
||||
'changedAccessRules',
|
||||
);
|
||||
export type TelemetryEvent = typeof TelemetryEvents.type;
|
||||
|
||||
@@ -169,6 +169,13 @@ export function isEmptyList(value: CellValue): boolean {
|
||||
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.List;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a value (as received in a DocAction) represents an empty reference list.
|
||||
*/
|
||||
export function isEmptyReferenceList(value: CellValue): boolean {
|
||||
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.ReferenceList;
|
||||
}
|
||||
|
||||
function isNumber(v: CellValue) { return typeof v === 'number' || typeof v === 'boolean'; }
|
||||
function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; }
|
||||
function isBoolean(v: CellValue) { return typeof v === 'boolean' || v === 1 || v === 0; }
|
||||
@@ -344,6 +351,21 @@ export function isValidRuleValue(value: CellValue|undefined) {
|
||||
return value === null || typeof value === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `value` is blank.
|
||||
*
|
||||
* Blank values include `null`, (trimmed) empty string, and 0-length lists and
|
||||
* reference lists.
|
||||
*/
|
||||
export function isBlankValue(value: CellValue) {
|
||||
return (
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim().length === 0) ||
|
||||
isEmptyList(value) ||
|
||||
isEmptyReferenceList(value)
|
||||
);
|
||||
}
|
||||
|
||||
export type RefListValue = [GristObjCode.List, ...number[]]|null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, Impor
|
||||
TransformRuleMap} from 'app/common/ActiveDocAPI';
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions';
|
||||
import {isBlankValue} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {localTimestampToUTC} from 'app/common/RelativeDates';
|
||||
import {DocStateComparison} from 'app/common/UserAPI';
|
||||
@@ -667,12 +668,6 @@ export class ActiveDocImport {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function that returns true if a given cell is blank (i.e. null or empty).
|
||||
function isBlank(value: CellValue): boolean {
|
||||
return value === null || (typeof value === 'string' && value.trim().length === 0);
|
||||
}
|
||||
|
||||
|
||||
// Helper function that returns new `colIds` with import prefixes stripped.
|
||||
function stripPrefixes(colIds: string[]): string[] {
|
||||
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?
|
||||
@@ -691,13 +686,13 @@ type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;
|
||||
function getMergeFunction({type}: MergeStrategy): MergeFunction {
|
||||
switch (type) {
|
||||
case 'replace-with-nonblank-source': {
|
||||
return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal;
|
||||
return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal;
|
||||
}
|
||||
case 'replace-all-fields': {
|
||||
return (srcVal, _destVal) => srcVal;
|
||||
}
|
||||
case 'replace-blank-fields-only': {
|
||||
return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal;
|
||||
return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal;
|
||||
}
|
||||
default: {
|
||||
// Normally, we should never arrive here. If we somehow do, throw an error.
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
UserAction
|
||||
} from 'app/common/DocActions';
|
||||
import {DocData} from 'app/common/DocData';
|
||||
import {extractTypeFromColType, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
|
||||
import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
|
||||
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
|
||||
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
|
||||
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
|
||||
@@ -573,6 +573,9 @@ export class DocWorkerApi {
|
||||
validateCore(RecordsPost, req, body);
|
||||
const ops = await getTableOperations(req, activeDoc);
|
||||
const records = await ops.create(body.records);
|
||||
if (req.query.utm_source === 'grist-forms') {
|
||||
activeDoc.logTelemetryEvent(docSessionFromRequest(req), 'submittedForm');
|
||||
}
|
||||
res.json({records});
|
||||
})
|
||||
);
|
||||
@@ -1422,7 +1425,7 @@ export class DocWorkerApi {
|
||||
.filter(f => {
|
||||
const col = Tables_column.getRecord(f.colRef);
|
||||
// Formulas and attachments are currently unsupported.
|
||||
return col && !(col.isFormula && col.formula) && col.type !== 'Attachment';
|
||||
return col && !(col.isFormula && col.formula) && col.type !== 'Attachments';
|
||||
});
|
||||
|
||||
let {layoutSpec: formLayoutSpec} = section;
|
||||
@@ -1474,7 +1477,8 @@ export class DocWorkerApi {
|
||||
if (!refTableId || !refColId) { return () => []; }
|
||||
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
|
||||
|
||||
return await getTableValues(refTableId, refColId);
|
||||
const values = await getTableValues(refTableId, refColId);
|
||||
return values.filter(([_id, value]) => !isBlankValue(value));
|
||||
};
|
||||
|
||||
const formFields = await Promise.all(fields.map(async (field) => {
|
||||
|
||||
Reference in New Issue
Block a user