(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 1 month ago
parent aff9c7075c
commit 418681915e

@ -1,20 +1,24 @@
import * as css from 'app/client/components/FormRendererCss';
import {FormField} from 'app/client/ui/FormAPI';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
import {isXSmallScreenObs} from 'app/client/ui2018/cssVars';
import {confirmModal} from 'app/client/ui2018/modals';
import {CellValue} from 'app/plugin/GristData';
import {Disposable, dom, DomContents, Observable} from 'grainjs';
import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs';
import {marked} from 'marked';
import {IPopupOptions, PopupControl} from 'popweasel';
export const CHOOSE_TEXT = '— Choose —';
const testId = makeTestId('test-form-');
/**
* A node in a recursive, tree-like hierarchy comprising the layout of a form.
*/
export interface FormLayoutNode {
/** Unique ID of the node. Used by FormView. */
id: string;
type: FormLayoutNodeType;
children?: Array<FormLayoutNode>;
// Unique ID of the field. Used only in the Form widget.
id?: string;
// Used by Layout.
submitText?: string;
successURL?: string;
@ -55,6 +59,24 @@ export interface FormRendererContext {
error: Observable<string|null>;
}
/**
* Returns a copy of `layoutSpec` with any leaf nodes that don't exist
* in `fieldIds` removed.
*/
export function patchLayoutSpec(
layoutSpec: FormLayoutNode,
fieldIds: Set<number>
): FormLayoutNode | null {
if (layoutSpec.leaf && !fieldIds.has(layoutSpec.leaf)) { return null; }
return {
...layoutSpec,
children: layoutSpec.children
?.map(child => patchLayoutSpec(child, fieldIds))
.filter((child): child is FormLayoutNode => child !== null),
};
}
/**
* A renderer for a form layout.
*
@ -68,20 +90,35 @@ export interface FormRendererContext {
* TODO: merge the two implementations or factor out what's common.
*/
export abstract class FormRenderer extends Disposable {
public static new(layoutNode: FormLayoutNode, context: FormRendererContext): FormRenderer {
public static new(
layoutNode: FormLayoutNode,
context: FormRendererContext,
parent?: FormRenderer
): FormRenderer {
const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
return new Renderer(layoutNode, context);
return new Renderer(layoutNode, context, parent);
}
protected children: FormRenderer[];
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) {
constructor(
protected layoutNode: FormLayoutNode,
protected context: FormRendererContext,
protected parent?: FormRenderer
) {
super();
this.children = (this.layoutNode.children ?? []).map((child) =>
this.autoDispose(FormRenderer.new(child, this.context)));
this.autoDispose(FormRenderer.new(child, this.context, this)));
}
public abstract render(): DomContents;
/**
* Reset the state of this layout node and all of its children.
*/
public reset() {
this.children.forEach((child) => child.reset());
}
}
class LabelRenderer extends FormRenderer {
@ -122,30 +159,45 @@ class SubmitRenderer extends FormRenderer {
public render() {
return [
css.error(dom.text(use => use(this.context.error) ?? '')),
css.submit(
dom('input',
css.submitButtons(
css.resetButton(
'Reset',
dom.boolAttr('disabled', this.context.disabled),
{
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit'
},
{type: 'button'},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
return confirmModal(
'Are you sure you want to reset your form?',
'Reset',
() => this.parent?.reset()
);
}),
)
testId('reset'),
),
css.submitButton(
dom('input',
dom.boolAttr('disabled', this.context.disabled),
{
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit',
},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
}),
)
),
),
];
}
@ -164,174 +216,380 @@ class LayoutRenderer extends FormRenderer {
}
class FieldRenderer extends FormRenderer {
public build(field: FormField) {
public renderer: BaseFieldRenderer;
public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) {
super(layoutNode, context);
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { throw new Error(); }
const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
return new Renderer();
this.renderer = this.autoDispose(new Renderer(field, context));
}
public render() {
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { return null; }
return css.field(this.renderer.render());
}
const renderer = this.build(field);
return css.field(renderer.render(field, this.context));
public reset() {
this.renderer.resetInput();
}
}
abstract class BaseFieldRenderer {
public render(field: FormField, context: FormRendererContext) {
abstract class BaseFieldRenderer extends Disposable {
public constructor(protected field: FormField, protected context: FormRendererContext) {
super();
}
public render() {
return css.field(
this.label(field),
dom('div', this.input(field, context)),
this.label(),
dom('div', this.input()),
);
}
public name(field: FormField) {
return field.colId;
public name() {
return this.field.colId;
}
public label(field: FormField) {
public label() {
return dom('label',
css.label.cls(''),
css.label.cls('-required', Boolean(field.options.formRequired)),
{for: this.name(field)},
field.question,
css.label.cls('-required', Boolean(this.field.options.formRequired)),
{for: this.name()},
this.field.question,
);
}
public abstract input(field: FormField, context: FormRendererContext): DomContents;
public abstract input(): DomContents;
public abstract resetInput(): void;
}
class TextRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'text',
name: this.name(field),
required: field.options.formRequired,
});
protected type = 'text';
private _value = Observable.create(this, '');
public input() {
return dom('input',
{
type: this.type,
name: this.name(),
required: this.field.options.formRequired,
},
dom.prop('value', this._value),
dom.on('input', (_e, elem) => this._value.set(elem.value)),
);
}
}
class DateRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'date',
name: this.name(field),
required: field.options.formRequired,
});
public resetInput(): void {
this._value.set('');
}
}
class DateTimeRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'datetime-local',
name: this.name(field),
required: field.options.formRequired,
});
}
class DateRenderer extends TextRenderer {
protected type = 'date';
}
class DateTimeRenderer extends TextRenderer {
protected type = 'datetime-local';
}
export const SELECT_PLACEHOLDER = 'Select...';
class ChoiceRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: Array<string|null> = field.options.choices || [];
// Insert empty option.
choices.unshift(null);
return css.select(
{name: this.name(field), required: field.options.formRequired},
choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT))
protected value = Observable.create<string>(this, '');
private _choices: string[];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
const choices = this.field.options.choices;
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
this._choices = [];
} else {
// Support for 1000 choices. TODO: make limit dynamic.
this._choices = choices.slice(0, 1000);
}
}
public input() {
return css.hybridSelect(
this._selectElement = css.select(
{name: this.name(), required: this.field.options.formRequired},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
this._choices.map((choice) => dom('option', {value: choice}, choice)),
dom.onKeyDown({
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
}),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
dom('div', dom.text(use => use(this.value) || SELECT_PLACEHOLDER)),
dropdownWithSearch<string>({
action: (value) => this.value.set(value),
options: () => [
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
...this._choices.map((choice) => ({
label: choice,
value: choice,
}),
)],
onClose: () => { setTimeout(() => this._selectElement.focus()); },
placeholder: 'Search',
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
popupOptions: {
trigger: [
'click',
(_el, ctl) => { this._ctl = ctl; },
],
},
matchTriggerElemWidth: true,
}),
css.searchSelectIcon('Collapse'),
testId('search-select'),
),
),
);
}
public resetInput(): void {
this.value.set('');
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
if (isXSmallScreenObs().get()) {
return;
}
ev.preventDefault();
ev.stopPropagation();
this._ctl?.open();
}
}
class BoolRenderer extends BaseFieldRenderer {
public render(field: FormField) {
protected checked = Observable.create<boolean>(this, false);
public render() {
return css.field(
dom('div', this.input(field)),
dom('div', this.input()),
);
}
public input(field: FormField) {
public input() {
return css.toggle(
css.label.cls('-required', Boolean(field.options.formRequired)),
dom('input', {
type: 'checkbox',
name: this.name(field),
value: '1',
required: field.options.formRequired,
}),
dom('input',
dom.prop('checked', this.checked),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
{
type: 'checkbox',
name: this.name(),
value: '1',
required: this.field.options.formRequired,
},
),
css.gristSwitch(
css.gristSwitchSlider(),
css.gristSwitchCircle(),
),
dom('span', field.question || field.colId)
css.toggleLabel(
css.label.cls('-required', Boolean(this.field.options.formRequired)),
this.field.question,
),
);
}
public resetInput(): void {
this.checked.set(false);
}
}
class ChoiceListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: string[] = field.options.choices ?? [];
const required = field.options.formRequired;
protected checkboxes: MutableObsArray<{
label: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
let choices = this.field.options.choices;
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
choices = [];
} else {
// Support for 30 choices. TODO: make limit dynamic.
choices = choices.slice(0, 30);
}
this.checkboxes.set(choices.map(choice => ({
label: choice,
checked: Observable.create(this, null),
})));
}
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
name: `${this.name(field)}[]`,
value: choice,
}),
dom('span', choice),
)),
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{
type: 'checkbox',
name: `${this.name()}[]`,
value: checkbox.label,
}
),
dom('span', checkbox.label),
)
),
);
}
public resetInput(): void {
this.checkboxes.get().forEach(checkbox => {
checkbox.checked.set(null);
});
}
}
class RefListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number, CellValue][] = field.refValues ?? [];
protected checkboxes: MutableObsArray<{
label: string;
value: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
const references = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 30 choices. TODO: make limit dynamic.
choices.splice(30);
const required = field.options.formRequired;
references.splice(30);
this.checkboxes.set(references.map(reference => ({
label: String(reference[1]),
value: String(reference[0]),
checked: Observable.create(this, null),
})));
}
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
'data-grist-type': field.type,
name: `${this.name(field)}[]`,
value: String(choice[0]),
}),
dom('span', String(choice[1] ?? '')),
)),
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{
type: 'checkbox',
'data-grist-type': this.field.type,
name: `${this.name()}[]`,
value: checkbox.value,
}
),
dom('span', checkbox.label),
)
),
);
}
public resetInput(): void {
this.checkboxes.get().forEach(checkbox => {
checkbox.checked.set(null);
});
}
}
class RefRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number|string, CellValue][] = field.refValues ?? [];
protected value = Observable.create(this, '');
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
public input() {
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices. TODO: make limit dynamic.
choices.splice(1000);
// Insert empty option.
choices.unshift(['', CHOOSE_TEXT]);
return css.select(
{
name: this.name(field),
'data-grist-type': field.type,
required: field.options.formRequired,
},
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))),
return css.hybridSelect(
this._selectElement = css.select(
{
name: this.name(),
'data-grist-type': this.field.type,
required: this.field.options.formRequired,
},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
dom.onKeyDown({
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
}),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
dom('div', dom.text(use => {
const choice = choices.find((c) => String(c[0]) === use(this.value));
return String(choice?.[1] || SELECT_PLACEHOLDER);
})),
dropdownWithSearch<string>({
action: (value) => this.value.set(value),
options: () => [
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
...choices.map((choice) => ({
label: String(choice[1]),
value: String(choice[0]),
}),
)],
onClose: () => { setTimeout(() => this._selectElement.focus()); },
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
placeholder: 'Search',
popupOptions: {
trigger: [
'click',
(_el, ctl) => { this._ctl = ctl; },
],
},
matchTriggerElemWidth: true,
}),
css.searchSelectIcon('Collapse'),
testId('search-select'),
),
)
);
}
public resetInput(): void {
this.value.set('');
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
if (isXSmallScreenObs().get()) {
return;
}
ev.preventDefault();
ev.stopPropagation();
this._ctl?.open();
}
}
const FieldRenderers = {

@ -1,4 +1,5 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
export const label = styled('div', `
@ -38,7 +39,36 @@ export const columns = styled('div', `
gap: 4px;
`);
export const submit = styled('div', `
export const submitButtons = styled('div', `
display: flex;
justify-content: center;
column-gap: 8px;
`);
export const resetButton = styled('button', `
line-height: inherit;
font-size: ${vars.mediumFontSize};
padding: 10px 24px;
cursor: pointer;
background-color: transparent;
color: ${vars.primaryBg};
border: 1px solid ${vars.primaryBg};
border-radius: 4px;
outline-color: ${vars.primaryBgHover};
&:hover {
color: ${vars.primaryBgHover};
border-color: ${vars.primaryBgHover};
}
&:disabled {
cursor: not-allowed;
color: ${colors.light};
background-color: ${colors.slate};
border-color: ${colors.slate};
}
`);
export const submitButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
@ -52,11 +82,18 @@ export const submit = styled('div', `
font-size: 13px;
cursor: pointer;
line-height: inherit;
outline-color: ${vars.primaryBgHover};
}
& input[type="submit"]:hover {
border-color: ${vars.primaryBgHover};
background-color: ${vars.primaryBgHover};
}
& input[type="submit"]:disabled {
cursor: not-allowed;
color: ${colors.light};
background-color: ${colors.slate};
border-color: ${colors.slate};
}
`);
// TODO: break up into multiple variables, one for each field type.
@ -72,12 +109,10 @@ export const field = styled('div', `
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline: none;
outline-color: ${vars.primaryBgHover};
}
& input[type="text"] {
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
line-height: inherit;
width: 100%;
color: ${colors.dark};
@ -101,6 +136,9 @@ export const field = styled('div', `
margin-right: 8px;
vertical-align: baseline;
}
& input[type="checkbox"]:focus {
outline-color: ${vars.primaryBgHover};
}
& input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg};
@ -171,11 +209,19 @@ export const toggle = styled('label', `
& input[type='checkbox'] {
position: absolute;
}
& input[type='checkbox']:focus {
outline: none;
}
& > span {
margin-left: 8px;
}
`);
export const toggleLabel = styled('span', `
font-size: 13px;
font-weight: 700;
`);
export const gristSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
@ -185,8 +231,8 @@ export const gristSwitchSlider = styled('div', `
bottom: 0;
background-color: #ccc;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: background-color .4s;
transition: background-color .4s;
&:hover {
box-shadow: 0 0 1px #2196F3;
@ -203,8 +249,8 @@ export const gristSwitchCircle = styled('div', `
bottom: 2px;
background-color: white;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: transform .4s;
transition: transform .4s;
`);
export const gristSwitch = styled('div', `
@ -214,6 +260,11 @@ export const gristSwitch = styled('div', `
display: inline-block;
flex: none;
input:focus + & > .${gristSwitchSlider.className} {
outline: 2px solid ${vars.primaryBgHover};
outline-offset: 1px;
}
input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg};
}
@ -239,16 +290,52 @@ export const checkbox = styled('label', `
}
`);
export const hybridSelect = styled('div', `
position: relative;
`);
export const select = styled('select', `
position: absolute;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline: none;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
@media ${mediaXSmall} {
& {
outline: revert;
outline-color: ${vars.primaryBgHover};
position: relative;
}
}
`);
export const searchSelect = styled('div', `
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
select:focus + & {
outline: 2px solid ${vars.primaryBgHover};
}
`);
export const searchSelectIcon = styled(icon, `
flex-shrink: 0;
`);

@ -9,6 +9,7 @@ import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
@ -93,17 +94,23 @@ export class ColumnsModel extends BoxModel {
const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
// Finally, remove the fields and save the changes to the layout.
await this.parent?.save(async () => {
// FormView is particularly sensitive to the order that view fields and
// the form layout are modified. Specifically, if the layout is
// modified before view fields are removed, deleting a column with
// mapped fields inside seems to break. The same issue affects sections
// containing mapped fields. Reversing the order causes no such issues.
//
// TODO: narrow down why this happens and see if it's worth fixing.
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
});
}
}
@ -218,16 +225,12 @@ export class PlaceholderModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {type: 'Paragraph', text, alignment};
}
export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'};
return {id: uuidv4(), type: 'Placeholder'};
}
export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]};
}
const cssPlaceholder = styled('div', `

@ -7,7 +7,7 @@ import {makeT} from 'app/client/lib/localization';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
const testId = makeTestId('test-forms-');
const t = makeT('FormView.Editor');
@ -27,9 +27,13 @@ interface Props {
*/
click?: (ev: MouseEvent, box: BoxModel) => void,
/**
* Custom remove icon. If null, then no drop icon is shown.
* Whether to show the remove button. Defaults to true.
*/
removeIcon?: IconName|null,
showRemoveButton?: BindableValue<boolean>,
/**
* Custom remove icon.
*/
removeIcon?: IconName,
/**
* Custom remove button rendered atop overlay.
*/
@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
}
await box.save(async () => {
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
// When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no
// field yet). In this case, we need to create a field first.
if (dropped.type === 'Field' && typeof dropped.leaf === 'string') {
dropped.leaf = await view.showColumn(dropped.leaf);
}
box.accept(dropped, wasBelow ? 'below' : 'above');
});
}),
@ -225,10 +234,9 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
testId('element'),
dom.attr('data-box-model', String(box.type)),
dom.maybe(overlay, () => style.cssSelectedOverlay()),
// Custom icons for removing.
props.removeIcon === null || props.removeButton ? null :
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
props.removeButton ?? null,
dom.maybe(props.showRemoveButton ?? true, () => [
props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
]),
...args,
);
}

@ -1,4 +1,4 @@
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
@ -8,6 +8,7 @@ import {refRecord} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {isBlankValue} from 'app/common/gristTypes';
import {Constructor, not} from 'app/common/gutil';
import {
BindableValue,
@ -102,18 +103,6 @@ export class FieldModel extends BoxModel {
);
}
public async afterDrop() {
// Base class does good job of handling drop.
await super.afterDrop();
if (this.isDisposed()) { return; }
// Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
// field yet). In this case, we need to create a field.
if (typeof this.leaf.get() === 'string') {
this.leaf.set(await this.view.showColumn(this.leaf.get()));
}
}
public override render(...args: IDomArgs<HTMLElement>): HTMLElement {
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
const save = (value: string) => {
@ -287,20 +276,14 @@ class TextModel extends Question {
class ChoiceModel extends Question {
protected choices: Computed<string[]> = Computed.create(this, use => {
// Read choices from field.
const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || [];
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
// Make sure it is array of strings.
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
// Make sure it is an array of strings.
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return [];
} else {
return choices;
}
return list;
});
protected choicesWithEmpty = Computed.create(this, use => {
const list: Array<string|null> = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(null);
return list;
});
public renderInput(): HTMLElement {
@ -309,21 +292,27 @@ class ChoiceModel extends Question {
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice ?? CHOOSE_TEXT, {value: choice ?? ''})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends ChoiceModel {
private _choices = Computed.create(this, use => {
// Support for 30 choices. TODO: make limit dynamic.
return use(this.choices).slice(0, 30);
});
public renderInput() {
const field = this.model.field;
return dom('div',
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
choice
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom.maybe(use => use(this._choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
);
@ -382,22 +371,22 @@ class DateTimeModel extends Question {
}
class RefListModel extends Question {
protected choices = this._subscribeForChoices();
protected options = this._getOptions();
public renderInput() {
return dom('div',
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
String(choice[1] ?? '')
option.label,
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom('div', 'No choices defined'),
dom.maybe(use => use(this.options).length === 0, () => [
dom('div', 'No values in show column of referenced table'),
]),
) as HTMLElement;
}
private _subscribeForChoices() {
private _getOptions() {
const tableId = Computed.create(this, use => {
const refTable = use(use(this.model.column).refTable);
return refTable ? use(refTable.tableId) : '';
@ -411,27 +400,23 @@ class RefListModel extends Question {
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
return Computed.create(this, use => {
const unsorted = use(observer);
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
return unsorted.slice(0, 50); // TODO: pagination or a waning
return use(observer)
.filter(([_id, value]) => !isBlankValue(value))
.map(([id, value]) => ({label: String(value), value: String(id)}))
.sort((a, b) => a.label.localeCompare(b.label))
.slice(0, 30); // TODO: make limit dynamic.
});
}
}
class RefModel extends RefListModel {
protected withEmpty = Computed.create(this, use => {
const list = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(['', CHOOSE_TEXT]);
return list;
});
public renderInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.model.colId),
dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
);
}
}

@ -1,7 +1,7 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {NewBox} from 'app/client/components/Forms/Menu';
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
@ -15,13 +15,16 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menuCssClass} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {isOwner} from 'app/common/roles';
@ -31,6 +34,7 @@ import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
import * as ko from 'knockout';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const t = makeT('FormView');
@ -42,6 +46,7 @@ export class FormView extends Disposable {
public viewSection: ViewSectionRec;
public selectedBox: Computed<BoxModel | null>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableDeleteSection: Computed<boolean>;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
@ -49,17 +54,20 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
private _url: Computed<string>;
private _copyingLink: Observable<boolean>;
private _previewUrl: Computed<string>;
private _pageShare: Computed<ShareRec | null>;
private _remoteShare: AsyncComputed<{key: string}|null>;
private _isFork: Computed<boolean>;
private _published: Computed<boolean>;
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
private _openingForm: Observable<boolean>;
private _formElement: HTMLElement;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
@ -124,15 +132,22 @@ export class FormView extends Disposable {
}));
this.viewSection.selectedFields(this.selectedColumns.peek());
this._formFields = Computed.create(this, use => {
const fields = use(use(this.viewSection.viewFields).getObservable());
return fields.filter(f => use(use(f.column).isFormCol));
});
this._autoLayout = Computed.create(this, use => {
// If the layout is already there, don't do anything.
const existing = use(this.viewSection.layoutSpecObj);
if (!existing || !existing.id) {
const fields = use(use(this.viewSection.viewFields).getObservable());
const fields = use(this._formFields);
const layout = use(this.viewSection.layoutSpecObj);
if (!layout || !layout.id) {
return this._formTemplate(fields);
} else {
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
return patchedLayout;
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
@ -166,12 +181,7 @@ export class FormView extends Disposable {
copy: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
// Add this box as a json to clipboard.
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
selected.copySelf().catch(reportError);
},
cut: () => {
const selected = this.selectedBox.get();
@ -179,7 +189,7 @@ export class FormView extends Disposable {
selected.cutSelf().catch(reportError);
},
paste: () => {
const doPast = async () => {
const doPaste = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) {
@ -187,13 +197,14 @@ export class FormView extends Disposable {
} else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
}
// Remove the original box from the clipboard.
const cut = this._root.find(boxInClipboard.id);
cut?.removeSelf();
const maybeCutBox = this._root.find(boxInClipboard.id);
if (maybeCutBox?.cut.get()) {
maybeCutBox.removeSelf();
}
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
doPaste().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
@ -242,7 +253,7 @@ export class FormView extends Disposable {
},
clearValues: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if (!selected || selected.canRemove?.() === false) { return; }
keyboardActions.nextField();
this.bundle(async () => {
await selected.deleteSelf();
@ -267,6 +278,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
insertField: (what: NewBox) => {
@ -287,6 +299,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
showColumns: (colIds: string[]) => {
@ -299,6 +312,7 @@ export class FormView extends Disposable {
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
id: uuidv4(),
leaf: fieldRef,
type: 'Field' as FormLayoutNodeType,
};
@ -332,7 +346,7 @@ export class FormView extends Disposable {
hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
this._previewUrl = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = urlState().makeUrl({
@ -344,8 +358,6 @@ export class FormView extends Disposable {
return url;
});
this._copyingLink = Observable.create(this, false);
this._pageShare = Computed.create(this, use => {
const page = use(use(this.viewSection.view).page);
if (!page) { return null; }
@ -366,7 +378,15 @@ export class FormView extends Disposable {
}
});
this._isFork = Computed.create(this, use => {
const {docPageModel} = this.gristDoc;
return use(docPageModel.isFork) || use(docPageModel.isPrefork);
});
this._published = Computed.create(this, use => {
const isFork = use(this._isFork);
if (isFork) { return false; }
const pageShare = use(this._pageShare);
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
const validShare = pageShare && remoteShare;
@ -384,6 +404,8 @@ export class FormView extends Disposable {
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
this._openingForm = Observable.create(this, false);
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
@ -401,7 +423,7 @@ export class FormView extends Disposable {
testId('editor'),
style.cssFormEditBody(
style.cssFormContainer(
dom.forEach(this._root.children, (child) => {
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
if (!child) {
return dom('div', 'Empty node');
}
@ -410,11 +432,12 @@ export class FormView extends Disposable {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
})),
this._buildPublisher(),
),
),
dom.on('click', () => this.selectedBox.set(null))
dom.on('click', () => this.selectedBox.set(null)),
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
);
}
@ -443,6 +466,7 @@ export class FormView extends Disposable {
}
// And add it into the layout.
this.selectedBox.set(insert({
id: uuidv4(),
leaf: fieldRef,
type: 'Field'
}));
@ -612,67 +636,90 @@ export class FormView extends Disposable {
private _buildPublisher() {
return style.cssSwitcher(
this._buildSwitcherMessage(),
this._buildNotifications(),
style.cssButtonGroup(
style.cssSmallIconButton(
style.cssIconButton.cls('-frameless'),
style.cssSmallButton(
style.cssSmallButton.cls('-frameless'),
icon('Revert'),
testId('reset'),
dom('div', 'Reset form'),
dom('div', t('Reset form')),
dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'),
dom.style('margin-right', 'auto'), // move it to the left
dom.on('click', () => {
this._resetForm().catch(reportError);
})
),
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());
}
return confirmModal(t('Are you sure you want to reset your form?'),
t('Reset'),
() => this._resetForm(),
);
})
),
style.cssIconButton(
icon('FieldAttachment'),
testId('link'),
dom('div', 'Copy Link'),
dom.prop('disabled', this._copyingLink),
dom.domComputed(this._published, published => {
if (published) {
return style.cssSmallButton(
testId('view'),
icon('EyeShow'),
t('View'),
dom.boolAttr('disabled', this._openingForm),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
await this.save();
}
try {
this._openingForm.set(true);
window.open(await this._getFormUrl());
} finally {
this._openingForm.set(false);
}
})
);
} else {
return style.cssSmallLinkButton(
testId('preview'),
icon('EyeShow'),
t('Preview'),
dom.attr('href', this._previewUrl),
dom.prop('target', '_blank'),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
stopEvent(ev);
await this.save();
window.open(this._previewUrl.get());
}
})
);
}
}),
style.cssSmallButton(
icon('Share'),
testId('share'),
dom('div', t('Share')),
dom.show(use => this._isOwner && use(this._published)),
dom.on('click', async (_event, element) => {
try {
this._copyingLink.set(true);
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
"text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
});
await copyToClipboard(data);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch (ex) {
if (ex.code === 'AUTH_NO_OWNER') {
throw new Error('Sharing a form is only available to owners');
}
} finally {
this._copyingLink.set(false);
}
}),
elem => {
setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
...defaultMenuOptions,
placement: 'top-end',
});
},
),
dom.domComputed(this._published, published => {
dom.domComputed(use => {
const isFork = use(this._isFork);
const published = use(this._published);
return published
? style.cssIconButton(
dom('div', 'Unpublish'),
? style.cssSmallButton(
dom('div', t('Unpublish')),
dom.show(this._isOwner),
style.cssIconButton.cls('-warning'),
style.cssSmallButton.cls('-warning'),
dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'),
)
: style.cssIconButton(
dom('div', 'Publish'),
: style.cssSmallButton(
dom('div', t('Publish')),
dom.boolAttr('disabled', isFork),
!isFork ? null : hoverTooltip(t('Save your document to publish this form.'), {
placement: 'top',
}),
dom.show(this._isOwner),
cssButton.cls('-primary'),
dom.on('click', () => this._handleClickPublish()),
@ -683,7 +730,7 @@ export class FormView extends Disposable {
);
}
private async _getFormLink() {
private async _getFormUrl() {
const share = this._pageShare.get();
if (!share) {
throw new Error('Unable to get form link: form is not published');
@ -703,7 +750,139 @@ export class FormView extends Disposable {
});
}
private _buildSwitcherMessage() {
private _buildShareMenu(ctl: IOpenController) {
const formUrl = Observable.create<string | null>(ctl, null);
const showEmbedCode = Observable.create(this, false);
const embedCode = Computed.create(ctl, formUrl, (_use, url) => {
if (!url) { return null; }
return '<iframe style="border: none; width: 640px; ' +
`height: ${this._getEstimatedFormHeightPx()}px" src="${url}"></iframe>`;
});
// Reposition the popup when its height changes.
ctl.autoDispose(formUrl.addListener(() => ctl.update()));
ctl.autoDispose(showEmbedCode.addListener(() => ctl.update()));
this._getFormUrl()
.then((url) => {
if (ctl.isDisposed()) { return; }
formUrl.set(url);
})
.catch((e) => {
ctl.close();
reportError(e);
});
return style.cssShareMenu(
dom.cls(menuCssClass),
style.cssShareMenuHeader(
style.cssShareMenuCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
),
),
style.cssShareMenuBody(
dom.domComputed(use => {
const url = use(formUrl);
const code = use(embedCode);
if (!url || !code) {
return style.cssShareMenuSpinner(loadingSpinner());
}
return [
dom('div',
style.cssShareMenuSectionHeading(
t('Share this form'),
),
dom('div',
style.cssShareMenuHintText(
t('Anyone with the link below can see the empty form and submit a response.'),
),
style.cssShareMenuUrlBlock(
style.cssShareMenuUrl(
{readonly: true, value: url},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCopyButton(
testId('link'),
t('Copy link'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(url);
showTransientTooltip(
el,
t('Link copied to clipboard'),
{key: 'share-form-menu'}
);
})
),
),
),
),
dom.domComputed(showEmbedCode, (showCode) => {
if (!showCode) {
return dom('div',
style.cssShareMenuEmbedFormButton(
t('Embed this form'),
dom.on('click', () => showEmbedCode.set(true)),
)
);
} else {
return dom('div',
style.cssShareMenuSectionHeading(t('Embed this form')),
dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock(
style.cssShareMenuCode(
code,
{readonly: true, rows: '3'},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCodeBlockButtons(
style.cssShareMenuCopyButton(
testId('code'),
t('Copy code'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(code);
showTransientTooltip(
el,
t('Code copied to clipboard'),
{key: 'share-form-menu'}
);
}),
),
),
)),
);
}
}),
];
}),
),
);
}
private _getEstimatedFormHeightPx() {
return (
// Form content height.
this._formElement.scrollHeight +
// Plus top/bottom page padding.
(2 * 52) +
// Plus top/bottom form padding.
(2 * 20) +
// Plus minimum form error height.
38 +
// Plus form footer height.
64
);
}
private _buildNotifications() {
return [
this._buildFormPublishedNotification(),
];
}
private _buildFormPublishedNotification() {
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage(
style.cssSwitcherMessageBody(
@ -726,29 +905,24 @@ export class FormView extends Disposable {
/**
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
id: uuidv4(),
type: 'Field',
leaf: f.id()
} as FormLayoutNode;
leaf: f.id(),
};
});
const section = {
type: 'Section',
children: [
{type: 'Paragraph', text: SECTION_TITLE},
{type: 'Paragraph', text: SECTION_DESC},
...boxes,
],
};
const section = components.Section(...boxes);
return {
id: uuidv4(),
type: 'Layout',
children: [
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{type: 'Submit'}
]
{id: uuidv4(), type: 'Submit'},
],
};
}
@ -758,19 +932,9 @@ export class FormView extends Disposable {
// First we will remove all fields from this section, and add top 9 back.
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
const toAdd = this.viewSection.table().columns().peek().filter(c => {
// If hidden than no.
if (c.isHiddenCol()) { return false; }
// If formula column, no.
if (c.isFormula() && c.formula()) { return false; }
// Attachments are currently unsupported in forms.
if (c.pureType() === 'Attachments') { return false; }
return true;
});
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
const toAdd = this.viewSection.table().columns().peek()
.filter(c => c.isFormCol())
.sort((a, b) => a.parentPos() - b.parentPos());
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
const parentId = colRef.map(() => this.viewSection.id());
@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents);
// Default values when form is reset.
const FORM_TITLE = "## **Form Title**";
const FORM_DESC = "Your form description goes here.";
const SECTION_TITLE = '### **Header**';
const SECTION_DESC = 'Description';

@ -16,7 +16,7 @@ const t = makeT('VisibleFieldsConfig');
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
* the ability to drag and drop fields onto the form.
*/
export class UnmappedFieldsConfig extends Disposable {
export class MappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
return [];
}
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
const cols = this._section.table().visibleColumns()
.filter(c => c.isFormCol() && !fields.has(c.colId()));
return cols.map(col => ({
col,
selected: Observable.create(null, false),
@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
if (this._section.isDisposed()) {
return [];
}
const cols = this._section.viewFields().map(f => f.column());
const cols = this._section.viewFields().map(f => f.column()).all()
.filter(c => c.isFormCol());
return cols.map(col => ({
col,
selected: Observable.create(null, false),
})).all();
}));
})));
const anyUnmappedSelected = Computed.create(this, use => {
@ -65,60 +67,60 @@ export class UnmappedFieldsConfig extends Disposable {
return [
cssHeader(
cssFieldListHeader(t("Unmapped")),
cssFieldListHeader(dom.text(t("Mapped"))),
selectAllLabel(
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',
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
testId('visible-fields'),
dom.forEach(mappedColumns, (field) => {
return this._buildMappedField(field);
})
),
dom.maybe(anyUnmappedSelected, () =>
dom.maybe(anyMappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Map fields")),
dom.on('click', mapSelected),
dom.text(t("Unmap fields")),
dom.on('click', unMapSelected),
testId('visible-hide')
),
basicButton(
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-batch-buttons')
),
),
cssHeader(
cssFieldListHeader(dom.text(t("Mapped"))),
cssFieldListHeader(t("Unmapped")),
selectAllLabel(
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',
testId('visible-fields'),
dom.forEach(mappedColumns, (field) => {
return this._buildMappedField(field);
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
})
),
dom.maybe(anyMappedSelected, () =>
dom.maybe(anyUnmappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Unmap fields")),
dom.on('click', unMapSelected),
dom.text(t("Map fields")),
dom.on('click', mapSelected),
testId('visible-hide')
),
basicButton(
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-batch-buttons')

@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
const unmapped = Computed.create(owner, (use) => {
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
const normalCols = use(viewSection.hiddenColumns).filter(col => {
if (use(col.isHiddenCol)) { return false; }
if (use(col.isFormula) && use(col.formula)) { return false; }
if (use(col.pureType) === 'Attachments') { return false; }
return true;
});
const normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));
const list = normalCols.map(col => {
return {
label: use(col.label),

@ -2,7 +2,6 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRend
import * as elements from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
type Callback = () => Promise<void>;
@ -33,9 +32,7 @@ export abstract class BoxModel extends Disposable {
}
/**
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
* The unique id of the box.
*/
public id: string;
/**
@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable {
parent.children.autoDispose(this);
}
// Store "pointer" to this element.
this.id = uuidv4();
this.id = box.id;
// Create observables for all properties.
this.type = box.type;
@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable {
this.onCreate();
}
/**
* Public method that should be called when this box is dropped somewhere. In derived classes
* this method can send some actions to the server, or do some other work. In particular Field
* will insert or reveal a column.
*/
public async afterDrop() {
}
/**
* The only method that derived classes need to implement. It should return a DOM element that
* represents this box.
@ -134,12 +121,19 @@ export abstract class BoxModel extends Disposable {
}
/**
* Cuts self and puts it into clipboard.
* Copies self and puts it into clipboard.
*/
public async cutSelf() {
public async copySelf() {
[...this.root().traverse()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
}
/**
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
await this.copySelf();
this.cut.set(true);
}
@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable {
this.prop(key).set(boxDef[key]);
}
// Add or delete any children that were removed or added.
const myLength = this.children.get().length;
const newLength = boxDef.children ? boxDef.children.length : 0;
if (myLength > newLength) {
this.children.splice(newLength, myLength - newLength);
} else if (myLength < newLength) {
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
// First remove any children from the model that aren't in `boxDef`.
const boxDefChildren = boxDef.children ?? [];
const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));
for (const child of this.children.get()) {
if (!boxDefChildrenIds.has(child.id)) {
child.removeSelf();
}
}
if (!boxDef.children) { return; }
// Update those that indices are the same.
const min = Math.min(myLength, newLength);
for (let i = 0; i < min; i++) {
const atIndex = this.children.get()[i];
const atIndexDef = boxDef.children[i];
atIndex.update(atIndexDef);
// Then add or update the children from `boxDef` to the model.
const newChildren: BoxModel[] = [];
const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));
for (const boxDefChild of boxDefChildren) {
if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {
newChildren.push(BoxModel.new(boxDefChild, this));
} else {
const existingChild = modelChildrenById.get(boxDefChild.id)!;
existingChild.update(boxDefChild);
newChildren.push(existingChild);
}
}
this.children.set(newChildren);
}
/**
@ -381,12 +376,18 @@ export abstract class BoxModel extends Disposable {
}
}
public canRemove() {
return true;
}
protected onCreate() {
}
}
export class LayoutModel extends BoxModel {
public disableDeleteSection: Computed<boolean>;
constructor(
box: FormLayoutNode,
public parent: BoxModel | null,
@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel {
public view: FormView
) {
super(box, parent, view);
this.disableDeleteSection = Computed.create(this, use => {
return use(this.children).filter(c => c.type === 'Section').length === 1;
});
}
public async save(clb?: Callback) {

@ -1,10 +1,12 @@
import * as css from './styles';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars';
import {not} from 'app/common/gutil';
import {Computed, dom, Observable, styled} from 'grainjs';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {v4 as uuidv4} from 'uuid';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
@ -60,6 +62,10 @@ export class ParagraphModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {id: uuidv4(), type: 'Paragraph', text, alignment};
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};

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

@ -1,5 +1,8 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
import {Paragraph} from 'app/client/components/Forms/Paragraph';
import {Section} from 'app/client/components/Forms/Section';
import {v4 as uuidv4} from 'uuid';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
@ -18,6 +21,7 @@ export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
case 'Placeholder': return Placeholder();
case 'Separator': return Paragraph('---');
case 'Header': return Paragraph('## **Header**', 'center');
default: return {type};
case 'Section': return Section();
default: return {id: uuidv4(), type};
}
}

@ -1,6 +1,6 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons';
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
import {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
@ -239,14 +239,6 @@ export const cssSelect = styled('select', `
border-radius: 3px;
outline: none;
pointer-events: none;
&-invalid {
color: ${theme.inputInvalid};
}
&:has(option[value='']:checked) {
font-style: italic;
color: ${colors.slate};
}
`);
export const cssFieldEditorContent = styled('div', `
@ -373,49 +365,23 @@ export const cssButtonGroup = styled('div', `
`);
export const cssIconLink = styled(bigBasicButtonLink, `
export const cssSmallLinkButton = styled(basicButtonLink, `
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning {
color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg};
border: none;
}
&-warning:hover {
color: ${theme.controlPrimaryFg};
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
min-height: 26px;
`);
export const cssSmallIconButton = styled(basicButton, `
export const cssSmallButton = styled(basicButton, `
display: flex;
align-items: center;
gap: 4px;
min-height: 26px;
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssIconButton = styled(bigBasicButton, `
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning {
color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg};
@ -426,10 +392,6 @@ export const cssIconButton = styled(bigBasicButton, `
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssMarkdownRendered = styled('div', `
@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', `
cursor: pointer;
}
.${cssFieldEditor.className}-selected > &,
.${cssFieldEditor.className}:hover > & {
.${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {
display: flex;
}
&-right {
@ -623,6 +585,124 @@ export const cssRemoveButton = styled('div', `
}
`);
export const cssShareMenu = styled('div', `
color: ${theme.text};
background-color: ${theme.popupBg};
width: min(calc(100% - 16px), 400px);
border-radius: 3px;
padding: 8px;
`);
export const cssShareMenuHeader = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuBody = styled('div', `
box-sizing: content-box;
display: flex;
flex-direction: column;
row-gap: 32px;
padding: 0px 16px 24px 16px;
min-height: 160px;
`);
export const cssShareMenuCloseButton = styled('div', `
flex-shrink: 0;
border-radius: 4px;
cursor: pointer;
padding: 4px;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
export const cssShareMenuSectionHeading = styled('div', `
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 16px;
`);
export const cssShareMenuHintText = styled('div', `
color: ${theme.lightText};
`);
export const cssShareMenuSpinner = styled('div', `
display: flex;
justify-content: center;
align-items: center;
min-height: inherit;
`);
export const cssShareMenuSectionButtons = styled('div', `
display: flex;
justify-content: flex-end;
margin-top: 16px;
`);
export const cssShareMenuUrlBlock = styled('div', `
display: flex;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
border-radius: 3px;
width: 100%;
margin-top: 16px;
`);
export const cssShareMenuUrl = styled('input', `
background: transparent;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
`);
export const cssShareMenuCopyButton = styled(textButton, `
margin-left: 4px;
font-weight: 500;
`);
export const cssShareMenuEmbedFormButton = styled(textButton, `
font-weight: 500;
`);
export const cssShareMenuCodeBlock = styled('div', `
border-radius: 3px;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
`);
export const cssShareMenuCodeBlockButtons = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuCode = styled('textarea', `
background-color: transparent;
border: none;
border-radius: 3px;
word-break: break-all;
width: 100%;
outline: none;
resize: none;
`);
export const cssFormDisabledOverlay = styled('div', `
background-color: ${theme.widgetBg};
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
`);
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
return [
dom.onKeyDown({

@ -129,6 +129,7 @@ async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSect
...record,
layoutSpec: JSON.stringify(viewSectionLayoutSpec),
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()],
shareOptions: '',
});
}
@ -201,7 +202,7 @@ function newViewSectionAction(widget: IPageWidget, viewId: number) {
*/
export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
return cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object') {
if (typeof val === 'object' && val !== null) {
if (mapIds[val.leaf]) {
return {...val, leaf: mapIds[val.leaf]};
}

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

@ -15,7 +15,6 @@ import split = require("lodash/split");
export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
// Note that items with empty cleanText are never suggested.
cleanText: string;
}
@ -65,6 +64,19 @@ interface Word {
pos: number; // Position of the word within the item where it occurred.
}
export interface ACIndexOptions {
/** The max number of items to suggest. Defaults to 50. */
maxResults?: number;
/**
* Suggested matches in the same relative order as items, rather than by score.
*
* Defaults to false.
*/
keepOrder?: boolean;
/** Show items with an empty `cleanText`. Defaults to false. */
showEmptyItems?: boolean;
}
/**
* Implements a search index. It doesn't currently support updates; when any values change, the
* index needs to be rebuilt from scratch.
@ -75,11 +87,12 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
// All words from _allItems, sorted.
private _words: Word[];
private _maxResults = this._options.maxResults ?? 50;
private _keepOrder = this._options.keepOrder ?? false;
private _showEmptyItems = this._options.showEmptyItems ?? false;
// Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50).
// If _keepOrder is true, best matches will be suggested in the order they occur in items,
// rather than order by best score.
constructor(items: Item[], private _maxResults: number = 50, private _keepOrder = false) {
constructor(items: Item[], private _options: ACIndexOptions = {}) {
this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems.
@ -132,7 +145,9 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
// Append enough non-matching indices to reach maxResults.
for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) {
if (this._allItems[i].cleanText && !myMatches.has(i)) {
if (myMatches.has(i)) { continue; }
if (this._allItems[i].cleanText || this._showEmptyItems) {
itemIndices.push(i);
}
}

@ -22,6 +22,7 @@ export type { IOption, IOptionFull } from 'popweasel';
export { getOptionFull } from 'popweasel';
export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {
matchTriggerElemWidth?: boolean;
headerDom?(): DomArg<HTMLElement>;
renderItem?(item: U): DomArg<HTMLElement>;
}
@ -42,6 +43,14 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
this.content = cssMenuWrap(
dom('div',
elem => {
if (opt.matchTriggerElemWidth) {
const style = elem.style;
style.minWidth = _ctl.getTriggerElem().getBoundingClientRect().width + 'px';
style.marginLeft = '0px';
style.marginRight = '0px';
}
},
{class: menuCssClass + ' grist-floating-menu'},
cssMenu.cls(''),
cssMenuExt.cls(''),
@ -113,7 +122,7 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
private _doAction(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element
// selected.
if (value) { this._action(value); }
if (value !== null) { this._action(value); }
this._ctl.close();
}

@ -1,4 +1,4 @@
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {FormLayoutNode, patchLayoutSpec} from 'app/client/components/FormRenderer';
import {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization';
import {getHomeUrl} from 'app/client/models/AppModel';
@ -25,7 +25,13 @@ export class FormModelImpl extends Disposable implements FormModel {
public readonly formLayout = Computed.create(this, this.form, (_use, form) => {
if (!form) { return null; }
return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode;
const layout = safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode | null;
if (!layout) { throw new Error('invalid formLayoutSpec'); }
const patchedLayout = patchLayoutSpec(layout, new Set(Object.keys(form.formFieldsById).map(Number)));
if (!patchedLayout) { throw new Error('invalid formLayoutSpec'); }
return patchedLayout;
});
public readonly submitting = Observable.create<boolean>(this, false);
public readonly submitted = Observable.create<boolean>(this, false);

@ -68,6 +68,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
isHiddenCol: ko.Computed<boolean>;
isFormCol: ko.Computed<boolean>;
// Returns the rowModel for the referenced table, or null, if is not a reference column.
refTable: ko.Computed<TableRec|null>;
@ -144,6 +145,11 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId()));
this.isFormCol = ko.pureComputed(() => (
!this.isHiddenCol() &&
this.pureType() !== 'Attachments' &&
!this.isRealFormula()
));
// Returns the rowModel for the referenced table, or null, if this is not a reference column.
this.refTable = ko.pureComputed(() => {

@ -130,7 +130,7 @@ function buildLocaleSelect(
locale: l.code,
cleanText: l.name.trim().toLowerCase(),
})).sort(propertyCompare("label"));
const acIndex = new ACIndexImpl<LocaleItem>(localeList, 200, true);
const acIndex = new ACIndexImpl<LocaleItem>(localeList, {maxResults: 200, keepOrder: true});
// AC select will show the value (in this case locale) not a label when something is selected.
// To show the label - create another observable that will be in sync with the value, but
// will contain text.

@ -106,7 +106,9 @@ export class FormAPIImpl extends BaseAPI implements FormAPI {
});
} else {
const {shareKey, tableId, colValues} = options;
return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, {
const url = new URL(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`);
url.searchParams.set('utm_source', 'grist-forms');
return this.requestJson(url.href, {
method: 'POST',
body: JSON.stringify({records: [{fields: colValues}]}),
});

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

@ -16,7 +16,7 @@
import * as commands from 'app/client/components/commands';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
import {MappedFieldsConfig} from 'app/client/components/Forms/MappedFieldsConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@ -559,7 +559,7 @@ export class RightPanel extends Disposable {
dom.maybe(this._isForm, () => [
cssSeparator(),
dom.create(UnmappedFieldsConfig, activeSection),
dom.create(MappedFieldsConfig, activeSection),
]),
]);
}
@ -996,19 +996,11 @@ export class RightPanel extends Disposable {
const fieldBox = box as FieldModel;
return use(fieldBox.field);
});
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
const hasText = Computed.create(owner, (use) => {
const selectedBoxWithOptions = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return false; }
switch (box.type) {
case 'Submit':
case 'Paragraph':
case 'Label':
return true;
default:
return false;
}
if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; }
return box;
});
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
@ -1036,24 +1028,12 @@ export class RightPanel extends Disposable {
testId('field-label'),
),
),
// TODO: this is for V1 as it requires full cell editor here.
// cssLabel(t("Default field value")),
// cssRow(
// cssTextInput(
// fromKo(defaultField),
// (val) => defaultField.setAndSave(val),
// ),
// ),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
cssSeparator(),
cssLabel(t("COLUMN TYPE")),
cssSection(
builder.buildSelectTypeDom(),
),
// V2 thing
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
cssSection(
builder.buildFormConfigDom(),
),
@ -1062,36 +1042,44 @@ export class RightPanel extends Disposable {
}),
// Box config
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
dom.maybe(selectedBoxWithOptions, (box) => [
cssLabel(dom.text(box.type)),
dom.maybe(hasText, () => [
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
// Default.
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
cssLabel(t('Layout')),
dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [
buildFormConfigPlaceholder(),
])
))));
}
}
function buildFormConfigPlaceholder() {
return cssFormConfigPlaceholder(
cssFormConfigImg(),
cssFormConfigMessage(
cssFormConfigMessageTitle(t('No field selected')),
dom('div', t('Select a field in the form widget to configure.')),
)
);
}
function disabledSection() {
return cssOverlay(
testId('panel-disabled-section'),
@ -1429,3 +1417,33 @@ const cssLinkInfoPre = styled("pre", `
font-size: ${vars.smallFontSize};
line-height: 1.2;
`);
const cssFormConfigPlaceholder = styled('div', `
display: flex;
flex-direction: column;
row-gap: 16px;
margin-top: 32px;
padding: 8px;
`);
const cssFormConfigImg = styled('div', `
height: 140px;
width: 100%;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-image: var(--icon-FormConfig);
`);
const cssFormConfigMessage = styled('div', `
display: flex;
flex-direction: column;
row-gap: 8px;
color: ${theme.text};
text-align: center;
`);
const cssFormConfigMessageTitle = styled('div', `
font-size: ${vars.largeFontSize};
font-weight: 600;
`);

@ -4,7 +4,8 @@
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { ACIndexImpl, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex";
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
normalizeText } from "app/client/lib/ACIndex";
import { menuDivider } from "app/client/ui2018/menus";
import { icon } from "app/client/ui2018/icons";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
@ -28,20 +29,54 @@ export interface IDropdownWithSearchOptions<T> {
// list of options
options: () => Array<IOption<T>>,
/** Called when the dropdown menu is disposed. */
onClose?: () => void;
// place holder for the search input. Default to 'Search'
placeholder?: string;
// popup options
popupOptions?: IPopupOptions;
/** ACIndexOptions to use for indexing and searching items. */
acOptions?: ACIndexOptions;
/**
* If set, the width of the dropdown menu will be equal to that of
* the trigger element.
*/
matchTriggerElemWidth?: boolean;
}
export interface OptionItemParams<T> {
/** Item label. Normalized and used by ACIndex for indexing and searching. */
label: string;
/** Item value. */
value: T;
/** Defaults to false. */
disabled?: boolean;
/**
* If true, marks this item as the "placeholder" item.
*
* The placeholder item is excluded from indexing, so it's label doesn't
* match search inputs. However, it's still shown when the search input is
* empty.
*
* Defaults to false.
*/
placeholder?: boolean;
}
export class OptionItem<T> implements ACItem, IOptionFull<T> {
public cleanText: string = normalizeText(this.label);
constructor(
public label: string,
public value: T,
public disabled?: boolean
) {}
public label = this._params.label;
public value = this._params.value;
public disabled = this._params.disabled;
public placeholder = this._params.placeholder;
public cleanText = this.placeholder ? '' : normalizeText(this.label);
constructor(private _params: OptionItemParams<T>) {
}
}
export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {
@ -52,7 +87,7 @@ export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): D
);
setPopupToFunc(
elem,
(ctl) => DropdownWithSearch<T>.create(null, ctl, options),
(ctl) => (DropdownWithSearch<T>).create(null, ctl, options),
popupOptions
);
};
@ -68,8 +103,8 @@ class DropdownWithSearch<T> extends Disposable {
constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {
super();
const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems);
const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);
this._items = Observable.create<OptionItem<T>[]>(this, acItems);
this._highlightFunc = () => [];
this._simpleList = this._buildSimpleList();
@ -77,6 +112,7 @@ class DropdownWithSearch<T> extends Disposable {
this._update();
// auto-focus the search input
setTimeout(() => this._inputElem.focus(), 1);
this._ctl.onDispose(() => _options.onClose?.());
}
public get content(): HTMLElement {
@ -87,7 +123,11 @@ class DropdownWithSearch<T> extends Disposable {
const action = this._action.bind(this);
const headerDom = this._buildHeader.bind(this);
const renderItem = this._buildItem.bind(this);
return SimpleList<T>.create(this, this._ctl, this._items, action, {headerDom, renderItem});
return (SimpleList<T>).create(this, this._ctl, this._items, action, {
matchTriggerElemWidth: this._options.matchTriggerElemWidth,
headerDom,
renderItem,
});
}
private _buildHeader() {
@ -110,7 +150,9 @@ class DropdownWithSearch<T> extends Disposable {
private _buildItem(item: OptionItem<T>) {
return [
buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
item.placeholder
? cssPlaceholderItem(item.label)
: buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
testId('searchable-list-item'),
];
}
@ -125,7 +167,7 @@ class DropdownWithSearch<T> extends Disposable {
private _action(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element
// selected.
if (value) {
if (value !== null) {
this._options.action(value);
}
this._ctl.close();
@ -171,3 +213,10 @@ const cssMenuDivider = styled(menuDivider, `
flex-shrink: 0;
margin: 0;
`);
const cssPlaceholderItem = styled('div', `
color: ${theme.inputPlaceholderFg};
.${cssMenuItem.className}-sel > & {
color: ${theme.menuItemSelectedFg};
}
`);

@ -76,6 +76,7 @@ export type IconName = "ChartArea" |
"FontItalic" |
"FontStrikethrough" |
"FontUnderline" |
"FormConfig" |
"FunctionResult" |
"GreenArrow" |
"Grow" |
@ -232,6 +233,7 @@ export const IconList: IconName[] = ["ChartArea",
"FontItalic",
"FontStrikethrough",
"FontUnderline",
"FormConfig",
"FunctionResult",
"GreenArrow",
"Grow",

@ -983,6 +983,22 @@ export function isNarrowScreenObs(): Observable<boolean> {
return _isNarrowScreenObs;
}
export function isXSmallScreen() {
return window.innerWidth < smallScreenWidth;
}
let _isXSmallScreenObs: Observable<boolean>|undefined;
// Returns a singleton observable for whether the screen is an extra small one.
export function isXSmallScreenObs(): Observable<boolean> {
if (!_isXSmallScreenObs) {
const obs = Observable.create<boolean>(null, isXSmallScreen());
window.addEventListener('resize', () => obs.set(isXSmallScreen()));
_isXSmallScreenObs = obs;
}
return _isXSmallScreenObs;
}
export const cssHideForNarrowScreen = styled('div', `
@media ${mediaSmall} {
& {

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

@ -35,7 +35,7 @@ export function buildCurrencyPicker(
// Create a computed that will display 'Local currency' as a value and label
// when `currency` is undefined.
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel);
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, 200, true);
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, {maxResults: 200, keepOrder: true});
return buildACSelect(owner,
{
acIndex, valueObs,

@ -34,6 +34,7 @@ import * as UserType from 'app/client/widgets/UserType';
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { WidgetType } from 'app/common/widgetTypes';
import { CellValue } from 'app/plugin/GristData';
import { bundleChanges, Computed, Disposable, fromKo,
dom as grainjsDom, makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
@ -129,9 +130,15 @@ export class FieldBuilder extends Disposable {
// Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => {
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => {
if (isForm && key === 'Attachments') {
// Attachments in forms are currently unsupported.
return;
}
const o: IOptionFull<string> = {
value: key as string,
label: def.label,

@ -52,7 +52,10 @@ export function buildTZAutocomplete(
) {
// Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
// fewer than 1000 in practice).
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), 1000, true);
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), {
maxResults: 1000,
keepOrder: true,
});
// Only save valid time zones. If there is no selected item, we'll auto-select and save only
// when there is a good match.

@ -1684,6 +1684,38 @@ export const TelemetryContracts: TelemetryContracts = {
},
},
},
submittedForm: {
category: 'WidgetUsage',
description: 'Triggered when a published form is submitted.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
siteId: {
description: 'The site id.',
dataType: 'number',
},
siteType: {
description: 'The site type.',
dataType: 'string',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
access: {
description: 'The document access level of the user that triggered this event.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
},
},
changedAccessRules: {
category: 'AccessRules',
description: 'Triggered when a change to access rules is saved.',
@ -1776,6 +1808,7 @@ export const TelemetryEvents = StringUnion(
'publishedForm',
'unpublishedForm',
'visitedForm',
'submittedForm',
'changedAccessRules',
);
export type TelemetryEvent = typeof TelemetryEvents.type;

@ -169,6 +169,13 @@ export function isEmptyList(value: CellValue): boolean {
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.List;
}
/**
* Returns whether a value (as received in a DocAction) represents an empty reference list.
*/
export function isEmptyReferenceList(value: CellValue): boolean {
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.ReferenceList;
}
function isNumber(v: CellValue) { return typeof v === 'number' || typeof v === 'boolean'; }
function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; }
function isBoolean(v: CellValue) { return typeof v === 'boolean' || v === 1 || v === 0; }
@ -344,6 +351,21 @@ export function isValidRuleValue(value: CellValue|undefined) {
return value === null || typeof value === 'boolean';
}
/**
* Returns true if `value` is blank.
*
* Blank values include `null`, (trimmed) empty string, and 0-length lists and
* reference lists.
*/
export function isBlankValue(value: CellValue) {
return (
value === null ||
(typeof value === 'string' && value.trim().length === 0) ||
isEmptyList(value) ||
isEmptyReferenceList(value)
);
}
export type RefListValue = [GristObjCode.List, ...number[]]|null;
/**

@ -10,6 +10,7 @@ import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, Impor
TransformRuleMap} from 'app/common/ActiveDocAPI';
import {ApiError} from 'app/common/ApiError';
import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions';
import {isBlankValue} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {localTimestampToUTC} from 'app/common/RelativeDates';
import {DocStateComparison} from 'app/common/UserAPI';
@ -667,12 +668,6 @@ export class ActiveDocImport {
}
}
// Helper function that returns true if a given cell is blank (i.e. null or empty).
function isBlank(value: CellValue): boolean {
return value === null || (typeof value === 'string' && value.trim().length === 0);
}
// Helper function that returns new `colIds` with import prefixes stripped.
function stripPrefixes(colIds: string[]): string[] {
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?
@ -691,13 +686,13 @@ type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;
function getMergeFunction({type}: MergeStrategy): MergeFunction {
switch (type) {
case 'replace-with-nonblank-source': {
return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal;
return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal;
}
case 'replace-all-fields': {
return (srcVal, _destVal) => srcVal;
}
case 'replace-blank-fields-only': {
return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal;
return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal;
}
default: {
// Normally, we should never arrive here. If we somehow do, throw an error.

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

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() {
const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map(
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),
["red", "reddish", "dark red", "radical green", "blue"]);
});
@ -48,7 +48,7 @@ describe('ACIndex', function() {
assert.deepEqual(acResult.items, colors);
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.selectIndex, -1);
@ -161,7 +161,7 @@ describe('ACIndex', 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>;
acResult = acIndex.search("red");
@ -247,7 +247,7 @@ describe('ACIndex', 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>;
// 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"]]);
// Try some messier cases.
const acIndex2 = new ACIndexImpl(messy, 6);
const acIndex2 = new ACIndexImpl(messy, {maxResults: 6});
acResult = acIndex2.search("#r");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "],
@ -280,7 +280,9 @@ describe('ACIndex', 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");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]);
@ -345,7 +347,7 @@ describe('ACIndex', function() {
// tslint:disable:no-console
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`);
const [searchTime, result] = repeat(10, () => acIndex.search("YORK"));

@ -20,29 +20,6 @@ describe('FormView', function() {
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) {
await gu.addNewSection('Form', 'Table1');
@ -69,8 +46,11 @@ describe('FormView', function() {
// Now open the form in external window.
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 driver.findWait('.test-forms-link', 1000).click();
await gu.waitToPass(async () => assert.match(
await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000);
await driver.find('#clipboardText').click();
@ -121,12 +101,9 @@ describe('FormView', function() {
const session = await gu.session().login();
docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
});
after(async function() {
await driver.executeScript(removeClipboardTextArea);
});
gu.withClipboardTextArea();
it('updates creator panel when navigated away', async function() {
// Add 2 new pages.
@ -186,6 +163,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
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 driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -201,6 +184,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
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 driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -216,9 +205,13 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await driver.executeScript(
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01'
);
await gu.sendKeys('01011999');
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 waitForConfirm();
});
@ -239,21 +232,30 @@ describe('FormView', function() {
await gu.choicesEditor.save();
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.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.
await gu.onNewTab(async () => {
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.
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 driver.find("option[value='Bar']").click();
await gu.sendKeys('Baz', Key.ENTER);
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 waitForConfirm();
});
@ -267,6 +269,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
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 driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -282,6 +290,11 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
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 waitForConfirm();
});
@ -314,7 +327,12 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
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[type="submit"]').click();
await waitForConfirm();
@ -339,17 +357,26 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000);
await driver.findWait('select[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()),
['— Choose —', ...['Bar', 'Baz', 'Foo']]
['Select...', ...['Bar', 'Baz', 'Foo']]
);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.value()),
['', ...['2', '3', '1']]
);
await select.click();
await driver.find('option[value="2"]').click();
await driver.find('.test-form-search-select').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 waitForConfirm();
});
@ -379,11 +406,16 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="1"]', 2000).click();
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.findWait('label:has(input[name="D[]"][value="1"])', 2000).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="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 waitForConfirm();
});
@ -402,12 +434,75 @@ describe('FormView', function() {
// Temporarily make A a formula column.
await gu.sendActions([
['AddRecord', 'Table1', null, {A: 'Foo'}],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '"hello"', isFormula: true}],
['ModifyColumn', 'Table1', 'A', {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']);
// 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 driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
@ -418,15 +513,25 @@ describe('FormView', function() {
});
// Make sure we see the new record.
await expectInD(['', 'Hello World']);
await expectInD(['Hello World']);
// 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([
['RemoveRecord', 'Table1', 1],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '', isFormula: false}],
['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
]);
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();
});
@ -851,28 +956,33 @@ describe('FormView', function() {
checkFieldInMore('Reference List');
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);
await plusButton().click();
await clickMenu(type);
await clickMenu(menuLabel ?? type);
await gu.waitForServer();
assert.equal(await elementCount(type), existing + 1);
await gu.undo();
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('Paragraph', 4);
it('basic section', async function() {
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.deepEqual(await readLabels(), ['A', 'B', 'C']);
@ -898,25 +1008,39 @@ describe('FormView', function() {
await gu.waitForServer();
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();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', '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 gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer();
assert.equal(await elementCount('Section'), 0);
assert.deepEqual(await readLabels(), []);
assert.equal(await elementCount('Section'), 1);
// 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();
assert.deepEqual(await hiddenColumns(), ['A', 'B', 'C', 'Choice', 'D']);
await gu.undo();
assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await elementCount('Section'), 2);
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D', 'E']);
assert.deepEqual(await hiddenColumns(), ['Choice']);
await revert();
@ -1243,12 +1367,9 @@ describe('FormView', function() {
const session = await gu.session().teamSite.login();
docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
});
after(async function() {
await driver.executeScript(removeClipboardTextArea);
});
gu.withClipboardTextArea();
it('can submit a form', async function() {
// 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();
}
function plusButton() {
return element('plus');
function plusButton(parent?: WebElement) {
return element('plus', parent);
}
function drops() {

@ -262,7 +262,8 @@ describe('GridViewNewColumnMenu', function () {
// Wait for the side panel animation.
await gu.waitForSidePanel();
//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.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
* each test.

Loading…
Cancel
Save