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