diff --git a/app/client/billingMain.ts b/app/client/billingMain.ts index 2326018a..e60548f4 100644 --- a/app/client/billingMain.ts +++ b/app/client/billingMain.ts @@ -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)); diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index 08ae6e04..0785968b 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -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, () => [ diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index 5331c697..ffafac5f 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -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); } + }, + { + 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.' + ), + ), + ) + ), + hideDontShowAgain: false, + }, + ); + } + } + + 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; } - 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(); + 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, + }), } + ]); + 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.' - ), - ), - 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.' - ), - ), - ) - ), - }, - ); + await this.save(); + this.viewSection.shareOptionsObj.update({ + form: true, + publish: true, + }); + await this.viewSection.shareOptionsObj.save(); + }); } - 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 _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.' + }, + { + 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'; diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index 4068fda9..b09b47ec 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -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 > &, diff --git a/app/client/errorMain.ts b/app/client/errorMain.ts index 55c7830e..89306031 100644 --- a/app/client/errorMain.ts +++ b/app/client/errorMain.ts @@ -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(); diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 21882ca5..4af06c73 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -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; } +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) => { diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 023fffe4..567fd78b 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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), ), ]; diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index 6944e752..94a2ff20 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -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; +`); diff --git a/app/client/ui/setupPage.ts b/app/client/ui/setUpPage.ts similarity index 71% rename from app/client/ui/setupPage.ts rename to app/client/ui/setUpPage.ts index dd0307d6..4e0d4f2c 100644 --- a/app/client/ui/setupPage.ts +++ b/app/client/ui/setUpPage.ts @@ -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(); diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 247561c4..d315f592 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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'), diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 1a32ad9d..2aaa3f87 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -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; @@ -353,22 +356,42 @@ export interface ConfirmModalOptions { export function confirmModal( title: DomElementArg, btnText: DomElementArg, - onConfirm: () => Promise, - {explanation, hideCancel, extraButtons, modalOptions, saveDisabled, width}: ConfirmModalOptions = {}, + onConfirm: (dontShowAgain?: boolean) => Promise, + 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}; +`); diff --git a/app/common/ApiError.ts b/app/common/ApiError.ts index ac799494..7f602775 100644 --- a/app/common/ApiError.ts +++ b/app/common/ApiError.ts @@ -37,7 +37,8 @@ export interface ApiErrorDetails { } export type ApiErrorCode = - | 'UserNotConfirmed'; + | 'UserNotConfirmed' + | 'FormNotFound'; /** * An error with an http status code. diff --git a/app/common/Forms.ts b/app/common/Forms.ts index 6a2415df..f6356308 100644 --- a/app/common/Forms.ts +++ b/app/common/Forms.ts @@ -1,3 +1,4 @@ +import {isHiddenCol} from 'app/common/gristTypes'; import {CellValue, GristType} from 'app/plugin/GristData'; import {MaybePromise} from 'app/plugin/gutil'; import _ from 'lodash'; @@ -70,6 +71,7 @@ export interface FieldModel { description: string; colId: string; type: string; + isFormula: boolean; options: FieldOptions; values(): MaybePromise<[number, CellValue][]>; } @@ -202,10 +204,19 @@ abstract class BaseQuestion implements Question { `; } + public name(field: FieldModel): string { + const excludeFromFormData = ( + field.isFormula || + field.type === 'Attachments' || + isHiddenCol(field.colId) + ); + return `${excludeFromFormData ? '_' : ''}${field.colId}`; + } + public label(field: FieldModel): string { // This might be HTML. const label = field.question; - const name = field.colId; + const name = this.name(field); return ` `; @@ -218,7 +229,7 @@ class Text extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` - + `; } } @@ -227,7 +238,7 @@ class Date extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` - + `; } } @@ -236,7 +247,7 @@ class DateTime extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` - + `; } } @@ -248,7 +259,7 @@ class Choice extends BaseQuestion { // Insert empty option. choices.unshift(''); return ` - ${choices.map((choice) => ``).join('')} `; @@ -271,7 +282,7 @@ class Bool extends BaseQuestion { const label = field.question ? field.question : field.colId; return `