(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

@@ -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 > &,