(core) Forms Improvements

Summary:
 - Forms now have a reset button.
 - Choice and Reference fields in forms now have an improved select menu.
 - Formula and attachments column types are no longer mappable or visible in forms.
 - Fields in a form widget are now removed if their column is deleted.
 - The preview button in a published form widget has been replaced with a view button. It now opens the published form in a new tab.
 - A new share menu for published form widgets, with options to copy a link or embed code.
 - Forms can now have multiple sections.
 - Form widgets now indicate when publishing is unavailable (e.g. in forks or unsaved documents).
 - General improvements to form styling.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4203
This commit is contained in:
George Gevoian
2024-03-20 10:51:59 -04:00
parent aff9c7075c
commit 418681915e
40 changed files with 1643 additions and 617 deletions

View File

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

View File

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

View File

@@ -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', `

View File

@@ -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,
);
}

View File

@@ -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})),
);
}
}

View File

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

View File

@@ -16,7 +16,7 @@ const t = makeT('VisibleFieldsConfig');
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
* the ability to drag and drop fields onto the form.
*/
export class UnmappedFieldsConfig extends Disposable {
export class MappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
@@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
return [];
}
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
const cols = this._section.table().visibleColumns()
.filter(c => c.isFormCol() && !fields.has(c.colId()));
return cols.map(col => ({
col,
selected: Observable.create(null, false),
@@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
if (this._section.isDisposed()) {
return [];
}
const cols = this._section.viewFields().map(f => f.column());
const cols = this._section.viewFields().map(f => f.column()).all()
.filter(c => c.isFormCol());
return cols.map(col => ({
col,
selected: Observable.create(null, false),
})).all();
}));
})));
const anyUnmappedSelected = Computed.create(this, use => {
@@ -64,36 +66,6 @@ export class UnmappedFieldsConfig extends Disposable {
};
return [
cssHeader(
cssFieldListHeader(t("Unmapped")),
selectAllLabel(
dom.on('click', () => {
unmappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
),
),
dom('div',
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
})
),
dom.maybe(anyUnmappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Map fields")),
dom.on('click', mapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
),
cssHeader(
cssFieldListHeader(dom.text(t("Mapped"))),
selectAllLabel(
@@ -124,6 +96,36 @@ export class UnmappedFieldsConfig extends Disposable {
testId('visible-batch-buttons')
),
),
cssHeader(
cssFieldListHeader(t("Unmapped")),
selectAllLabel(
dom.on('click', () => {
unmappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
),
),
dom('div',
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
})
),
dom.maybe(anyUnmappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Map fields")),
dom.on('click', mapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
),
];
}

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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};

View File

@@ -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', `

View File

@@ -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};
}
}

View File

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

View File

@@ -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]};
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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.

View File

@@ -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}]}),
});

View File

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

View File

@@ -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;
`);

View File

@@ -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};
}
`);

View File

@@ -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",

View File

@@ -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} {
& {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;
/**

View File

@@ -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.

View File

@@ -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) => {