(core) Polish forms

Summary:
  - Updates styling of form submitted page.
  - Tweaks styling of checkboxes, labels, and questions on form page.
  - Adds new form 404 page.
  - Adds checkbox to not show warning again when publishing or un-publishing a form.
  - Excludes formula, hidden, and attachment columns in submitted form data.
  - Adds placeholder text to form configuration inputs.
  - Improves dark mode styling in Form widget.
  - Updates default title and description of new forms.
  - Updates styling of Form widget buttons.
  - Fixes form success text input handling.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4170
This commit is contained in:
George Gevoian
2024-01-24 01:58:19 -08:00
parent b77c762358
commit 6cb8614017
26 changed files with 769 additions and 302 deletions

View File

@@ -1,5 +1,5 @@
import {BillingPage} from 'app/client/ui/BillingPage';
import {setupPage} from 'app/client/ui/setupPage';
import {setUpPage} from 'app/client/ui/setUpPage';
import {dom} from 'grainjs';
setupPage((appModel) => dom.create(BillingPage, appModel));
setUpPage((appModel) => dom.create(BillingPage, appModel));

View File

@@ -369,11 +369,8 @@ class RefListModel extends Question {
public renderInput() {
return dom('div',
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssLabel(
dom('input',
dom.prop('name', this.model.colId),
{type: 'checkbox', value: String(choice[0]), style: 'margin-right: 5px;'}
),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
String(choice[1] ?? '')
)),
dom.maybe(use => use(this.choices).length === 0, () => [

View File

@@ -361,7 +361,7 @@ export class FormView extends Disposable {
}
public buildDom() {
return style.cssFormView(
return style.cssFormView(
testId('editor'),
style.cssFormEditBody(
style.cssFormContainer(
@@ -427,120 +427,145 @@ export class FormView extends Disposable {
}
}
private async _publish() {
confirmModal(t('Publish your form?'),
t('Publish'),
async () => {
const page = this.viewSection.view().page();
if (!page) {
throw new Error('Unable to publish form: undefined page');
}
let validShare = page.shareRef() !== 0;
// If page is shared, make sure home server is aware of it.
if (validShare) {
try {
const pageShare = page.share();
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
validShare = !!serverShare;
} catch(ex) {
// TODO: for now ignore the error, but the UI should be updated to not show editor
if (ex.code === 'AUTH_NO_OWNER') {
return;
}
throw ex;
private async _handleClickPublish() {
if (this.gristDoc.appModel.dismissedPopups.get().includes('publishForm')) {
await this._publishForm();
} else {
confirmModal(t('Publish your form?'),
t('Publish'),
async (dontShowAgain) => {
await this._publishForm();
if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('publishForm').set(true);
}
}
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
if (!validShare) {
const shareRef = await this.gristDoc.docModel.docData.sendAction([
'AddRecord',
'_grist_Shares',
null,
{
linkId: uuidv4(),
options: JSON.stringify({
publish: true,
}),
}
]);
await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]);
} else {
const share = page.share();
share.optionsObj.update({publish: true});
await share.optionsObj.save();
}
await this.save();
this.viewSection.shareOptionsObj.update({
form: true,
publish: true,
});
await this.viewSection.shareOptionsObj.save();
});
},
{
explanation: (
dom('div',
style.cssParagraph(
t(
'Publishing your form will generate a share link. Anyone with the link can ' +
'see the empty form and submit a response.'
},
{
explanation: (
dom('div',
style.cssParagraph(
t(
'Publishing your form will generate a share link. Anyone with the link can ' +
'see the empty form and submit a response.'
),
),
),
style.cssParagraph(
t(
'Users are limited to submitting ' +
'entries (records in your table) and reading pre-set values in designated ' +
'fields, such as reference and choice columns.'
style.cssParagraph(
t(
'Users are limited to submitting ' +
'entries (records in your table) and reading pre-set values in designated ' +
'fields, such as reference and choice columns.'
),
),
),
)
),
},
);
)
),
hideDontShowAgain: false,
},
);
}
}
private async _unpublish() {
confirmModal(t('Unpublish your form?'),
t('Unpublish'),
async () => {
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
this.viewSection.shareOptionsObj.update({
publish: false,
});
await this.viewSection.shareOptionsObj.save();
const view = this.viewSection.view();
if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) {
const share = this._pageShare.get();
if (!share) { return; }
share.optionsObj.update({
publish: false,
});
await share.optionsObj.save();
private async _publishForm() {
const page = this.viewSection.view().page();
if (!page) {
throw new Error('Unable to publish form: undefined page');
}
let validShare = page.shareRef() !== 0;
// If page is shared, make sure home server is aware of it.
if (validShare) {
try {
const pageShare = page.share();
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
validShare = !!serverShare;
} catch(ex) {
// TODO: for now ignore the error, but the UI should be updated to not show editor
if (ex.code === 'AUTH_NO_OWNER') {
return;
}
throw ex;
}
}
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
if (!validShare) {
const shareRef = await this.gristDoc.docModel.docData.sendAction([
'AddRecord',
'_grist_Shares',
null,
{
linkId: uuidv4(),
options: JSON.stringify({
publish: true,
}),
}
});
},
{
explanation: (
dom('div',
style.cssParagraph(
t(
'Unpublishing the form will disable the share link so that users accessing ' +
'your form via that link will see an error.'
),
),
)
),
},
);
]);
await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]);
} else {
const share = page.share();
share.optionsObj.update({publish: true});
await share.optionsObj.save();
}
await this.save();
this.viewSection.shareOptionsObj.update({
form: true,
publish: true,
});
await this.viewSection.shareOptionsObj.save();
});
}
private async _handleClickUnpublish() {
if (this.gristDoc.appModel.dismissedPopups.get().includes('unpublishForm')) {
await this._unpublishForm();
} else {
confirmModal(t('Unpublish your form?'),
t('Unpublish'),
async (dontShowAgain) => {
await this._unpublishForm();
if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('unpublishForm').set(true);
}
},
{
explanation: (
dom('div',
style.cssParagraph(
t(
'Unpublishing the form will disable the share link so that users accessing ' +
'your form via that link will see an error.'
),
),
)
),
hideDontShowAgain: false,
},
);
}
}
private async _unpublishForm() {
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
this.viewSection.shareOptionsObj.update({
publish: false,
});
await this.viewSection.shareOptionsObj.save();
const view = this.viewSection.view();
if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) {
const share = this._pageShare.get();
if (!share) { return; }
share.optionsObj.update({
publish: false,
});
await share.optionsObj.save();
}
});
}
private _buildPublisher() {
return style.cssSwitcher(
this._buildSwitcherMessage(),
style.cssButtonGroup(
style.cssIconButton(
style.cssSmallIconButton(
style.cssIconButton.cls('-frameless'),
icon('Revert'),
testId('reset'),
@@ -608,14 +633,14 @@ export class FormView extends Disposable {
dom('div', 'Unpublish'),
dom.show(this.gristDoc.appModel.isOwner()),
style.cssIconButton.cls('-warning'),
dom.on('click', () => this._unpublish()),
dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'),
)
: style.cssIconButton(
dom('div', 'Publish'),
dom.show(this.gristDoc.appModel.isOwner()),
cssButton.cls('-primary'),
dom.on('click', () => this._publish()),
dom.on('click', () => this._handleClickPublish()),
testId('publish'),
);
}),
@@ -685,6 +710,9 @@ export class FormView extends Disposable {
// 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());
@@ -714,9 +742,8 @@ defaults(FormView.prototype, BaseView.prototype);
Object.assign(FormView.prototype, BackboneEvents);
// Default values when form is reset.
const FORM_TITLE = "## **My Super Form**";
const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " +
"give you the best possible experience with this feature";
const FORM_TITLE = "## **Form Title**";
const FORM_DESC = "Your form description goes here.";
const SECTION_TITLE = '### **Header**';
const SECTION_DESC = 'Description';

View File

@@ -1,7 +1,7 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {basicButton, bigBasicButton, bigBasicButtonLink} 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';
import {marked} from 'marked';
@@ -37,8 +37,6 @@ export const cssFormContainer = styled('div', `
gap: 8px;
`);
export const cssFieldEditor = styled('div.hover_border.field_editor', `
position: relative;
cursor: pointer;
@@ -51,18 +49,18 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', `
transition: transform 0.2s ease-in-out;
&:hover:not(:has(.hover_border:hover),&-cut) {
--hover-visible: visible;
outline: 1px solid ${colors.lightGreen};
outline: 1px solid ${theme.controlPrimaryBg};
}
&-selected:not(&-cut) {
background: #F7F7F7;
outline: 1px solid ${colors.lightGreen};
background: ${theme.lightHover};
outline: 1px solid ${theme.controlPrimaryBg};
--selected-block: block;
}
&:active:not(:has(&:active)) {
outline: 1px solid ${colors.darkGreen};
outline: 1px solid ${theme.controlPrimaryHoverBg};
}
&-drag-hover {
outline: 2px dashed ${colors.lightGreen};
outline: 2px dashed ${theme.controlPrimaryBg};
outline-offset: 2px;
}
&-cut {
@@ -100,14 +98,6 @@ export const cssSection = styled('div', `
}
`);
export const cssLabel = styled('label', `
font-size: 15px;
font-weight: normal;
user-select: none;
display: block;
margin: 0px;
`);
export const cssCheckboxLabel = styled('label', `
font-size: 15px;
font-weight: normal;
@@ -139,7 +129,7 @@ export const cssEditableLabel = styled(textarea, `
cursor: pointer;
min-height: 1.5rem;
color: ${colors.darkText};
color: ${theme.mediumText};
font-size: 12px;
font-weight: 700;
@@ -149,12 +139,12 @@ export const cssEditableLabel = styled(textarea, `
&-edit {
cursor: auto;
background: ${theme.inputBg};
outline: 2px solid black;
outline: 2px solid ${theme.accessRulesFormulaEditorFocus};
outline-offset: 1px;
border-radius: 2px;
}
&-normal {
color: ${colors.darkText};
color: ${theme.mediumText};
font-size: 15px;
font-weight: normal;
}
@@ -172,15 +162,16 @@ export const cssDesc = styled('div', `
`);
export const cssInput = styled('input', `
background-color: ${theme.inputDisabledBg};
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
cursor-events: none;
pointer-events: none;
&-invalid {
color: red;
color: ${theme.inputInvalid};
}
&[type="number"], &[type="date"], &[type="datetime-local"], &[type="text"] {
width: 100%;
@@ -190,19 +181,19 @@ export const cssInput = styled('input', `
export const cssSelect = styled('select', `
flex: auto;
width: 100%;
background-color: ${theme.inputDisabledBg};
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
cursor-events: none;
pointer-events: none;
&-invalid {
color: red;
color: ${theme.inputInvalid};
}
`);
export const cssFieldEditorContent = styled('div', `
`);
@@ -221,14 +212,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
}
`);
export const cssControlsLabel = styled('div', `
background: ${colors.lightGreen};
color: ${colors.light};
padding: 1px 2px;
min-width: 24px;
`);
export const cssPlusButton = styled('div', `
position: relative;
min-height: 32px;
@@ -242,32 +225,18 @@ export const cssCircle = styled('div', `
border-radius: 50%;
width: 24px;
height: 24px;
background-color: ${colors.lightGreen};
color: ${colors.light};
background-color: ${theme.addNewCircleSmallBg};
color: ${theme.addNewCircleSmallFg};
display: flex;
justify-content: center;
align-items: center;
.${cssPlusButton.className}:hover & {
background: ${colors.darkGreen};
background: ${theme.addNewCircleSmallHoverBg};
}
`);
export const cssPlusIcon = styled(icon, `
--icon-color: ${colors.light};
`);
export const cssAddText = styled('div', `
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
z-index: 1;
&:before {
content: "Add a field";
}
.${cssPlusButton.className}-hover &:before {
content: "Drop here";
}
--icon-color: ${theme.controlPrimaryFg};
`);
export const cssPadding = styled('div', `
@@ -300,27 +269,27 @@ export const cssColumn = styled('div', `
justify-content: center;
align-items: center;
padding-right: 8px;
--icon-color: ${colors.slate};
--icon-color: ${theme.lightText};
align-self: stretch;
transition: height 0.2s ease-in-out;
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
color: ${colors.slate};
border: 2px dashed ${theme.inputBorder};
background: ${theme.lightHover};
color: ${theme.lightText};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
}
&-selected {
border: 2px dashed ${colors.slate};
border: 2px dashed ${theme.lightText};
}
&-empty:hover, &-add-button:hover {
border: 2px dashed ${colors.slate};
border: 2px dashed ${theme.lightText};
}
&-drag-over {
outline: 2px dashed ${colors.lightGreen};
outline: 2px dashed ${theme.controlPrimaryBg};
}
&-add-button {
@@ -352,13 +321,10 @@ export const cssButtonGroup = styled('div', `
`);
export const cssIconLink = styled(basicButtonLink, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
export const cssIconLink = styled(bigBasicButtonLink, `
display: flex;
align-items: center;
gap: 4px;
min-height: 24px;
&-standard {
background-color: ${theme.leftPanelBg};
@@ -379,13 +345,21 @@ export const cssIconLink = styled(basicButtonLink, `
}
`);
export const cssIconButton = styled(basicButton, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
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;
min-height: 24px;
&-standard {
background-color: ${theme.leftPanelBg};
@@ -412,6 +386,9 @@ export const cssMarkdownRendered = styled('div', `
& textarea {
font-size: 15px;
}
&-edit textarea {
outline: 2px solid ${theme.accessRulesFormulaEditorFocus};
}
& strong {
font-weight: 600;
}
@@ -425,7 +402,7 @@ export const cssMarkdownRendered = styled('div', `
text-align: right;
}
& hr {
border-color: ${colors.darkGrey};
border-color: ${theme.inputBorder};
margin: 8px 0px;
}
&-separator {
@@ -506,7 +483,7 @@ export const cssDrag = styled(icon, `
top: calc(50% - 16px / 2);
width: 16px;
height: 16px;
--icon-color: ${colors.lightGreen};
--icon-color: ${theme.controlPrimaryBg};
&-top {
top: 16px;
}
@@ -569,7 +546,7 @@ export const cssRemoveButton = styled('div', `
right: 11px;
top: 11px;
border-radius: 3px;
background: ${colors.darkGrey};
background: ${theme.attachmentsEditorButtonHoverBg};
display: none;
height: 16px;
width: 16px;
@@ -582,7 +559,7 @@ export const cssRemoveButton = styled('div', `
width: 13px;
}
&:hover {
background: ${colors.mediumGreyOpaque};
background: ${theme.controlSecondaryHoverBg};
cursor: pointer;
}
.${cssFieldEditor.className}-selected > &,

View File

@@ -1,4 +1,3 @@
import {createErrPage} from 'app/client/ui/errorPages';
import {setupPage} from 'app/client/ui/setupPage';
import {setUpErrPage} from 'app/client/ui/errorPages';
setupPage((appModel) => createErrPage(appModel));
setUpErrPage();

View File

@@ -48,6 +48,7 @@ const G = getBrowserGlobals('document', 'window');
// TopAppModel is the part of the app model that persists across org and user switches.
export interface TopAppModel {
options: TopAppModelOptions;
api: UserAPI;
isSingleOrg: boolean;
productFlavor: ProductFlavor;
@@ -147,6 +148,11 @@ export interface AppModel {
switchUser(user: FullUser, org?: string): Promise<void>;
}
export interface TopAppModelOptions {
/** Defaults to true. */
attachTheme?: boolean;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly isSingleOrg: boolean;
public readonly productFlavor: ProductFlavor;
@@ -167,6 +173,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
constructor(
window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
super();
setErrorNotifier(this.notifier);
@@ -350,7 +357,7 @@ export class AppModelImpl extends Disposable implements AppModel {
) {
super();
this._setTheme();
this._setUpTheme();
this._recordSignUpIfIsNewUser();
const state = urlState().state.get();
@@ -525,9 +532,14 @@ export class AppModelImpl extends Disposable implements AppModel {
);
}
private _setTheme() {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
private _setUpTheme() {
if (
this.topAppModel.options.attachTheme === false ||
// Custom CSS is incompatible with custom themes.
getGristConfig().enableCustomCss
) {
return;
}
attachCssThemeVars(this.currentTheme.get());
this.autoDispose(this.currentTheme.addListener((newTheme, oldTheme) => {

View File

@@ -31,7 +31,6 @@ import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {autoGrow} from 'app/client/ui/forms';
import {GridOptions} from 'app/client/ui/GridOptions';
import {textarea} from 'app/client/ui/inputs';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
@@ -927,11 +926,15 @@ export class RightPanel extends Disposable {
return [
cssLabel(t("Submit button label")),
cssRow(
cssTextInput(submitButton, (val) => submitButton.set(val)),
cssTextInput(submitButton, (val) => submitButton.set(val), {placeholder: 'Submit'}),
),
cssLabel(t("Success text")),
cssRow(
cssTextArea(successText, {onInput: true}, autoGrow(successText)),
cssTextArea(
successText,
{autoGrow: true, save: (val) => successText.set(val)},
{placeholder: 'Thank you! Your response has been recorded.'}
),
),
cssLabel(t("Submit another response")),
cssRow(
@@ -944,7 +947,7 @@ export class RightPanel extends Disposable {
labeledSquareCheckbox(redirection, t('Redirect automatically after submission')),
),
cssRow(
cssTextInput(successURL, (val) => successURL.set(val)),
cssTextInput(successURL, (val) => successURL.set(val), {placeholder: t('Enter redirect URL')}),
dom.show(redirection),
),
];

View File

@@ -4,10 +4,12 @@ import {getLoginUrl, getMainOrgUrl, getSignupUrl, urlState} from 'app/client/mod
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {setUpPage} from 'app/client/ui/setUpPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix, GristLoadConfig} from 'app/common/gristUrls';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
@@ -15,14 +17,22 @@ const testId = makeTestId('test-');
const t = makeT('errorPages');
export function setUpErrPage() {
const {errPage} = getGristConfig();
const attachTheme = errPage !== 'form-not-found';
setUpPage((appModel) => {
return createErrPage(appModel);
}, {attachTheme});
}
export function createErrPage(appModel: AppModel) {
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
const message = gristConfig.errMessage;
return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) :
gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) :
gristConfig.errPage === 'access-denied' ? createForbiddenPage(appModel, message) :
gristConfig.errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
createOtherErrorPage(appModel, message);
const {errMessage, errPage} = getGristConfig();
return errPage === 'signed-out' ? createSignedOutPage(appModel) :
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
errPage === 'form-not-found' ? createFormNotFoundPage(errMessage) :
createOtherErrorPage(appModel, errMessage);
}
/**
@@ -99,6 +109,43 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
]);
}
/**
* Creates a form-specific "Not Found" page.
*/
export function createFormNotFoundPage(message?: string) {
document.title = t("Form not found");
return cssFormErrorPage(
cssFormErrorContainer(
cssFormError(
cssFormErrorBody(
cssFormErrorImage({src: 'forms/form-not-found.svg'}),
cssFormErrorText(
message ?? t('An unknown error occurred.'),
testId('error-text'),
),
),
cssFormErrorFooter(
cssFormPoweredByGrist(
cssFormPoweredByGristLink(
{href: 'https://www.getgrist.com', target: '_blank'},
t('Powered by'),
cssGristLogo(),
)
),
cssFormBuildForm(
cssFormBuildFormLink(
{href: 'https://www.getgrist.com', target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
),
),
),
);
}
/**
* Creates a generic error page with the given message.
*/
@@ -178,3 +225,102 @@ const cssErrorText = styled('div', `
const cssButtonWrap = styled('div', `
margin-bottom: 8px;
`);
const cssFormErrorPage = styled('div', `
--grist-form-padding: 48px;
min-height: 100%;
width: 100%;
padding-top: 52px;
`);
const cssFormErrorContainer = styled('div', `
padding-left: 16px;
padding-right: 16px;
`);
const cssFormError = styled('div', `
display: flex;
text-align: center;
flex-direction: column;
align-items: center;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormErrorBody = styled('div', `
padding: 48px 16px 0px 16px;
`);
const cssFormErrorImage = styled('img', `
width: 250px;
height: 281px;
`);
const cssFormErrorText = styled('div', `
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
const cssFormErrorFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
const cssFormPoweredByGrist = 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;
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
`);
const cssFormPoweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
const cssFormBuildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
const cssFormBuildFormLink = 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};
`);
const cssGristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(forms/logo.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);

View File

@@ -1,6 +1,6 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {AppModel, newUserAPIImpl, TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
@@ -10,13 +10,22 @@ import {dom, DomContents} from 'grainjs';
const G = getBrowserGlobals('document', 'window');
export interface SetUpPageOptions {
/** Defaults to true. */
attachTheme?: boolean;
}
/**
* Sets up error handling and global styles, and replaces the DOM body with
* the result of calling `buildPage`.
*/
export function setupPage(buildPage: (appModel: AppModel) => DomContents) {
export function setUpPage(
buildPage: (appModel: AppModel) => DomContents,
options: SetUpPageOptions = {}
) {
const {attachTheme = true} = options;
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
const topAppModel = TopAppModelImpl.create(null, {}, newUserAPIImpl(), {attachTheme});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();

View File

@@ -167,6 +167,7 @@ export const theme = {
/* Text */
text: new CustomProp('theme-text', undefined, colors.dark),
lightText: new CustomProp('theme-text-light', undefined, colors.slate),
mediumText: new CustomProp('theme-text-medium', undefined, colors.darkText),
darkText: new CustomProp('theme-text-dark', undefined, 'black'),
errorText: new CustomProp('theme-text-error', undefined, colors.error),
errorTextHover: new CustomProp('theme-text-error-hover', undefined, '#BF0A31'),

View File

@@ -4,13 +4,14 @@ import {reportError} from 'app/client/models/errors';
import {cssInput} from 'app/client/ui/cssInput';
import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
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 {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs';
import {cssMenuElem} from 'app/client/ui2018/menus';
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
const t = makeT('modals');
@@ -339,6 +340,8 @@ export function saveModal(
export interface ConfirmModalOptions {
explanation?: DomElementArg,
hideCancel?: boolean;
/** Defaults to true. */
hideDontShowAgain?: boolean;
extraButtons?: DomContents;
modalOptions?: IModalOptions;
saveDisabled?: Observable<boolean>;
@@ -353,22 +356,42 @@ export interface ConfirmModalOptions {
export function confirmModal(
title: DomElementArg,
btnText: DomElementArg,
onConfirm: () => Promise<void>,
{explanation, hideCancel, extraButtons, modalOptions, saveDisabled, width}: ConfirmModalOptions = {},
onConfirm: (dontShowAgain?: boolean) => Promise<void>,
options: ConfirmModalOptions = {},
): void {
return saveModal((ctl, owner): ISaveModalOptions => ({
title,
body: explanation || null,
saveLabel: btnText,
saveFunc: onConfirm,
const {
explanation,
hideCancel,
width: width ?? 'normal',
hideDontShowAgain = true,
extraButtons,
modalOptions,
saveDisabled,
}), modalOptions);
width
} = options;
return saveModal((_ctl, owner): ISaveModalOptions => {
const dontShowAgain = Observable.create(owner, false);
return {
title,
body: [
explanation || null,
hideDontShowAgain ? null : dom('div',
cssDontShowAgainCheckbox(
dontShowAgain,
cssDontShowAgainCheckboxLabel(t("Don't show again")),
testId('modal-dont-show-again'),
),
),
],
saveLabel: btnText,
saveFunc: () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
hideCancel,
width: width ?? 'normal',
extraButtons,
saveDisabled,
};
}, modalOptions);
}
/**
* Creates a simple prompt modal (replacement for the native one).
* Closed via clicking anywhere outside the modal or Cancel button.
@@ -669,3 +692,11 @@ export const cssAnimatedModal = styled('div', `
animation-duration: 0.4s;
position: relative;
`);
const cssDontShowAgainCheckbox = styled(labeledSquareCheckbox, `
line-height: normal;
`);
const cssDontShowAgainCheckboxLabel = styled('span', `
color: ${theme.lightText};
`);