(core) Forms Improvements

Summary:
 - Forms now have a reset button.
 - Choice and Reference fields in forms now have an improved select menu.
 - Formula and attachments column types are no longer mappable or visible in forms.
 - Fields in a form widget are now removed if their column is deleted.
 - The preview button in a published form widget has been replaced with a view button. It now opens the published form in a new tab.
 - A new share menu for published form widgets, with options to copy a link or embed code.
 - Forms can now have multiple sections.
 - Form widgets now indicate when publishing is unavailable (e.g. in forks or unsaved documents).
 - General improvements to form styling.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4203
This commit is contained in:
George Gevoian
2024-03-20 10:51:59 -04:00
parent aff9c7075c
commit 418681915e
40 changed files with 1643 additions and 617 deletions

View File

@@ -130,7 +130,7 @@ function buildLocaleSelect(
locale: l.code,
cleanText: l.name.trim().toLowerCase(),
})).sort(propertyCompare("label"));
const acIndex = new ACIndexImpl<LocaleItem>(localeList, 200, true);
const acIndex = new ACIndexImpl<LocaleItem>(localeList, {maxResults: 200, keepOrder: true});
// AC select will show the value (in this case locale) not a label when something is selected.
// To show the label - create another observable that will be in sync with the value, but
// will contain text.

View File

@@ -106,7 +106,9 @@ export class FormAPIImpl extends BaseAPI implements FormAPI {
});
} else {
const {shareKey, tableId, colValues} = options;
return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, {
const url = new URL(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`);
url.searchParams.set('utm_source', 'grist-forms');
return this.requestJson(url.href, {
method: 'POST',
body: JSON.stringify({records: [{fields: colValues}]}),
});

View File

@@ -398,7 +398,7 @@ export class PageWidgetSelect extends Disposable {
this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', {
popupOptions: {
attach: null,
placement: 'bottom',
placement: 'bottom-start',
}
}),
]},

View File

@@ -16,7 +16,7 @@
import * as commands from 'app/client/components/commands';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
import {MappedFieldsConfig} from 'app/client/components/Forms/MappedFieldsConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@@ -559,7 +559,7 @@ export class RightPanel extends Disposable {
dom.maybe(this._isForm, () => [
cssSeparator(),
dom.create(UnmappedFieldsConfig, activeSection),
dom.create(MappedFieldsConfig, activeSection),
]),
]);
}
@@ -996,19 +996,11 @@ export class RightPanel extends Disposable {
const fieldBox = box as FieldModel;
return use(fieldBox.field);
});
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
const hasText = Computed.create(owner, (use) => {
const selectedBoxWithOptions = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return false; }
switch (box.type) {
case 'Submit':
case 'Paragraph':
case 'Label':
return true;
default:
return false;
}
if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; }
return box;
});
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
@@ -1036,24 +1028,12 @@ export class RightPanel extends Disposable {
testId('field-label'),
),
),
// TODO: this is for V1 as it requires full cell editor here.
// cssLabel(t("Default field value")),
// cssRow(
// cssTextInput(
// fromKo(defaultField),
// (val) => defaultField.setAndSave(val),
// ),
// ),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
cssSeparator(),
cssLabel(t("COLUMN TYPE")),
cssSection(
builder.buildSelectTypeDom(),
),
// V2 thing
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
cssSection(
builder.buildFormConfigDom(),
),
@@ -1062,36 +1042,44 @@ export class RightPanel extends Disposable {
}),
// Box config
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
dom.maybe(selectedBoxWithOptions, (box) => [
cssLabel(dom.text(box.type)),
dom.maybe(hasText, () => [
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
// Default.
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
cssLabel(t('Layout')),
dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [
buildFormConfigPlaceholder(),
])
))));
}
}
function buildFormConfigPlaceholder() {
return cssFormConfigPlaceholder(
cssFormConfigImg(),
cssFormConfigMessage(
cssFormConfigMessageTitle(t('No field selected')),
dom('div', t('Select a field in the form widget to configure.')),
)
);
}
function disabledSection() {
return cssOverlay(
testId('panel-disabled-section'),
@@ -1429,3 +1417,33 @@ const cssLinkInfoPre = styled("pre", `
font-size: ${vars.smallFontSize};
line-height: 1.2;
`);
const cssFormConfigPlaceholder = styled('div', `
display: flex;
flex-direction: column;
row-gap: 16px;
margin-top: 32px;
padding: 8px;
`);
const cssFormConfigImg = styled('div', `
height: 140px;
width: 100%;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-image: var(--icon-FormConfig);
`);
const cssFormConfigMessage = styled('div', `
display: flex;
flex-direction: column;
row-gap: 8px;
color: ${theme.text};
text-align: center;
`);
const cssFormConfigMessageTitle = styled('div', `
font-size: ${vars.largeFontSize};
font-weight: 600;
`);

View File

@@ -4,7 +4,8 @@
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { ACIndexImpl, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex";
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
normalizeText } from "app/client/lib/ACIndex";
import { menuDivider } from "app/client/ui2018/menus";
import { icon } from "app/client/ui2018/icons";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
@@ -28,20 +29,54 @@ export interface IDropdownWithSearchOptions<T> {
// list of options
options: () => Array<IOption<T>>,
/** Called when the dropdown menu is disposed. */
onClose?: () => void;
// place holder for the search input. Default to 'Search'
placeholder?: string;
// popup options
popupOptions?: IPopupOptions;
/** ACIndexOptions to use for indexing and searching items. */
acOptions?: ACIndexOptions;
/**
* If set, the width of the dropdown menu will be equal to that of
* the trigger element.
*/
matchTriggerElemWidth?: boolean;
}
export interface OptionItemParams<T> {
/** Item label. Normalized and used by ACIndex for indexing and searching. */
label: string;
/** Item value. */
value: T;
/** Defaults to false. */
disabled?: boolean;
/**
* If true, marks this item as the "placeholder" item.
*
* The placeholder item is excluded from indexing, so it's label doesn't
* match search inputs. However, it's still shown when the search input is
* empty.
*
* Defaults to false.
*/
placeholder?: boolean;
}
export class OptionItem<T> implements ACItem, IOptionFull<T> {
public cleanText: string = normalizeText(this.label);
constructor(
public label: string,
public value: T,
public disabled?: boolean
) {}
public label = this._params.label;
public value = this._params.value;
public disabled = this._params.disabled;
public placeholder = this._params.placeholder;
public cleanText = this.placeholder ? '' : normalizeText(this.label);
constructor(private _params: OptionItemParams<T>) {
}
}
export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {
@@ -52,7 +87,7 @@ export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): D
);
setPopupToFunc(
elem,
(ctl) => DropdownWithSearch<T>.create(null, ctl, options),
(ctl) => (DropdownWithSearch<T>).create(null, ctl, options),
popupOptions
);
};
@@ -68,8 +103,8 @@ class DropdownWithSearch<T> extends Disposable {
constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {
super();
const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems);
const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);
this._items = Observable.create<OptionItem<T>[]>(this, acItems);
this._highlightFunc = () => [];
this._simpleList = this._buildSimpleList();
@@ -77,6 +112,7 @@ class DropdownWithSearch<T> extends Disposable {
this._update();
// auto-focus the search input
setTimeout(() => this._inputElem.focus(), 1);
this._ctl.onDispose(() => _options.onClose?.());
}
public get content(): HTMLElement {
@@ -87,7 +123,11 @@ class DropdownWithSearch<T> extends Disposable {
const action = this._action.bind(this);
const headerDom = this._buildHeader.bind(this);
const renderItem = this._buildItem.bind(this);
return SimpleList<T>.create(this, this._ctl, this._items, action, {headerDom, renderItem});
return (SimpleList<T>).create(this, this._ctl, this._items, action, {
matchTriggerElemWidth: this._options.matchTriggerElemWidth,
headerDom,
renderItem,
});
}
private _buildHeader() {
@@ -110,7 +150,9 @@ class DropdownWithSearch<T> extends Disposable {
private _buildItem(item: OptionItem<T>) {
return [
buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
item.placeholder
? cssPlaceholderItem(item.label)
: buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
testId('searchable-list-item'),
];
}
@@ -125,7 +167,7 @@ class DropdownWithSearch<T> extends Disposable {
private _action(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element
// selected.
if (value) {
if (value !== null) {
this._options.action(value);
}
this._ctl.close();
@@ -171,3 +213,10 @@ const cssMenuDivider = styled(menuDivider, `
flex-shrink: 0;
margin: 0;
`);
const cssPlaceholderItem = styled('div', `
color: ${theme.inputPlaceholderFg};
.${cssMenuItem.className}-sel > & {
color: ${theme.menuItemSelectedFg};
}
`);