mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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>;
|
||||
|
||||
@@ -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};
|
||||
`);
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
@@ -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%;
|
||||
`);
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user