mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-20 01:02:22 +00:00
418681915e
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
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
import {allCommands} from 'app/client/components/commands';
|
|
import {BoxModel, parseBox} from 'app/client/components/Forms/Model';
|
|
import {buildMenu} from 'app/client/components/Forms/Menu';
|
|
import * as style from 'app/client/components/Forms/styles';
|
|
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
|
|
|
const testId = makeTestId('test-forms-');
|
|
const t = makeT('FormView.Editor');
|
|
|
|
interface Props {
|
|
box: BoxModel,
|
|
/** Should we show an overlay */
|
|
overlay?: Observable<boolean>,
|
|
/** Custom drag indicator slot */
|
|
drag?: HTMLElement,
|
|
/**
|
|
* Actual element to put into the editor. This is the main content of the editor.
|
|
*/
|
|
content: DomContents,
|
|
/**
|
|
* Click handler. If not provided, then clicking on the editor will select it.
|
|
*/
|
|
click?: (ev: MouseEvent, box: BoxModel) => void,
|
|
/**
|
|
* Whether to show the remove button. Defaults to true.
|
|
*/
|
|
showRemoveButton?: BindableValue<boolean>,
|
|
/**
|
|
* Custom remove icon.
|
|
*/
|
|
removeIcon?: IconName,
|
|
/**
|
|
* Custom remove button rendered atop overlay.
|
|
*/
|
|
removeButton?: DomContents,
|
|
/**
|
|
* Tooltip for the remove button.
|
|
*/
|
|
removeTooltip?: string,
|
|
/**
|
|
* Position of the remove button. Defaults to inside.
|
|
*/
|
|
removePosition?: 'inside'|'right',
|
|
editMode?: Observable<boolean>,
|
|
}
|
|
|
|
export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
|
const owner: MultiHolder = new MultiHolder();
|
|
const {box, overlay} = props;
|
|
const view = box.view;
|
|
const dragHover = Observable.create(owner, false);
|
|
let element: HTMLElement;
|
|
|
|
// When element is selected, scroll it into view.
|
|
owner.autoDispose(view.selectedBox.addListener(selectedBox => {
|
|
if (selectedBox === box) {
|
|
element?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
|
|
}
|
|
}));
|
|
|
|
// Default remove icon, can be overriden by props.
|
|
const defaultRemoveButton = () => style.cssRemoveButton(
|
|
icon((props.removeIcon as any) ?? 'RemoveBig'),
|
|
dom.on('click', ev => {
|
|
stopEvent(ev);
|
|
box.view.selectedBox.set(box);
|
|
allCommands.deleteFields.run();
|
|
}),
|
|
props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t('Delete')),
|
|
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
|
|
);
|
|
|
|
const onClick = (ev: MouseEvent) => {
|
|
// Only if the click was in this element.
|
|
const target = ev.target as HTMLElement;
|
|
if (!target.closest) { return; }
|
|
// Make sure that the closest editor is this one.
|
|
const closest = target.closest(`.${style.cssFieldEditor.className}`);
|
|
if (closest !== element) { return; }
|
|
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
props.click?.(ev, props.box);
|
|
|
|
// Mark this box as selected.
|
|
box.view.selectedBox.set(box);
|
|
};
|
|
|
|
const dragAbove = Observable.create(owner, false);
|
|
const dragBelow = Observable.create(owner, false);
|
|
const dragging = Observable.create(owner, false);
|
|
|
|
|
|
return element = style.cssFieldEditor(
|
|
testId('editor'),
|
|
|
|
style.cssFieldEditor.cls('-drag-above', use => use(dragAbove) && use(dragHover)),
|
|
style.cssFieldEditor.cls('-drag-below', use => use(dragBelow) && use(dragHover)),
|
|
|
|
props.drag ?? style.cssDragWrapper(style.cssDrag('DragDrop')),
|
|
style.cssFieldEditor.cls(`-${props.box.type}`),
|
|
|
|
// Turn on active like state when we clicked here.
|
|
style.cssFieldEditor.cls('-selected', box.selected),
|
|
style.cssFieldEditor.cls('-cut', box.cut),
|
|
testId('field-editor-selected', box.selected),
|
|
|
|
// Select on click.
|
|
dom.on('click', onClick),
|
|
|
|
// Attach context menu.
|
|
buildMenu({
|
|
box,
|
|
context: true,
|
|
}),
|
|
|
|
// And now drag and drop support.
|
|
{draggable: "true"},
|
|
|
|
// When started, we just put the box into the dataTransfer as a plain text.
|
|
// TODO: this might be very sofisticated in the future.
|
|
dom.on('dragstart', (ev) => {
|
|
// Prevent propagation, as we might be in a nested editor.
|
|
ev.stopPropagation();
|
|
if (props.editMode?.get()) {
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
|
|
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
|
|
ev.dataTransfer!.dropEffect = "move";
|
|
dragging.set(true);
|
|
}),
|
|
|
|
dom.on('dragover', (ev) => {
|
|
// As usual, prevent propagation.
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
ev.stopImmediatePropagation();
|
|
// Here we just change the style of the element.
|
|
ev.dataTransfer!.dropEffect = "move";
|
|
dragHover.set(true);
|
|
|
|
// If we are being dragged, don't animate anything.
|
|
if (dragging.get()) { return; }
|
|
|
|
// We only animate if the box will add dropped element as sibling.
|
|
if (box.willAccept() !== 'sibling') {
|
|
return;
|
|
}
|
|
|
|
const myHeight = element.offsetHeight;
|
|
const percentHeight = Math.round((ev.offsetY / myHeight) * 100);
|
|
|
|
// If we are in the top half, we want to animate ourselves and transform a little below.
|
|
if (percentHeight < 40) {
|
|
dragAbove.set(true);
|
|
dragBelow.set(false);
|
|
} else if (percentHeight > 60) {
|
|
dragAbove.set(false);
|
|
dragBelow.set(true);
|
|
} else {
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
}
|
|
}),
|
|
|
|
dom.on('dragleave', (ev) => {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
// Just remove the style and stop propagation.
|
|
dragHover.set(false);
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
}),
|
|
|
|
dom.on('dragend', () => {
|
|
dragHover.set(false);
|
|
dragAbove.set(false);
|
|
dragBelow.set(false);
|
|
dragging.set(false);
|
|
}),
|
|
|
|
dom.on('drop', async (ev) => {
|
|
stopEvent(ev);
|
|
dragHover.set(false);
|
|
dragging.set(false);
|
|
dragAbove.set(false);
|
|
const wasBelow = dragBelow.get();
|
|
dragBelow.set(false);
|
|
|
|
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
|
|
if (!dropped) { return; }
|
|
// We need to remove it from the parent, so find it first.
|
|
const droppedId = dropped.id;
|
|
if (droppedId === box.id) { return; }
|
|
const droppedModel = box.root().find(droppedId);
|
|
// It might happen that parent is dropped into child, so we need to check for that.
|
|
if (droppedModel?.find(box.id)) { return; }
|
|
|
|
if (!box.willAccept(droppedModel)) {
|
|
return;
|
|
}
|
|
|
|
// TODO: accept should do the swapping.
|
|
if (box.willAccept(droppedModel) === 'swap') {
|
|
await box.save(async () => {
|
|
box.parent!.swap(box, droppedModel!);
|
|
});
|
|
return;
|
|
}
|
|
|
|
await box.save(async () => {
|
|
// When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no
|
|
// field yet). In this case, we need to create a field first.
|
|
if (dropped.type === 'Field' && typeof dropped.leaf === 'string') {
|
|
dropped.leaf = await view.showColumn(dropped.leaf);
|
|
}
|
|
box.accept(dropped, wasBelow ? 'below' : 'above');
|
|
});
|
|
}),
|
|
|
|
style.cssFieldEditor.cls('-drag-hover', dragHover),
|
|
style.cssFieldEditorContent(
|
|
props.content,
|
|
style.cssDrop(),
|
|
),
|
|
testId(box.type),
|
|
testId('element'),
|
|
dom.attr('data-box-model', String(box.type)),
|
|
dom.maybe(overlay, () => style.cssSelectedOverlay()),
|
|
dom.maybe(props.showRemoveButton ?? true, () => [
|
|
props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
|
]),
|
|
...args,
|
|
);
|
|
}
|