(core) Refactor forms implementation

Summary: WIP

Test Plan: Existing tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4196
This commit is contained in:
George Gevoian
2024-02-21 14:22:01 -05:00
parent 6800ebfbad
commit c6fd79ac1f
53 changed files with 1746 additions and 1811 deletions

View File

@@ -0,0 +1,360 @@
import * as css from 'app/client/components/FormRendererCss';
import {FormField} from 'app/client/ui/FormAPI';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {CellValue} from 'app/plugin/GristData';
import {Disposable, dom, DomContents, Observable} from 'grainjs';
import {marked} from 'marked';
export const CHOOSE_TEXT = '— Choose —';
/**
* A node in a recursive, tree-like hierarchy comprising the layout of a form.
*/
export interface FormLayoutNode {
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;
successText?: string;
anotherResponse?: boolean;
// Used by Field.
formRequired?: boolean;
leaf?: number;
// Used by Label and Paragraph.
text?: string;
// Used by Paragraph.
alignment?: string;
}
export type FormLayoutNodeType =
| 'Paragraph'
| 'Section'
| 'Columns'
| 'Submit'
| 'Placeholder'
| 'Layout'
| 'Field'
| 'Label'
| 'Separator'
| 'Header';
/**
* Context used by FormRenderer to build each node.
*/
export interface FormRendererContext {
/** Field metadata, keyed by field id. */
fields: Record<number, FormField>;
/** The root of the FormLayoutNode tree. */
rootLayoutNode: FormLayoutNode;
/** Disables the Submit node if true. */
disabled: Observable<boolean>;
/** Error to show above the Submit node. */
error: Observable<string|null>;
}
/**
* A renderer for a form layout.
*
* Takes the root FormLayoutNode and additional context for each node, and returns
* the DomContents of the rendered form.
*
* A closely related set of classes exist in `app/client/components/Forms/*`; those are
* specifically used to render a version of a form that is suitable for displaying within
* a Form widget, where submitting a form isn't possible.
*
* 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 {
const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
return new Renderer(layoutNode, context);
}
protected children: FormRenderer[];
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) {
super();
this.children = (this.layoutNode.children ?? []).map((child) =>
this.autoDispose(FormRenderer.new(child, this.context)));
}
public abstract render(): DomContents;
}
class LabelRenderer extends FormRenderer {
public render() {
return css.label(this.layoutNode.text ?? '');
}
}
class ParagraphRenderer extends FormRenderer {
public render() {
return css.paragraph(
css.paragraph.cls(`-alignment-${this.layoutNode.alignment || 'left'}`),
el => {
el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor'));
},
);
}
}
class SectionRenderer extends FormRenderer {
public render() {
return css.section(
this.children.map((child) => child.render()),
);
}
}
class ColumnsRenderer extends FormRenderer {
public render() {
return css.columns(
{style: `--grist-columns-count: ${this.children.length || 1}`},
this.children.map((child) => child.render()),
);
}
}
class SubmitRenderer extends FormRenderer {
public render() {
return [
css.error(dom.text(use => use(this.context.error) ?? '')),
css.submit(
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');
});
}),
)
),
];
}
}
class PlaceholderRenderer extends FormRenderer {
public render() {
return dom('div');
}
}
class LayoutRenderer extends FormRenderer {
public render() {
return this.children.map((child) => child.render());
}
}
class FieldRenderer extends FormRenderer {
public build(field: FormField) {
const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
return new Renderer();
}
public render() {
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { return null; }
const renderer = this.build(field);
return css.field(renderer.render(field, this.context));
}
}
abstract class BaseFieldRenderer {
public render(field: FormField, context: FormRendererContext) {
return css.field(
this.label(field),
dom('div', this.input(field, context)),
);
}
public name(field: FormField) {
return field.colId;
}
public label(field: FormField) {
return dom('label',
css.label.cls(''),
css.label.cls('-required', Boolean(field.options.formRequired)),
{for: this.name(field)},
field.question,
);
}
public abstract input(field: FormField, context: FormRendererContext): DomContents;
}
class TextRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'text',
name: this.name(field),
required: field.options.formRequired,
});
}
}
class DateRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'date',
name: this.name(field),
required: field.options.formRequired,
});
}
}
class DateTimeRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'datetime-local',
name: this.name(field),
required: field.options.formRequired,
});
}
}
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))
);
}
}
class BoolRenderer extends BaseFieldRenderer {
public render(field: FormField) {
return css.field(
dom('div', this.input(field)),
);
}
public input(field: FormField) {
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,
}),
css.gristSwitch(
css.gristSwitchSlider(),
css.gristSwitchCircle(),
),
dom('span', field.question || field.colId)
);
}
}
class ChoiceListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: string[] = field.options.choices ?? [];
const required = 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),
)),
);
}
}
class RefListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number, CellValue][] = 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 30 choices. TODO: make limit dynamic.
choices.splice(30);
const required = 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] ?? '')),
)),
);
}
}
class RefRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number|string, CellValue][] = 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] ?? ''))),
);
}
}
const FieldRenderers = {
'Text': TextRenderer,
'Choice': ChoiceRenderer,
'Bool': BoolRenderer,
'ChoiceList': ChoiceListRenderer,
'Date': DateRenderer,
'DateTime': DateTimeRenderer,
'Ref': RefRenderer,
'RefList': RefListRenderer,
};
const FormRenderers = {
'Paragraph': ParagraphRenderer,
'Section': SectionRenderer,
'Columns': ColumnsRenderer,
'Submit': SubmitRenderer,
'Placeholder': PlaceholderRenderer,
'Layout': LayoutRenderer,
'Field': FieldRenderer,
'Label': LabelRenderer,
// Aliases for Paragraph.
'Separator': ParagraphRenderer,
'Header': ParagraphRenderer,
};

View File

@@ -0,0 +1,254 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {styled} from 'grainjs';
export const label = styled('div', `
&-required::after {
content: "*";
color: ${vars.primaryBg};
margin-left: 4px;
}
`);
export const paragraph = styled('div', `
&-alignment-left {
text-align: left;
}
&-alignment-center {
text-align: center;
}
&-alignment-right {
text-align: right;
}
`);
export const section = styled('div', `
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
padding: 24px;
margin-top: 24px;
& > div + div {
margin-top: 16px;
}
`);
export const columns = styled('div', `
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
`);
export const submit = styled('div', `
display: flex;
justify-content: center;
align-items: center;
& input[type="submit"] {
background-color: ${vars.primaryBg};
border: 1px solid ${vars.primaryBg};
color: white;
padding: 10px 24px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
line-height: inherit;
}
& input[type="submit"]:hover {
border-color: ${vars.primaryBgHover};
background-color: ${vars.primaryBgHover};
}
`);
// TODO: break up into multiple variables, one for each field type.
export const field = styled('div', `
display: flex;
flex-direction: column;
& input[type="text"],
& input[type="date"],
& input[type="datetime-local"],
& input[type="number"] {
height: 27px;
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline: none;
}
& input[type="text"] {
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
line-height: inherit;
width: 100%;
color: ${colors.dark};
background-color: ${colors.light};
}
& input[type="datetime-local"],
& input[type="date"] {
width: 100%;
line-height: inherit;
}
& input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
--radius: 3px;
position: relative;
margin-right: 8px;
vertical-align: baseline;
}
& input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg};
}
& input[type="checkbox"]:disabled {
--color: ${colors.darkGrey};
cursor: not-allowed;
}
& input[type="checkbox"]::before,
& input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, ${colors.darkGrey});
border-radius: var(--radius);
}
& input[type="checkbox"]:checked::before,
& input[type="checkbox"]:disabled::before,
& input[type="checkbox"]:indeterminate::before {
background-color: var(--color);
}
& input[type="checkbox"]:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
& input[type="checkbox"]:not(:disabled)::after {
background-color: ${colors.light};
}
& input[type="checkbox"]:checked::after,
& input[type="checkbox"]:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: ${colors.light};
}
& > .${label.className} {
color: ${colors.dark};
font-size: 13px;
font-style: normal;
font-weight: 700;
line-height: 16px; /* 145.455% */
margin-top: 8px;
margin-bottom: 8px;
display: block;
}
`);
export const error = styled('div', `
text-align: center;
color: ${colors.error};
min-height: 22px;
`);
export const toggle = styled('label', `
position: relative;
cursor: pointer;
display: inline-flex;
align-items: center;
& input[type='checkbox'] {
position: absolute;
}
& > span {
margin-left: 8px;
}
`);
export const gristSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
&:hover {
box-shadow: 0 0 1px #2196F3;
}
`);
export const gristSwitchCircle = styled('div', `
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
`);
export const gristSwitch = styled('div', `
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg};
}
input:checked + & > .${gristSwitchCircle.className} {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
`);
export const checkboxList = styled('div', `
display: flex;
flex-direction: column;
gap: 4px;
`);
export const checkbox = styled('label', `
display: flex;
&:hover {
--color: ${colors.hover};
}
`);
export const select = styled('select', `
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
`);

View File

@@ -1,3 +1,4 @@
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
@@ -6,7 +7,6 @@ import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {Box} from 'app/common/Forms';
import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
@@ -28,7 +28,7 @@ export class ColumnsModel extends BoxModel {
}
// Dropping a box on this component (Columns) directly will add it as a new column.
public accept(dropped: Box): BoxModel {
public accept(dropped: FormLayoutNode): BoxModel {
if (!this.parent) { throw new Error('No parent'); }
// We need to remove it from the parent, so find it first.
@@ -206,7 +206,7 @@ export class PlaceholderModel extends BoxModel {
...args,
);
function insertBox(childBox: Box) {
function insertBox(childBox: FormLayoutNode) {
// Make sure we have at least as many columns as the index we are inserting at.
if (!box.parent) { throw new Error('No parent'); }
return box.parent.replace(box, childBox);
@@ -218,15 +218,15 @@ export class PlaceholderModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box {
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {type: 'Paragraph', text, alignment};
}
export function Placeholder(): Box {
export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'};
}
export function Columns(): Box {
export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
}

View File

@@ -1,3 +1,4 @@
import {CHOOSE_TEXT, FormLayoutNode} 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';
@@ -7,7 +8,6 @@ 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 {Box, CHOOSE_TEXT} from 'app/common/Forms';
import {Constructor, not} from 'app/common/gutil';
import {
BindableValue,
@@ -78,7 +78,7 @@ export class FieldModel extends BoxModel {
return instance;
});
constructor(box: Box, parent: BoxModel | null, view: FormView) {
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
this.required = Computed.create(this, (use) => {

View File

@@ -1,6 +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 * 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';
@@ -16,13 +17,13 @@ import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {urlState} from 'app/client/models/gristUrlState';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {confirmModal} from 'app/client/ui2018/modals';
import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
@@ -47,7 +48,7 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _autoLayout: Computed<Box>;
private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
@@ -290,14 +291,14 @@ export class FormView extends Disposable {
// Sanity check that type is correct.
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
this._root.save(async () => {
const boxes: Box[] = [];
const boxes: FormLayoutNode[] = [];
for (const colId of colIds) {
const fieldRef = await this.viewSection.showColumn(colId);
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
leaf: fieldRef,
type: 'Field' as BoxType,
type: 'Field' as FormLayoutNodeType,
};
boxes.push(box);
}
@@ -333,8 +334,7 @@ export class FormView extends Disposable {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = urlState().makeUrl({
api: true,
doc: doc.id,
...docUrl(doc),
form: {
vsId: use(this.viewSection.id),
},
@@ -723,11 +723,11 @@ export class FormView extends Disposable {
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
const boxes: Box[] = fields.map(f => {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
type: 'Field',
leaf: f.id()
} as Box;
} as FormLayoutNode;
});
const section = {
type: 'Section',

View File

@@ -1,4 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {FormLayoutNodeType} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, Place} from 'app/client/components/Forms/Model';
@@ -7,14 +8,13 @@ import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import * as menus from 'app/client/ui2018/menus';
import {BoxType} from 'app/common/Forms';
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
const t = makeT('FormView');
const testId = makeTestId('test-forms-menu-');
// New box to add, either a new column of type, an existing column (by column id), or a structure.
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
export type NewBox = {add: string} | {show: string} | {structure: FormLayoutNodeType};
interface Props {
/**
@@ -77,7 +77,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
box?.view.selectedBox.set(box);
// Same for structure.
const struct = (structure: BoxType) => ({structure});
const struct = (structure: FormLayoutNodeType) => ({structure});
// Actions:

View File

@@ -1,6 +1,6 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import * as elements from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {Box, BoxType} from 'app/common/Forms';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
@@ -9,7 +9,7 @@ type Callback = () => Promise<void>;
/**
* A place where to insert a box.
*/
export type Place = (box: Box) => BoxModel;
export type Place = (box: FormLayoutNode) => BoxModel;
/**
* View model constructed from a box JSON structure.
@@ -19,7 +19,7 @@ export abstract class BoxModel extends Disposable {
/**
* A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
*/
public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
public static new(box: FormLayoutNode, parent: BoxModel | null, view: FormView | null = null): BoxModel {
const subClassName = `${box.type.split(':')[0]}Model`;
const factories = elements as any;
const factory = factories[subClassName];
@@ -42,7 +42,7 @@ export abstract class BoxModel extends Disposable {
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
* to change the type of the box just by changing this value. The box is then replaced in the parent.
*/
public type: BoxType;
public type: FormLayoutNodeType;
/**
* List of children boxes.
*/
@@ -65,7 +65,7 @@ export abstract class BoxModel extends Disposable {
/**
* Don't use it directly, use the BoxModel.new factory method instead.
*/
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
constructor(box: FormLayoutNode, public parent: BoxModel | null, public view: FormView) {
super();
this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus));
@@ -149,7 +149,7 @@ export abstract class BoxModel extends Disposable {
* - child: it will add it as a child.
* - swap: swaps with the box
*/
public willAccept(box?: Box|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
public willAccept(box?: FormLayoutNode|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
// If myself and the dropped element share the same parent, and the parent is a column
// element, just swap us.
if (this.parent && box instanceof BoxModel && this.parent === box?.parent && box.parent?.type === 'Columns') {
@@ -166,7 +166,7 @@ export abstract class BoxModel extends Disposable {
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public accept(dropped: Box, hint: 'above'|'below' = 'above') {
public accept(dropped: FormLayoutNode, hint: 'above'|'below' = 'above') {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {
@@ -200,7 +200,7 @@ export abstract class BoxModel extends Disposable {
/**
* Replaces children at index.
*/
public replaceAtIndex(box: Box, index: number) {
public replaceAtIndex(box: FormLayoutNode, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 1, newOne);
return newOne;
@@ -216,13 +216,13 @@ export abstract class BoxModel extends Disposable {
this.replace(box2, box1JSON);
}
public append(box: Box) {
public append(box: FormLayoutNode) {
const newOne = BoxModel.new(box, this);
this.children.push(newOne);
return newOne;
}
public insert(box: Box, index: number) {
public insert(box: FormLayoutNode, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 0, newOne);
return newOne;
@@ -232,7 +232,7 @@ export abstract class BoxModel extends Disposable {
/**
* Replaces existing box with a new one, whenever it is found.
*/
public replace(existing: BoxModel, newOne: Box|BoxModel) {
public replace(existing: BoxModel, newOne: FormLayoutNode|BoxModel) {
const index = this.children.get().indexOf(existing);
if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
@@ -246,20 +246,20 @@ export abstract class BoxModel extends Disposable {
* Creates a place to insert a box before this box.
*/
public placeBeforeFirstChild() {
return (box: Box) => this.insert(box, 0);
return (box: FormLayoutNode) => this.insert(box, 0);
}
// Some other places.
public placeAfterListChild() {
return (box: Box) => this.insert(box, this.children.get().length);
return (box: FormLayoutNode) => this.insert(box, this.children.get().length);
}
public placeAt(index: number) {
return (box: Box) => this.insert(box, index);
return (box: FormLayoutNode) => this.insert(box, index);
}
public placeAfterChild(child: BoxModel) {
return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
return (box: FormLayoutNode) => this.insert(box, this.children.get().indexOf(child) + 1);
}
public placeAfterMe() {
@@ -319,7 +319,7 @@ export abstract class BoxModel extends Disposable {
* The core responsibility of this method is to update this box and all children based on the box JSON.
* This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
*/
public update(boxDef: Box) {
public update(boxDef: FormLayoutNode) {
// If we have a type and the type is changed, then we need to replace the box.
if (this.type && boxDef.type !== this.type) {
if (!this.parent) { throw new Error('Cannot replace detached box'); }
@@ -329,7 +329,7 @@ export abstract class BoxModel extends Disposable {
// Update all properties of self.
for (const someKey in boxDef) {
const key = someKey as keyof Box;
const key = someKey as keyof FormLayoutNode;
// Skip some keys.
if (key === 'id' || key === 'type' || key === 'children') { continue; }
// Skip any inherited properties.
@@ -365,7 +365,7 @@ export abstract class BoxModel extends Disposable {
/**
* Serialize this box to JSON.
*/
public toJSON(): Box {
public toJSON(): FormLayoutNode {
return {
id: this.id,
type: this.type,
@@ -388,7 +388,7 @@ export abstract class BoxModel extends Disposable {
export class LayoutModel extends BoxModel {
constructor(
box: Box,
box: FormLayoutNode,
public parent: BoxModel | null,
public _save: (clb?: Callback) => Promise<void>,
public view: FormView
@@ -420,7 +420,7 @@ export function unwrap<T>(val: T | Computed<T>): T {
return val instanceof Computed ? val.get() : val;
}
export function parseBox(text: string): Box|null {
export function parseBox(text: string): FormLayoutNode|null {
try {
const json = JSON.parse(text);
return json && typeof json === 'object' && json.type ? json : null;

View File

@@ -1,10 +1,10 @@
import * as style from './styles';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {Box} from 'app/common/Forms';
import {dom, styled} from 'grainjs';
const testId = makeTestId('test-forms-');
@@ -51,7 +51,7 @@ export class SectionModel extends BoxModel {
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public override accept(dropped: Box) {
public override accept(dropped: FormLayoutNode) {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {

View File

@@ -1,5 +1,5 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
import {Box, BoxType} from 'app/common/Forms';
/**
* 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
@@ -12,7 +12,7 @@ export * from './Columns';
export * from './Submit';
export * from './Label';
export function defaultElement(type: BoxType): Box {
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
switch(type) {
case 'Columns': return Columns();
case 'Placeholder': return Placeholder();