(core) New Grist Forms styling and field options

Summary:
 - New styling for forms.
 - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort).
 - Improved alignment of form fields in columns.
 - Support for additional select input keyboard shortcuts (Enter and Backspace).
 - Prevent submitting form on Enter if an input has focus.
 - Fix for changing form field type causing the field to disappear.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
George Gevoian
2024-04-10 23:50:30 -07:00
parent 661f1c1804
commit 86062a8c28
35 changed files with 2037 additions and 716 deletions

View File

@@ -41,13 +41,52 @@ export interface FormField {
refValues: [number, CellValue][] | null;
}
interface FormFieldOptions {
/** True if the field is required to submit the form. */
formRequired?: boolean;
/** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */
export interface FormFieldOptions {
/** Choices for a Choice or Choice List field. */
choices?: string[];
/** Text or Any field format. Defaults to `"singleline"`. */
formTextFormat?: FormTextFormat;
/** Number of lines/rows for the `"multiline"` option of `formTextFormat`. Defaults to `3`. */
formTextLineCount?: number;
/** Numeric or Int field format. Defaults to `"text"`. */
formNumberFormat?: FormNumberFormat;
/** Toggle field format. Defaults to `"switch"`. */
formToggleFormat?: FormToggleFormat;
/** Choice or Reference field format. Defaults to `"select"`. */
formSelectFormat?: FormSelectFormat;
/**
* Field options alignment.
*
* Only applicable to Choice List and Reference List fields, and Choice and Reference fields
* when `formSelectFormat` is `"radio"`.
*
* Defaults to `"vertical"`.
*/
formOptionsAlignment?: FormOptionsAlignment;
/**
* Field options sort order.
*
* Only applicable to Choice, Choice List, Reference, and Reference List fields.
*
* Defaults to `"default"`.
*/
formOptionsSortOrder?: FormOptionsSortOrder;
/** True if the field is required. Defaults to `false`. */
formRequired?: boolean;
}
export type FormTextFormat = 'singleline' | 'multiline';
export type FormNumberFormat = 'text' | 'spinner';
export type FormToggleFormat = 'switch' | 'checkbox';
export type FormSelectFormat = 'select' | 'radio';
export type FormOptionsAlignment = 'vertical' | 'horizontal';
export type FormOptionsSortOrder = 'default' | 'ascending' | 'descending';
export interface FormAPI {
getForm(options: GetFormOptions): Promise<Form>;
createRecord(options: CreateRecordOptions): Promise<void>;

View File

@@ -1,36 +1,144 @@
import {makeT} from 'app/client/lib/localization';
import * as css from 'app/client/ui/FormPagesCss';
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls} from 'app/common/gristUrls';
import {DomContents, makeTestId} from 'grainjs';
import {DomContents, DomElementArg, styled} from 'grainjs';
const t = makeT('FormContainer');
const testId = makeTestId('test-form-');
export function buildFormContainer(buildBody: () => DomContents) {
return css.formContainer(
css.form(
css.formBody(
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
return cssFormMessagePage(
cssFormMessage(
cssFormMessageBody(
buildBody(),
),
css.formFooter(
css.poweredByGrist(
css.poweredByGristLink(
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
css.gristLogo(),
)
),
css.buildForm(
css.buildFormLink(
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
cssFormMessageFooter(
buildFormFooter(),
),
),
testId('container'),
...args,
);
}
export function buildFormFooter() {
return [
cssPoweredByGrist(
cssPoweredByGristLink(
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
cssGristLogo(),
)
),
cssBuildForm(
cssBuildFormLink(
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
];
}
export const cssFormMessageImageContainer = styled('div', `
margin-top: 28px;
display: flex;
justify-content: center;
`);
export const cssFormMessageImage = styled('img', `
height: 100%;
width: 100%;
`);
export const cssFormMessageText = styled('div', `
color: ${colors.dark};
text-align: center;
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
const cssFormMessagePage = styled('div', `
padding: 16px;
`);
const cssFormMessage = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormMessageBody = styled('div', `
width: 100%;
padding: 20px 48px 20px 48px;
@media ${mediaSmall} {
& {
padding: 20px;
}
}
`);
const cssFormMessageFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
const cssPoweredByGrist = styled('div', `
color: ${colors.darkText};
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0px 10px;
`);
const cssPoweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
const cssGristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(img/logo-grist.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);
const cssBuildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
const cssBuildFormLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
`);

View File

@@ -1,9 +1,13 @@
import {makeT} from 'app/client/lib/localization';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import * as css from 'app/client/ui/FormPagesCss';
import {
buildFormMessagePage,
cssFormMessageImage,
cssFormMessageImageContainer,
cssFormMessageText,
} from 'app/client/ui/FormContainer';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, makeTestId} from 'grainjs';
import {Disposable, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-form-');
@@ -16,11 +20,20 @@ export class FormErrorPage extends Disposable {
}
public buildDom() {
return buildFormContainer(() => [
css.formErrorMessageImageContainer(css.formErrorMessageImage({
src: 'img/form-error.svg',
})),
css.formMessageText(this._message, testId('error-text')),
]);
return buildFormMessagePage(() => [
cssFormErrorMessageImageContainer(
cssFormErrorMessageImage({src: 'img/form-error.svg'}),
),
cssFormMessageText(this._message, testId('error-page-text')),
], testId('error-page'));
}
}
const cssFormErrorMessageImageContainer = styled(cssFormMessageImageContainer, `
height: 281px;
`);
const cssFormErrorMessageImage = styled(cssFormMessageImage, `
max-height: 281px;
max-width: 250px;
`);

View File

@@ -2,18 +2,19 @@ import {FormRenderer} from 'app/client/components/FormRenderer';
import {handleSubmit, TypedFormData} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization';
import {FormModel, FormModelImpl} from 'app/client/models/FormModel';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import {buildFormFooter} from 'app/client/ui/FormContainer';
import {FormErrorPage} from 'app/client/ui/FormErrorPage';
import * as css from 'app/client/ui/FormPagesCss';
import {FormSuccessPage} from 'app/client/ui/FormSuccessPage';
import {colors} from 'app/client/ui2018/cssVars';
import {ApiError} from 'app/common/ApiError';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, Observable, styled, subscribe} from 'grainjs';
import {Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
const t = makeT('FormPage');
const testId = makeTestId('test-form-');
export class FormPage extends Disposable {
private readonly _model: FormModel = new FormModelImpl();
private readonly _error = Observable.create<string|null>(this, null);
@@ -30,7 +31,7 @@ export class FormPage extends Disposable {
}
public buildDom() {
return css.pageContainer(
return cssPageContainer(
dom.domComputed(use => {
const error = use(this._model.error);
if (error) { return dom.create(FormErrorPage, error); }
@@ -38,12 +39,12 @@ export class FormPage extends Disposable {
const submitted = use(this._model.submitted);
if (submitted) { return dom.create(FormSuccessPage, this._model); }
return this._buildFormDom();
return this._buildFormPageDom();
}),
);
}
private _buildFormDom() {
private _buildFormPageDom() {
return dom.domComputed(use => {
const form = use(this._model.form);
const rootLayoutNode = use(this._model.formLayout);
@@ -56,16 +57,24 @@ export class FormPage extends Disposable {
error: this._error,
});
return buildFormContainer(() =>
return dom('div',
cssForm(
dom.autoDispose(formRenderer),
formRenderer.render(),
handleSubmit(this._model.submitting,
(_formData, formElement) => this._handleFormSubmit(formElement),
() => this._handleFormSubmitSuccess(),
(e) => this._handleFormError(e),
cssFormBody(
cssFormContent(
dom.autoDispose(formRenderer),
formRenderer.render(),
handleSubmit(this._model.submitting,
(_formData, formElement) => this._handleFormSubmit(formElement),
() => this._handleFormSubmitSuccess(),
(e) => this._handleFormError(e),
),
),
),
cssFormFooter(
buildFormFooter(),
),
),
testId('page'),
);
});
}
@@ -101,22 +110,40 @@ export class FormPage extends Disposable {
}
}
// TODO: see if we can move the rest of this to `FormRenderer.ts`.
const cssForm = styled('form', `
const cssPageContainer = styled('div', `
height: 100%;
width: 100%;
padding: 20px;
overflow: auto;
`);
const cssForm = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormBody = styled('div', `
width: 100%;
`);
// TODO: break up and move to `FormRendererCss.ts`.
const cssFormContent = styled('form', `
color: ${colors.dark};
font-size: 15px;
line-height: 1.42857143;
& > div + div {
margin-top: 16px;
}
& h1,
& h2,
& h3,
& h4,
& h5,
& h6 {
margin: 4px 0px;
margin: 8px 0px 12px 0px;
font-weight: normal;
}
& h1 {
@@ -149,3 +176,8 @@ const cssForm = styled('form', `
margin: 4px 0px;
}
`);
const cssFormFooter = styled('div', `
padding: 8px 16px;
width: 100%;
`);

View File

@@ -1,139 +0,0 @@
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
import {styled} from 'grainjs';
export const pageContainer = styled('div', `
background-color: ${colors.lightGrey};
height: 100%;
width: 100%;
padding: 52px 0px 52px 0px;
overflow: auto;
@media ${mediaSmall} {
& {
padding: 20px 0px 20px 0px;
}
}
`);
export const formContainer = styled('div', `
padding-left: 16px;
padding-right: 16px;
`);
export const form = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
export const formBody = styled('div', `
width: 100%;
padding: 20px 48px 20px 48px;
@media ${mediaSmall} {
& {
padding: 20px;
}
}
`);
const formMessageImageContainer = styled('div', `
margin-top: 28px;
display: flex;
justify-content: center;
`);
export const formErrorMessageImageContainer = styled(formMessageImageContainer, `
height: 281px;
`);
export const formSuccessMessageImageContainer = styled(formMessageImageContainer, `
height: 215px;
`);
export const formMessageImage = styled('img', `
height: 100%;
width: 100%;
`);
export const formErrorMessageImage = styled(formMessageImage, `
max-height: 281px;
max-width: 250px;
`);
export const formSuccessMessageImage = styled(formMessageImage, `
max-height: 215px;
max-width: 250px;
`);
export const formMessageText = styled('div', `
color: ${colors.dark};
text-align: center;
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
export const formFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
export const poweredByGrist = styled('div', `
color: ${colors.darkText};
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0px 10px;
`);
export const poweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
export const buildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
export const buildFormLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
`);
export const gristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(img/logo-grist.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);

View File

@@ -1,7 +1,11 @@
import {makeT} from 'app/client/lib/localization';
import {FormModel } from 'app/client/models/FormModel';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import * as css from 'app/client/ui/FormPagesCss';
import {FormModel} from 'app/client/models/FormModel';
import {
buildFormMessagePage,
cssFormMessageImage,
cssFormMessageImageContainer,
cssFormMessageText,
} from 'app/client/ui/FormContainer';
import {vars} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
@@ -28,20 +32,20 @@ export class FormSuccessPage extends Disposable {
}
public buildDom() {
return buildFormContainer(() => [
css.formSuccessMessageImageContainer(css.formSuccessMessageImage({
src: 'img/form-success.svg',
})),
css.formMessageText(dom.text(this._successText), testId('success-text')),
return buildFormMessagePage(() => [
cssFormSuccessMessageImageContainer(
cssFormSuccessMessageImage({src: 'img/form-success.svg'}),
),
cssFormMessageText(dom.text(this._successText), testId('success-page-text')),
dom.maybe(this._showNewResponseButton, () =>
cssFormButtons(
cssFormNewResponseButton(
'Submit new response',
t('Submit new response'),
dom.on('click', () => this._handleClickNewResponseButton()),
),
)
),
]);
], testId('success-page'));
}
private async _handleClickNewResponseButton() {
@@ -49,6 +53,15 @@ export class FormSuccessPage extends Disposable {
}
}
const cssFormSuccessMessageImageContainer = styled(cssFormMessageImageContainer, `
height: 215px;
`);
const cssFormSuccessMessageImage = styled(cssFormMessageImage, `
max-height: 215px;
max-width: 250px;
`);
const cssFormButtons = styled('div', `
display: flex;
justify-content: center;

View File

@@ -1,5 +1,6 @@
import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {numericSpinner} from 'app/client/widgets/NumericSpinner';
import {styled} from 'grainjs';
export const cssIcon = styled(icon, `
@@ -89,3 +90,7 @@ export const cssPinButton = styled('div', `
background-color: ${theme.hover};
}
`);
export const cssNumericSpinner = styled(numericSpinner, `
height: 28px;
`);