mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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.
|
||||
|
||||
@@ -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}]}),
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', {
|
||||
popupOptions: {
|
||||
attach: null,
|
||||
placement: 'bottom',
|
||||
placement: 'bottom-start',
|
||||
}
|
||||
}),
|
||||
]},
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user