Summary: Forms improvements and following new design - New headers - New UI - New right panel options Test Plan: Tests updated Reviewers: georgegevoian, dsagal Reviewed By: georgegevoian Subscribers: dsagal, paulfitz Differential Revision: https://phab.getgrist.com/D4158pull/829/head
@ -0,0 +1,209 @@
|
||||
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 {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,
|
||||
/**
|
||||
* Custom remove icon. If null, then no drop icon is shown.
|
||||
*/
|
||||
removeIcon?: IconName|null,
|
||||
/**
|
||||
* 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();
|
||||
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 (dragging.get() || props.box.type === 'Section') { 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'));
|
||||
// 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().get(droppedId);
|
||||
// It might happen that parent is dropped into child, so we need to check for that.
|
||||
if (droppedModel?.get(box.id)) { return; }
|
||||
await box.save(async () => {
|
||||
droppedModel?.removeSelf();
|
||||
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
|
||||
});
|
||||
}),
|
||||
|
||||
style.cssFieldEditor.cls('-drag-hover', dragHover),
|
||||
style.cssFieldEditorContent(
|
||||
props.content,
|
||||
style.cssDrop(),
|
||||
),
|
||||
testId(box.type),
|
||||
testId('element'),
|
||||
dom.maybe(overlay, () => style.cssSelectedOverlay()),
|
||||
// Custom icons for removing.
|
||||
props.removeIcon === null || props.removeButton ? null :
|
||||
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
||||
props.removeButton ?? null,
|
||||
...args,
|
||||
);
|
||||
}
|
@ -1,140 +0,0 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const testId = makeTestId('test-vfc-');
|
||||
const t = makeT('VisibleFieldsConfig');
|
||||
|
||||
/**
|
||||
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
|
||||
* the ability to drag and drop fields onto the form.
|
||||
*/
|
||||
export class HiddenQuestionConfig extends Disposable {
|
||||
|
||||
constructor(private _section: ViewSectionRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const hiddenColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
|
||||
return this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
|
||||
})));
|
||||
return [
|
||||
cssHeader(
|
||||
cssFieldListHeader(dom.text(t("Hidden fields"))),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(hiddenColumns, (field) => {
|
||||
return this._buildHiddenFieldItem(field);
|
||||
})
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
private _buildHiddenFieldItem(column: ColumnRec) {
|
||||
return cssDragRow(
|
||||
testId('hidden-field'),
|
||||
{draggable: "true"},
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify({
|
||||
type: 'Field',
|
||||
leaf: column.colId.peek(), // TODO: convert to Field
|
||||
}));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
}),
|
||||
cssSimpleDragger(),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeShow',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.showColumns.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO: reuse them
|
||||
const cssDragRow = styled('div', `
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 16px 0px 0px;
|
||||
margin-bottom: 2px;
|
||||
cursor: grab;
|
||||
`);
|
||||
|
||||
const cssFieldEntry = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.hover};
|
||||
border-radius: 2px;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 4px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssSimpleDragger = styled(cssDragger, `
|
||||
cursor: grab;
|
||||
.${cssDragRow.className}:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHideIcon = styled(icon, `
|
||||
--icon-color: ${theme.lightText};
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
.${cssFieldEntry.className}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFieldLabel = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 auto;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssFieldListHeader = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 0px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
`);
|
||||
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
overflow: hidden;
|
||||
--icon-color: ${theme.lightText};
|
||||
& > .${cssButton.className} {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHeader = styled(cssRow, `
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
`);
|
@ -0,0 +1,85 @@
|
||||
import * as css from './styles';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {stopEvent} from 'app/client/lib/domUtils';
|
||||
import {not} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable} from 'grainjs';
|
||||
|
||||
export class LabelModel extends BoxModel {
|
||||
public edit = Observable.create(this, false);
|
||||
|
||||
protected defaultValue = '';
|
||||
|
||||
public render(): HTMLElement {
|
||||
let element: HTMLTextAreaElement;
|
||||
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
||||
const cssClass = this.prop('cssClass', '') as Observable<string>;
|
||||
const editableText = Observable.create(this, text.get() || '');
|
||||
const overlay = Computed.create(this, use => !use(this.edit));
|
||||
|
||||
this.autoDispose(text.addListener((v) => editableText.set(v || '')));
|
||||
|
||||
const save = (ok: boolean) => {
|
||||
if (ok) {
|
||||
text.set(editableText.get());
|
||||
void this.parent?.save().catch(reportError);
|
||||
} else {
|
||||
editableText.set(text.get() || '');
|
||||
}
|
||||
};
|
||||
|
||||
const mode = (edit: boolean) => {
|
||||
if (this.isDisposed() || this.edit.isDisposed()) { return; }
|
||||
if (this.edit.get() === edit) { return; }
|
||||
this.edit.set(edit);
|
||||
};
|
||||
|
||||
return buildEditor(
|
||||
{
|
||||
box: this,
|
||||
editMode: this.edit,
|
||||
overlay,
|
||||
click: (ev) => {
|
||||
stopEvent(ev);
|
||||
// If selected, then edit.
|
||||
if (!this.selected.get()) { return; }
|
||||
if (document.activeElement === element) { return; }
|
||||
editableText.set(text.get() || '');
|
||||
this.edit.set(true);
|
||||
setTimeout(() => {
|
||||
element.focus();
|
||||
element.select();
|
||||
}, 10);
|
||||
},
|
||||
content: element = css.cssEditableLabel(
|
||||
editableText,
|
||||
{onInput: true, autoGrow: true},
|
||||
{placeholder: `Empty label`},
|
||||
dom.on('click', ev => {
|
||||
stopEvent(ev);
|
||||
}),
|
||||
// Styles saved (for titles and such)
|
||||
css.cssEditableLabel.cls(use => `-${use(cssClass)}`),
|
||||
// Disable editing if not in edit mode.
|
||||
dom.boolAttr('readonly', not(this.edit)),
|
||||
// Pass edit to css.
|
||||
css.cssEditableLabel.cls('-edit', this.edit),
|
||||
// Attach default save controls (Enter, Esc) and so on.
|
||||
css.saveControls(this.edit, save),
|
||||
// Turn off resizable for textarea.
|
||||
dom.style('resize', 'none'),
|
||||
),
|
||||
},
|
||||
dom.onKeyDown({Enter$: (ev) => {
|
||||
// If no in edit mode, change it.
|
||||
if (!this.edit.get()) {
|
||||
mode(true);
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
}})
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
|
||||
|
||||
const t = makeT('FormView');
|
||||
const testId = makeTestId('test-forms-menu-');
|
||||
|
||||
// New box to add, either a new column of type, an existing column (by column id), or a structure.
|
||||
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
|
||||
|
||||
interface Props {
|
||||
box?: BoxModel;
|
||||
view?: FormView;
|
||||
context?: boolean;
|
||||
customItems?: Element[],
|
||||
insertBox?: Place
|
||||
}
|
||||
|
||||
export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArgs<HTMLElement> {
|
||||
const {box, context, customItems} = props;
|
||||
const view = box?.view ?? props.view;
|
||||
if (!view) { throw new Error("No view provided"); }
|
||||
const gristDoc = view.gristDoc;
|
||||
const viewSection = view.viewSection;
|
||||
const owner = new MultiHolder();
|
||||
|
||||
const unmapped = Computed.create(owner, (use) => {
|
||||
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
|
||||
const normalCols = use(viewSection.hiddenColumns).filter(col => {
|
||||
if (use(col.isHiddenCol)) { return false; }
|
||||
if (use(col.isFormula) && use(col.formula)) { return false; }
|
||||
if (use(col.pureType) === 'Attachments') { return false; }
|
||||
return true;
|
||||
});
|
||||
const list = normalCols.map(col => {
|
||||
return {
|
||||
label: use(col.label),
|
||||
icon: types.find(type => type.colType === use(col.pureType))?.icon ?? 'TypeCell',
|
||||
colId: use(col.colId),
|
||||
};
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
const oneTo5 = Computed.create(owner, (use) => use(unmapped).length > 0 && use(unmapped).length <= 5);
|
||||
const moreThan5 = Computed.create(owner, (use) => use(unmapped).length > 5);
|
||||
|
||||
return [
|
||||
dom.autoDispose(owner),
|
||||
menus.menu((ctl) => {
|
||||
box?.view.selectedBox.set(box);
|
||||
|
||||
// Same for structure.
|
||||
const struct = (structure: BoxType) => ({structure});
|
||||
|
||||
// Actions:
|
||||
|
||||
// Insert field before and after.
|
||||
const above = (el: NewBox) => () => {
|
||||
allCommands.insertFieldBefore.run(el);
|
||||
};
|
||||
const below = (el: NewBox) => () => {
|
||||
allCommands.insertFieldAfter.run(el);
|
||||
};
|
||||
const atEnd = (el: NewBox) => () => {
|
||||
allCommands.insertField.run(el);
|
||||
};
|
||||
const custom = props.insertBox ? (el: NewBox) => () => {
|
||||
if ('add' in el || 'show' in el) {
|
||||
return view.addNewQuestion(props.insertBox!, el);
|
||||
} else {
|
||||
props.insertBox!(components.defaultElement(el.structure));
|
||||
return view.save();
|
||||
}
|
||||
} : null;
|
||||
|
||||
// Field menus.
|
||||
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
|
||||
const disabled = ['Attachments'];
|
||||
const commonTypes = () => getNewColumnTypes(gristDoc, viewSection.tableId());
|
||||
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
|
||||
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
|
||||
const isEnabled = ({colType}: {colType: string}) => !disabled.includes(colType);
|
||||
|
||||
const insertMenu = (where: typeof above) => () => {
|
||||
return [
|
||||
menus.menuSubHeader('New question'),
|
||||
...commonTypes()
|
||||
.filter(isQuick)
|
||||
.filter(isEnabled)
|
||||
.map(ct => menus.menuItem(where({add: ct.colType}), menus.menuIcon(ct.icon!), ct.displayName))
|
||||
,
|
||||
menus.menuItemSubmenu(
|
||||
() => commonTypes()
|
||||
.filter(notQuick)
|
||||
.filter(isEnabled)
|
||||
.map(ct => menus.menuItem(
|
||||
where({add: ct.colType}),
|
||||
menus.menuIcon(ct.icon!),
|
||||
ct.displayName,
|
||||
)),
|
||||
{},
|
||||
menus.menuIcon('Dots'),
|
||||
dom('span', "More", dom.style('margin-right', '8px'))
|
||||
),
|
||||
dom.maybe(oneTo5, () => [
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader(t('Unmapped fields')),
|
||||
dom.domComputed(unmapped, (uf) =>
|
||||
uf.map(({label, icon, colId}) => menus.menuItem(
|
||||
where({show: colId}),
|
||||
menus.menuIcon(icon),
|
||||
label,
|
||||
testId('unmapped'),
|
||||
testId('unmapped-' + colId)
|
||||
)),
|
||||
),
|
||||
]),
|
||||
dom.maybe(moreThan5, () => [
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeaderMenu(
|
||||
() => unmapped.get().map(
|
||||
({label, icon, colId}) => menus.menuItem(
|
||||
where({show: colId}),
|
||||
menus.menuIcon(icon),
|
||||
label,
|
||||
testId('unmapped'),
|
||||
testId('unmapped-' + colId)
|
||||
)),
|
||||
{},
|
||||
dom('span', "Unmapped fields", dom.style('margin-right', '8px'))
|
||||
),
|
||||
]),
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader(t('Building blocks')),
|
||||
menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")),
|
||||
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")),
|
||||
menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")),
|
||||
];
|
||||
};
|
||||
|
||||
if (!props.context) {
|
||||
return insertMenu(custom ?? atEnd)();
|
||||
}
|
||||
|
||||
return [
|
||||
menus.menuItemSubmenu(insertMenu(above), {action: above({add: 'Text'})}, t("Insert question above")),
|
||||
menus.menuItemSubmenu(insertMenu(below), {action: below({add: 'Text'})}, t("Insert question below")),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.contextMenuCopy, t("Copy")),
|
||||
menus.menuItemCmd(allCommands.contextMenuCut, t("Cut")),
|
||||
menus.menuItemCmd(allCommands.contextMenuPaste, t("Paste")),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
|
||||
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
|
||||
customItems?.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
|
||||
...(customItems ?? []),
|
||||
...args,
|
||||
];
|
||||
}, {trigger: [context ? 'contextmenu' : 'click']}),
|
||||
context ? dom.on('contextmenu', stopEvent) : null,
|
||||
];
|
||||
}
|
@ -1,25 +1,75 @@
|
||||
import * as style from './styles';
|
||||
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {dom} from 'grainjs';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {Box, BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
/**
|
||||
* Component that renders a section of the form.
|
||||
*/
|
||||
export class SectionModel extends BoxModel {
|
||||
public render(context: RenderContext) {
|
||||
public override render(): HTMLElement {
|
||||
const children = this.children;
|
||||
context.overlay.set(false);
|
||||
const view = this.view;
|
||||
const box = this;
|
||||
|
||||
const element = style.cssSection(
|
||||
style.cssDrag(),
|
||||
dom.forEach(children, (child) =>
|
||||
child ? view.renderBox(children, child) : dom('div', 'Empty')
|
||||
),
|
||||
view.buildDropzone(children, box.placeAfterListChild()),
|
||||
return buildEditor({
|
||||
box: this,
|
||||
// Custom drag element that is little bigger and at the top of the section.
|
||||
drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))),
|
||||
// No way to remove section now.
|
||||
removeIcon: null,
|
||||
// Content is just a list of children.
|
||||
content: style.cssSection(
|
||||
// Wrap them in a div that mutes hover events.
|
||||
cssSectionItems(
|
||||
dom.forEach(children, (child) => child.render()),
|
||||
),
|
||||
// Plus icon
|
||||
style.cssPlusButton(
|
||||
testId('plus'),
|
||||
style.cssDrop(),
|
||||
style.cssCircle(
|
||||
style.cssPlusIcon('Plus'),
|
||||
buildMenu({
|
||||
box: this,
|
||||
})
|
||||
),
|
||||
)
|
||||
)},
|
||||
style.cssSectionEditor.cls(''),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return element;
|
||||
/**
|
||||
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
|
||||
* as a first child. Default implementation is to insert before self.
|
||||
*/
|
||||
public override accept(dropped: Box) {
|
||||
// Get the box that was dropped.
|
||||
if (!dropped) { return null; }
|
||||
if (dropped.id === this.id) {
|
||||
return null;
|
||||
}
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
const droppedRef = this.root().get(droppedId);
|
||||
if (droppedRef) {
|
||||
droppedRef.removeSelf();
|
||||
}
|
||||
|
||||
// Depending of the type of dropped box we need to insert it in different places.
|
||||
// By default we insert it before this box.
|
||||
let place = this.placeBeforeMe();
|
||||
if (dropped.type === 'Field') {
|
||||
// Fields are inserted after last child.
|
||||
place = this.placeAfterListChild();
|
||||
}
|
||||
|
||||
return place(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
const cssSectionItems = styled('div.hover_border', `
|
||||
`);
|
||||
|
@ -1,10 +1,16 @@
|
||||
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {primaryButton} from 'app/client/ui2018/buttons';
|
||||
const testId = makeTestId('test-forms-');
|
||||
import { BoxModel } from "app/client/components/Forms/Model";
|
||||
import { makeTestId } from "app/client/lib/domUtils";
|
||||
import { bigPrimaryButton } from "app/client/ui2018/buttons";
|
||||
import { dom } from "grainjs";
|
||||
const testId = makeTestId("test-forms-");
|
||||
|
||||
export class SubmitModel extends BoxModel {
|
||||
public render(context: RenderContext) {
|
||||
return primaryButton('Submit', testId('submit'));
|
||||
public override render() {
|
||||
const text = this.view.viewSection.layoutSpecObj.prop('submitText');
|
||||
return dom(
|
||||
"div",
|
||||
{ style: "text-align: center; margin-top: 20px;" },
|
||||
bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
import * as style from './styles';
|
||||
import {Builder, ignoreClick} from 'app/client/components/Forms/Model';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId} from 'grainjs';
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
export const buildTextField: Builder = (owner: IDisposableOwner, {box, view}) => {
|
||||
|
||||
const field = Computed.create(owner, use => {
|
||||
return view.gristDoc.docModel.viewFields.getRowModel(use(box.prop('leaf')));
|
||||
});
|
||||
return dom('div',
|
||||
testId('question'),
|
||||
testId('question-Text'),
|
||||
style.cssLabel(
|
||||
testId('label'),
|
||||
dom.text(use => use(use(field).question) || use(use(field).origLabel))
|
||||
),
|
||||
style.cssInput(
|
||||
testId('input'),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
ignoreClick),
|
||||
dom.maybe(use => use(use(field).description), (description) => [
|
||||
style.cssDesc(description, testId('description')),
|
||||
]),
|
||||
);
|
||||
};
|
@ -0,0 +1,274 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const testId = makeTestId('test-vfc-');
|
||||
const t = makeT('VisibleFieldsConfig');
|
||||
|
||||
/**
|
||||
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
|
||||
* the ability to drag and drop fields onto the form.
|
||||
*/
|
||||
export class UnmappedFieldsConfig extends Disposable {
|
||||
|
||||
constructor(private _section: ViewSectionRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const unmappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
if (this._section.isDisposed()) {
|
||||
return [];
|
||||
}
|
||||
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
|
||||
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
}));
|
||||
})));
|
||||
const mappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
if (this._section.isDisposed()) {
|
||||
return [];
|
||||
}
|
||||
const cols = this._section.viewFields().map(f => f.column());
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
})).all();
|
||||
})));
|
||||
|
||||
const anyUnmappedSelected = Computed.create(this, use => {
|
||||
return use(unmappedColumns).some(c => use(c.selected));
|
||||
});
|
||||
|
||||
const anyMappedSelected = Computed.create(this, use => {
|
||||
return use(mappedColumns).some(c => use(c.selected));
|
||||
});
|
||||
|
||||
const mapSelected = async () => {
|
||||
await allCommands.showColumns.run(
|
||||
unmappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
|
||||
};
|
||||
|
||||
const unMapSelected = async () => {
|
||||
await allCommands.hideFields.run(
|
||||
mappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
|
||||
};
|
||||
|
||||
return [
|
||||
cssHeader(
|
||||
cssFieldListHeader(t("Unmapped")),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
unmappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(unmappedColumns, (field) => {
|
||||
return this._buildUnmappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyUnmappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Map fields")),
|
||||
dom.on('click', mapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
cssHeader(
|
||||
cssFieldListHeader(dom.text(t("Mapped"))),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
mappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('visible-fields'),
|
||||
dom.forEach(mappedColumns, (field) => {
|
||||
return this._buildMappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyMappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Unmap fields")),
|
||||
dom.on('click', unMapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildUnmappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
|
||||
const column = props.col;
|
||||
return cssDragRow(
|
||||
testId('hidden-field'),
|
||||
{draggable: "true"},
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify({
|
||||
type: 'Field',
|
||||
leaf: column.colId.peek(), // TODO: convert to Field
|
||||
}));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
}),
|
||||
cssSimpleDragger(),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeShow',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.showColumns.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
squareCheckbox(props.selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private _buildMappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
|
||||
const column = props.col;
|
||||
return cssDragRow(
|
||||
testId('visible-field'),
|
||||
cssSimpleDragger(
|
||||
cssSimpleDragger.cls('-hidden'),
|
||||
),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeHide',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.hideFields.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
squareCheckbox(props.selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllLabel(...args: any[]) {
|
||||
return cssControlLabel(
|
||||
testId('select-all'),
|
||||
icon('Tick'),
|
||||
dom('span', t("Select All")),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssControlLabel = styled('div', `
|
||||
--icon-color: ${theme.controlFg};
|
||||
color: ${theme.controlFg};
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
`);
|
||||
|
||||
|
||||
// TODO: reuse them
|
||||
const cssDragRow = styled('div', `
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 16px 0px 0px;
|
||||
margin-bottom: 2px;
|
||||
cursor: grab;
|
||||
`);
|
||||
|
||||
const cssFieldEntry = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.hover};
|
||||
border-radius: 2px;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 4px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssSimpleDragger = styled(cssDragger, `
|
||||
cursor: grab;
|
||||
.${cssDragRow.className}:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
&-hidden {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHideIcon = styled(icon, `
|
||||
--icon-color: ${theme.lightText};
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
.${cssFieldEntry.className}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFieldLabel = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 auto;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssFieldListHeader = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 0px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
`);
|
||||
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
overflow: hidden;
|
||||
--icon-color: ${theme.lightText};
|
||||
& > .${cssButton.className} {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHeader = styled(cssRow, `
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1em;
|
||||
& * {
|
||||
line-height: 1em;
|
||||
}
|
||||
`);
|
@ -0,0 +1,434 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
background-color: #f7f7f7;
|
||||
line-height: 1.42857143;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grist-form-container {
|
||||
--icon-Tick: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+);
|
||||
--icon-Minus: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=);
|
||||
--primary: #16b378;
|
||||
--primary-dark: #009058;
|
||||
--dark-gray: #D9D9D9;
|
||||
--light-gray: #bfbfbf;
|
||||
--light: white;
|
||||
|
||||
color: #262633;
|
||||
background-color: #f7f7f7;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding-top: 52px;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
|
||||
|
||||
.grist-form-container .grist-form-confirm {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grist-form {
|
||||
margin: 0px auto;
|
||||
background-color: white;
|
||||
border: 1px solid #E8E8E8;
|
||||
width: 600px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: calc(100% - 32px);
|
||||
margin-bottom: 16px;
|
||||
padding-top: 20px;
|
||||
--grist-form-padding: 48px;
|
||||
padding-left: var(--grist-form-padding);
|
||||
padding-right: var(--grist-form-padding);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.grist-form-container {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.grist-form {
|
||||
--grist-form-padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.grist-form > div + div {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.grist-form .grist-section {
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
padding: 16px 24px;
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.grist-form .grist-section > div + div {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.grist-form input[type="text"],
|
||||
.grist-form input[type="date"],
|
||||
.grist-form input[type="datetime-local"],
|
||||
.grist-form input[type="number"] {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #D9D9D9;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.grist-form .grist-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grist-form .grist-field .grist-field-description {
|
||||
color: #222;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.grist-form .grist-field input[type="text"] {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
line-height: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grist-form .grist-submit, .grist-form-container button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grist-form input[type="submit"], .grist-form-container button {
|
||||
background-color: #16b378;
|
||||
border: 1px solid #16b378;
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.grist-form input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.grist-form input[type="date"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
|
||||
.grist-form input[type="checkbox"] {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.grist-form .grist-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.grist-form select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grist-form .grist-choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.grist-form .grist-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
--color: var(--dark-gray);
|
||||
}
|
||||
.grist-form .grist-checkbox:hover {
|
||||
--color: var(--light-gray);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
outline: none !important;
|
||||
--radius: 3px;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
margin-right: 4px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:checked:enabled, .grist-form input[type="checkbox"]:indeterminate:enabled {
|
||||
--color: var(--primary);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:disabled {
|
||||
--color: var(--dark-gray);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]::before, .grist-form input[type="checkbox"]::after {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--color, var(--dark-gray));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:checked::before, .grist-form input[type="checkbox"]:disabled::before, .grist-form input[type="checkbox"]:indeterminate::before {
|
||||
background-color: var(--color);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:not(:checked):indeterminate::after {
|
||||
-webkit-mask-image: var(--icon-Minus);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:not(:disabled)::after {
|
||||
background-color: var(--light);
|
||||
}
|
||||
|
||||
.grist-form input[type="checkbox"]:checked::after, .grist-form input[type="checkbox"]:indeterminate::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
-webkit-mask-image: var(--icon-Tick);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
background-color: var(--light);
|
||||
}
|
||||
|
||||
|
||||
.grist-form .grist-submit input[type="submit"]:hover, .grist-form-container button:hover {
|
||||
border-color: var(--primary-dark);
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.grist-power-by {
|
||||
margin-top: 24px;
|
||||
color: var(--dark-text, #494949);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-top: 1px solid var(--dark-gray);
|
||||
padding: 10px;
|
||||
margin-left: calc(-1 * var(--grist-form-padding));
|
||||
margin-right: calc(-1 * var(--grist-form-padding));
|
||||
}
|
||||
|
||||
.grist-power-by a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--dark-text, #494949);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grist-logo {
|
||||
width: 58px;
|
||||
height: 20.416px;
|
||||
flex-shrink: 0;
|
||||
background: url(logo.png);
|
||||
background-position: 0 0;
|
||||
background-size: contain;
|
||||
background-color: transparent;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.grist-question > .grist-label {
|
||||
color: var(--dark, #262633);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 16px; /* 145.455% */
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* Markdown reset */
|
||||
|
||||
.grist-form h1,
|
||||
.grist-form h2,
|
||||
.grist-form h3,
|
||||
.grist-form h4,
|
||||
.grist-form h5,
|
||||
.grist-form h6 {
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.grist-form h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.grist-form h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.grist-form h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.grist-form h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.grist-form h5 {
|
||||
font-size: 11px;
|
||||
}
|
||||
.grist-form h6 {
|
||||
font-size: 10px;
|
||||
}
|
||||
.grist-form p {
|
||||
margin: 0px;
|
||||
}
|
||||
.grist-form strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.grist-form hr {
|
||||
border: 0px;
|
||||
border-top: 1px solid var(--dark-gray);
|
||||
margin: 4px 0px;
|
||||
}
|
||||
|
||||
.grist-text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.grist-text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.grist-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grist-switch {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.grist-switch input[type='checkbox']::after {
|
||||
content: none;
|
||||
}
|
||||
.grist-switch input[type='checkbox']::before {
|
||||
content: none;
|
||||
}
|
||||
.grist-switch input[type='checkbox'] {
|
||||
position: absolute;
|
||||
}
|
||||
.grist-switch > span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Slider component */
|
||||
.grist-widget_switch {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 17px;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.grist-switch_slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--grist-theme-switch-slider-fg, #ccc);
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.grist-switch_slider:hover {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
}
|
||||
|
||||
.grist-switch_circle {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: var(--grist-theme-switch-circle-fg, white);
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
input:checked + .grist-switch_transition > .grist-switch_slider {
|
||||
background-color: var(--primary, #16b378);
|
||||
}
|
||||
|
||||
input:checked + .grist-switch_transition > .grist-switch_circle {
|
||||
-webkit-transform: translateX(13px);
|
||||
-ms-transform: translateX(13px);
|
||||
transform: translateX(13px);
|
||||
}
|
||||
|
||||
.grist-switch_on > .grist-switch_slider {
|
||||
background-color: var(--grist-actual-cell-color, #2CB0AF);
|
||||
}
|
||||
|
||||
.grist-switch_on > .grist-switch_circle {
|
||||
-webkit-transform: translateX(13px);
|
||||
-ms-transform: translateX(13px);
|
||||
transform: translateX(13px);
|
||||
}
|
||||
|
||||
.grist-switch_transition > .grist-switch_slider, .grist-switch_transition > .grist-switch_circle {
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
@ -1,156 +1,64 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT BASE -->
|
||||
{{#if BASE}}
|
||||
<base href="{{ BASE }}">
|
||||
{{/if}}
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
background-color: #f7f7f7;
|
||||
line-height: 1.42857143;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="forms/grist-form-submit.js"></script>
|
||||
<script src="forms/purify.min.js"></script>
|
||||
<style>
|
||||
.grist-form-container {
|
||||
color: #262633;
|
||||
background-color: #f7f7f7;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding-top: 52px;
|
||||
padding-bottom: 32px;
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
.grist-form-container .grist-form-confirm {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form.grist-form {
|
||||
padding: 32px;
|
||||
margin: 0px auto;
|
||||
background-color: white;
|
||||
border: 1px solid #E8E8E8;
|
||||
width: 640px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
form.grist-form .grist-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field label {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field .grist-field-description {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
color: #929299;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field input[type="text"] {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
line-height: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form.grist-form input[type="submit"] {
|
||||
background-color: #16b378;
|
||||
border: 1px solid #16b378;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="date"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="submit"]:hover {
|
||||
border-color: #009058;
|
||||
background-color: #009058;
|
||||
}
|
||||
|
||||
form.grist-form input[type="checkbox"] {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
form.grist-form .grist-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
form.grist-form select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form.grist-form .grist-choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="forms/form.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class='grist-form-container'>
|
||||
<form class='grist-form'
|
||||
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'block', event.target.style.display = 'none'"
|
||||
data-grist-doc="<!-- INSERT DOC URL -->"
|
||||
data-grist-table="<!-- INSERT TABLE ID -->">
|
||||
<script>
|
||||
document.write(DOMPurify.sanitize(`<!-- INSERT CONTENT -->`));
|
||||
</script>
|
||||
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'flex', event.target.style.display = 'none'"
|
||||
data-grist-doc="{{ DOC_URL }}"
|
||||
data-grist-table="{{ TABLE_ID }}"
|
||||
data-grist-success-url="{{ SUCCESS_URL }}"
|
||||
>
|
||||
{{ dompurify CONTENT }}
|
||||
<div class="grist-power-by">
|
||||
<a href="https://getgrist.com" target="_blank">
|
||||
<div>Powered by</div>
|
||||
<div class="grist-logo"></div>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class='grist-form-confirm' style='display: none'>
|
||||
Thank you! Your response has been recorded.
|
||||
<div>
|
||||
{{ SUCCESS_TEXT }}
|
||||
</div>
|
||||
{{#if ANOTHER_RESPONSE }}
|
||||
<button onclick="window.location.reload()">Submit another response</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
// Validate choice list on submit
|
||||
document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) {
|
||||
// When submit is pressed make sure that all choice lists that are required
|
||||
// have at least one option selected
|
||||
const choiceLists = document.querySelectorAll('.grist-choice-list.required:not(:has(input:checked))');
|
||||
Array.from(choiceLists).forEach(function(choiceList) {
|
||||
// If the form has at least one checkbox make it required
|
||||
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// All other required choice lists with at least one option selected are no longer required
|
||||
const choiceListsRequired = document.querySelectorAll('.grist-choice-list.required:has(input:checked)');
|
||||
Array.from(choiceListsRequired).forEach(function(choiceList) {
|
||||
// If the form has at least one checkbox make it required
|
||||
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
|
||||
firstCheckbox?.removeAttribute('required');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 442 B |
After Width: | Height: | Size: 574 B |
After Width: | Height: | Size: 609 B |
After Width: | Height: | Size: 533 B |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 453 B |