(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
pull/911/head
George Gevoian 2 months ago
parent aff9c7075c
commit 418681915e

@ -1,20 +1,24 @@
import * as css from 'app/client/components/FormRendererCss'; import * as css from 'app/client/components/FormRendererCss';
import {FormField} from 'app/client/ui/FormAPI'; import {FormField} from 'app/client/ui/FormAPI';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; 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 {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 {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. * A node in a recursive, tree-like hierarchy comprising the layout of a form.
*/ */
export interface FormLayoutNode { export interface FormLayoutNode {
/** Unique ID of the node. Used by FormView. */
id: string;
type: FormLayoutNodeType; type: FormLayoutNodeType;
children?: Array<FormLayoutNode>; children?: Array<FormLayoutNode>;
// Unique ID of the field. Used only in the Form widget.
id?: string;
// Used by Layout. // Used by Layout.
submitText?: string; submitText?: string;
successURL?: string; successURL?: string;
@ -55,6 +59,24 @@ export interface FormRendererContext {
error: Observable<string|null>; 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. * A renderer for a form layout.
* *
@ -68,20 +90,35 @@ export interface FormRendererContext {
* TODO: merge the two implementations or factor out what's common. * TODO: merge the two implementations or factor out what's common.
*/ */
export abstract class FormRenderer extends Disposable { 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; const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
return new Renderer(layoutNode, context); return new Renderer(layoutNode, context, parent);
} }
protected children: FormRenderer[]; protected children: FormRenderer[];
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) { constructor(
protected layoutNode: FormLayoutNode,
protected context: FormRendererContext,
protected parent?: FormRenderer
) {
super(); super();
this.children = (this.layoutNode.children ?? []).map((child) => 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; 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 { class LabelRenderer extends FormRenderer {
@ -122,30 +159,45 @@ class SubmitRenderer extends FormRenderer {
public render() { public render() {
return [ return [
css.error(dom.text(use => use(this.context.error) ?? '')), css.error(dom.text(use => use(this.context.error) ?? '')),
css.submit( css.submitButtons(
dom('input', css.resetButton(
'Reset',
dom.boolAttr('disabled', this.context.disabled), dom.boolAttr('disabled', this.context.disabled),
{ {type: 'button'},
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit'
},
dom.on('click', () => { dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected. return confirmModal(
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))'); 'Are you sure you want to reset your form?',
Array.from(lists).forEach(function(list) { 'Reset',
// If the form has at least one checkbox, make it required. () => this.parent?.reset()
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');
});
}), }),
) 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 { 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; const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
return new Renderer(); this.renderer = this.autoDispose(new Renderer(field, context));
} }
public render() { public render() {
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null; return css.field(this.renderer.render());
if (!field) { return null; } }
const renderer = this.build(field); public reset() {
return css.field(renderer.render(field, this.context)); this.renderer.resetInput();
} }
} }
abstract class BaseFieldRenderer { abstract class BaseFieldRenderer extends Disposable {
public render(field: FormField, context: FormRendererContext) { public constructor(protected field: FormField, protected context: FormRendererContext) {
super();
}
public render() {
return css.field( return css.field(
this.label(field), this.label(),
dom('div', this.input(field, context)), dom('div', this.input()),
); );
} }
public name(field: FormField) { public name() {
return field.colId; return this.field.colId;
} }
public label(field: FormField) { public label() {
return dom('label', return dom('label',
css.label.cls(''), css.label.cls(''),
css.label.cls('-required', Boolean(field.options.formRequired)), css.label.cls('-required', Boolean(this.field.options.formRequired)),
{for: this.name(field)}, {for: this.name()},
field.question, this.field.question,
); );
} }
public abstract input(field: FormField, context: FormRendererContext): DomContents; public abstract input(): DomContents;
public abstract resetInput(): void;
} }
class TextRenderer extends BaseFieldRenderer { class TextRenderer extends BaseFieldRenderer {
public input(field: FormField) { protected type = 'text';
return dom('input', { private _value = Observable.create(this, '');
type: 'text',
name: this.name(field), public input() {
required: field.options.formRequired, 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)),
);
} }
}
class DateRenderer extends BaseFieldRenderer { public resetInput(): void {
public input(field: FormField) { this._value.set('');
return dom('input', {
type: 'date',
name: this.name(field),
required: field.options.formRequired,
});
} }
} }
class DateTimeRenderer extends BaseFieldRenderer { class DateRenderer extends TextRenderer {
public input(field: FormField) { protected type = 'date';
return dom('input', { }
type: 'datetime-local',
name: this.name(field), class DateTimeRenderer extends TextRenderer {
required: field.options.formRequired, protected type = 'datetime-local';
});
}
} }
export const SELECT_PLACEHOLDER = 'Select...';
class ChoiceRenderer extends BaseFieldRenderer { class ChoiceRenderer extends BaseFieldRenderer {
public input(field: FormField) { protected value = Observable.create<string>(this, '');
const choices: Array<string|null> = field.options.choices || []; private _choices: string[];
// Insert empty option. private _selectElement: HTMLElement;
choices.unshift(null); private _ctl?: PopupControl<IPopupOptions>;
return css.select(
{name: this.name(field), required: field.options.formRequired}, public constructor(field: FormField, context: FormRendererContext) {
choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT)) 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 { class BoolRenderer extends BaseFieldRenderer {
public render(field: FormField) { protected checked = Observable.create<boolean>(this, false);
public render() {
return css.field( return css.field(
dom('div', this.input(field)), dom('div', this.input()),
); );
} }
public input(field: FormField) { public input() {
return css.toggle( return css.toggle(
css.label.cls('-required', Boolean(field.options.formRequired)), dom('input',
dom('input', { dom.prop('checked', this.checked),
type: 'checkbox', dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
name: this.name(field), {
value: '1', type: 'checkbox',
required: field.options.formRequired, name: this.name(),
}), value: '1',
required: this.field.options.formRequired,
},
),
css.gristSwitch( css.gristSwitch(
css.gristSwitchSlider(), css.gristSwitchSlider(),
css.gristSwitchCircle(), 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 { class ChoiceListRenderer extends BaseFieldRenderer {
public input(field: FormField) { protected checkboxes: MutableObsArray<{
const choices: string[] = field.options.choices ?? []; label: string;
const required = field.options.formRequired; 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( return css.checkboxList(
dom.cls('grist-checkbox-list'), dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)), dom.cls('required', Boolean(required)),
{name: this.name(field), required}, {name: this.name(), required},
choices.map(choice => css.checkbox( dom.forEach(this.checkboxes, (checkbox) =>
dom('input', { css.checkbox(
type: 'checkbox', dom('input',
name: `${this.name(field)}[]`, dom.prop('checked', checkbox.checked),
value: choice, dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
}), {
dom('span', choice), 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 { class RefListRenderer extends BaseFieldRenderer {
public input(field: FormField) { protected checkboxes: MutableObsArray<{
const choices: [number, CellValue][] = field.refValues ?? []; 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. // 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. // Support for 30 choices. TODO: make limit dynamic.
choices.splice(30); references.splice(30);
const required = field.options.formRequired; 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( return css.checkboxList(
dom.cls('grist-checkbox-list'), dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)), dom.cls('required', Boolean(required)),
{name: this.name(field), required}, {name: this.name(), required},
choices.map(choice => css.checkbox( dom.forEach(this.checkboxes, (checkbox) =>
dom('input', { css.checkbox(
type: 'checkbox', dom('input',
'data-grist-type': field.type, dom.prop('checked', checkbox.checked),
name: `${this.name(field)}[]`, dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
value: String(choice[0]), {
}), type: 'checkbox',
dom('span', String(choice[1] ?? '')), '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 { class RefRenderer extends BaseFieldRenderer {
public input(field: FormField) { protected value = Observable.create(this, '');
const choices: [number|string, CellValue][] = field.refValues ?? []; 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. // Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices. TODO: make limit dynamic. // Support for 1000 choices. TODO: make limit dynamic.
choices.splice(1000); choices.splice(1000);
// Insert empty option. return css.hybridSelect(
choices.unshift(['', CHOOSE_TEXT]); this._selectElement = css.select(
return css.select( {
{ name: this.name(),
name: this.name(field), 'data-grist-type': this.field.type,
'data-grist-type': field.type, required: this.field.options.formRequired,
required: field.options.formRequired, },
}, dom.prop('value', this.value),
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))), 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 = { 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'; import {styled} from 'grainjs';
export const label = styled('div', ` export const label = styled('div', `
@ -38,7 +39,36 @@ export const columns = styled('div', `
gap: 4px; 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; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -52,11 +82,18 @@ export const submit = styled('div', `
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
line-height: inherit; line-height: inherit;
outline-color: ${vars.primaryBgHover};
} }
& input[type="submit"]:hover { & input[type="submit"]:hover {
border-color: ${vars.primaryBgHover}; border-color: ${vars.primaryBgHover};
background-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. // TODO: break up into multiple variables, one for each field type.
@ -72,12 +109,10 @@ export const field = styled('div', `
padding: 4px 8px; padding: 4px 8px;
border: 1px solid ${colors.darkGrey}; border: 1px solid ${colors.darkGrey};
border-radius: 3px; border-radius: 3px;
outline: none; outline-color: ${vars.primaryBgHover};
} }
& input[type="text"] { & input[type="text"] {
font-size: 13px; font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
line-height: inherit; line-height: inherit;
width: 100%; width: 100%;
color: ${colors.dark}; color: ${colors.dark};
@ -101,6 +136,9 @@ export const field = styled('div', `
margin-right: 8px; margin-right: 8px;
vertical-align: baseline; vertical-align: baseline;
} }
& input[type="checkbox"]:focus {
outline-color: ${vars.primaryBgHover};
}
& input[type="checkbox"]:checked:enabled, & input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled { & input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg}; --color: ${vars.primaryBg};
@ -171,11 +209,19 @@ export const toggle = styled('label', `
& input[type='checkbox'] { & input[type='checkbox'] {
position: absolute; position: absolute;
} }
& input[type='checkbox']:focus {
outline: none;
}
& > span { & > span {
margin-left: 8px; margin-left: 8px;
} }
`); `);
export const toggleLabel = styled('span', `
font-size: 13px;
font-weight: 700;
`);
export const gristSwitchSlider = styled('div', ` export const gristSwitchSlider = styled('div', `
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
@ -185,8 +231,8 @@ export const gristSwitchSlider = styled('div', `
bottom: 0; bottom: 0;
background-color: #ccc; background-color: #ccc;
border-radius: 17px; border-radius: 17px;
-webkit-transition: .4s; -webkit-transition: background-color .4s;
transition: .4s; transition: background-color .4s;
&:hover { &:hover {
box-shadow: 0 0 1px #2196F3; box-shadow: 0 0 1px #2196F3;
@ -203,8 +249,8 @@ export const gristSwitchCircle = styled('div', `
bottom: 2px; bottom: 2px;
background-color: white; background-color: white;
border-radius: 17px; border-radius: 17px;
-webkit-transition: .4s; -webkit-transition: transform .4s;
transition: .4s; transition: transform .4s;
`); `);
export const gristSwitch = styled('div', ` export const gristSwitch = styled('div', `
@ -214,6 +260,11 @@ export const gristSwitch = styled('div', `
display: inline-block; display: inline-block;
flex: none; flex: none;
input:focus + & > .${gristSwitchSlider.className} {
outline: 2px solid ${vars.primaryBgHover};
outline-offset: 1px;
}
input:checked + & > .${gristSwitchSlider.className} { input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg}; 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', ` export const select = styled('select', `
position: absolute;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
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; padding: 4px 8px;
border-radius: 3px; border-radius: 3px;
border: 1px solid ${colors.darkGrey}; border: 1px solid ${colors.darkGrey};
font-size: 13px; font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
background: white; background: white;
line-height: inherit; line-height: inherit;
height: 27px; height: 27px;
flex: auto; flex: auto;
width: 100%; 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 * as menus from 'app/client/ui2018/menus';
import {inlineStyle, not} from 'app/common/gutil'; import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-'); 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 fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get()); 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 () => { 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) { if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove); 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 { export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'}; return {id: uuidv4(), type: 'Placeholder'};
} }
export function Columns(): FormLayoutNode { export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]}; return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]};
} }
const cssPlaceholder = styled('div', ` const cssPlaceholder = styled('div', `

@ -7,7 +7,7 @@ import {makeT} from 'app/client/lib/localization';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons'; 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 testId = makeTestId('test-forms-');
const t = makeT('FormView.Editor'); const t = makeT('FormView.Editor');
@ -27,9 +27,13 @@ interface Props {
*/ */
click?: (ev: MouseEvent, box: BoxModel) => void, 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. * Custom remove button rendered atop overlay.
*/ */
@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
} }
await box.save(async () => { 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'), testId('element'),
dom.attr('data-box-model', String(box.type)), dom.attr('data-box-model', String(box.type)),
dom.maybe(overlay, () => style.cssSelectedOverlay()), dom.maybe(overlay, () => style.cssSelectedOverlay()),
// Custom icons for removing. dom.maybe(props.showRemoveButton ?? true, () => [
props.removeIcon === null || props.removeButton ? null : props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton), ]),
props.removeButton ?? null,
...args, ...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 {buildEditor} from 'app/client/components/Forms/Editor';
import {FormView} from 'app/client/components/Forms/FormView'; import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; 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 {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox'; import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars'; import {colors} from 'app/client/ui2018/cssVars';
import {isBlankValue} from 'app/common/gristTypes';
import {Constructor, not} from 'app/common/gutil'; import {Constructor, not} from 'app/common/gutil';
import { import {
BindableValue, 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 { 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). // 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) => { const save = (value: string) => {
@ -287,20 +276,14 @@ class TextModel extends Question {
class ChoiceModel extends Question { class ChoiceModel extends Question {
protected choices: Computed<string[]> = Computed.create(this, use => { protected choices: Computed<string[]> = Computed.create(this, use => {
// Read choices from field. // 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. // Make sure it is an array of strings.
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) { if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return []; 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 { public renderInput(): HTMLElement {
@ -309,21 +292,27 @@ class ChoiceModel extends Question {
{tabIndex: "-1"}, {tabIndex: "-1"},
ignoreClick, ignoreClick,
dom.prop('name', use => use(use(field).colId)), 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 { 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() { public renderInput() {
const field = this.model.field; const field = this.model.field;
return dom('div', return dom('div',
dom.prop('name', use => use(use(field).colId)), 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)), squareCheckbox(observable(false)),
choice choice
)), )),
dom.maybe(use => use(this.choices).length === 0, () => [ dom.maybe(use => use(this._choices).length === 0, () => [
dom('div', 'No choices defined'), dom('div', 'No choices defined'),
]), ]),
); );
@ -382,22 +371,22 @@ class DateTimeModel extends Question {
} }
class RefListModel extends Question { class RefListModel extends Question {
protected choices = this._subscribeForChoices(); protected options = this._getOptions();
public renderInput() { public renderInput() {
return dom('div', return dom('div',
dom.prop('name', this.model.colId), dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel( dom.forEach(this.options, (option) => css.cssCheckboxLabel(
squareCheckbox(observable(false)), squareCheckbox(observable(false)),
String(choice[1] ?? '') option.label,
)), )),
dom.maybe(use => use(this.choices).length === 0, () => [ dom.maybe(use => use(this.options).length === 0, () => [
dom('div', 'No choices defined'), dom('div', 'No values in show column of referenced table'),
]), ]),
) as HTMLElement; ) as HTMLElement;
} }
private _subscribeForChoices() { private _getOptions() {
const tableId = Computed.create(this, use => { const tableId = Computed.create(this, use => {
const refTable = use(use(this.model.column).refTable); const refTable = use(use(this.model.column).refTable);
return refTable ? use(refTable.tableId) : ''; return refTable ? use(refTable.tableId) : '';
@ -411,27 +400,23 @@ class RefListModel extends Question {
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId); const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
return Computed.create(this, use => { return Computed.create(this, use => {
const unsorted = use(observer); return use(observer)
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); .filter(([_id, value]) => !isBlankValue(value))
return unsorted.slice(0, 50); // TODO: pagination or a waning .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 { 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() { public renderInput() {
return css.cssSelect( return css.cssSelect(
{tabIndex: "-1"}, {tabIndex: "-1"},
ignoreClick, ignoreClick,
dom.prop('name', this.model.colId), 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 BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor'; 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 * as components from 'app/client/components/Forms/elements';
import {NewBox} from 'app/client/components/Forms/Menu'; import {NewBox} from 'app/client/components/Forms/Menu';
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model'; 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 {logTelemetryEvent} from 'app/client/lib/telemetry';
import DataTableModel from 'app/client/models/DataTableModel'; import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {ShareRec} from 'app/client/models/entities/ShareRec'; import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset'; 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 {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons'; 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 {confirmModal} from 'app/client/ui2018/modals';
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms'; import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {isOwner} from 'app/common/roles'; import {isOwner} from 'app/common/roles';
@ -31,6 +34,7 @@ import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid'; import {v4 as uuidv4} from 'uuid';
import * as ko from 'knockout'; import * as ko from 'knockout';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const t = makeT('FormView'); const t = makeT('FormView');
@ -42,6 +46,7 @@ export class FormView extends Disposable {
public viewSection: ViewSectionRec; public viewSection: ViewSectionRec;
public selectedBox: Computed<BoxModel | null>; public selectedBox: Computed<BoxModel | null>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null; public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableDeleteSection: Computed<boolean>;
protected sortedRows: SortedRowSet; protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel; protected tableModel: DataTableModel;
@ -49,17 +54,20 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>; protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>; protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>; private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel; private _root: BoxModel;
private _savedLayout: any; private _savedLayout: any;
private _saving: boolean = false; private _saving: boolean = false;
private _url: Computed<string>; private _previewUrl: Computed<string>;
private _copyingLink: Observable<boolean>;
private _pageShare: Computed<ShareRec | null>; private _pageShare: Computed<ShareRec | null>;
private _remoteShare: AsyncComputed<{key: string}|null>; private _remoteShare: AsyncComputed<{key: string}|null>;
private _isFork: Computed<boolean>;
private _published: Computed<boolean>; private _published: Computed<boolean>;
private _showPublishedMessage: Observable<boolean>; private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean; private _isOwner: boolean;
private _openingForm: Observable<boolean>;
private _formElement: HTMLElement;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); 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.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 => { this._autoLayout = Computed.create(this, use => {
// If the layout is already there, don't do anything. const fields = use(this._formFields);
const existing = use(this.viewSection.layoutSpecObj); const layout = use(this.viewSection.layoutSpecObj);
if (!existing || !existing.id) { if (!layout || !layout.id) {
const fields = use(use(this.viewSection.viewFields).getObservable());
return this._formTemplate(fields); 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>) => { this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
@ -166,12 +181,7 @@ export class FormView extends Disposable {
copy: () => { copy: () => {
const selected = this.selectedBox.get(); const selected = this.selectedBox.get();
if (!selected) { return; } if (!selected) { return; }
// Add this box as a json to clipboard. selected.copySelf().catch(reportError);
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
}, },
cut: () => { cut: () => {
const selected = this.selectedBox.get(); const selected = this.selectedBox.get();
@ -179,7 +189,7 @@ export class FormView extends Disposable {
selected.cutSelf().catch(reportError); selected.cutSelf().catch(reportError);
}, },
paste: () => { paste: () => {
const doPast = async () => { const doPaste = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText()); const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; } if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) { if (!this.selectedBox.get()) {
@ -187,13 +197,14 @@ export class FormView extends Disposable {
} else { } else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard)); this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
} }
// Remove the original box from the clipboard. const maybeCutBox = this._root.find(boxInClipboard.id);
const cut = this._root.find(boxInClipboard.id); if (maybeCutBox?.cut.get()) {
cut?.removeSelf(); maybeCutBox.removeSelf();
}
await this._root.save(); await this._root.save();
await navigator.clipboard.writeText(''); await navigator.clipboard.writeText('');
}; };
doPast().catch(reportError); doPaste().catch(reportError);
}, },
nextField: () => { nextField: () => {
const current = this.selectedBox.get(); const current = this.selectedBox.get();
@ -242,7 +253,7 @@ export class FormView extends Disposable {
}, },
clearValues: () => { clearValues: () => {
const selected = this.selectedBox.get(); const selected = this.selectedBox.get();
if (!selected) { return; } if (!selected || selected.canRemove?.() === false) { return; }
keyboardActions.nextField(); keyboardActions.nextField();
this.bundle(async () => { this.bundle(async () => {
await selected.deleteSelf(); await selected.deleteSelf();
@ -267,6 +278,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError); this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
} else { } else {
selected.insertBefore(components.defaultElement(what.structure)); selected.insertBefore(components.defaultElement(what.structure));
this.save().catch(reportError);
} }
}, },
insertField: (what: NewBox) => { insertField: (what: NewBox) => {
@ -287,6 +299,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError); this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
} else { } else {
selected.insertAfter(components.defaultElement(what.structure)); selected.insertAfter(components.defaultElement(what.structure));
this.save().catch(reportError);
} }
}, },
showColumns: (colIds: string[]) => { showColumns: (colIds: string[]) => {
@ -299,6 +312,7 @@ export class FormView extends Disposable {
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef); const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; } if (!field) { continue; }
const box = { const box = {
id: uuidv4(),
leaf: fieldRef, leaf: fieldRef,
type: 'Field' as FormLayoutNodeType, type: 'Field' as FormLayoutNodeType,
}; };
@ -332,7 +346,7 @@ export class FormView extends Disposable {
hideFields: keyboardActions.hideFields, hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus)); }, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => { this._previewUrl = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc); const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; } if (!doc) { return ''; }
const url = urlState().makeUrl({ const url = urlState().makeUrl({
@ -344,8 +358,6 @@ export class FormView extends Disposable {
return url; return url;
}); });
this._copyingLink = Observable.create(this, false);
this._pageShare = Computed.create(this, use => { this._pageShare = Computed.create(this, use => {
const page = use(use(this.viewSection.view).page); const page = use(use(this.viewSection.view).page);
if (!page) { return null; } 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 => { this._published = Computed.create(this, use => {
const isFork = use(this._isFork);
if (isFork) { return false; }
const pageShare = use(this._pageShare); const pageShare = use(this._pageShare);
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty); const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
const validShare = pageShare && remoteShare; const validShare = pageShare && remoteShare;
@ -384,6 +404,8 @@ export class FormView extends Disposable {
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get()); this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
this._openingForm = Observable.create(this, false);
// Last line, build the dom. // Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom()); this.viewPane = this.autoDispose(this.buildDom());
} }
@ -401,7 +423,7 @@ export class FormView extends Disposable {
testId('editor'), testId('editor'),
style.cssFormEditBody( style.cssFormEditBody(
style.cssFormContainer( style.cssFormContainer(
dom.forEach(this._root.children, (child) => { this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
if (!child) { if (!child) {
return dom('div', 'Empty node'); return dom('div', 'Empty node');
} }
@ -410,11 +432,12 @@ export class FormView extends Disposable {
throw new Error('Element is not an HTMLElement'); throw new Error('Element is not an HTMLElement');
} }
return element; return element;
}), })),
this._buildPublisher(), 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. // And add it into the layout.
this.selectedBox.set(insert({ this.selectedBox.set(insert({
id: uuidv4(),
leaf: fieldRef, leaf: fieldRef,
type: 'Field' type: 'Field'
})); }));
@ -612,67 +636,90 @@ export class FormView extends Disposable {
private _buildPublisher() { private _buildPublisher() {
return style.cssSwitcher( return style.cssSwitcher(
this._buildSwitcherMessage(), this._buildNotifications(),
style.cssButtonGroup( style.cssButtonGroup(
style.cssSmallIconButton( style.cssSmallButton(
style.cssIconButton.cls('-frameless'), style.cssSmallButton.cls('-frameless'),
icon('Revert'), icon('Revert'),
testId('reset'), 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.style('margin-right', 'auto'), // move it to the left
dom.on('click', () => { 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( dom.domComputed(this._published, published => {
icon('FieldAttachment'), if (published) {
testId('link'), return style.cssSmallButton(
dom('div', 'Copy Link'), testId('view'),
dom.prop('disabled', this._copyingLink), 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)), dom.show(use => this._isOwner && use(this._published)),
dom.on('click', async (_event, element) => { elem => {
try { setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
this._copyingLink.set(true); ...defaultMenuOptions,
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({ placement: 'top-end',
"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 => { dom.domComputed(use => {
const isFork = use(this._isFork);
const published = use(this._published);
return published return published
? style.cssIconButton( ? style.cssSmallButton(
dom('div', 'Unpublish'), dom('div', t('Unpublish')),
dom.show(this._isOwner), dom.show(this._isOwner),
style.cssIconButton.cls('-warning'), style.cssSmallButton.cls('-warning'),
dom.on('click', () => this._handleClickUnpublish()), dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'), testId('unpublish'),
) )
: style.cssIconButton( : style.cssSmallButton(
dom('div', 'Publish'), 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), dom.show(this._isOwner),
cssButton.cls('-primary'), cssButton.cls('-primary'),
dom.on('click', () => this._handleClickPublish()), 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(); const share = this._pageShare.get();
if (!share) { if (!share) {
throw new Error('Unable to get form link: form is not published'); 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 dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage( return style.cssSwitcherMessage(
style.cssSwitcherMessageBody( style.cssSwitcherMessageBody(
@ -726,29 +905,24 @@ export class FormView extends Disposable {
/** /**
* Generates a form template based on the fields in the view section. * 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 => { const boxes: FormLayoutNode[] = fields.map(f => {
return { return {
id: uuidv4(),
type: 'Field', type: 'Field',
leaf: f.id() leaf: f.id(),
} as FormLayoutNode; };
}); });
const section = { const section = components.Section(...boxes);
type: 'Section',
children: [
{type: 'Paragraph', text: SECTION_TITLE},
{type: 'Paragraph', text: SECTION_DESC},
...boxes,
],
};
return { return {
id: uuidv4(),
type: 'Layout', type: 'Layout',
children: [ children: [
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section, 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. // 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 toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
const toAdd = this.viewSection.table().columns().peek().filter(c => { const toAdd = this.viewSection.table().columns().peek()
// If hidden than no. .filter(c => c.isFormCol())
if (c.isHiddenCol()) { return false; } .sort((a, b) => a.parentPos() - b.parentPos());
// 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 colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id()); const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
const parentId = colRef.map(() => this.viewSection.id()); const parentId = colRef.map(() => this.viewSection.id());
@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents);
// Default values when form is reset. // Default values when form is reset.
const FORM_TITLE = "## **Form Title**"; const FORM_TITLE = "## **Form Title**";
const FORM_DESC = "Your form description goes here."; 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 * 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. * the ability to drag and drop fields onto the form.
*/ */
export class UnmappedFieldsConfig extends Disposable { export class MappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) { constructor(private _section: ViewSectionRec) {
super(); super();
@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
return []; return [];
} }
const fields = new Set(this._section.viewFields().map(f => f.colId()).all()); 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 => ({ return cols.map(col => ({
col, col,
selected: Observable.create(null, false), selected: Observable.create(null, false),
@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
if (this._section.isDisposed()) { if (this._section.isDisposed()) {
return []; 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 => ({ return cols.map(col => ({
col, col,
selected: Observable.create(null, false), selected: Observable.create(null, false),
})).all(); }));
}))); })));
const anyUnmappedSelected = Computed.create(this, use => { const anyUnmappedSelected = Computed.create(this, use => {
@ -65,60 +67,60 @@ export class UnmappedFieldsConfig extends Disposable {
return [ return [
cssHeader( cssHeader(
cssFieldListHeader(t("Unmapped")), cssFieldListHeader(dom.text(t("Mapped"))),
selectAllLabel( selectAllLabel(
dom.on('click', () => { dom.on('click', () => {
unmappedColumns.get().forEach((col) => col.selected.set(true)); mappedColumns.get().forEach((col) => col.selected.set(true));
}), }),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0), dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
), ),
), ),
dom('div', dom('div',
testId('hidden-fields'), testId('visible-fields'),
dom.forEach(unmappedColumns, (field) => { dom.forEach(mappedColumns, (field) => {
return this._buildUnmappedField(field); return this._buildMappedField(field);
}) })
), ),
dom.maybe(anyUnmappedSelected, () => dom.maybe(anyMappedSelected, () =>
cssRow( cssRow(
primaryButton( primaryButton(
dom.text(t("Map fields")), dom.text(t("Unmap fields")),
dom.on('click', mapSelected), dom.on('click', unMapSelected),
testId('visible-hide') testId('visible-hide')
), ),
basicButton( basicButton(
t("Clear"), t("Clear"),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))), dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear') testId('visible-clear')
), ),
testId('visible-batch-buttons') testId('visible-batch-buttons')
), ),
), ),
cssHeader( cssHeader(
cssFieldListHeader(dom.text(t("Mapped"))), cssFieldListHeader(t("Unmapped")),
selectAllLabel( selectAllLabel(
dom.on('click', () => { dom.on('click', () => {
mappedColumns.get().forEach((col) => col.selected.set(true)); unmappedColumns.get().forEach((col) => col.selected.set(true));
}), }),
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0), dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
), ),
), ),
dom('div', dom('div',
testId('visible-fields'), testId('hidden-fields'),
dom.forEach(mappedColumns, (field) => { dom.forEach(unmappedColumns, (field) => {
return this._buildMappedField(field); return this._buildUnmappedField(field);
}) })
), ),
dom.maybe(anyMappedSelected, () => dom.maybe(anyUnmappedSelected, () =>
cssRow( cssRow(
primaryButton( primaryButton(
dom.text(t("Unmap fields")), dom.text(t("Map fields")),
dom.on('click', unMapSelected), dom.on('click', mapSelected),
testId('visible-hide') testId('visible-hide')
), ),
basicButton( basicButton(
t("Clear"), t("Clear"),
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))), dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear') testId('visible-clear')
), ),
testId('visible-batch-buttons') testId('visible-batch-buttons')

@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
const unmapped = Computed.create(owner, (use) => { const unmapped = Computed.create(owner, (use) => {
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId)); const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
const normalCols = use(viewSection.hiddenColumns).filter(col => { const normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));
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 list = normalCols.map(col => { const list = normalCols.map(col => {
return { return {
label: use(col.label), 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 * as elements from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView'; import {FormView} from 'app/client/components/Forms/FormView';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
type Callback = () => Promise<void>; 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 * The unique id of the box.
* 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.
*/ */
public id: string; public id: string;
/** /**
@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable {
parent.children.autoDispose(this); parent.children.autoDispose(this);
} }
// Store "pointer" to this element. this.id = box.id;
this.id = uuidv4();
// Create observables for all properties. // Create observables for all properties.
this.type = box.type; this.type = box.type;
@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable {
this.onCreate(); 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 * The only method that derived classes need to implement. It should return a DOM element that
* represents this box. * 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)); [...this.root().traverse()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard. // Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON())); 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); this.cut.set(true);
} }
@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable {
this.prop(key).set(boxDef[key]); this.prop(key).set(boxDef[key]);
} }
// Add or delete any children that were removed or added. // First remove any children from the model that aren't in `boxDef`.
const myLength = this.children.get().length; const boxDefChildren = boxDef.children ?? [];
const newLength = boxDef.children ? boxDef.children.length : 0; const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));
if (myLength > newLength) { for (const child of this.children.get()) {
this.children.splice(newLength, myLength - newLength); if (!boxDefChildrenIds.has(child.id)) {
} else if (myLength < newLength) { child.removeSelf();
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
} }
} }
if (!boxDef.children) { return; } // Then add or update the children from `boxDef` to the model.
const newChildren: BoxModel[] = [];
// Update those that indices are the same. const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));
const min = Math.min(myLength, newLength); for (const boxDefChild of boxDefChildren) {
for (let i = 0; i < min; i++) { if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {
const atIndex = this.children.get()[i]; newChildren.push(BoxModel.new(boxDefChild, this));
const atIndexDef = boxDef.children[i]; } else {
atIndex.update(atIndexDef); 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() { protected onCreate() {
} }
} }
export class LayoutModel extends BoxModel { export class LayoutModel extends BoxModel {
public disableDeleteSection: Computed<boolean>;
constructor( constructor(
box: FormLayoutNode, box: FormLayoutNode,
public parent: BoxModel | null, public parent: BoxModel | null,
@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel {
public view: FormView public view: FormView
) { ) {
super(box, parent, view); 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) { 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 {BoxModel} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
import {textarea} from 'app/client/ui/inputs'; import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars'; import {theme} from 'app/client/ui2018/cssVars';
import {not} from 'app/common/gutil'; import {not} from 'app/common/gutil';
import {Computed, dom, Observable, styled} from 'grainjs'; 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 { export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false); 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, ` const cssTextArea = styled(textarea, `
color: ${theme.inputFg}; color: ${theme.inputFg};
background-color: ${theme.mainPanelBg}; background-color: ${theme.mainPanelBg};

@ -1,11 +1,19 @@
import {allCommands} from 'app/client/components/commands';
import {FormLayoutNode} from 'app/client/components/FormRenderer'; import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor'; import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field'; 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 {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 * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils'; 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 {dom, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const t = makeT('FormView');
const testId = makeTestId('test-forms-'); const testId = makeTestId('test-forms-');
@ -13,14 +21,17 @@ const testId = makeTestId('test-forms-');
* Component that renders a section of the form. * Component that renders a section of the form.
*/ */
export class SectionModel extends BoxModel { export class SectionModel extends BoxModel {
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
}
public override render(): HTMLElement { public override render(): HTMLElement {
const children = this.children; const children = this.children;
return buildEditor({ return buildEditor({
box: this, box: this,
// Custom drag element that is little bigger and at the top of the section. // Custom drag element that is little bigger and at the top of the section.
drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))), drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))),
// No way to remove section now. showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection),
removeIcon: null,
// Content is just a list of children. // Content is just a list of children.
content: style.cssSection( content: style.cssSection(
// Wrap them in a div that mutes hover events. // Wrap them in a div that mutes hover events.
@ -35,6 +46,18 @@ export class SectionModel extends BoxModel {
style.cssPlusIcon('Plus'), style.cssPlusIcon('Plus'),
buildMenu({ buildMenu({
box: this, 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 fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get()); 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 () => { await this.parent?.save(async () => {
// Remove the fields.
if (fieldIdsToRemove.length > 0) { if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove); 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', ` const cssSectionItems = styled('div.hover_border', `

@ -1,5 +1,8 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; 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. * 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 * 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 'Placeholder': return Placeholder();
case 'Separator': return Paragraph('---'); case 'Separator': return Paragraph('---');
case 'Header': return Paragraph('## **Header**', 'center'); 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 {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; 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 {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
@ -239,14 +239,6 @@ export const cssSelect = styled('select', `
border-radius: 3px; border-radius: 3px;
outline: none; outline: none;
pointer-events: none; pointer-events: none;
&-invalid {
color: ${theme.inputInvalid};
}
&:has(option[value='']:checked) {
font-style: italic;
color: ${colors.slate};
}
`); `);
export const cssFieldEditorContent = styled('div', ` export const cssFieldEditorContent = styled('div', `
@ -373,49 +365,23 @@ export const cssButtonGroup = styled('div', `
`); `);
export const cssIconLink = styled(bigBasicButtonLink, ` export const cssSmallLinkButton = styled(basicButtonLink, `
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
min-height: 26px;
&-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 cssSmallIconButton = styled(basicButton, ` export const cssSmallButton = styled(basicButton, `
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
min-height: 26px;
&-frameless { &-frameless {
background-color: transparent; background-color: transparent;
border: none; border: none;
} }
`);
export const cssIconButton = styled(bigBasicButton, `
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning { &-warning {
color: ${theme.controlPrimaryFg}; color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg}; background-color: ${theme.toastWarningBg};
@ -426,10 +392,6 @@ export const cssIconButton = styled(bigBasicButton, `
background-color: #B8791B; background-color: #B8791B;
border: none; border: none;
} }
&-frameless {
background-color: transparent;
border: none;
}
`); `);
export const cssMarkdownRendered = styled('div', ` export const cssMarkdownRendered = styled('div', `
@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', `
cursor: pointer; cursor: pointer;
} }
.${cssFieldEditor.className}-selected > &, .${cssFieldEditor.className}-selected > &,
.${cssFieldEditor.className}:hover > & { .${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {
display: flex; display: flex;
} }
&-right { &-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) { export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
return [ return [
dom.onKeyDown({ dom.onKeyDown({

@ -129,6 +129,7 @@ async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSect
...record, ...record,
layoutSpec: JSON.stringify(viewSectionLayoutSpec), layoutSpec: JSON.stringify(viewSectionLayoutSpec),
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()], 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}) { export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
return cloneDeepWith(layoutSpec, (val) => { return cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object') { if (typeof val === 'object' && val !== null) {
if (mapIds[val.leaf]) { if (mapIds[val.leaf]) {
return {...val, leaf: mapIds[val.leaf]}; return {...val, leaf: mapIds[val.leaf]};
} }

@ -321,10 +321,12 @@ const cssArrowContainer = styled('div', `
${sideSelectorChunk('top')} > & { ${sideSelectorChunk('top')} > & {
bottom: -17px; bottom: -17px;
margin: 0px 16px;
} }
${sideSelectorChunk('bottom')} > & { ${sideSelectorChunk('bottom')} > & {
top: -14px; top: -14px;
margin: 0px 16px;
} }
${sideSelectorChunk('right')} > & { ${sideSelectorChunk('right')} > & {

@ -15,7 +15,6 @@ import split = require("lodash/split");
export interface ACItem { export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor. // 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; cleanText: string;
} }
@ -65,6 +64,19 @@ interface Word {
pos: number; // Position of the word within the item where it occurred. 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 * Implements a search index. It doesn't currently support updates; when any values change, the
* index needs to be rebuilt from scratch. * 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. // All words from _allItems, sorted.
private _words: Word[]; 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. // Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50). constructor(items: Item[], private _options: ACIndexOptions = {}) {
// 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) {
this._allItems = items.slice(0); this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems. // 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. // Append enough non-matching indices to reach maxResults.
for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) { 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); itemIndices.push(i);
} }
} }

@ -22,6 +22,7 @@ export type { IOption, IOptionFull } from 'popweasel';
export { getOptionFull } from 'popweasel'; export { getOptionFull } from 'popweasel';
export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> { export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {
matchTriggerElemWidth?: boolean;
headerDom?(): DomArg<HTMLElement>; headerDom?(): DomArg<HTMLElement>;
renderItem?(item: U): 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); const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
this.content = cssMenuWrap( this.content = cssMenuWrap(
dom('div', 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'}, {class: menuCssClass + ' grist-floating-menu'},
cssMenu.cls(''), cssMenu.cls(''),
cssMenuExt.cls(''), cssMenuExt.cls(''),
@ -113,7 +122,7 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
private _doAction(value: T | null) { private _doAction(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element // If value is null, simply close the menu. This happens when pressing enter with no element
// selected. // selected.
if (value) { this._action(value); } if (value !== null) { this._action(value); }
this._ctl.close(); 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 {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {getHomeUrl} from 'app/client/models/AppModel'; 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) => { public readonly formLayout = Computed.create(this, this.form, (_use, form) => {
if (!form) { return null; } 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 submitting = Observable.create<boolean>(this, false);
public readonly submitted = 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. disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
isHiddenCol: ko.Computed<boolean>; isHiddenCol: ko.Computed<boolean>;
isFormCol: ko.Computed<boolean>;
// Returns the rowModel for the referenced table, or null, if is not a reference column. // Returns the rowModel for the referenced table, or null, if is not a reference column.
refTable: ko.Computed<TableRec|null>; 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.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId())); 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. // Returns the rowModel for the referenced table, or null, if this is not a reference column.
this.refTable = ko.pureComputed(() => { this.refTable = ko.pureComputed(() => {

@ -130,7 +130,7 @@ function buildLocaleSelect(
locale: l.code, locale: l.code,
cleanText: l.name.trim().toLowerCase(), cleanText: l.name.trim().toLowerCase(),
})).sort(propertyCompare("label")); })).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. // 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 // To show the label - create another observable that will be in sync with the value, but
// will contain text. // will contain text.

@ -106,7 +106,9 @@ export class FormAPIImpl extends BaseAPI implements FormAPI {
}); });
} else { } else {
const {shareKey, tableId, colValues} = options; 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', method: 'POST',
body: JSON.stringify({records: [{fields: colValues}]}), body: JSON.stringify({records: [{fields: colValues}]}),
}); });

@ -398,7 +398,7 @@ export class PageWidgetSelect extends Disposable {
this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', { this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', {
popupOptions: { popupOptions: {
attach: null, attach: null,
placement: 'bottom', placement: 'bottom-start',
} }
}), }),
]}, ]},

@ -16,7 +16,7 @@
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {FieldModel} from 'app/client/components/Forms/Field'; import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView'; 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 {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState"; import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect'; import {RefSelect} from 'app/client/components/RefSelect';
@ -559,7 +559,7 @@ export class RightPanel extends Disposable {
dom.maybe(this._isForm, () => [ dom.maybe(this._isForm, () => [
cssSeparator(), cssSeparator(),
dom.create(UnmappedFieldsConfig, activeSection), dom.create(MappedFieldsConfig, activeSection),
]), ]),
]); ]);
} }
@ -996,19 +996,11 @@ export class RightPanel extends Disposable {
const fieldBox = box as FieldModel; const fieldBox = box as FieldModel;
return use(fieldBox.field); return use(fieldBox.field);
}); });
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol)); const selectedBoxWithOptions = Computed.create(owner, (use) => {
const hasText = Computed.create(owner, (use) => {
const box = use(selectedBox); const box = use(selectedBox);
if (!box) { return false; } if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; }
switch (box.type) {
case 'Submit': return box;
case 'Paragraph':
case 'Label':
return true;
default:
return false;
}
}); });
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
@ -1036,24 +1028,12 @@ export class RightPanel extends Disposable {
testId('field-label'), 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 => [ dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
cssSeparator(), cssSeparator(),
cssLabel(t("COLUMN TYPE")), cssLabel(t("COLUMN TYPE")),
cssSection( cssSection(
builder.buildSelectTypeDom(), builder.buildSelectTypeDom(),
), ),
// V2 thing
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
cssSection( cssSection(
builder.buildFormConfigDom(), builder.buildFormConfigDom(),
), ),
@ -1062,36 +1042,44 @@ export class RightPanel extends Disposable {
}), }),
// Box config // Box config
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [ dom.maybe(selectedBoxWithOptions, (box) => [
cssLabel(dom.text(box.type)), cssLabel(dom.text(box.type)),
dom.maybe(hasText, () => [ cssRow(
cssRow( cssTextArea(
cssTextArea( box.prop('text'),
box.prop('text'), {onInput: true, autoGrow: true},
{onInput: true, autoGrow: true}, dom.on('blur', () => box.save().catch(reportError)),
dom.on('blur', () => box.save().catch(reportError)), {placeholder: t('Enter text')},
{placeholder: t('Enter text')},
),
), ),
cssRow( ),
buttonSelect(box.prop('alignment'), [ cssRow(
{value: 'left', icon: 'LeftAlign'}, buttonSelect(box.prop('alignment'), [
{value: 'center', icon: 'CenterAlign'}, {value: 'left', icon: 'LeftAlign'},
{value: 'right', icon: 'RightAlign'} {value: 'center', icon: 'CenterAlign'},
]), {value: 'right', icon: 'RightAlign'}
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))), ]),
) dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
]), )
]), ]),
// Default. // Default.
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [ dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [
cssLabel(t('Layout')), 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() { function disabledSection() {
return cssOverlay( return cssOverlay(
testId('panel-disabled-section'), testId('panel-disabled-section'),
@ -1429,3 +1417,33 @@ const cssLinkInfoPre = styled("pre", `
font-size: ${vars.smallFontSize}; font-size: ${vars.smallFontSize};
line-height: 1.2; 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 { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import { theme, vars } from 'app/client/ui2018/cssVars'; 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 { menuDivider } from "app/client/ui2018/menus";
import { icon } from "app/client/ui2018/icons"; import { icon } from "app/client/ui2018/icons";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel"; import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
@ -28,20 +29,54 @@ export interface IDropdownWithSearchOptions<T> {
// list of options // list of options
options: () => Array<IOption<T>>, options: () => Array<IOption<T>>,
/** Called when the dropdown menu is disposed. */
onClose?: () => void;
// place holder for the search input. Default to 'Search' // place holder for the search input. Default to 'Search'
placeholder?: string; placeholder?: string;
// popup options // popup options
popupOptions?: IPopupOptions; 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> { export class OptionItem<T> implements ACItem, IOptionFull<T> {
public cleanText: string = normalizeText(this.label); public label = this._params.label;
constructor( public value = this._params.value;
public label: string, public disabled = this._params.disabled;
public value: T, public placeholder = this._params.placeholder;
public disabled?: boolean public cleanText = this.placeholder ? '' : normalizeText(this.label);
) {}
constructor(private _params: OptionItemParams<T>) {
}
} }
export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod { export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {
@ -52,7 +87,7 @@ export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): D
); );
setPopupToFunc( setPopupToFunc(
elem, elem,
(ctl) => DropdownWithSearch<T>.create(null, ctl, options), (ctl) => (DropdownWithSearch<T>).create(null, ctl, options),
popupOptions popupOptions
); );
}; };
@ -68,8 +103,8 @@ class DropdownWithSearch<T> extends Disposable {
constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) { constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {
super(); super();
const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled)); const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems); this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);
this._items = Observable.create<OptionItem<T>[]>(this, acItems); this._items = Observable.create<OptionItem<T>[]>(this, acItems);
this._highlightFunc = () => []; this._highlightFunc = () => [];
this._simpleList = this._buildSimpleList(); this._simpleList = this._buildSimpleList();
@ -77,6 +112,7 @@ class DropdownWithSearch<T> extends Disposable {
this._update(); this._update();
// auto-focus the search input // auto-focus the search input
setTimeout(() => this._inputElem.focus(), 1); setTimeout(() => this._inputElem.focus(), 1);
this._ctl.onDispose(() => _options.onClose?.());
} }
public get content(): HTMLElement { public get content(): HTMLElement {
@ -87,7 +123,11 @@ class DropdownWithSearch<T> extends Disposable {
const action = this._action.bind(this); const action = this._action.bind(this);
const headerDom = this._buildHeader.bind(this); const headerDom = this._buildHeader.bind(this);
const renderItem = this._buildItem.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() { private _buildHeader() {
@ -110,7 +150,9 @@ class DropdownWithSearch<T> extends Disposable {
private _buildItem(item: OptionItem<T>) { private _buildItem(item: OptionItem<T>) {
return [ return [
buildHighlightedDom(item.label, this._highlightFunc, cssMatchText), item.placeholder
? cssPlaceholderItem(item.label)
: buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
testId('searchable-list-item'), testId('searchable-list-item'),
]; ];
} }
@ -125,7 +167,7 @@ class DropdownWithSearch<T> extends Disposable {
private _action(value: T | null) { private _action(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element // If value is null, simply close the menu. This happens when pressing enter with no element
// selected. // selected.
if (value) { if (value !== null) {
this._options.action(value); this._options.action(value);
} }
this._ctl.close(); this._ctl.close();
@ -171,3 +213,10 @@ const cssMenuDivider = styled(menuDivider, `
flex-shrink: 0; flex-shrink: 0;
margin: 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" | "FontItalic" |
"FontStrikethrough" | "FontStrikethrough" |
"FontUnderline" | "FontUnderline" |
"FormConfig" |
"FunctionResult" | "FunctionResult" |
"GreenArrow" | "GreenArrow" |
"Grow" | "Grow" |
@ -232,6 +233,7 @@ export const IconList: IconName[] = ["ChartArea",
"FontItalic", "FontItalic",
"FontStrikethrough", "FontStrikethrough",
"FontUnderline", "FontUnderline",
"FormConfig",
"FunctionResult", "FunctionResult",
"GreenArrow", "GreenArrow",
"Grow", "Grow",

@ -983,6 +983,22 @@ export function isNarrowScreenObs(): Observable<boolean> {
return _isNarrowScreenObs; 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', ` export const cssHideForNarrowScreen = styled('div', `
@media ${mediaSmall} { @media ${mediaSmall} {
& { & {

@ -9,6 +9,7 @@ import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {cssMenuElem} from 'app/client/ui2018/menus'; import {cssMenuElem} from 'app/client/ui2018/menus';
import {waitGrainObs} from 'app/common/gutil'; import {waitGrainObs} from 'app/common/gutil';
import {MaybePromise} from 'app/plugin/gutil';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes, import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs'; MultiHolder, Observable, styled} from 'grainjs';
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel'; import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
@ -356,7 +357,7 @@ export interface ConfirmModalOptions {
export function confirmModal( export function confirmModal(
title: DomElementArg, title: DomElementArg,
btnText: DomElementArg, btnText: DomElementArg,
onConfirm: (dontShowAgain?: boolean) => Promise<void>, onConfirm: (dontShowAgain?: boolean) => MaybePromise<void>,
options: ConfirmModalOptions = {}, options: ConfirmModalOptions = {},
): void { ): void {
const { const {
@ -383,7 +384,7 @@ export function confirmModal(
), ),
], ],
saveLabel: btnText, saveLabel: btnText,
saveFunc: () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()), saveFunc: async () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
hideCancel, hideCancel,
width: width ?? 'normal', width: width ?? 'normal',
extraButtons, extraButtons,

@ -35,7 +35,7 @@ export function buildCurrencyPicker(
// Create a computed that will display 'Local currency' as a value and label // Create a computed that will display 'Local currency' as a value and label
// when `currency` is undefined. // when `currency` is undefined.
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel); 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, return buildACSelect(owner,
{ {
acIndex, valueObs, acIndex, valueObs,

@ -34,6 +34,7 @@ import * as UserType from 'app/client/widgets/UserType';
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl'; import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
import * as gristTypes from 'app/common/gristTypes'; import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes'; import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { WidgetType } from 'app/common/widgetTypes';
import { CellValue } from 'app/plugin/GristData'; import { CellValue } from 'app/plugin/GristData';
import { bundleChanges, Computed, Disposable, fromKo, import { bundleChanges, Computed, Disposable, fromKo,
dom as grainjsDom, makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs'; 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. // Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => { this._availableTypes = Computed.create(this, (use) => {
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
const isFormula = use(this.origColumn.isFormula); const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = []; const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => { _.each(UserType.typeDefs, (def: any, key: string|number) => {
if (isForm && key === 'Attachments') {
// Attachments in forms are currently unsupported.
return;
}
const o: IOptionFull<string> = { const o: IOptionFull<string> = {
value: key as string, value: key as string,
label: def.label, 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 // Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
// fewer than 1000 in practice). // 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 // Only save valid time zones. If there is no selected item, we'll auto-select and save only
// when there is a good match. // 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: { changedAccessRules: {
category: 'AccessRules', category: 'AccessRules',
description: 'Triggered when a change to access rules is saved.', description: 'Triggered when a change to access rules is saved.',
@ -1776,6 +1808,7 @@ export const TelemetryEvents = StringUnion(
'publishedForm', 'publishedForm',
'unpublishedForm', 'unpublishedForm',
'visitedForm', 'visitedForm',
'submittedForm',
'changedAccessRules', 'changedAccessRules',
); );
export type TelemetryEvent = typeof TelemetryEvents.type; 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; 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 isNumber(v: CellValue) { return typeof v === 'number' || typeof v === 'boolean'; }
function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; } function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; }
function isBoolean(v: CellValue) { return typeof v === 'boolean' || v === 1 || v === 0; } 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'; 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; export type RefListValue = [GristObjCode.List, ...number[]]|null;
/** /**

@ -10,6 +10,7 @@ import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, Impor
TransformRuleMap} from 'app/common/ActiveDocAPI'; TransformRuleMap} from 'app/common/ActiveDocAPI';
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions'; import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions';
import {isBlankValue} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
import {localTimestampToUTC} from 'app/common/RelativeDates'; import {localTimestampToUTC} from 'app/common/RelativeDates';
import {DocStateComparison} from 'app/common/UserAPI'; 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. // Helper function that returns new `colIds` with import prefixes stripped.
function stripPrefixes(colIds: string[]): string[] { function stripPrefixes(colIds: string[]): string[] {
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ? 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 { function getMergeFunction({type}: MergeStrategy): MergeFunction {
switch (type) { switch (type) {
case 'replace-with-nonblank-source': { case 'replace-with-nonblank-source': {
return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal; return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal;
} }
case 'replace-all-fields': { case 'replace-all-fields': {
return (srcVal, _destVal) => srcVal; return (srcVal, _destVal) => srcVal;
} }
case 'replace-blank-fields-only': { case 'replace-blank-fields-only': {
return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal; return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal;
} }
default: { default: {
// Normally, we should never arrive here. If we somehow do, throw an error. // Normally, we should never arrive here. If we somehow do, throw an error.

@ -12,7 +12,7 @@ import {
UserAction UserAction
} from 'app/common/DocActions'; } from 'app/common/DocActions';
import {DocData} from 'app/common/DocData'; 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 {INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
@ -573,6 +573,9 @@ export class DocWorkerApi {
validateCore(RecordsPost, req, body); validateCore(RecordsPost, req, body);
const ops = await getTableOperations(req, activeDoc); const ops = await getTableOperations(req, activeDoc);
const records = await ops.create(body.records); const records = await ops.create(body.records);
if (req.query.utm_source === 'grist-forms') {
activeDoc.logTelemetryEvent(docSessionFromRequest(req), 'submittedForm');
}
res.json({records}); res.json({records});
}) })
); );
@ -1422,7 +1425,7 @@ export class DocWorkerApi {
.filter(f => { .filter(f => {
const col = Tables_column.getRecord(f.colRef); const col = Tables_column.getRecord(f.colRef);
// Formulas and attachments are currently unsupported. // 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; let {layoutSpec: formLayoutSpec} = section;
@ -1474,7 +1477,8 @@ export class DocWorkerApi {
if (!refTableId || !refColId) { return () => []; } if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { 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) => { const formFields = await Promise.all(fields.map(async (field) => {

File diff suppressed because one or more lines are too long

@ -0,0 +1,32 @@
<svg width="150" height="140" viewBox="0 0 150 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5883 140L15 139.989C15.2275 133.508 15.9255 127.052 17.0885 120.67C19.6326 107.007 23.863 98.034 29.6622 94L30 94.477C16.453 103.9 15.5952 139.64 15.5883 140Z" fill="#494949"/>
<path d="M22.6089 139L22 138.989C22.0131 138.346 22.3937 123.189 28.6503 119L29 119.475C22.9958 123.495 22.6119 138.845 22.6089 139Z" fill="#494949"/>
<path d="M33 94C34.6569 94 36 92.6568 36 91C36 89.3431 34.6569 88 33 88C31.3431 88 30 89.3431 30 91C30 92.6568 31.3431 94 33 94Z" fill="#16B378"/>
<path d="M31 119C32.6569 119 34 117.657 34 116C34 114.343 32.6569 113 31 113C29.3431 113 28 114.343 28 116C28 117.657 29.3431 119 31 119Z" fill="#16B378"/>
<path d="M20.8746 96.1993C21.4233 99.7892 19.9934 103 19.9934 103C19.9934 103 17.674 100.391 17.1254 96.8007C16.5767 93.2108 18.0066 90 18.0066 90C18.0066 90 20.326 92.6095 20.8746 96.1993Z" fill="#494949"/>
<path d="M30.6081 104.375C27.2943 105.576 24 104.676 24 104.676C24 104.676 26.0782 101.827 29.3919 100.625C32.7056 99.4235 36 100.324 36 100.324C36 100.324 33.9218 103.173 30.6081 104.375Z" fill="#494949"/>
<path d="M30.4054 126.629C28.9789 127.053 27.4598 127.115 26 126.809C26.9269 125.695 28.1752 124.857 29.5946 124.398C31.014 123.938 32.5439 123.875 34 124.218C33.0461 125.305 31.8065 126.137 30.4054 126.629Z" fill="#494949"/>
<path d="M107.999 136H103.934L102 121L108 121L107.999 136Z" fill="#FFF3DE"/>
<path d="M108 140L96 140V139.842C96.0001 138.558 96.4922 137.326 97.3681 136.418C98.244 135.51 99.432 135 100.671 135L108 135L108 140Z" fill="#494949"/>
<path d="M84.9994 136H80.9339L79 121L85 121L84.9994 136Z" fill="#FFF3DE"/>
<path d="M84.9998 140L73 140V139.842C73.0001 138.558 73.4922 137.326 74.3681 136.418C75.244 135.51 76.432 135 77.6707 135H77.671L85 135L84.9998 140Z" fill="#494949"/>
<path d="M74 53.8448L68.7607 53L67.068 55.8431L48.8528 60.6883L48.9021 60.9503C48.3869 60.389 47.7106 60.0182 46.9755 59.8938C46.2404 59.7694 45.4865 59.8983 44.8278 60.261C44.1691 60.6236 43.6416 61.2003 43.325 61.9037C43.0085 62.6072 42.9201 63.399 43.0733 64.1595C43.2265 64.9201 43.613 65.6078 44.1741 66.1187C44.7353 66.6295 45.4407 66.9357 46.1836 66.991C46.9264 67.0462 47.6663 66.8474 48.2912 66.4247C48.9161 66.0019 49.3921 65.3783 49.647 64.6481L71.7062 59.8473L74 53.8448Z" fill="#FFF3DE"/>
<path d="M127.561 50.0617C127.129 50.0621 126.7 50.1453 126.298 50.3069L126.403 50.1222L103.168 38L100 43.1689L124.157 53.9924C124.244 54.6529 124.519 55.274 124.947 55.7812C125.376 56.2885 125.94 56.6604 126.572 56.8526C127.204 57.0448 127.878 57.0491 128.513 56.865C129.147 56.6808 129.716 56.3161 130.15 55.8143C130.585 55.3126 130.867 54.6951 130.963 54.0357C131.06 53.3763 130.965 52.7029 130.692 52.096C130.419 51.4892 129.979 50.9746 129.423 50.6139C128.868 50.2531 128.222 50.0614 127.561 50.0617Z" fill="#FFF3DE"/>
<path d="M91.4035 18.0095C92.3294 12.2849 88.4401 6.88869 82.7165 5.95678C76.9929 5.02486 71.6024 8.91011 70.6765 14.6347C69.7506 20.3593 73.6399 25.7555 79.3635 26.6874C85.0872 27.6193 90.4776 23.734 91.4035 18.0095Z" fill="#494949"/>
<path d="M80.5 27C84.0899 27 87 24.0899 87 20.5C87 16.9101 84.0899 14 80.5 14C76.9102 14 74 16.9101 74 20.5C74 24.0899 76.9102 27 80.5 27Z" fill="#FFF3DE"/>
<path d="M80 19C83.866 19 87 16.9853 87 14.5C87 12.0147 83.866 10 80 10C76.134 10 73 12.0147 73 14.5C73 16.9853 76.134 19 80 19Z" fill="#494949"/>
<path d="M81.5 11C83.9853 11 86 8.98528 86 6.5C86 4.01472 83.9853 2 81.5 2C79.0147 2 77 4.01472 77 6.5C77 8.98528 79.0147 11 81.5 11Z" fill="#494949"/>
<path d="M75.9391 4.49998C75.9392 3.38767 76.3542 2.3148 77.1041 1.48831C77.854 0.66183 78.8856 0.140301 80 0.024332C79.8431 0.0081623 79.6855 4.01157e-05 79.5278 0C78.3265 0.00139078 77.1748 0.476108 76.3259 1.31987C75.4769 2.16363 75 3.30743 75 4.5C75 5.69257 75.4769 6.83636 76.3259 7.68013C77.1748 8.52389 78.3265 8.99861 79.5278 9C79.6855 8.99996 79.8431 8.99184 80 8.97567C78.8856 8.8597 77.854 8.33817 77.1041 7.51168C76.3542 6.68519 75.9392 5.6123 75.9391 4.49998Z" fill="#494949"/>
<path d="M76.6948 36.9326L77.2211 31.8285C77.2211 31.8285 84.3618 27.0813 86.7887 29.887L101.294 54.9226C101.294 54.9226 110.31 58.1564 109.992 70.5046L109.561 130.192L99.3364 131.323L93.1277 84.7871L87.5181 133L75.6148 132.624L76.6857 100.862L82.3616 70.0724L82.3073 59.8557L79.8075 55.7325C79.8075 55.7325 75.2283 53.8768 75.1003 48.6324L75 41.2606L76.6948 36.9326Z" fill="#494949"/>
<path d="M84 31.3536L84.14 29C84.14 29 105.607 34.648 103.904 38.6864C102.201 42.7247 99.0077 44 99.0077 44L86.6609 39.1115L84 31.3536Z" fill="#494949"/>
<path d="M78.1294 36.7761L76.5437 35C76.5437 35 63.8958 53.641 67.7205 55.7238C71.5452 57.8066 74.7569 56.7131 74.7569 56.7131L81 44.6897L78.1294 36.7761Z" fill="#494949"/>
<path d="M52.8642 97L135 80.5341L128.136 46L46 62.4659L52.8642 97Z" fill="white"/>
<path d="M135 81.4211L52.0609 98L45 62.5789L127.939 46L135 81.4211ZM52.9544 96.6589L133.661 80.5263L127.046 47.3411L46.3392 63.4737L52.9544 96.6589Z" fill="#D9D9D9"/>
<path d="M122.676 57.646L56 71L56.3903 72.9616L123.066 59.6076L122.676 57.646Z" fill="#D9D9D9"/>
<path d="M123.676 63.646L57 77L57.3903 78.9616L124.066 65.6076L123.676 63.646Z" fill="#D9D9D9"/>
<path d="M125.676 70.646L59 84L59.3903 85.9616L126.066 72.6076L125.676 70.646Z" fill="#D9D9D9"/>
<path d="M90.4449 67.5415L87.9873 67.988C87.8078 68.0204 87.6215 67.9867 87.4692 67.8942C87.3169 67.8018 87.2112 67.6581 87.1751 67.4948L86.0132 62.1976C85.9776 62.0342 86.0146 61.8646 86.1162 61.7261C86.2178 61.5875 86.3756 61.4913 86.5551 61.4585L89.0127 61.012C89.1922 60.9796 89.3786 61.0133 89.5308 61.1058C89.6831 61.1982 89.7888 61.3419 89.8249 61.5052L90.9868 66.8024C91.0224 66.9658 90.9854 67.1354 90.8838 67.2739C90.7822 67.4125 90.6244 67.5087 90.4449 67.5415Z" fill="#16B378"/>
<path d="M117.445 69.5415L114.987 69.988C114.808 70.0204 114.621 69.9867 114.469 69.8942C114.317 69.8018 114.211 69.6581 114.175 69.4948L113.013 64.1976C112.978 64.0342 113.015 63.8646 113.116 63.7261C113.218 63.5875 113.376 63.4913 113.555 63.4585L116.013 63.012C116.192 62.9796 116.379 63.0133 116.531 63.1058C116.683 63.1982 116.789 63.3419 116.825 63.5052L117.987 68.8024C118.022 68.9658 117.985 69.1354 117.884 69.2739C117.782 69.4125 117.624 69.5087 117.445 69.5415Z" fill="#16B378"/>
<path d="M102.445 79.5415L99.9873 79.988C99.8078 80.0204 99.6215 79.9867 99.4692 79.8942C99.3169 79.8018 99.2112 79.6581 99.1752 79.4948L98.0132 74.1976C97.9776 74.0342 98.0146 73.8646 98.1162 73.7261C98.2178 73.5875 98.3756 73.4913 98.5551 73.4585L101.013 73.012C101.192 72.9796 101.379 73.0133 101.531 73.1058C101.683 73.1982 101.789 73.3419 101.825 73.5052L102.987 78.8024C103.022 78.9658 102.985 79.1354 102.884 79.2739C102.782 79.4125 102.624 79.5087 102.445 79.5415Z" fill="#16B378"/>
<path d="M130.826 140H0.173971C0.127831 140 0.0835806 139.947 0.0509547 139.854C0.0183289 139.76 0 139.633 0 139.5C0 139.367 0.0183289 139.24 0.0509547 139.146C0.0835806 139.053 0.127831 139 0.173971 139H130.826C130.872 139 130.916 139.053 130.949 139.146C130.982 139.24 131 139.367 131 139.5C131 139.633 130.982 139.76 130.949 139.854C130.916 139.947 130.872 140 130.826 140Z" fill="#CACACA"/>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@ -37,7 +37,7 @@ describe('ACIndex', function() {
it('should find items with matching words', function() { it('should find items with matching words', function() {
const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map( const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map(
c => ({cleanText: c})); c => ({cleanText: c}));
const acIndex = new ACIndexImpl(items, 5); const acIndex = new ACIndexImpl(items, {maxResults: 5});
assert.deepEqual(acIndex.search("red").items.map((item) => item.cleanText), assert.deepEqual(acIndex.search("red").items.map((item) => item.cleanText),
["red", "reddish", "dark red", "radical green", "blue"]); ["red", "reddish", "dark red", "radical green", "blue"]);
}); });
@ -48,7 +48,7 @@ describe('ACIndex', function() {
assert.deepEqual(acResult.items, colors); assert.deepEqual(acResult.items, colors);
assert.deepEqual(acResult.selectIndex, -1); assert.deepEqual(acResult.selectIndex, -1);
acResult = new ACIndexImpl(colors, 3).search(""); acResult = new ACIndexImpl(colors, {maxResults: 3}).search("");
assert.deepEqual(acResult.items, colors.slice(0, 3)); assert.deepEqual(acResult.items, colors.slice(0, 3));
assert.deepEqual(acResult.selectIndex, -1); assert.deepEqual(acResult.selectIndex, -1);
@ -161,7 +161,7 @@ describe('ACIndex', function() {
}); });
it('should limit results to maxResults', function() { it('should limit results to maxResults', function() {
const acIndex = new ACIndexImpl(colors, 3); const acIndex = new ACIndexImpl(colors, {maxResults: 3});
let acResult: ACResults<TestACItem>; let acResult: ACResults<TestACItem>;
acResult = acIndex.search("red"); acResult = acIndex.search("red");
@ -247,7 +247,7 @@ describe('ACIndex', function() {
}); });
it('should return a useful highlight function', function() { it('should return a useful highlight function', function() {
const acIndex = new ACIndexImpl(colors, 3); const acIndex = new ACIndexImpl(colors, {maxResults: 3});
let acResult: ACResults<TestACItem>; let acResult: ACResults<TestACItem>;
// Here we split the items' (uncleaned) text with the returned highlightFunc. The values at // Here we split the items' (uncleaned) text with the returned highlightFunc. The values at
@ -267,7 +267,7 @@ describe('ACIndex', function() {
[["Blue"], ["Dark Red"], ["Reddish"]]); [["Blue"], ["Dark Red"], ["Reddish"]]);
// Try some messier cases. // Try some messier cases.
const acIndex2 = new ACIndexImpl(messy, 6); const acIndex2 = new ACIndexImpl(messy, {maxResults: 6});
acResult = acIndex2.search("#r"); acResult = acIndex2.search("#r");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "], [["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "],
@ -280,7 +280,9 @@ describe('ACIndex', function() {
}); });
it('should highlight multi-byte unicode', function() { it('should highlight multi-byte unicode', function() {
const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), 3); const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), {
maxResults: 3,
});
let acResult: ACResults<TestACItem> = acIndex.search("mañ моск am"); let acResult: ACResults<TestACItem> = acIndex.search("mañ моск am");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]); [["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]);
@ -345,7 +347,7 @@ describe('ACIndex', function() {
// tslint:disable:no-console // tslint:disable:no-console
it('main algorithm', function() { it('main algorithm', function() {
const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, 100)); const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, {maxResults: 100}));
console.log(`Time to build index (${items.length} items): ${buildTime} ms`); console.log(`Time to build index (${items.length} items): ${buildTime} ms`);
const [searchTime, result] = repeat(10, () => acIndex.search("YORK")); const [searchTime, result] = repeat(10, () => acIndex.search("YORK"));

@ -20,29 +20,6 @@ describe('FormView', function() {
afterEach(() => gu.checkForErrors()); afterEach(() => gu.checkForErrors());
/**
* Adds a temporary textarea to the document for pasting the contents of
* the clipboard.
*
* Used to test copying of form URLs to the clipboard.
*/
function createClipboardTextArea() {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.top = '0';
textArea.style.height = '2rem';
textArea.style.width = '16rem';
textArea.id = 'clipboardText';
window.document.body.appendChild(textArea);
}
function removeClipboardTextArea() {
const textArea = document.getElementById('clipboardText');
if (textArea) {
window.document.body.removeChild(textArea);
}
}
async function createFormWith(type: string, more = false) { async function createFormWith(type: string, more = false) {
await gu.addNewSection('Form', 'Table1'); await gu.addNewSection('Form', 'Table1');
@ -69,8 +46,11 @@ describe('FormView', function() {
// Now open the form in external window. // Now open the form in external window.
await clipboard.lockAndPerform(async (cb) => { await clipboard.lockAndPerform(async (cb) => {
await driver.find(`.test-forms-link`).click(); const shareButton = await driver.find(`.test-forms-share`);
await gu.scrollIntoView(shareButton);
await shareButton.click();
await gu.waitForServer(); await gu.waitForServer();
await driver.findWait('.test-forms-link', 1000).click();
await gu.waitToPass(async () => assert.match( await gu.waitToPass(async () => assert.match(
await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000); await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000);
await driver.find('#clipboardText').click(); await driver.find('#clipboardText').click();
@ -121,12 +101,9 @@ describe('FormView', function() {
const session = await gu.session().login(); const session = await gu.session().login();
docId = await session.tempNewDoc(cleanup); docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi(); api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
}); });
after(async function() { gu.withClipboardTextArea();
await driver.executeScript(removeClipboardTextArea);
});
it('updates creator panel when navigated away', async function() { it('updates creator panel when navigated away', async function() {
// Add 2 new pages. // Add 2 new pages.
@ -186,6 +163,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click(); await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello');
assert.equal(await driver.find('input[name="D"]').value(), 'Hello');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('Hello World'); await gu.sendKeys('Hello World');
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
@ -201,6 +184,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click(); await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984'); await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
@ -216,9 +205,13 @@ describe('FormView', function() {
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click(); await driver.findWait('input[name="D"]', 2000).click();
await driver.executeScript( await gu.sendKeys('01011999');
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01' assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '1999-01-01');
); await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('01012000');
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
}); });
@ -239,21 +232,30 @@ describe('FormView', function() {
await gu.choicesEditor.save(); await gu.choicesEditor.save();
await gu.toggleSidePanel('right', 'close'); await gu.toggleSidePanel('right', 'close');
// We need to press preview, as form is not saved yet. // We need to press view, as form is not saved yet.
await gu.scrollActiveViewTop(); await gu.scrollActiveViewTop();
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.isTrue(await driver.find('.test-forms-preview').isDisplayed()); assert.isTrue(await driver.find('.test-forms-view').isDisplayed());
}); });
// We are in a new window. // We are in a new window.
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000); await driver.findWait('select[name="D"]', 2000);
// Make sure options are there. // Make sure options are there.
assert.deepEqual( assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()), ['— Choose —', 'Foo', 'Bar', 'Baz'] await driver.findAll('select[name="D"] option', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz']
);
await driver.find('.test-form-search-select').click();
assert.deepEqual(
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz']
); );
await select.click(); await gu.sendKeys('Baz', Key.ENTER);
await driver.find("option[value='Bar']").click(); assert.equal(await driver.find('select[name="D"]').value(), 'Baz');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
}); });
@ -267,6 +269,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click(); await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984'); await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
@ -282,6 +290,11 @@ describe('FormView', function() {
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).findClosest("label").click(); await driver.findWait('input[name="D"]', 2000).findClosest("label").click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
await driver.find('input[name="D"]').findClosest("label").click();
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
}); });
@ -314,7 +327,12 @@ describe('FormView', function() {
// We are in a new window. // We are in a new window.
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="Foo"]', 2000).click(); await driver.findWait('input[name="D[]"][value="Bar"]', 2000).click();
assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="Foo"]').click();
await driver.find('input[name="D[]"][value="Baz"]').click(); await driver.find('input[name="D[]"][value="Baz"]').click();
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
@ -339,17 +357,26 @@ describe('FormView', function() {
// We are in a new window. // We are in a new window.
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000); await driver.findWait('select[name="D"]', 2000);
assert.deepEqual( assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()), await driver.findAll('select[name="D"] option', e => e.getText()),
['— Choose —', ...['Bar', 'Baz', 'Foo']] ['Select...', ...['Bar', 'Baz', 'Foo']]
); );
assert.deepEqual( assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.value()), await driver.findAll('select[name="D"] option', e => e.value()),
['', ...['2', '3', '1']] ['', ...['2', '3', '1']]
); );
await select.click(); await driver.find('.test-form-search-select').click();
await driver.find('option[value="2"]').click(); assert.deepEqual(
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo']
);
await gu.sendKeys('Baz', Key.ENTER);
assert.equal(await driver.find('select[name="D"]').value(), '3');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
}); });
@ -379,11 +406,16 @@ describe('FormView', function() {
// We are in a new window. // We are in a new window.
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="1"]', 2000).click(); assert.equal(await driver.findWait('label:has(input[name="D[]"][value="1"])', 2000).getText(), 'Foo');
await driver.find('input[name="D[]"][value="2"]').click();
assert.equal(await driver.find('label:has(input[name="D[]"][value="1"])').getText(), 'Foo');
assert.equal(await driver.find('label:has(input[name="D[]"][value="2"])').getText(), 'Bar'); assert.equal(await driver.find('label:has(input[name="D[]"][value="2"])').getText(), 'Bar');
assert.equal(await driver.find('label:has(input[name="D[]"][value="3"])').getText(), 'Baz'); assert.equal(await driver.find('label:has(input[name="D[]"][value="3"])').getText(), 'Baz');
await driver.find('input[name="D[]"][value="1"]').click();
assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="1"]').click();
await driver.find('input[name="D[]"][value="2"]').click();
await driver.find('input[type="submit"]').click(); await driver.find('input[type="submit"]').click();
await waitForConfirm(); await waitForConfirm();
}); });
@ -402,12 +434,75 @@ describe('FormView', function() {
// Temporarily make A a formula column. // Temporarily make A a formula column.
await gu.sendActions([ await gu.sendActions([
['AddRecord', 'Table1', null, {A: 'Foo'}], ['ModifyColumn', 'Table1', 'A', {formula: '"hello"', isFormula: true}],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '"hello"', isFormula: true}],
]); ]);
// Check that A is hidden in the form editor.
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D']));
await gu.openWidgetPanel('widget');
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
// Check that A is excluded from the published form.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello World');
assert.isFalse(await driver.find('input[name="A"]').isPresent());
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectInD(['Hello World']);
// And check that A was not modified.
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello']); assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello']);
// Check that A is excluded from the form, and we can still submit it. // Revert A and check that it's visible again in the editor.
await gu.sendActions([
['ModifyColumn', 'Table1', 'A', {formula: '', isFormula: false}],
]);
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']));
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['A', 'B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
await removeForm();
});
it('excludes attachment fields from forms', async function() {
const formUrl = await createFormWith('Text');
// Temporarily make A an attachments column.
await gu.sendActions([
['ModifyColumn', 'Table1', 'A', {type: 'Attachments'}],
]);
// Check that A is hidden in the form editor.
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D']));
await gu.openWidgetPanel('widget');
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
// Check that A is excluded from the published form.
await gu.onNewTab(async () => { await gu.onNewTab(async () => {
await driver.get(formUrl); await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click(); await driver.findWait('input[name="D"]', 2000).click();
@ -418,15 +513,25 @@ describe('FormView', function() {
}); });
// Make sure we see the new record. // Make sure we see the new record.
await expectInD(['', 'Hello World']); await expectInD(['Hello World']);
// And check that A was not modified. // And check that A was not modified.
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello', 'hello']); assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), [null]);
// Revert A and check that it's visible again in the editor.
await gu.sendActions([ await gu.sendActions([
['RemoveRecord', 'Table1', 1], ['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '', isFormula: false}],
]); ]);
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']));
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['A', 'B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
await removeForm(); await removeForm();
}); });
@ -851,28 +956,33 @@ describe('FormView', function() {
checkFieldInMore('Reference List'); checkFieldInMore('Reference List');
const testStruct = (type: string, existing = 0) => { const testStruct = (type: string, existing = 0) => {
it(`can add structure ${type} element`, async function() { async function doTestStruct(menuLabel?: string) {
assert.equal(await elementCount(type), existing); assert.equal(await elementCount(type), existing);
await plusButton().click(); await plusButton().click();
await clickMenu(type); await clickMenu(menuLabel ?? type);
await gu.waitForServer(); await gu.waitForServer();
assert.equal(await elementCount(type), existing + 1); assert.equal(await elementCount(type), existing + 1);
await gu.undo(); await gu.undo();
assert.equal(await elementCount(type), existing); assert.equal(await elementCount(type), existing);
}
it(`can add structure ${type} element`, async function() {
if (type === 'Section') {
await doTestStruct('Insert section above');
await doTestStruct('Insert section below');
} else {
await doTestStruct();
}
}); });
}; };
// testStruct('Section'); // There is already a section testStruct('Section', 1);
testStruct('Columns'); testStruct('Columns');
testStruct('Paragraph', 4); testStruct('Paragraph', 4);
it('basic section', async function() { it('basic section', async function() {
const revert = await gu.begin(); const revert = await gu.begin();
// Adding section is disabled for now, so this test is altered to use the existing section.
// await drop().click();
// await clickMenu('Section');
// await gu.waitForServer();
assert.equal(await elementCount('Section'), 1); assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']); assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
@ -898,25 +1008,39 @@ describe('FormView', function() {
await gu.waitForServer(); await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']); assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
// Make sure that it is not inside the section anymore.
// assert.equal(await element('Section', 1).element('label').isPresent(), false);
await gu.undo(); await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await element('Section', 1).element('label', 4).getText(), 'D'); assert.equal(await element('Section', 1).element('label', 4).getText(), 'D');
// Make sure that deleting the section also hides its fields and unmaps them. // Check that we can't delete a section if it's the only one.
await element('Section').element('Paragraph', 1).click(); await element('Section').element('Paragraph', 1).click();
await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE); await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer(); await gu.waitForServer();
assert.equal(await elementCount('Section'), 0); assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), []);
// Add a new section below it.
await plusButton().click();
await clickMenu('Insert section below');
await gu.waitForServer();
assert.equal(await elementCount('Section'), 2);
await plusButton(element('Section', 2)).click();
await clickMenu('Text');
await gu.waitForServer();
// Now check that we can delete the first section.
await element('Section', 1).element('Paragraph', 1).click();
await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer();
assert.equal(await elementCount('Section'), 1);
// Make sure that deleting the section also hides its fields and unmaps them.
assert.deepEqual(await readLabels(), ['E']);
await gu.openWidgetPanel(); await gu.openWidgetPanel();
assert.deepEqual(await hiddenColumns(), ['A', 'B', 'C', 'Choice', 'D']); assert.deepEqual(await hiddenColumns(), ['A', 'B', 'C', 'Choice', 'D']);
await gu.undo(); await gu.undo();
assert.equal(await elementCount('Section'), 1); assert.equal(await elementCount('Section'), 2);
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D', 'E']);
assert.deepEqual(await hiddenColumns(), ['Choice']); assert.deepEqual(await hiddenColumns(), ['Choice']);
await revert(); await revert();
@ -1243,12 +1367,9 @@ describe('FormView', function() {
const session = await gu.session().teamSite.login(); const session = await gu.session().teamSite.login();
docId = await session.tempNewDoc(cleanup); docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi(); api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
}); });
after(async function() { gu.withClipboardTextArea();
await driver.executeScript(removeClipboardTextArea);
});
it('can submit a form', async function() { it('can submit a form', async function() {
// A bug was preventing this by forcing a login redirect from the public form URL. // A bug was preventing this by forcing a login redirect from the public form URL.
@ -1309,8 +1430,8 @@ function questionType(label: string) {
return question(label).find('.test-forms-type').value(); return question(label).find('.test-forms-type').value();
} }
function plusButton() { function plusButton(parent?: WebElement) {
return element('plus'); return element('plus', parent);
} }
function drops() { function drops() {

@ -262,7 +262,8 @@ describe('GridViewNewColumnMenu', function () {
// Wait for the side panel animation. // Wait for the side panel animation.
await gu.waitForSidePanel(); await gu.waitForSidePanel();
//check if right menu is opened on column section //check if right menu is opened on column section
assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed()); await gu.waitForSidePanel();
assert.isTrue(await driver.find('.test-right-tab-field').isDisplayed());
await gu.toggleSidePanel("right", "close"); await gu.toggleSidePanel("right", "close");
await gu.undo(1); await gu.undo(1);
}); });

@ -3469,6 +3469,52 @@ export async function switchToWindow(target: string) {
} }
} }
/**
* Creates a temporary textarea to the document for pasting the contents of
* the clipboard.
*/
export async function createClipboardTextArea() {
function createTextArea() {
const textArea = window.document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.top = '0';
textArea.style.height = '2rem';
textArea.style.width = '16rem';
textArea.id = 'clipboardText';
window.document.body.appendChild(textArea);
}
await driver.executeScript(createTextArea);
}
/**
* Removes the temporary textarea added by `createClipboardTextArea`.
*/
export async function removeClipboardTextArea() {
function removeTextArea() {
const textArea = window.document.getElementById('clipboardText');
if (textArea) {
window.document.body.removeChild(textArea);
}
}
await driver.executeScript(removeTextArea);
}
/**
* Sets up a temporary textarea for pasting the contents of the clipboard,
* removing it after all tests have run.
*/
export function withClipboardTextArea() {
before(async function() {
await createClipboardTextArea();
});
after(async function() {
await removeClipboardTextArea();
});
}
/* /*
* Returns an instance of `LockableClipboard`, making sure to unlock it after * Returns an instance of `LockableClipboard`, making sure to unlock it after
* each test. * each test.

Loading…
Cancel
Save