Merge branch 'main' into webhook-trigger-update-by-column

pull/832/head
CamilleLegeron 3 months ago
commit c5dfa4a20d

@ -60,7 +60,7 @@ RUN \
# Fetch gvisor-based sandbox. Note, to enable it to run within default
# unprivileged docker, layers of protection that require privilege have
# been stripped away, see https://github.com/google/gvisor/issues/4371
FROM gristlabs/gvisor-unprivileged:buster as sandbox
FROM docker.io/gristlabs/gvisor-unprivileged:buster as sandbox
################################################################################
## Run-time stage

@ -1,20 +1,24 @@
import * as css from 'app/client/components/FormRendererCss';
import {FormField} from 'app/client/ui/FormAPI';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
import {isXSmallScreenObs} from 'app/client/ui2018/cssVars';
import {confirmModal} from 'app/client/ui2018/modals';
import {CellValue} from 'app/plugin/GristData';
import {Disposable, dom, DomContents, Observable} from 'grainjs';
import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs';
import {marked} from 'marked';
import {IPopupOptions, PopupControl} from 'popweasel';
export const CHOOSE_TEXT = '— Choose —';
const testId = makeTestId('test-form-');
/**
* A node in a recursive, tree-like hierarchy comprising the layout of a form.
*/
export interface FormLayoutNode {
/** Unique ID of the node. Used by FormView. */
id: string;
type: FormLayoutNodeType;
children?: Array<FormLayoutNode>;
// Unique ID of the field. Used only in the Form widget.
id?: string;
// Used by Layout.
submitText?: string;
successURL?: string;
@ -55,6 +59,24 @@ export interface FormRendererContext {
error: Observable<string|null>;
}
/**
* Returns a copy of `layoutSpec` with any leaf nodes that don't exist
* in `fieldIds` removed.
*/
export function patchLayoutSpec(
layoutSpec: FormLayoutNode,
fieldIds: Set<number>
): FormLayoutNode | null {
if (layoutSpec.leaf && !fieldIds.has(layoutSpec.leaf)) { return null; }
return {
...layoutSpec,
children: layoutSpec.children
?.map(child => patchLayoutSpec(child, fieldIds))
.filter((child): child is FormLayoutNode => child !== null),
};
}
/**
* A renderer for a form layout.
*
@ -68,20 +90,35 @@ export interface FormRendererContext {
* TODO: merge the two implementations or factor out what's common.
*/
export abstract class FormRenderer extends Disposable {
public static new(layoutNode: FormLayoutNode, context: FormRendererContext): FormRenderer {
public static new(
layoutNode: FormLayoutNode,
context: FormRendererContext,
parent?: FormRenderer
): FormRenderer {
const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
return new Renderer(layoutNode, context);
return new Renderer(layoutNode, context, parent);
}
protected children: FormRenderer[];
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) {
constructor(
protected layoutNode: FormLayoutNode,
protected context: FormRendererContext,
protected parent?: FormRenderer
) {
super();
this.children = (this.layoutNode.children ?? []).map((child) =>
this.autoDispose(FormRenderer.new(child, this.context)));
this.autoDispose(FormRenderer.new(child, this.context, this)));
}
public abstract render(): DomContents;
/**
* Reset the state of this layout node and all of its children.
*/
public reset() {
this.children.forEach((child) => child.reset());
}
}
class LabelRenderer extends FormRenderer {
@ -122,30 +159,45 @@ class SubmitRenderer extends FormRenderer {
public render() {
return [
css.error(dom.text(use => use(this.context.error) ?? '')),
css.submit(
dom('input',
css.submitButtons(
css.resetButton(
'Reset',
dom.boolAttr('disabled', this.context.disabled),
{
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit'
},
{type: 'button'},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
return confirmModal(
'Are you sure you want to reset your form?',
'Reset',
() => this.parent?.reset()
);
}),
)
testId('reset'),
),
css.submitButton(
dom('input',
dom.boolAttr('disabled', this.context.disabled),
{
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit',
},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
}),
)
),
),
];
}
@ -164,174 +216,380 @@ class LayoutRenderer extends FormRenderer {
}
class FieldRenderer extends FormRenderer {
public build(field: FormField) {
public renderer: BaseFieldRenderer;
public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) {
super(layoutNode, context);
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { throw new Error(); }
const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
return new Renderer();
this.renderer = this.autoDispose(new Renderer(field, context));
}
public render() {
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { return null; }
return css.field(this.renderer.render());
}
const renderer = this.build(field);
return css.field(renderer.render(field, this.context));
public reset() {
this.renderer.resetInput();
}
}
abstract class BaseFieldRenderer {
public render(field: FormField, context: FormRendererContext) {
abstract class BaseFieldRenderer extends Disposable {
public constructor(protected field: FormField, protected context: FormRendererContext) {
super();
}
public render() {
return css.field(
this.label(field),
dom('div', this.input(field, context)),
this.label(),
dom('div', this.input()),
);
}
public name(field: FormField) {
return field.colId;
public name() {
return this.field.colId;
}
public label(field: FormField) {
public label() {
return dom('label',
css.label.cls(''),
css.label.cls('-required', Boolean(field.options.formRequired)),
{for: this.name(field)},
field.question,
css.label.cls('-required', Boolean(this.field.options.formRequired)),
{for: this.name()},
this.field.question,
);
}
public abstract input(field: FormField, context: FormRendererContext): DomContents;
public abstract input(): DomContents;
public abstract resetInput(): void;
}
class TextRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'text',
name: this.name(field),
required: field.options.formRequired,
});
protected type = 'text';
private _value = Observable.create(this, '');
public input() {
return dom('input',
{
type: this.type,
name: this.name(),
required: this.field.options.formRequired,
},
dom.prop('value', this._value),
dom.on('input', (_e, elem) => this._value.set(elem.value)),
);
}
}
class DateRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'date',
name: this.name(field),
required: field.options.formRequired,
});
public resetInput(): void {
this._value.set('');
}
}
class DateTimeRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'datetime-local',
name: this.name(field),
required: field.options.formRequired,
});
}
class DateRenderer extends TextRenderer {
protected type = 'date';
}
class DateTimeRenderer extends TextRenderer {
protected type = 'datetime-local';
}
export const SELECT_PLACEHOLDER = 'Select...';
class ChoiceRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: Array<string|null> = field.options.choices || [];
// Insert empty option.
choices.unshift(null);
return css.select(
{name: this.name(field), required: field.options.formRequired},
choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT))
protected value = Observable.create<string>(this, '');
private _choices: string[];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
const choices = this.field.options.choices;
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
this._choices = [];
} else {
// Support for 1000 choices. TODO: make limit dynamic.
this._choices = choices.slice(0, 1000);
}
}
public input() {
return css.hybridSelect(
this._selectElement = css.select(
{name: this.name(), required: this.field.options.formRequired},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
this._choices.map((choice) => dom('option', {value: choice}, choice)),
dom.onKeyDown({
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
}),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
dom('div', dom.text(use => use(this.value) || SELECT_PLACEHOLDER)),
dropdownWithSearch<string>({
action: (value) => this.value.set(value),
options: () => [
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
...this._choices.map((choice) => ({
label: choice,
value: choice,
}),
)],
onClose: () => { setTimeout(() => this._selectElement.focus()); },
placeholder: 'Search',
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
popupOptions: {
trigger: [
'click',
(_el, ctl) => { this._ctl = ctl; },
],
},
matchTriggerElemWidth: true,
}),
css.searchSelectIcon('Collapse'),
testId('search-select'),
),
),
);
}
public resetInput(): void {
this.value.set('');
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
if (isXSmallScreenObs().get()) {
return;
}
ev.preventDefault();
ev.stopPropagation();
this._ctl?.open();
}
}
class BoolRenderer extends BaseFieldRenderer {
public render(field: FormField) {
protected checked = Observable.create<boolean>(this, false);
public render() {
return css.field(
dom('div', this.input(field)),
dom('div', this.input()),
);
}
public input(field: FormField) {
public input() {
return css.toggle(
css.label.cls('-required', Boolean(field.options.formRequired)),
dom('input', {
type: 'checkbox',
name: this.name(field),
value: '1',
required: field.options.formRequired,
}),
dom('input',
dom.prop('checked', this.checked),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
{
type: 'checkbox',
name: this.name(),
value: '1',
required: this.field.options.formRequired,
},
),
css.gristSwitch(
css.gristSwitchSlider(),
css.gristSwitchCircle(),
),
dom('span', field.question || field.colId)
css.toggleLabel(
css.label.cls('-required', Boolean(this.field.options.formRequired)),
this.field.question,
),
);
}
public resetInput(): void {
this.checked.set(false);
}
}
class ChoiceListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: string[] = field.options.choices ?? [];
const required = field.options.formRequired;
protected checkboxes: MutableObsArray<{
label: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
let choices = this.field.options.choices;
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
choices = [];
} else {
// Support for 30 choices. TODO: make limit dynamic.
choices = choices.slice(0, 30);
}
this.checkboxes.set(choices.map(choice => ({
label: choice,
checked: Observable.create(this, null),
})));
}
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
name: `${this.name(field)}[]`,
value: choice,
}),
dom('span', choice),
)),
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{
type: 'checkbox',
name: `${this.name()}[]`,
value: checkbox.label,
}
),
dom('span', checkbox.label),
)
),
);
}
public resetInput(): void {
this.checkboxes.get().forEach(checkbox => {
checkbox.checked.set(null);
});
}
}
class RefListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number, CellValue][] = field.refValues ?? [];
protected checkboxes: MutableObsArray<{
label: string;
value: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
const references = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 30 choices. TODO: make limit dynamic.
choices.splice(30);
const required = field.options.formRequired;
references.splice(30);
this.checkboxes.set(references.map(reference => ({
label: String(reference[1]),
value: String(reference[0]),
checked: Observable.create(this, null),
})));
}
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
'data-grist-type': field.type,
name: `${this.name(field)}[]`,
value: String(choice[0]),
}),
dom('span', String(choice[1] ?? '')),
)),
{name: this.name(), required},
dom.forEach(this.checkboxes, (checkbox) =>
css.checkbox(
dom('input',
dom.prop('checked', checkbox.checked),
dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)),
{
type: 'checkbox',
'data-grist-type': this.field.type,
name: `${this.name()}[]`,
value: checkbox.value,
}
),
dom('span', checkbox.label),
)
),
);
}
public resetInput(): void {
this.checkboxes.get().forEach(checkbox => {
checkbox.checked.set(null);
});
}
}
class RefRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number|string, CellValue][] = field.refValues ?? [];
protected value = Observable.create(this, '');
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
public input() {
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices. TODO: make limit dynamic.
choices.splice(1000);
// Insert empty option.
choices.unshift(['', CHOOSE_TEXT]);
return css.select(
{
name: this.name(field),
'data-grist-type': field.type,
required: field.options.formRequired,
},
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))),
return css.hybridSelect(
this._selectElement = css.select(
{
name: this.name(),
'data-grist-type': this.field.type,
required: this.field.options.formRequired,
},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
dom.onKeyDown({
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
}),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
dom('div', dom.text(use => {
const choice = choices.find((c) => String(c[0]) === use(this.value));
return String(choice?.[1] || SELECT_PLACEHOLDER);
})),
dropdownWithSearch<string>({
action: (value) => this.value.set(value),
options: () => [
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
...choices.map((choice) => ({
label: String(choice[1]),
value: String(choice[0]),
}),
)],
onClose: () => { setTimeout(() => this._selectElement.focus()); },
acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true},
placeholder: 'Search',
popupOptions: {
trigger: [
'click',
(_el, ctl) => { this._ctl = ctl; },
],
},
matchTriggerElemWidth: true,
}),
css.searchSelectIcon('Collapse'),
testId('search-select'),
),
)
);
}
public resetInput(): void {
this.value.set('');
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
if (isXSmallScreenObs().get()) {
return;
}
ev.preventDefault();
ev.stopPropagation();
this._ctl?.open();
}
}
const FieldRenderers = {

@ -1,4 +1,5 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
export const label = styled('div', `
@ -38,7 +39,36 @@ export const columns = styled('div', `
gap: 4px;
`);
export const submit = styled('div', `
export const submitButtons = styled('div', `
display: flex;
justify-content: center;
column-gap: 8px;
`);
export const resetButton = styled('button', `
line-height: inherit;
font-size: ${vars.mediumFontSize};
padding: 10px 24px;
cursor: pointer;
background-color: transparent;
color: ${vars.primaryBg};
border: 1px solid ${vars.primaryBg};
border-radius: 4px;
outline-color: ${vars.primaryBgHover};
&:hover {
color: ${vars.primaryBgHover};
border-color: ${vars.primaryBgHover};
}
&:disabled {
cursor: not-allowed;
color: ${colors.light};
background-color: ${colors.slate};
border-color: ${colors.slate};
}
`);
export const submitButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
@ -52,11 +82,18 @@ export const submit = styled('div', `
font-size: 13px;
cursor: pointer;
line-height: inherit;
outline-color: ${vars.primaryBgHover};
}
& input[type="submit"]:hover {
border-color: ${vars.primaryBgHover};
background-color: ${vars.primaryBgHover};
}
& input[type="submit"]:disabled {
cursor: not-allowed;
color: ${colors.light};
background-color: ${colors.slate};
border-color: ${colors.slate};
}
`);
// TODO: break up into multiple variables, one for each field type.
@ -72,12 +109,10 @@ export const field = styled('div', `
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline: none;
outline-color: ${vars.primaryBgHover};
}
& input[type="text"] {
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
line-height: inherit;
width: 100%;
color: ${colors.dark};
@ -101,6 +136,9 @@ export const field = styled('div', `
margin-right: 8px;
vertical-align: baseline;
}
& input[type="checkbox"]:focus {
outline-color: ${vars.primaryBgHover};
}
& input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg};
@ -171,11 +209,19 @@ export const toggle = styled('label', `
& input[type='checkbox'] {
position: absolute;
}
& input[type='checkbox']:focus {
outline: none;
}
& > span {
margin-left: 8px;
}
`);
export const toggleLabel = styled('span', `
font-size: 13px;
font-weight: 700;
`);
export const gristSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
@ -185,8 +231,8 @@ export const gristSwitchSlider = styled('div', `
bottom: 0;
background-color: #ccc;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: background-color .4s;
transition: background-color .4s;
&:hover {
box-shadow: 0 0 1px #2196F3;
@ -203,8 +249,8 @@ export const gristSwitchCircle = styled('div', `
bottom: 2px;
background-color: white;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: transform .4s;
transition: transform .4s;
`);
export const gristSwitch = styled('div', `
@ -214,6 +260,11 @@ export const gristSwitch = styled('div', `
display: inline-block;
flex: none;
input:focus + & > .${gristSwitchSlider.className} {
outline: 2px solid ${vars.primaryBgHover};
outline-offset: 1px;
}
input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg};
}
@ -239,16 +290,52 @@ export const checkbox = styled('label', `
}
`);
export const hybridSelect = styled('div', `
position: relative;
`);
export const select = styled('select', `
position: absolute;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline: none;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
@media ${mediaXSmall} {
& {
outline: revert;
outline-color: ${vars.primaryBgHover};
position: relative;
}
}
`);
export const searchSelect = styled('div', `
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
select:focus + & {
outline: 2px solid ${vars.primaryBgHover};
}
`);
export const searchSelectIcon = styled(icon, `
flex-shrink: 0;
`);

@ -9,6 +9,7 @@ import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
@ -93,17 +94,23 @@ export class ColumnsModel extends BoxModel {
const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
// Finally, remove the fields and save the changes to the layout.
await this.parent?.save(async () => {
// FormView is particularly sensitive to the order that view fields and
// the form layout are modified. Specifically, if the layout is
// modified before view fields are removed, deleting a column with
// mapped fields inside seems to break. The same issue affects sections
// containing mapped fields. Reversing the order causes no such issues.
//
// TODO: narrow down why this happens and see if it's worth fixing.
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
});
}
}
@ -218,16 +225,12 @@ export class PlaceholderModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {type: 'Paragraph', text, alignment};
}
export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'};
return {id: uuidv4(), type: 'Placeholder'};
}
export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]};
}
const cssPlaceholder = styled('div', `

@ -7,7 +7,7 @@ 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';
import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
const testId = makeTestId('test-forms-');
const t = makeT('FormView.Editor');
@ -27,9 +27,13 @@ interface Props {
*/
click?: (ev: MouseEvent, box: BoxModel) => void,
/**
* Custom remove icon. If null, then no drop icon is shown.
* Whether to show the remove button. Defaults to true.
*/
removeIcon?: IconName|null,
showRemoveButton?: BindableValue<boolean>,
/**
* Custom remove icon.
*/
removeIcon?: IconName,
/**
* Custom remove button rendered atop overlay.
*/
@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
}
await box.save(async () => {
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
// 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');
});
}),
@ -225,10 +234,9 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
testId('element'),
dom.attr('data-box-model', String(box.type)),
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,
dom.maybe(props.showRemoveButton ?? true, () => [
props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
]),
...args,
);
}

@ -1,4 +1,4 @@
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
@ -8,6 +8,7 @@ import {refRecord} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {isBlankValue} from 'app/common/gristTypes';
import {Constructor, not} from 'app/common/gutil';
import {
BindableValue,
@ -102,18 +103,6 @@ export class FieldModel extends BoxModel {
);
}
public async afterDrop() {
// Base class does good job of handling drop.
await super.afterDrop();
if (this.isDisposed()) { return; }
// Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
// field yet). In this case, we need to create a field.
if (typeof this.leaf.get() === 'string') {
this.leaf.set(await this.view.showColumn(this.leaf.get()));
}
}
public override render(...args: IDomArgs<HTMLElement>): HTMLElement {
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
const save = (value: string) => {
@ -287,20 +276,14 @@ class TextModel extends Question {
class ChoiceModel extends Question {
protected choices: Computed<string[]> = Computed.create(this, use => {
// Read choices from field.
const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || [];
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
// Make sure it is array of strings.
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
// Make sure it is an array of strings.
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return [];
} else {
return choices;
}
return list;
});
protected choicesWithEmpty = Computed.create(this, use => {
const list: Array<string|null> = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(null);
return list;
});
public renderInput(): HTMLElement {
@ -309,21 +292,27 @@ class ChoiceModel extends Question {
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice ?? CHOOSE_TEXT, {value: choice ?? ''})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends ChoiceModel {
private _choices = Computed.create(this, use => {
// Support for 30 choices. TODO: make limit dynamic.
return use(this.choices).slice(0, 30);
});
public renderInput() {
const field = this.model.field;
return dom('div',
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
choice
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom.maybe(use => use(this._choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
);
@ -382,22 +371,22 @@ class DateTimeModel extends Question {
}
class RefListModel extends Question {
protected choices = this._subscribeForChoices();
protected options = this._getOptions();
public renderInput() {
return dom('div',
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
String(choice[1] ?? '')
option.label,
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom('div', 'No choices defined'),
dom.maybe(use => use(this.options).length === 0, () => [
dom('div', 'No values in show column of referenced table'),
]),
) as HTMLElement;
}
private _subscribeForChoices() {
private _getOptions() {
const tableId = Computed.create(this, use => {
const refTable = use(use(this.model.column).refTable);
return refTable ? use(refTable.tableId) : '';
@ -411,27 +400,23 @@ class RefListModel extends Question {
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
return Computed.create(this, use => {
const unsorted = use(observer);
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
return unsorted.slice(0, 50); // TODO: pagination or a waning
return use(observer)
.filter(([_id, value]) => !isBlankValue(value))
.map(([id, value]) => ({label: String(value), value: String(id)}))
.sort((a, b) => a.label.localeCompare(b.label))
.slice(0, 30); // TODO: make limit dynamic.
});
}
}
class RefModel extends RefListModel {
protected withEmpty = Computed.create(this, use => {
const list = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(['', CHOOSE_TEXT]);
return list;
});
public renderInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.model.colId),
dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
);
}
}

@ -1,7 +1,7 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {NewBox} from 'app/client/components/Forms/Menu';
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
@ -15,13 +15,16 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menuCssClass} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {isOwner} from 'app/common/roles';
@ -31,6 +34,7 @@ import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
import * as ko from 'knockout';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const t = makeT('FormView');
@ -42,6 +46,7 @@ export class FormView extends Disposable {
public viewSection: ViewSectionRec;
public selectedBox: Computed<BoxModel | null>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableDeleteSection: Computed<boolean>;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
@ -49,17 +54,20 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
private _url: Computed<string>;
private _copyingLink: Observable<boolean>;
private _previewUrl: Computed<string>;
private _pageShare: Computed<ShareRec | null>;
private _remoteShare: AsyncComputed<{key: string}|null>;
private _isFork: Computed<boolean>;
private _published: Computed<boolean>;
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
private _openingForm: Observable<boolean>;
private _formElement: HTMLElement;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
@ -124,15 +132,22 @@ export class FormView extends Disposable {
}));
this.viewSection.selectedFields(this.selectedColumns.peek());
this._formFields = Computed.create(this, use => {
const fields = use(use(this.viewSection.viewFields).getObservable());
return fields.filter(f => use(use(f.column).isFormCol));
});
this._autoLayout = Computed.create(this, use => {
// If the layout is already there, don't do anything.
const existing = use(this.viewSection.layoutSpecObj);
if (!existing || !existing.id) {
const fields = use(use(this.viewSection.viewFields).getObservable());
const fields = use(this._formFields);
const layout = use(this.viewSection.layoutSpecObj);
if (!layout || !layout.id) {
return this._formTemplate(fields);
} else {
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
return patchedLayout;
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
@ -166,12 +181,7 @@ export class FormView extends Disposable {
copy: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
// Add this box as a json to clipboard.
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
selected.copySelf().catch(reportError);
},
cut: () => {
const selected = this.selectedBox.get();
@ -179,7 +189,7 @@ export class FormView extends Disposable {
selected.cutSelf().catch(reportError);
},
paste: () => {
const doPast = async () => {
const doPaste = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) {
@ -187,13 +197,14 @@ export class FormView extends Disposable {
} else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
}
// Remove the original box from the clipboard.
const cut = this._root.find(boxInClipboard.id);
cut?.removeSelf();
const maybeCutBox = this._root.find(boxInClipboard.id);
if (maybeCutBox?.cut.get()) {
maybeCutBox.removeSelf();
}
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
doPaste().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
@ -242,7 +253,7 @@ export class FormView extends Disposable {
},
clearValues: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if (!selected || selected.canRemove?.() === false) { return; }
keyboardActions.nextField();
this.bundle(async () => {
await selected.deleteSelf();
@ -267,6 +278,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
insertField: (what: NewBox) => {
@ -287,6 +299,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
showColumns: (colIds: string[]) => {
@ -299,6 +312,7 @@ export class FormView extends Disposable {
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
id: uuidv4(),
leaf: fieldRef,
type: 'Field' as FormLayoutNodeType,
};
@ -332,7 +346,7 @@ export class FormView extends Disposable {
hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
this._previewUrl = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = urlState().makeUrl({
@ -344,8 +358,6 @@ export class FormView extends Disposable {
return url;
});
this._copyingLink = Observable.create(this, false);
this._pageShare = Computed.create(this, use => {
const page = use(use(this.viewSection.view).page);
if (!page) { return null; }
@ -366,7 +378,15 @@ export class FormView extends Disposable {
}
});
this._isFork = Computed.create(this, use => {
const {docPageModel} = this.gristDoc;
return use(docPageModel.isFork) || use(docPageModel.isPrefork);
});
this._published = Computed.create(this, use => {
const isFork = use(this._isFork);
if (isFork) { return false; }
const pageShare = use(this._pageShare);
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
const validShare = pageShare && remoteShare;
@ -384,6 +404,8 @@ export class FormView extends Disposable {
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
this._openingForm = Observable.create(this, false);
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
@ -401,7 +423,7 @@ export class FormView extends Disposable {
testId('editor'),
style.cssFormEditBody(
style.cssFormContainer(
dom.forEach(this._root.children, (child) => {
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
if (!child) {
return dom('div', 'Empty node');
}
@ -410,11 +432,12 @@ export class FormView extends Disposable {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
})),
this._buildPublisher(),
),
),
dom.on('click', () => this.selectedBox.set(null))
dom.on('click', () => this.selectedBox.set(null)),
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
);
}
@ -443,6 +466,7 @@ export class FormView extends Disposable {
}
// And add it into the layout.
this.selectedBox.set(insert({
id: uuidv4(),
leaf: fieldRef,
type: 'Field'
}));
@ -612,67 +636,90 @@ export class FormView extends Disposable {
private _buildPublisher() {
return style.cssSwitcher(
this._buildSwitcherMessage(),
this._buildNotifications(),
style.cssButtonGroup(
style.cssSmallIconButton(
style.cssIconButton.cls('-frameless'),
style.cssSmallButton(
style.cssSmallButton.cls('-frameless'),
icon('Revert'),
testId('reset'),
dom('div', 'Reset form'),
dom('div', t('Reset form')),
dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'),
dom.style('margin-right', 'auto'), // move it to the left
dom.on('click', () => {
this._resetForm().catch(reportError);
})
),
style.cssIconLink(
testId('preview'),
icon('EyeShow'),
dom.text('Preview'),
dom.prop('href', this._url),
dom.prop('target', '_blank'),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
stopEvent(ev);
await this.save();
window.open(this._url.get());
}
return confirmModal(t('Are you sure you want to reset your form?'),
t('Reset'),
() => this._resetForm(),
);
})
),
style.cssIconButton(
icon('FieldAttachment'),
testId('link'),
dom('div', 'Copy Link'),
dom.prop('disabled', this._copyingLink),
dom.domComputed(this._published, published => {
if (published) {
return style.cssSmallButton(
testId('view'),
icon('EyeShow'),
t('View'),
dom.boolAttr('disabled', this._openingForm),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
await this.save();
}
try {
this._openingForm.set(true);
window.open(await this._getFormUrl());
} finally {
this._openingForm.set(false);
}
})
);
} else {
return style.cssSmallLinkButton(
testId('preview'),
icon('EyeShow'),
t('Preview'),
dom.attr('href', this._previewUrl),
dom.prop('target', '_blank'),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
stopEvent(ev);
await this.save();
window.open(this._previewUrl.get());
}
})
);
}
}),
style.cssSmallButton(
icon('Share'),
testId('share'),
dom('div', t('Share')),
dom.show(use => this._isOwner && use(this._published)),
dom.on('click', async (_event, element) => {
try {
this._copyingLink.set(true);
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
"text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
});
await copyToClipboard(data);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch (ex) {
if (ex.code === 'AUTH_NO_OWNER') {
throw new Error('Sharing a form is only available to owners');
}
} finally {
this._copyingLink.set(false);
}
}),
elem => {
setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
...defaultMenuOptions,
placement: 'top-end',
});
},
),
dom.domComputed(this._published, published => {
dom.domComputed(use => {
const isFork = use(this._isFork);
const published = use(this._published);
return published
? style.cssIconButton(
dom('div', 'Unpublish'),
? style.cssSmallButton(
dom('div', t('Unpublish')),
dom.show(this._isOwner),
style.cssIconButton.cls('-warning'),
style.cssSmallButton.cls('-warning'),
dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'),
)
: style.cssIconButton(
dom('div', 'Publish'),
: style.cssSmallButton(
dom('div', t('Publish')),
dom.boolAttr('disabled', isFork),
!isFork ? null : hoverTooltip(t('Save your document to publish this form.'), {
placement: 'top',
}),
dom.show(this._isOwner),
cssButton.cls('-primary'),
dom.on('click', () => this._handleClickPublish()),
@ -683,7 +730,7 @@ export class FormView extends Disposable {
);
}
private async _getFormLink() {
private async _getFormUrl() {
const share = this._pageShare.get();
if (!share) {
throw new Error('Unable to get form link: form is not published');
@ -703,7 +750,139 @@ export class FormView extends Disposable {
});
}
private _buildSwitcherMessage() {
private _buildShareMenu(ctl: IOpenController) {
const formUrl = Observable.create<string | null>(ctl, null);
const showEmbedCode = Observable.create(this, false);
const embedCode = Computed.create(ctl, formUrl, (_use, url) => {
if (!url) { return null; }
return '<iframe style="border: none; width: 640px; ' +
`height: ${this._getEstimatedFormHeightPx()}px" src="${url}"></iframe>`;
});
// Reposition the popup when its height changes.
ctl.autoDispose(formUrl.addListener(() => ctl.update()));
ctl.autoDispose(showEmbedCode.addListener(() => ctl.update()));
this._getFormUrl()
.then((url) => {
if (ctl.isDisposed()) { return; }
formUrl.set(url);
})
.catch((e) => {
ctl.close();
reportError(e);
});
return style.cssShareMenu(
dom.cls(menuCssClass),
style.cssShareMenuHeader(
style.cssShareMenuCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
),
),
style.cssShareMenuBody(
dom.domComputed(use => {
const url = use(formUrl);
const code = use(embedCode);
if (!url || !code) {
return style.cssShareMenuSpinner(loadingSpinner());
}
return [
dom('div',
style.cssShareMenuSectionHeading(
t('Share this form'),
),
dom('div',
style.cssShareMenuHintText(
t('Anyone with the link below can see the empty form and submit a response.'),
),
style.cssShareMenuUrlBlock(
style.cssShareMenuUrl(
{readonly: true, value: url},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCopyButton(
testId('link'),
t('Copy link'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(url);
showTransientTooltip(
el,
t('Link copied to clipboard'),
{key: 'share-form-menu'}
);
})
),
),
),
),
dom.domComputed(showEmbedCode, (showCode) => {
if (!showCode) {
return dom('div',
style.cssShareMenuEmbedFormButton(
t('Embed this form'),
dom.on('click', () => showEmbedCode.set(true)),
)
);
} else {
return dom('div',
style.cssShareMenuSectionHeading(t('Embed this form')),
dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock(
style.cssShareMenuCode(
code,
{readonly: true, rows: '3'},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCodeBlockButtons(
style.cssShareMenuCopyButton(
testId('code'),
t('Copy code'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(code);
showTransientTooltip(
el,
t('Code copied to clipboard'),
{key: 'share-form-menu'}
);
}),
),
),
)),
);
}
}),
];
}),
),
);
}
private _getEstimatedFormHeightPx() {
return (
// Form content height.
this._formElement.scrollHeight +
// Plus top/bottom page padding.
(2 * 52) +
// Plus top/bottom form padding.
(2 * 20) +
// Plus minimum form error height.
38 +
// Plus form footer height.
64
);
}
private _buildNotifications() {
return [
this._buildFormPublishedNotification(),
];
}
private _buildFormPublishedNotification() {
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage(
style.cssSwitcherMessageBody(
@ -726,29 +905,24 @@ export class FormView extends Disposable {
/**
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
id: uuidv4(),
type: 'Field',
leaf: f.id()
} as FormLayoutNode;
leaf: f.id(),
};
});
const section = {
type: 'Section',
children: [
{type: 'Paragraph', text: SECTION_TITLE},
{type: 'Paragraph', text: SECTION_DESC},
...boxes,
],
};
const section = components.Section(...boxes);
return {
id: uuidv4(),
type: 'Layout',
children: [
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{type: 'Submit'}
]
{id: uuidv4(), type: 'Submit'},
],
};
}
@ -758,19 +932,9 @@ export class FormView extends Disposable {
// First we will remove all fields from this section, and add top 9 back.
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
const toAdd = this.viewSection.table().columns().peek().filter(c => {
// If hidden than no.
if (c.isHiddenCol()) { return false; }
// 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());
const toAdd = this.viewSection.table().columns().peek()
.filter(c => c.isFormCol())
.sort((a, b) => a.parentPos() - b.parentPos());
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
const parentId = colRef.map(() => this.viewSection.id());
@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents);
// Default values when form is reset.
const FORM_TITLE = "## **Form Title**";
const FORM_DESC = "Your form description goes here.";
const SECTION_TITLE = '### **Header**';
const SECTION_DESC = 'Description';

@ -16,7 +16,7 @@ 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 {
export class MappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
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()));
const cols = this._section.table().visibleColumns()
.filter(c => c.isFormCol() && !fields.has(c.colId()));
return cols.map(col => ({
col,
selected: Observable.create(null, false),
@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
if (this._section.isDisposed()) {
return [];
}
const cols = this._section.viewFields().map(f => f.column());
const cols = this._section.viewFields().map(f => f.column()).all()
.filter(c => c.isFormCol());
return cols.map(col => ({
col,
selected: Observable.create(null, false),
})).all();
}));
})));
const anyUnmappedSelected = Computed.create(this, use => {
@ -65,60 +67,60 @@ export class UnmappedFieldsConfig extends Disposable {
return [
cssHeader(
cssFieldListHeader(t("Unmapped")),
cssFieldListHeader(dom.text(t("Mapped"))),
selectAllLabel(
dom.on('click', () => {
unmappedColumns.get().forEach((col) => col.selected.set(true));
mappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
),
),
dom('div',
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
testId('visible-fields'),
dom.forEach(mappedColumns, (field) => {
return this._buildMappedField(field);
})
),
dom.maybe(anyUnmappedSelected, () =>
dom.maybe(anyMappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Map fields")),
dom.on('click', mapSelected),
dom.text(t("Unmap fields")),
dom.on('click', unMapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
),
cssHeader(
cssFieldListHeader(dom.text(t("Mapped"))),
cssFieldListHeader(t("Unmapped")),
selectAllLabel(
dom.on('click', () => {
mappedColumns.get().forEach((col) => col.selected.set(true));
unmappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
),
),
dom('div',
testId('visible-fields'),
dom.forEach(mappedColumns, (field) => {
return this._buildMappedField(field);
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
})
),
dom.maybe(anyMappedSelected, () =>
dom.maybe(anyUnmappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Unmap fields")),
dom.on('click', unMapSelected),
dom.text(t("Map fields")),
dom.on('click', mapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')

@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
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 normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));
const list = normalCols.map(col => {
return {
label: use(col.label),

@ -2,7 +2,6 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRend
import * as elements from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
type Callback = () => Promise<void>;
@ -33,9 +32,7 @@ export abstract class BoxModel extends Disposable {
}
/**
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
* The unique id of the box.
*/
public id: string;
/**
@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable {
parent.children.autoDispose(this);
}
// Store "pointer" to this element.
this.id = uuidv4();
this.id = box.id;
// Create observables for all properties.
this.type = box.type;
@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable {
this.onCreate();
}
/**
* Public method that should be called when this box is dropped somewhere. In derived classes
* this method can send some actions to the server, or do some other work. In particular Field
* will insert or reveal a column.
*/
public async afterDrop() {
}
/**
* The only method that derived classes need to implement. It should return a DOM element that
* represents this box.
@ -134,12 +121,19 @@ export abstract class BoxModel extends Disposable {
}
/**
* Cuts self and puts it into clipboard.
* Copies self and puts it into clipboard.
*/
public async cutSelf() {
public async copySelf() {
[...this.root().traverse()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
}
/**
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
await this.copySelf();
this.cut.set(true);
}
@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable {
this.prop(key).set(boxDef[key]);
}
// Add or delete any children that were removed or added.
const myLength = this.children.get().length;
const newLength = boxDef.children ? boxDef.children.length : 0;
if (myLength > newLength) {
this.children.splice(newLength, myLength - newLength);
} else if (myLength < newLength) {
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
// First remove any children from the model that aren't in `boxDef`.
const boxDefChildren = boxDef.children ?? [];
const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));
for (const child of this.children.get()) {
if (!boxDefChildrenIds.has(child.id)) {
child.removeSelf();
}
}
if (!boxDef.children) { return; }
// Update those that indices are the same.
const min = Math.min(myLength, newLength);
for (let i = 0; i < min; i++) {
const atIndex = this.children.get()[i];
const atIndexDef = boxDef.children[i];
atIndex.update(atIndexDef);
// Then add or update the children from `boxDef` to the model.
const newChildren: BoxModel[] = [];
const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));
for (const boxDefChild of boxDefChildren) {
if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {
newChildren.push(BoxModel.new(boxDefChild, this));
} else {
const existingChild = modelChildrenById.get(boxDefChild.id)!;
existingChild.update(boxDefChild);
newChildren.push(existingChild);
}
}
this.children.set(newChildren);
}
/**
@ -381,12 +376,18 @@ export abstract class BoxModel extends Disposable {
}
}
public canRemove() {
return true;
}
protected onCreate() {
}
}
export class LayoutModel extends BoxModel {
public disableDeleteSection: Computed<boolean>;
constructor(
box: FormLayoutNode,
public parent: BoxModel | null,
@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel {
public view: FormView
) {
super(box, parent, view);
this.disableDeleteSection = Computed.create(this, use => {
return use(this.children).filter(c => c.type === 'Section').length === 1;
});
}
public async save(clb?: Callback) {

@ -1,10 +1,12 @@
import * as css from './styles';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars';
import {not} from 'app/common/gutil';
import {Computed, dom, Observable, styled} from 'grainjs';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {v4 as uuidv4} from 'uuid';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
@ -60,6 +62,10 @@ export class ParagraphModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {id: uuidv4(), type: 'Paragraph', text, alignment};
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};

@ -1,11 +1,19 @@
import {allCommands} from 'app/client/components/commands';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import {BoxModel, LayoutModel} from 'app/client/components/Forms/Model';
import {Paragraph} from 'app/client/components/Forms/Paragraph';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import * as menus from 'app/client/ui2018/menus';
import {dom, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const t = makeT('FormView');
const testId = makeTestId('test-forms-');
@ -13,14 +21,17 @@ const testId = makeTestId('test-forms-');
* Component that renders a section of the form.
*/
export class SectionModel extends BoxModel {
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
}
public override render(): HTMLElement {
const children = this.children;
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,
showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection),
// Content is just a list of children.
content: style.cssSection(
// Wrap them in a div that mutes hover events.
@ -35,6 +46,18 @@ export class SectionModel extends BoxModel {
style.cssPlusIcon('Plus'),
buildMenu({
box: this,
customItems: [
menus.menuItem(
() => allCommands.insertFieldBefore.run({structure: 'Section'}),
menus.menuIcon('Section'),
t('Insert section above'),
),
menus.menuItem(
() => allCommands.insertFieldAfter.run({structure: 'Section'}),
menus.menuIcon('Section'),
t('Insert section below'),
),
],
})
),
)
@ -79,19 +102,35 @@ export class SectionModel extends BoxModel {
const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
// Remove each child of this section from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this section from the layout.
this.removeSelf();
// Finally, remove the fields and save the changes to the layout.
await this.parent?.save(async () => {
// Remove the fields.
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
// Remove each child of this section from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this section from the layout.
this.removeSelf();
});
}
public canRemove() {
return !((this.parent as LayoutModel).disableDeleteSection.get());
}
}
export function Section(...children: FormLayoutNode[]): FormLayoutNode {
return {
id: uuidv4(),
type: 'Section',
children: [
Paragraph('### **Header**'),
Paragraph('Description'),
...children,
],
};
}
const cssSectionItems = styled('div.hover_border', `

@ -1,5 +1,8 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
import {Paragraph} from 'app/client/components/Forms/Paragraph';
import {Section} from 'app/client/components/Forms/Section';
import {v4 as uuidv4} from 'uuid';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
@ -18,6 +21,7 @@ export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
case 'Placeholder': return Placeholder();
case 'Separator': return Paragraph('---');
case 'Header': return Paragraph('## **Header**', 'center');
default: return {type};
case 'Section': return Section();
default: return {id: uuidv4(), type};
}
}

@ -1,6 +1,6 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons';
import {basicButton, basicButtonLink, textButton} 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';
@ -239,14 +239,6 @@ export const cssSelect = styled('select', `
border-radius: 3px;
outline: none;
pointer-events: none;
&-invalid {
color: ${theme.inputInvalid};
}
&:has(option[value='']:checked) {
font-style: italic;
color: ${colors.slate};
}
`);
export const cssFieldEditorContent = styled('div', `
@ -373,49 +365,23 @@ export const cssButtonGroup = styled('div', `
`);
export const cssIconLink = styled(bigBasicButtonLink, `
export const cssSmallLinkButton = styled(basicButtonLink, `
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning {
color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg};
border: none;
}
&-warning:hover {
color: ${theme.controlPrimaryFg};
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
min-height: 26px;
`);
export const cssSmallIconButton = styled(basicButton, `
export const cssSmallButton = styled(basicButton, `
display: flex;
align-items: center;
gap: 4px;
min-height: 26px;
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssIconButton = styled(bigBasicButton, `
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning {
color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg};
@ -426,10 +392,6 @@ export const cssIconButton = styled(bigBasicButton, `
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssMarkdownRendered = styled('div', `
@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', `
cursor: pointer;
}
.${cssFieldEditor.className}-selected > &,
.${cssFieldEditor.className}:hover > & {
.${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {
display: flex;
}
&-right {
@ -623,6 +585,124 @@ export const cssRemoveButton = styled('div', `
}
`);
export const cssShareMenu = styled('div', `
color: ${theme.text};
background-color: ${theme.popupBg};
width: min(calc(100% - 16px), 400px);
border-radius: 3px;
padding: 8px;
`);
export const cssShareMenuHeader = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuBody = styled('div', `
box-sizing: content-box;
display: flex;
flex-direction: column;
row-gap: 32px;
padding: 0px 16px 24px 16px;
min-height: 160px;
`);
export const cssShareMenuCloseButton = styled('div', `
flex-shrink: 0;
border-radius: 4px;
cursor: pointer;
padding: 4px;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
export const cssShareMenuSectionHeading = styled('div', `
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 16px;
`);
export const cssShareMenuHintText = styled('div', `
color: ${theme.lightText};
`);
export const cssShareMenuSpinner = styled('div', `
display: flex;
justify-content: center;
align-items: center;
min-height: inherit;
`);
export const cssShareMenuSectionButtons = styled('div', `
display: flex;
justify-content: flex-end;
margin-top: 16px;
`);
export const cssShareMenuUrlBlock = styled('div', `
display: flex;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
border-radius: 3px;
width: 100%;
margin-top: 16px;
`);
export const cssShareMenuUrl = styled('input', `
background: transparent;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
`);
export const cssShareMenuCopyButton = styled(textButton, `
margin-left: 4px;
font-weight: 500;
`);
export const cssShareMenuEmbedFormButton = styled(textButton, `
font-weight: 500;
`);
export const cssShareMenuCodeBlock = styled('div', `
border-radius: 3px;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
`);
export const cssShareMenuCodeBlockButtons = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuCode = styled('textarea', `
background-color: transparent;
border: none;
border-radius: 3px;
word-break: break-all;
width: 100%;
outline: none;
resize: none;
`);
export const cssFormDisabledOverlay = styled('div', `
background-color: ${theme.widgetBg};
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
`);
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
return [
dom.onKeyDown({

@ -1222,6 +1222,16 @@ export class GristDoc extends DisposableWithEvents {
return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadCsvUrl(params);
}
public getTsvLink() {
const params = this._getDocApiDownloadParams();
return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadTsvUrl(params);
}
public getDsvLink() {
const params = this._getDocApiDownloadParams();
return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadDsvUrl(params);
}
public getXlsxActiveViewLink() {
const params = this._getDocApiDownloadParams();
return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadXlsxUrl(params);

@ -129,6 +129,7 @@ async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSect
...record,
layoutSpec: JSON.stringify(viewSectionLayoutSpec),
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()],
shareOptions: '',
});
}
@ -201,7 +202,7 @@ function newViewSectionAction(widget: IPageWidget, viewId: number) {
*/
export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
return cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object') {
if (typeof val === 'object' && val !== null) {
if (mapIds[val.leaf]) {
return {...val, leaf: mapIds[val.leaf]};
}

@ -321,10 +321,12 @@ const cssArrowContainer = styled('div', `
${sideSelectorChunk('top')} > & {
bottom: -17px;
margin: 0px 16px;
}
${sideSelectorChunk('bottom')} > & {
top: -14px;
margin: 0px 16px;
}
${sideSelectorChunk('right')} > & {

@ -15,7 +15,6 @@ import split = require("lodash/split");
export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
// Note that items with empty cleanText are never suggested.
cleanText: string;
}
@ -65,6 +64,19 @@ interface Word {
pos: number; // Position of the word within the item where it occurred.
}
export interface ACIndexOptions {
/** The max number of items to suggest. Defaults to 50. */
maxResults?: number;
/**
* Suggested matches in the same relative order as items, rather than by score.
*
* Defaults to false.
*/
keepOrder?: boolean;
/** Show items with an empty `cleanText`. Defaults to false. */
showEmptyItems?: boolean;
}
/**
* Implements a search index. It doesn't currently support updates; when any values change, the
* index needs to be rebuilt from scratch.
@ -75,11 +87,12 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
// All words from _allItems, sorted.
private _words: Word[];
private _maxResults = this._options.maxResults ?? 50;
private _keepOrder = this._options.keepOrder ?? false;
private _showEmptyItems = this._options.showEmptyItems ?? false;
// Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50).
// If _keepOrder is true, best matches will be suggested in the order they occur in items,
// rather than order by best score.
constructor(items: Item[], private _maxResults: number = 50, private _keepOrder = false) {
constructor(items: Item[], private _options: ACIndexOptions = {}) {
this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems.
@ -132,7 +145,9 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
// Append enough non-matching indices to reach maxResults.
for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) {
if (this._allItems[i].cleanText && !myMatches.has(i)) {
if (myMatches.has(i)) { continue; }
if (this._allItems[i].cleanText || this._showEmptyItems) {
itemIndices.push(i);
}
}

@ -22,6 +22,7 @@ export type { IOption, IOptionFull } from 'popweasel';
export { getOptionFull } from 'popweasel';
export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {
matchTriggerElemWidth?: boolean;
headerDom?(): DomArg<HTMLElement>;
renderItem?(item: U): DomArg<HTMLElement>;
}
@ -42,6 +43,14 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
this.content = cssMenuWrap(
dom('div',
elem => {
if (opt.matchTriggerElemWidth) {
const style = elem.style;
style.minWidth = _ctl.getTriggerElem().getBoundingClientRect().width + 'px';
style.marginLeft = '0px';
style.marginRight = '0px';
}
},
{class: menuCssClass + ' grist-floating-menu'},
cssMenu.cls(''),
cssMenuExt.cls(''),
@ -113,7 +122,7 @@ export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable
private _doAction(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element
// selected.
if (value) { this._action(value); }
if (value !== null) { this._action(value); }
this._ctl.close();
}

@ -31,7 +31,7 @@ export interface SelectFileOptions extends UploadOptions {
// e.g. [".jpg", ".png"]
}
export const IMPORTABLE_EXTENSIONS = [".grist", ".csv", ".tsv", ".txt", ".xlsx", ".xlsm"];
export const IMPORTABLE_EXTENSIONS = [".grist", ".csv", ".tsv", ".dsv", ".txt", ".xlsx", ".xlsm"];
/**
* Shows the file-picker dialog with the given options, and uploads the selected files. If under

@ -1,4 +1,4 @@
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {FormLayoutNode, patchLayoutSpec} from 'app/client/components/FormRenderer';
import {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization';
import {getHomeUrl} from 'app/client/models/AppModel';
@ -25,7 +25,13 @@ export class FormModelImpl extends Disposable implements FormModel {
public readonly formLayout = Computed.create(this, this.form, (_use, form) => {
if (!form) { return null; }
return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode;
const layout = safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode | null;
if (!layout) { throw new Error('invalid formLayoutSpec'); }
const patchedLayout = patchLayoutSpec(layout, new Set(Object.keys(form.formFieldsById).map(Number)));
if (!patchedLayout) { throw new Error('invalid formLayoutSpec'); }
return patchedLayout;
});
public readonly submitting = Observable.create<boolean>(this, false);
public readonly submitted = Observable.create<boolean>(this, false);

@ -68,6 +68,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
isHiddenCol: ko.Computed<boolean>;
isFormCol: ko.Computed<boolean>;
// Returns the rowModel for the referenced table, or null, if is not a reference column.
refTable: ko.Computed<TableRec|null>;
@ -144,6 +145,11 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId()));
this.isFormCol = ko.pureComputed(() => (
!this.isHiddenCol() &&
this.pureType() !== 'Attachments' &&
!this.isRealFormula()
));
// Returns the rowModel for the referenced table, or null, if this is not a reference column.
this.refTable = ko.pureComputed(() => {

@ -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;
`);

@ -10,7 +10,8 @@ import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {primaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuAnnotate, menuDivider, menuIcon, menuItem, menuItemLink, menuText} from 'app/client/ui2018/menus';
import {menu, menuAnnotate, menuDivider, menuIcon, menuItem, menuItemLink, menuItemSubmenu,
menuText} from 'app/client/ui2018/menus';
import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
@ -262,7 +263,7 @@ function menuWorkOnCopy(pageModel: DocPageModel) {
}
/**
* The part of the menu with "Download" and "Export CSV" items.
* The part of the menu with "Download" and "Export as..." items.
*/
function menuExports(doc: Document, pageModel: DocPageModel) {
const isElectron = (window as any).isRunningUnderElectron;
@ -278,12 +279,24 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
menuItem(() => downloadDocModal(doc, pageModel),
menuIcon('Download'), t("Download..."), testId('tb-share-option'))
),
menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}),
menuIcon('Download'), t("Export CSV"), testId('tb-share-option')),
menuItemLink(hooks.maybeModifyLinkAttrs({
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
target: '_blank', download: ''
}), menuIcon('Download'), t("Export XLSX"), testId('tb-share-option')),
menuItemSubmenu(
() => [
menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}),
t("Comma Separated Values (.csv)"), testId('tb-share-option')),
menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getTsvLink(), target: '_blank', download: ''}),
t("Tab Separated Values (.tsv)"), testId('tb-share-option')),
menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getDsvLink(), target: '_blank', download: ''}),
t("DOO Separated Values (.dsv)"), testId('tb-share-option')),
menuItemLink(hooks.maybeModifyLinkAttrs({
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
target: '_blank', download: ''
}), t("Microsoft Excel (.xlsx)"), testId('tb-share-option')),
],
{},
menuIcon('Download'),
t("Export as..."),
testId('tb-share-option'),
),
(!isFeatureEnabled("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
menuIcon('Download'), t("Send to Google Drive"), testId('tb-share-option'))),
];

@ -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};
}
`);

@ -76,6 +76,7 @@ export type IconName = "ChartArea" |
"FontItalic" |
"FontStrikethrough" |
"FontUnderline" |
"FormConfig" |
"FunctionResult" |
"GreenArrow" |
"Grow" |
@ -232,6 +233,7 @@ export const IconList: IconName[] = ["ChartArea",
"FontItalic",
"FontStrikethrough",
"FontUnderline",
"FormConfig",
"FunctionResult",
"GreenArrow",
"Grow",

@ -983,6 +983,22 @@ export function isNarrowScreenObs(): Observable<boolean> {
return _isNarrowScreenObs;
}
export function isXSmallScreen() {
return window.innerWidth < smallScreenWidth;
}
let _isXSmallScreenObs: Observable<boolean>|undefined;
// Returns a singleton observable for whether the screen is an extra small one.
export function isXSmallScreenObs(): Observable<boolean> {
if (!_isXSmallScreenObs) {
const obs = Observable.create<boolean>(null, isXSmallScreen());
window.addEventListener('resize', () => obs.set(isXSmallScreen()));
_isXSmallScreenObs = obs;
}
return _isXSmallScreenObs;
}
export const cssHideForNarrowScreen = styled('div', `
@media ${mediaSmall} {
& {

@ -9,6 +9,7 @@ import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {cssMenuElem} from 'app/client/ui2018/menus';
import {waitGrainObs} from 'app/common/gutil';
import {MaybePromise} from 'app/plugin/gutil';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs';
import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
@ -356,7 +357,7 @@ export interface ConfirmModalOptions {
export function confirmModal(
title: DomElementArg,
btnText: DomElementArg,
onConfirm: (dontShowAgain?: boolean) => Promise<void>,
onConfirm: (dontShowAgain?: boolean) => MaybePromise<void>,
options: ConfirmModalOptions = {},
): void {
const {
@ -383,7 +384,7 @@ export function confirmModal(
),
],
saveLabel: btnText,
saveFunc: () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
saveFunc: async () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),
hideCancel,
width: width ?? 'normal',
extraButtons,

@ -35,7 +35,7 @@ export function buildCurrencyPicker(
// Create a computed that will display 'Local currency' as a value and label
// when `currency` is undefined.
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel);
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, 200, true);
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, {maxResults: 200, keepOrder: true});
return buildACSelect(owner,
{
acIndex, valueObs,

@ -34,6 +34,7 @@ import * as UserType from 'app/client/widgets/UserType';
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { WidgetType } from 'app/common/widgetTypes';
import { CellValue } from 'app/plugin/GristData';
import { bundleChanges, Computed, Disposable, fromKo,
dom as grainjsDom, makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
@ -129,9 +130,15 @@ export class FieldBuilder extends Disposable {
// Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => {
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => {
if (isForm && key === 'Attachments') {
// Attachments in forms are currently unsupported.
return;
}
const o: IOptionFull<string> = {
value: key as string,
label: def.label,

@ -52,7 +52,10 @@ export function buildTZAutocomplete(
) {
// Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
// fewer than 1000 in practice).
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), 1000, true);
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), {
maxResults: 1000,
keepOrder: true,
});
// Only save valid time zones. If there is no selected item, we'll auto-select and save only
// when there is a good match.

@ -92,8 +92,13 @@ export interface TransformColumn {
widgetOptions: string;
}
export interface ImportParseOptions extends ParseOptions {
delimiter?: string;
encoding?: string;
}
export interface ImportResult {
options: ParseOptions;
options: ImportParseOptions;
tables: ImportTableResult[];
}
@ -106,7 +111,7 @@ export interface ImportTableResult {
}
export interface ImportOptions {
parseOptions?: ParseOptions; // Options for parsing the source file.
parseOptions?: ImportParseOptions; // Options for parsing the source file.
mergeOptionMaps?: MergeOptionsMap[]; // Options for merging fields, indexed by uploadFileIndex.
}
@ -328,7 +333,7 @@ export interface ActiveDocAPI {
* Imports files, removes previously created temporary hidden tables and creates the new ones.
*/
importFiles(dataSource: DataSourceTransformed,
parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult>;
parseOptions: ImportParseOptions, prevTableIds: string[]): Promise<ImportResult>;
/**
* Finishes import files, creates the new tables, and cleans up temporary hidden tables and uploads.

@ -1684,6 +1684,38 @@ export const TelemetryContracts: TelemetryContracts = {
},
},
},
submittedForm: {
category: 'WidgetUsage',
description: 'Triggered when a published form is submitted.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
siteId: {
description: 'The site id.',
dataType: 'number',
},
siteType: {
description: 'The site type.',
dataType: 'string',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
access: {
description: 'The document access level of the user that triggered this event.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
},
},
changedAccessRules: {
category: 'AccessRules',
description: 'Triggered when a change to access rules is saved.',
@ -1776,6 +1808,7 @@ export const TelemetryEvents = StringUnion(
'publishedForm',
'unpublishedForm',
'visitedForm',
'submittedForm',
'changedAccessRules',
);
export type TelemetryEvent = typeof TelemetryEvents.type;

@ -470,6 +470,8 @@ export interface DocAPI {
getDownloadUrl(options: {template: boolean, removeHistory: boolean}): string;
getDownloadXlsxUrl(params?: DownloadDocParams): string;
getDownloadCsvUrl(params: DownloadDocParams): string;
getDownloadTsvUrl(params: DownloadDocParams): string;
getDownloadDsvUrl(params: DownloadDocParams): string;
getDownloadTableSchemaUrl(params: DownloadDocParams): string;
/**
* Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first
@ -1057,6 +1059,14 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this._url + '/download/csv?' + encodeQueryParams({...params});
}
public getDownloadTsvUrl(params: DownloadDocParams) {
return this._url + '/download/tsv?' + encodeQueryParams({...params});
}
public getDownloadDsvUrl(params: DownloadDocParams) {
return this._url + '/download/dsv?' + encodeQueryParams({...params});
}
public getDownloadTableSchemaUrl(params: DownloadDocParams) {
// We spread `params` to work around TypeScript being overly cautious.
return this._url + '/download/table-schema?' + encodeQueryParams({...params});

@ -169,6 +169,13 @@ export function isEmptyList(value: CellValue): boolean {
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.List;
}
/**
* Returns whether a value (as received in a DocAction) represents an empty reference list.
*/
export function isEmptyReferenceList(value: CellValue): boolean {
return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.ReferenceList;
}
function isNumber(v: CellValue) { return typeof v === 'number' || typeof v === 'boolean'; }
function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; }
function isBoolean(v: CellValue) { return typeof v === 'boolean' || v === 1 || v === 0; }
@ -344,6 +351,21 @@ export function isValidRuleValue(value: CellValue|undefined) {
return value === null || typeof value === 'boolean';
}
/**
* Returns true if `value` is blank.
*
* Blank values include `null`, (trimmed) empty string, and 0-length lists and
* reference lists.
*/
export function isBlankValue(value: CellValue) {
return (
value === null ||
(typeof value === 'string' && value.trim().length === 0) ||
isEmptyList(value) ||
isEmptyReferenceList(value)
);
}
export type RefListValue = [GristObjCode.List, ...number[]]|null;
/**

@ -10,6 +10,7 @@ import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, Impor
TransformRuleMap} from 'app/common/ActiveDocAPI';
import {ApiError} from 'app/common/ApiError';
import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions';
import {isBlankValue} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {localTimestampToUTC} from 'app/common/RelativeDates';
import {DocStateComparison} from 'app/common/UserAPI';
@ -345,8 +346,17 @@ export class ActiveDocImport {
if (file.ext) {
origName = path.basename(origName, path.extname(origName)) + file.ext;
}
const fileParseOptions = {...parseOptions};
if (file.ext === '.dsv') {
if (!fileParseOptions.delimiter) {
fileParseOptions.delimiter = '💩';
}
if (!fileParseOptions.encoding) {
fileParseOptions.encoding = 'utf-8';
}
}
const res = await this._importFileAsNewTable(docSession, file.absPath, {
parseOptions,
parseOptions: fileParseOptions,
mergeOptionsMap: mergeOptionMaps[index] || {},
isHidden,
originalFilename: origName,
@ -658,12 +668,6 @@ export class ActiveDocImport {
}
}
// Helper function that returns true if a given cell is blank (i.e. null or empty).
function isBlank(value: CellValue): boolean {
return value === null || (typeof value === 'string' && value.trim().length === 0);
}
// Helper function that returns new `colIds` with import prefixes stripped.
function stripPrefixes(colIds: string[]): string[] {
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?
@ -682,13 +686,13 @@ type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;
function getMergeFunction({type}: MergeStrategy): MergeFunction {
switch (type) {
case 'replace-with-nonblank-source': {
return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal;
return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal;
}
case 'replace-all-fields': {
return (srcVal, _destVal) => srcVal;
}
case 'replace-blank-fields-only': {
return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal;
return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal;
}
default: {
// Normally, we should never arrive here. If we somehow do, throw an error.

@ -12,7 +12,7 @@ import {
UserAction
} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {extractTypeFromColType, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
@ -53,7 +53,7 @@ import {docSessionFromRequest, getDocSessionShare, makeExceptionalDocSession,
import {DocWorker} from "app/server/lib/DocWorker";
import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
import {downloadCSV} from "app/server/lib/ExportCSV";
import {downloadDSV} from "app/server/lib/ExportDSV";
import {collectTableSchemaInFrictionlessFormat} from "app/server/lib/ExportTableSchema";
import {streamXLSX} from "app/server/lib/ExportXLSX";
import {expressWrap} from 'app/server/lib/expressWrap';
@ -590,6 +590,9 @@ export class DocWorkerApi {
validateCore(RecordsPost, req, body);
const ops = await getTableOperations(req, activeDoc);
const records = await ops.create(body.records);
if (req.query.utm_source === 'grist-forms') {
activeDoc.logTelemetryEvent(docSessionFromRequest(req), 'submittedForm');
}
res.json({records});
})
);
@ -1263,7 +1266,19 @@ export class DocWorkerApi {
this._app.get('/api/docs/:docId/download/csv', canView, withDoc(async (activeDoc, req, res) => {
const options = await this._getDownloadOptions(req);
await downloadCSV(activeDoc, req, res, options);
await downloadDSV(activeDoc, req, res, {...options, delimiter: ','});
}));
this._app.get('/api/docs/:docId/download/tsv', canView, withDoc(async (activeDoc, req, res) => {
const options = await this._getDownloadOptions(req);
await downloadDSV(activeDoc, req, res, {...options, delimiter: '\t'});
}));
this._app.get('/api/docs/:docId/download/dsv', canView, withDoc(async (activeDoc, req, res) => {
const options = await this._getDownloadOptions(req);
await downloadDSV(activeDoc, req, res, {...options, delimiter: '💩'});
}));
this._app.get('/api/docs/:docId/download/xlsx', canView, withDoc(async (activeDoc, req, res) => {
@ -1426,7 +1441,7 @@ export class DocWorkerApi {
.filter(f => {
const col = Tables_column.getRecord(f.colRef);
// Formulas and attachments are currently unsupported.
return col && !(col.isFormula && col.formula) && col.type !== 'Attachment';
return col && !(col.isFormula && col.formula) && col.type !== 'Attachments';
});
let {layoutSpec: formLayoutSpec} = section;
@ -1478,7 +1493,8 @@ export class DocWorkerApi {
if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
return await getTableValues(refTableId, refColId);
const values = await getTableValues(refTableId, refColId);
return values.filter(([_id, value]) => !isBlankValue(value));
};
const formFields = await Promise.all(fields.map(async (field) => {

@ -146,6 +146,8 @@ export class DocPluginManager {
'.xlsx' : 'Excel',
'.json' : 'JSON',
'.csv' : 'CSV',
'.tsv' : 'TSV',
'.dsv' : 'PSV',
};
const fileType = extToType[path.extname(fileName)] || path.extname(fileName);
throw new Error(`Failed to parse ${fileType} file.\nError: ${messages.join("; ")}`);

@ -11,26 +11,38 @@ import * as express from 'express';
// promisify csv
bluebird.promisifyAll(csv);
export interface DownloadDsvOptions extends DownloadOptions {
delimiter: Delimiter;
}
type Delimiter = ',' | '\t' | '💩';
/**
* Converts `activeDoc` to a CSV and sends the converted data through `res`.
* Converts `activeDoc` to delimiter-separated values (e.g. CSV) and sends
* the converted data through `res`.
*/
export async function downloadCSV(activeDoc: ActiveDoc, req: express.Request,
res: express.Response, options: DownloadOptions) {
log.info('Generating .csv file...');
const {filename, tableId, viewSectionId, filters, sortOrder, linkingFilter, header} = options;
export async function downloadDSV(
activeDoc: ActiveDoc,
req: express.Request,
res: express.Response,
options: DownloadDsvOptions
) {
const {filename, tableId, viewSectionId, filters, sortOrder, linkingFilter, delimiter, header} = options;
const extension = getDSVFileExtension(delimiter);
log.info(`Generating ${extension} file...`);
const data = viewSectionId ?
await makeCSVFromViewSection({
await makeDSVFromViewSection({
activeDoc, viewSectionId, sortOrder: sortOrder || null, filters: filters || null,
linkingFilter: linkingFilter || null, header, req
linkingFilter: linkingFilter || null, header, delimiter, req
}) :
await makeCSVFromTable({activeDoc, tableId, header, req});
res.set('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', contentDisposition(filename + '.csv'));
await makeDSVFromTable({activeDoc, tableId, header, delimiter, req});
res.set('Content-Type', getDSVMimeType(delimiter));
res.setHeader('Content-Disposition', contentDisposition(filename + extension));
res.send(data);
}
/**
* Returns a csv stream of a view section that can be transformed or parsed.
* Returns a DSV stream of a view section that can be transformed or parsed.
*
* See https://github.com/wdavidw/node-csv for API details.
*
@ -40,13 +52,21 @@ export async function downloadCSV(activeDoc: ActiveDoc, req: express.Request,
* @param {Integer[]} options.activeSortOrder (optional) - overriding sort order.
* @param {Filter[]} options.filters (optional) - filters defined from ui.
* @param {FilterColValues} options.linkingFilter (optional) - linking filter defined from ui.
* @param {Delimiter} options.delimiter - delimiter to separate fields with
* @param {string} options.header (optional) - which field of the column to use as header
* @param {express.Request} options.req - the request object.
*
* @return {Promise<string>} Promise for the resulting CSV.
* @return {Promise<string>} Promise for the resulting DSV.
*/
export async function makeCSVFromViewSection({
activeDoc, viewSectionId, sortOrder = null, filters = null, linkingFilter = null, header, req
export async function makeDSVFromViewSection({
activeDoc,
viewSectionId,
sortOrder = null,
filters = null,
linkingFilter = null,
delimiter,
header,
req
}: {
activeDoc: ActiveDoc,
viewSectionId: number,
@ -54,28 +74,31 @@ export async function makeCSVFromViewSection({
filters: Filter[] | null,
linkingFilter: FilterColValues | null,
header?: ExportHeader,
delimiter: Delimiter,
req: express.Request
}) {
const data = await exportSection(activeDoc, viewSectionId, sortOrder, filters, linkingFilter, req);
const file = convertToCsv(data, { header });
const file = convertToDsv(data, { header, delimiter });
return file;
}
/**
* Returns a csv stream of a table that can be transformed or parsed.
* Returns a DSV stream of a table that can be transformed or parsed.
*
* @param {Object} options - options for the export.
* @param {Object} options.activeDoc - the activeDoc that the table being converted belongs to.
* @param {Integer} options.tableId - id of the table to export.
* @param {Delimiter} options.delimiter - delimiter to separate fields with
* @param {string} options.header (optional) - which field of the column to use as header
* @param {express.Request} options.req - the request object.
*
* @return {Promise<string>} Promise for the resulting CSV.
* @return {Promise<string>} Promise for the resulting DSV.
*/
export async function makeCSVFromTable({ activeDoc, tableId, header, req }: {
export async function makeDSVFromTable({ activeDoc, tableId, delimiter, header, req }: {
activeDoc: ActiveDoc,
tableId: string,
delimiter: Delimiter,
header?: ExportHeader,
req: express.Request
}) {
@ -93,24 +116,63 @@ export async function makeCSVFromTable({ activeDoc, tableId, header, req }: {
}
const data = await exportTable(activeDoc, tableRef, req);
const file = convertToCsv(data, { header });
const file = convertToDsv(data, { header, delimiter });
return file;
}
function convertToCsv({
rowIds,
access,
columns: viewColumns,
}: ExportData, options: { header?: ExportHeader }) {
interface ConvertToDsvOptions {
delimiter: Delimiter;
header?: ExportHeader;
}
function convertToDsv(data: ExportData, options: ConvertToDsvOptions) {
const {rowIds, access, columns: viewColumns} = data;
const {delimiter, header} = options;
// create formatters for columns
const formatters = viewColumns.map(col => col.formatter);
// Arrange the data into a row-indexed matrix, starting with column headers.
const colPropertyAsHeader = options.header ?? 'label';
const colPropertyAsHeader = header ?? 'label';
const csvMatrix = [viewColumns.map(col => col[colPropertyAsHeader])];
// populate all the rows with values as strings
rowIds.forEach(row => {
csvMatrix.push(access.map((getter, c) => formatters[c].formatAny(getter(row))));
});
return csv.stringifyAsync(csvMatrix);
return csv.stringifyAsync(csvMatrix, {delimiter});
}
type DSVFileExtension = '.csv' | '.tsv' | '.dsv';
function getDSVFileExtension(delimiter: Delimiter): DSVFileExtension {
switch (delimiter) {
case ',': {
return '.csv';
}
case '\t': {
return '.tsv';
}
case '💩': {
return '.dsv';
}
}
}
type DSVMimeType =
| 'text/csv'
// Reference: https://www.iana.org/assignments/media-types/text/tab-separated-values
| 'text/tab-separated-values'
// Note: not a registered MIME type, hence the "x-" prefix.
| 'text/x-doo-separated-values';
function getDSVMimeType(delimiter: Delimiter): DSVMimeType {
switch (delimiter) {
case ',': {
return 'text/csv';
}
case '\t': {
return 'text/tab-separated-values';
}
case '💩': {
return 'text/x-doo-separated-values';
}
}
}

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Regenerates typescript files with schema and sql for grist documents.
# This needs to run whenever the document schema is changed in the data

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e

@ -5,7 +5,7 @@ components:
safePython: sandbox/main.py
contributions:
fileParsers:
- fileExtensions: ["csv", "tsv", "txt"]
- fileExtensions: ["csv", "tsv", "dsv", "txt"]
parseFile:
component: safePython
name: csv_parser

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Package up Grist code as a stand-alone wheel.
# This is useful for grist-static.

@ -1178,7 +1178,7 @@ class UserActions(object):
# to strings containing comma-separated row IDs.
# We need to get the values before changing the column type.
table = self._engine.tables[table_id]
new_values = [",".join(map(str, row)) for row in self._get_column_values(col)]
new_values = [",".join(map(str, row or [])) for row in self._get_column_values(col)]
self.ModifyColumn(table_id, col_id, dict(type="Text"))
self.BulkUpdateRecord(table_id, list(table.row_ids), {col_id: new_values})
return

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e

File diff suppressed because one or more lines are too long

@ -655,7 +655,9 @@
"Submit button label": "Submit button label",
"Success text": "Success text",
"Table column name": "Table column name",
"Enter redirect URL": "Enter redirect URL"
"Enter redirect URL": "Enter redirect URL",
"No field selected": "No field selected",
"Select a field in the form widget to configure.": "Select a field in the form widget to configure."
},
"RowContextMenu": {
"Copy anchor link": "Copy anchor link",
@ -692,7 +694,12 @@
"Unsaved": "Unsaved",
"Work on a Copy": "Work on a Copy",
"Share": "Share",
"Download...": "Download..."
"Download...": "Download...",
"Comma Separated Values (.csv)": "Comma Separated Values (.csv)",
"DOO Separated Values (.dsv)": "DOO Separated Values (.dsv)",
"Export as...": "Export as...",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Tab Separated Values (.tsv)": "Tab Separated Values (.tsv)"
},
"SiteSwitcher": {
"Create new team site": "Create new team site",
@ -1338,7 +1345,21 @@
"Publish": "Publish",
"Publish your form?": "Publish your form?",
"Unpublish": "Unpublish",
"Unpublish your form?": "Unpublish your form?"
"Unpublish your form?": "Unpublish your form?",
"Anyone with the link below can see the empty form and submit a response.": "Anyone with the link below can see the empty form and submit a response.",
"Are you sure you want to reset your form?": "Are you sure you want to reset your form?",
"Code copied to clipboard": "Code copied to clipboard",
"Copy code": "Copy code",
"Copy link": "Copy link",
"Embed this form": "Embed this form",
"Link copied to clipboard": "Link copied to clipboard",
"Preview": "Preview",
"Reset": "Reset",
"Reset form": "Reset form",
"Save your document to publish this form.": "Save your document to publish this form.",
"Share": "Share",
"Share this form": "Share this form",
"View": "View"
},
"Editor": {
"Delete": "Delete"
@ -1401,5 +1422,17 @@
"This week": "This week",
"This year": "This year",
"Today": "Today"
},
"MappedFieldsConfig": {
"Clear": "Clear",
"Map fields": "Map fields",
"Mapped": "Mapped",
"Select All": "Select All",
"Unmap fields": "Unmap fields",
"Unmapped": "Unmapped"
},
"Section": {
"Insert section above": "Insert section above",
"Insert section below": "Insert section below"
}
}

@ -586,7 +586,8 @@
"Insert row below": "Insertar fila debajo",
"Duplicate rows_one": "Duplicar fila",
"Duplicate rows_other": "Duplicar filas",
"View as card": "Ver como tarjeta"
"View as card": "Ver como tarjeta",
"Use as table headers": "Usar como encabezados de la tabla"
},
"ShareMenu": {
"Access Details": "Detalles de Acceso",

@ -496,7 +496,7 @@
"Welcome to {{orgName}}": "Добро пожаловать в {{orgName}}",
"personal site": "личный сайт",
"You have read-only access to this site. Currently there are no documents.": "Вы имеете доступ к этому сайту только для просмотра. В настоящее время документов нет.",
"{{signUp}} to save your work. ": "{{signUp}} сохранить свою работу. ",
"{{signUp}} to save your work. ": "{{signUp}} для сохранения своих данных. ",
"Welcome to Grist, {{- name}}!": "Добро пожаловать в Grist, {{- name}}!",
"Welcome to {{- orgName}}": "Добро пожаловать в {{- orgName}}",
"Visit our {{link}} to learn more about Grist.": "Посетите наш {{link}} чтобы узнать больше о Grist.",

@ -0,0 +1,32 @@
<svg width="150" height="140" viewBox="0 0 150 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5883 140L15 139.989C15.2275 133.508 15.9255 127.052 17.0885 120.67C19.6326 107.007 23.863 98.034 29.6622 94L30 94.477C16.453 103.9 15.5952 139.64 15.5883 140Z" fill="#494949"/>
<path d="M22.6089 139L22 138.989C22.0131 138.346 22.3937 123.189 28.6503 119L29 119.475C22.9958 123.495 22.6119 138.845 22.6089 139Z" fill="#494949"/>
<path d="M33 94C34.6569 94 36 92.6568 36 91C36 89.3431 34.6569 88 33 88C31.3431 88 30 89.3431 30 91C30 92.6568 31.3431 94 33 94Z" fill="#16B378"/>
<path d="M31 119C32.6569 119 34 117.657 34 116C34 114.343 32.6569 113 31 113C29.3431 113 28 114.343 28 116C28 117.657 29.3431 119 31 119Z" fill="#16B378"/>
<path d="M20.8746 96.1993C21.4233 99.7892 19.9934 103 19.9934 103C19.9934 103 17.674 100.391 17.1254 96.8007C16.5767 93.2108 18.0066 90 18.0066 90C18.0066 90 20.326 92.6095 20.8746 96.1993Z" fill="#494949"/>
<path d="M30.6081 104.375C27.2943 105.576 24 104.676 24 104.676C24 104.676 26.0782 101.827 29.3919 100.625C32.7056 99.4235 36 100.324 36 100.324C36 100.324 33.9218 103.173 30.6081 104.375Z" fill="#494949"/>
<path d="M30.4054 126.629C28.9789 127.053 27.4598 127.115 26 126.809C26.9269 125.695 28.1752 124.857 29.5946 124.398C31.014 123.938 32.5439 123.875 34 124.218C33.0461 125.305 31.8065 126.137 30.4054 126.629Z" fill="#494949"/>
<path d="M107.999 136H103.934L102 121L108 121L107.999 136Z" fill="#FFF3DE"/>
<path d="M108 140L96 140V139.842C96.0001 138.558 96.4922 137.326 97.3681 136.418C98.244 135.51 99.432 135 100.671 135L108 135L108 140Z" fill="#494949"/>
<path d="M84.9994 136H80.9339L79 121L85 121L84.9994 136Z" fill="#FFF3DE"/>
<path d="M84.9998 140L73 140V139.842C73.0001 138.558 73.4922 137.326 74.3681 136.418C75.244 135.51 76.432 135 77.6707 135H77.671L85 135L84.9998 140Z" fill="#494949"/>
<path d="M74 53.8448L68.7607 53L67.068 55.8431L48.8528 60.6883L48.9021 60.9503C48.3869 60.389 47.7106 60.0182 46.9755 59.8938C46.2404 59.7694 45.4865 59.8983 44.8278 60.261C44.1691 60.6236 43.6416 61.2003 43.325 61.9037C43.0085 62.6072 42.9201 63.399 43.0733 64.1595C43.2265 64.9201 43.613 65.6078 44.1741 66.1187C44.7353 66.6295 45.4407 66.9357 46.1836 66.991C46.9264 67.0462 47.6663 66.8474 48.2912 66.4247C48.9161 66.0019 49.3921 65.3783 49.647 64.6481L71.7062 59.8473L74 53.8448Z" fill="#FFF3DE"/>
<path d="M127.561 50.0617C127.129 50.0621 126.7 50.1453 126.298 50.3069L126.403 50.1222L103.168 38L100 43.1689L124.157 53.9924C124.244 54.6529 124.519 55.274 124.947 55.7812C125.376 56.2885 125.94 56.6604 126.572 56.8526C127.204 57.0448 127.878 57.0491 128.513 56.865C129.147 56.6808 129.716 56.3161 130.15 55.8143C130.585 55.3126 130.867 54.6951 130.963 54.0357C131.06 53.3763 130.965 52.7029 130.692 52.096C130.419 51.4892 129.979 50.9746 129.423 50.6139C128.868 50.2531 128.222 50.0614 127.561 50.0617Z" fill="#FFF3DE"/>
<path d="M91.4035 18.0095C92.3294 12.2849 88.4401 6.88869 82.7165 5.95678C76.9929 5.02486 71.6024 8.91011 70.6765 14.6347C69.7506 20.3593 73.6399 25.7555 79.3635 26.6874C85.0872 27.6193 90.4776 23.734 91.4035 18.0095Z" fill="#494949"/>
<path d="M80.5 27C84.0899 27 87 24.0899 87 20.5C87 16.9101 84.0899 14 80.5 14C76.9102 14 74 16.9101 74 20.5C74 24.0899 76.9102 27 80.5 27Z" fill="#FFF3DE"/>
<path d="M80 19C83.866 19 87 16.9853 87 14.5C87 12.0147 83.866 10 80 10C76.134 10 73 12.0147 73 14.5C73 16.9853 76.134 19 80 19Z" fill="#494949"/>
<path d="M81.5 11C83.9853 11 86 8.98528 86 6.5C86 4.01472 83.9853 2 81.5 2C79.0147 2 77 4.01472 77 6.5C77 8.98528 79.0147 11 81.5 11Z" fill="#494949"/>
<path d="M75.9391 4.49998C75.9392 3.38767 76.3542 2.3148 77.1041 1.48831C77.854 0.66183 78.8856 0.140301 80 0.024332C79.8431 0.0081623 79.6855 4.01157e-05 79.5278 0C78.3265 0.00139078 77.1748 0.476108 76.3259 1.31987C75.4769 2.16363 75 3.30743 75 4.5C75 5.69257 75.4769 6.83636 76.3259 7.68013C77.1748 8.52389 78.3265 8.99861 79.5278 9C79.6855 8.99996 79.8431 8.99184 80 8.97567C78.8856 8.8597 77.854 8.33817 77.1041 7.51168C76.3542 6.68519 75.9392 5.6123 75.9391 4.49998Z" fill="#494949"/>
<path d="M76.6948 36.9326L77.2211 31.8285C77.2211 31.8285 84.3618 27.0813 86.7887 29.887L101.294 54.9226C101.294 54.9226 110.31 58.1564 109.992 70.5046L109.561 130.192L99.3364 131.323L93.1277 84.7871L87.5181 133L75.6148 132.624L76.6857 100.862L82.3616 70.0724L82.3073 59.8557L79.8075 55.7325C79.8075 55.7325 75.2283 53.8768 75.1003 48.6324L75 41.2606L76.6948 36.9326Z" fill="#494949"/>
<path d="M84 31.3536L84.14 29C84.14 29 105.607 34.648 103.904 38.6864C102.201 42.7247 99.0077 44 99.0077 44L86.6609 39.1115L84 31.3536Z" fill="#494949"/>
<path d="M78.1294 36.7761L76.5437 35C76.5437 35 63.8958 53.641 67.7205 55.7238C71.5452 57.8066 74.7569 56.7131 74.7569 56.7131L81 44.6897L78.1294 36.7761Z" fill="#494949"/>
<path d="M52.8642 97L135 80.5341L128.136 46L46 62.4659L52.8642 97Z" fill="white"/>
<path d="M135 81.4211L52.0609 98L45 62.5789L127.939 46L135 81.4211ZM52.9544 96.6589L133.661 80.5263L127.046 47.3411L46.3392 63.4737L52.9544 96.6589Z" fill="#D9D9D9"/>
<path d="M122.676 57.646L56 71L56.3903 72.9616L123.066 59.6076L122.676 57.646Z" fill="#D9D9D9"/>
<path d="M123.676 63.646L57 77L57.3903 78.9616L124.066 65.6076L123.676 63.646Z" fill="#D9D9D9"/>
<path d="M125.676 70.646L59 84L59.3903 85.9616L126.066 72.6076L125.676 70.646Z" fill="#D9D9D9"/>
<path d="M90.4449 67.5415L87.9873 67.988C87.8078 68.0204 87.6215 67.9867 87.4692 67.8942C87.3169 67.8018 87.2112 67.6581 87.1751 67.4948L86.0132 62.1976C85.9776 62.0342 86.0146 61.8646 86.1162 61.7261C86.2178 61.5875 86.3756 61.4913 86.5551 61.4585L89.0127 61.012C89.1922 60.9796 89.3786 61.0133 89.5308 61.1058C89.6831 61.1982 89.7888 61.3419 89.8249 61.5052L90.9868 66.8024C91.0224 66.9658 90.9854 67.1354 90.8838 67.2739C90.7822 67.4125 90.6244 67.5087 90.4449 67.5415Z" fill="#16B378"/>
<path d="M117.445 69.5415L114.987 69.988C114.808 70.0204 114.621 69.9867 114.469 69.8942C114.317 69.8018 114.211 69.6581 114.175 69.4948L113.013 64.1976C112.978 64.0342 113.015 63.8646 113.116 63.7261C113.218 63.5875 113.376 63.4913 113.555 63.4585L116.013 63.012C116.192 62.9796 116.379 63.0133 116.531 63.1058C116.683 63.1982 116.789 63.3419 116.825 63.5052L117.987 68.8024C118.022 68.9658 117.985 69.1354 117.884 69.2739C117.782 69.4125 117.624 69.5087 117.445 69.5415Z" fill="#16B378"/>
<path d="M102.445 79.5415L99.9873 79.988C99.8078 80.0204 99.6215 79.9867 99.4692 79.8942C99.3169 79.8018 99.2112 79.6581 99.1752 79.4948L98.0132 74.1976C97.9776 74.0342 98.0146 73.8646 98.1162 73.7261C98.2178 73.5875 98.3756 73.4913 98.5551 73.4585L101.013 73.012C101.192 72.9796 101.379 73.0133 101.531 73.1058C101.683 73.1982 101.789 73.3419 101.825 73.5052L102.987 78.8024C103.022 78.9658 102.985 79.1354 102.884 79.2739C102.782 79.4125 102.624 79.5087 102.445 79.5415Z" fill="#16B378"/>
<path d="M130.826 140H0.173971C0.127831 140 0.0835806 139.947 0.0509547 139.854C0.0183289 139.76 0 139.633 0 139.5C0 139.367 0.0183289 139.24 0.0509547 139.146C0.0835806 139.053 0.127831 139 0.173971 139H130.826C130.872 139 130.916 139.053 130.949 139.146C130.982 139.24 131 139.367 131 139.5C131 139.633 130.982 139.76 130.949 139.854C130.916 139.947 130.872 140 130.826 140Z" fill="#CACACA"/>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@ -37,7 +37,7 @@ describe('ACIndex', function() {
it('should find items with matching words', function() {
const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map(
c => ({cleanText: c}));
const acIndex = new ACIndexImpl(items, 5);
const acIndex = new ACIndexImpl(items, {maxResults: 5});
assert.deepEqual(acIndex.search("red").items.map((item) => item.cleanText),
["red", "reddish", "dark red", "radical green", "blue"]);
});
@ -48,7 +48,7 @@ describe('ACIndex', function() {
assert.deepEqual(acResult.items, colors);
assert.deepEqual(acResult.selectIndex, -1);
acResult = new ACIndexImpl(colors, 3).search("");
acResult = new ACIndexImpl(colors, {maxResults: 3}).search("");
assert.deepEqual(acResult.items, colors.slice(0, 3));
assert.deepEqual(acResult.selectIndex, -1);
@ -161,7 +161,7 @@ describe('ACIndex', function() {
});
it('should limit results to maxResults', function() {
const acIndex = new ACIndexImpl(colors, 3);
const acIndex = new ACIndexImpl(colors, {maxResults: 3});
let acResult: ACResults<TestACItem>;
acResult = acIndex.search("red");
@ -247,7 +247,7 @@ describe('ACIndex', function() {
});
it('should return a useful highlight function', function() {
const acIndex = new ACIndexImpl(colors, 3);
const acIndex = new ACIndexImpl(colors, {maxResults: 3});
let acResult: ACResults<TestACItem>;
// Here we split the items' (uncleaned) text with the returned highlightFunc. The values at
@ -267,7 +267,7 @@ describe('ACIndex', function() {
[["Blue"], ["Dark Red"], ["Reddish"]]);
// Try some messier cases.
const acIndex2 = new ACIndexImpl(messy, 6);
const acIndex2 = new ACIndexImpl(messy, {maxResults: 6});
acResult = acIndex2.search("#r");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "],
@ -280,7 +280,9 @@ describe('ACIndex', function() {
});
it('should highlight multi-byte unicode', function() {
const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), 3);
const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), {
maxResults: 3,
});
let acResult: ACResults<TestACItem> = acIndex.search("mañ моск am");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]);
@ -345,7 +347,7 @@ describe('ACIndex', function() {
// tslint:disable:no-console
it('main algorithm', function() {
const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, 100));
const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, {maxResults: 100}));
console.log(`Time to build index (${items.length} items): ${buildTime} ms`);
const [searchTime, result] = repeat(10, () => acIndex.search("YORK"));

File diff suppressed because it is too large Load Diff

@ -0,0 +1,4 @@
Foo💩Bar💩Id is Baz Label is this💩Link💩Formula
1💩a💩hello💩grist https://www.getgrist.com/💩a --- grist https://www.getgrist.com/
2💩b ,d💩world💩https://www.getgrist.com/💩b ,d --- https://www.getgrist.com/
3💩"the ""quote marks"" ?"💩💩💩"the ""quote marks"" ? --- "

File diff suppressed because it is too large Load Diff

@ -0,0 +1,4 @@
Foo Bar Id is Baz Label is this Link Formula
1 a hello grist https://www.getgrist.com/ a --- grist https://www.getgrist.com/
2 b ,d world https://www.getgrist.com/ b ,d --- https://www.getgrist.com/
3 "the ""quote marks"" ?" "the ""quote marks"" ? --- "
1 Foo Bar Id is Baz Label is this Link Formula
2 1 a hello grist https://www.getgrist.com/ a --- grist https://www.getgrist.com/
3 2 b ,d world https://www.getgrist.com/ b ,d --- https://www.getgrist.com/
4 3 the "quote marks" ? the "quote marks" ? ---

@ -1,4 +1,4 @@
import { assert } from 'mocha-webdriver';
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
const fse = require('fs-extra');
@ -35,7 +35,10 @@ describe('Export.ntest', function() {
await $('.test-tb-share').click();
// Once the menu opens, get the href of the link.
await $('.grist-floating-menu').wait();
const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href');
const submenu = $('.test-tb-share-option:contains(Export as...)');
await driver.withActions(a => a.move({origin: submenu.elem()}));
const href = await $('.grist-floating-menu a:contains(Comma Separated Values)').wait()
.getAttribute('href');
// Download the data at the link and compare to expected.
const resp = await axios.get(href, {responseType: 'text', headers});
assert.equal(resp.headers['content-disposition'],
@ -50,7 +53,10 @@ describe('Export.ntest', function() {
await $('.test-tb-share').click();
// Once the menu opens, get the href of the link.
await $('.grist-floating-menu').wait();
const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href');
const submenu = $('.test-tb-share-option:contains(Export as...)');
await driver.withActions(a => a.move({origin: submenu.elem()}));
const href = await $('.grist-floating-menu a:contains(Comma Separated Values)').wait()
.getAttribute('href');
// Download the data at the link and compare to expected.
const resp = await axios.get(href, {responseType: 'text', headers});
assert.equal(resp.data, dataExpected.sorted);

@ -20,29 +20,6 @@ describe('FormView', function() {
afterEach(() => gu.checkForErrors());
/**
* Adds a temporary textarea to the document for pasting the contents of
* the clipboard.
*
* Used to test copying of form URLs to the clipboard.
*/
function createClipboardTextArea() {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.top = '0';
textArea.style.height = '2rem';
textArea.style.width = '16rem';
textArea.id = 'clipboardText';
window.document.body.appendChild(textArea);
}
function removeClipboardTextArea() {
const textArea = document.getElementById('clipboardText');
if (textArea) {
window.document.body.removeChild(textArea);
}
}
async function createFormWith(type: string, more = false) {
await gu.addNewSection('Form', 'Table1');
@ -69,8 +46,11 @@ describe('FormView', function() {
// Now open the form in external window.
await clipboard.lockAndPerform(async (cb) => {
await driver.find(`.test-forms-link`).click();
const shareButton = await driver.find(`.test-forms-share`);
await gu.scrollIntoView(shareButton);
await shareButton.click();
await gu.waitForServer();
await driver.findWait('.test-forms-link', 1000).click();
await gu.waitToPass(async () => assert.match(
await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000);
await driver.find('#clipboardText').click();
@ -121,12 +101,9 @@ describe('FormView', function() {
const session = await gu.session().login();
docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
});
after(async function() {
await driver.executeScript(removeClipboardTextArea);
});
gu.withClipboardTextArea();
it('updates creator panel when navigated away', async function() {
// Add 2 new pages.
@ -186,6 +163,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello');
assert.equal(await driver.find('input[name="D"]').value(), 'Hello');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('Hello World');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -201,6 +184,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -216,9 +205,13 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await driver.executeScript(
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01'
);
await gu.sendKeys('01011999');
assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '1999-01-01');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('01012000');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -239,21 +232,30 @@ describe('FormView', function() {
await gu.choicesEditor.save();
await gu.toggleSidePanel('right', 'close');
// We need to press preview, as form is not saved yet.
// We need to press view, as form is not saved yet.
await gu.scrollActiveViewTop();
await gu.waitToPass(async () => {
assert.isTrue(await driver.find('.test-forms-preview').isDisplayed());
assert.isTrue(await driver.find('.test-forms-view').isDisplayed());
});
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000);
await driver.findWait('select[name="D"]', 2000);
// Make sure options are there.
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()), ['— Choose —', 'Foo', 'Bar', 'Baz']
await driver.findAll('select[name="D"] option', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz']
);
await driver.find('.test-form-search-select').click();
assert.deepEqual(
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz']
);
await select.click();
await driver.find("option[value='Bar']").click();
await gu.sendKeys('Baz', Key.ENTER);
assert.equal(await driver.find('select[name="D"]').value(), 'Baz');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -267,6 +269,12 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -282,6 +290,11 @@ describe('FormView', function() {
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).findClosest("label").click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
await driver.find('input[name="D"]').findClosest("label").click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -314,7 +327,12 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="Foo"]', 2000).click();
await driver.findWait('input[name="D[]"][value="Bar"]', 2000).click();
assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="Foo"]').click();
await driver.find('input[name="D[]"][value="Baz"]').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -339,17 +357,26 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000);
await driver.findWait('select[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()),
['— Choose —', ...['Bar', 'Baz', 'Foo']]
['Select...', ...['Bar', 'Baz', 'Foo']]
);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.value()),
['', ...['2', '3', '1']]
);
await select.click();
await driver.find('option[value="2"]').click();
await driver.find('.test-form-search-select').click();
assert.deepEqual(
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo']
);
await gu.sendKeys('Baz', Key.ENTER);
assert.equal(await driver.find('select[name="D"]').value(), '3');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -379,11 +406,16 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="1"]', 2000).click();
await driver.find('input[name="D[]"][value="2"]').click();
assert.equal(await driver.find('label:has(input[name="D[]"][value="1"])').getText(), 'Foo');
assert.equal(await driver.findWait('label:has(input[name="D[]"][value="1"])', 2000).getText(), 'Foo');
assert.equal(await driver.find('label:has(input[name="D[]"][value="2"])').getText(), 'Bar');
assert.equal(await driver.find('label:has(input[name="D[]"][value="3"])').getText(), 'Baz');
await driver.find('input[name="D[]"][value="1"]').click();
assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="1"]').click();
await driver.find('input[name="D[]"][value="2"]').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -402,12 +434,75 @@ describe('FormView', function() {
// Temporarily make A a formula column.
await gu.sendActions([
['AddRecord', 'Table1', null, {A: 'Foo'}],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '"hello"', isFormula: true}],
['ModifyColumn', 'Table1', 'A', {formula: '"hello"', isFormula: true}],
]);
// Check that A is hidden in the form editor.
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D']));
await gu.openWidgetPanel('widget');
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
// Check that A is excluded from the published form.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello World');
assert.isFalse(await driver.find('input[name="A"]').isPresent());
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectInD(['Hello World']);
// And check that A was not modified.
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello']);
// Check that A is excluded from the form, and we can still submit it.
// Revert A and check that it's visible again in the editor.
await gu.sendActions([
['ModifyColumn', 'Table1', 'A', {formula: '', isFormula: false}],
]);
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']));
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['A', 'B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
await removeForm();
});
it('excludes attachment fields from forms', async function() {
const formUrl = await createFormWith('Text');
// Temporarily make A an attachments column.
await gu.sendActions([
['ModifyColumn', 'Table1', 'A', {type: 'Attachments'}],
]);
// Check that A is hidden in the form editor.
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D']));
await gu.openWidgetPanel('widget');
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
// Check that A is excluded from the published form.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
@ -418,15 +513,25 @@ describe('FormView', function() {
});
// Make sure we see the new record.
await expectInD(['', 'Hello World']);
await expectInD(['Hello World']);
// And check that A was not modified.
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello', 'hello']);
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), [null]);
// Revert A and check that it's visible again in the editor.
await gu.sendActions([
['RemoveRecord', 'Table1', 1],
['UpdateRecord', '_grist_Tables_column', 2, {formula: '', isFormula: false}],
['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
]);
await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']));
assert.deepEqual(
await driver.findAll('.test-vfc-visible-field', (e) => e.getText()),
['A', 'B', 'C', 'D']
);
assert.deepEqual(
await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()),
[]
);
await removeForm();
});
@ -851,28 +956,33 @@ describe('FormView', function() {
checkFieldInMore('Reference List');
const testStruct = (type: string, existing = 0) => {
it(`can add structure ${type} element`, async function() {
async function doTestStruct(menuLabel?: string) {
assert.equal(await elementCount(type), existing);
await plusButton().click();
await clickMenu(type);
await clickMenu(menuLabel ?? type);
await gu.waitForServer();
assert.equal(await elementCount(type), existing + 1);
await gu.undo();
assert.equal(await elementCount(type), existing);
}
it(`can add structure ${type} element`, async function() {
if (type === 'Section') {
await doTestStruct('Insert section above');
await doTestStruct('Insert section below');
} else {
await doTestStruct();
}
});
};
// testStruct('Section'); // There is already a section
testStruct('Section', 1);
testStruct('Columns');
testStruct('Paragraph', 4);
it('basic section', async function() {
const revert = await gu.begin();
// Adding section is disabled for now, so this test is altered to use the existing section.
// await drop().click();
// await clickMenu('Section');
// await gu.waitForServer();
assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
@ -898,25 +1008,39 @@ describe('FormView', function() {
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
// Make sure that it is not inside the section anymore.
// assert.equal(await element('Section', 1).element('label').isPresent(), false);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await element('Section', 1).element('label', 4).getText(), 'D');
// Make sure that deleting the section also hides its fields and unmaps them.
// Check that we can't delete a section if it's the only one.
await element('Section').element('Paragraph', 1).click();
await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer();
assert.equal(await elementCount('Section'), 0);
assert.deepEqual(await readLabels(), []);
assert.equal(await elementCount('Section'), 1);
// Add a new section below it.
await plusButton().click();
await clickMenu('Insert section below');
await gu.waitForServer();
assert.equal(await elementCount('Section'), 2);
await plusButton(element('Section', 2)).click();
await clickMenu('Text');
await gu.waitForServer();
// Now check that we can delete the first section.
await element('Section', 1).element('Paragraph', 1).click();
await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer();
assert.equal(await elementCount('Section'), 1);
// Make sure that deleting the section also hides its fields and unmaps them.
assert.deepEqual(await readLabels(), ['E']);
await gu.openWidgetPanel();
assert.deepEqual(await hiddenColumns(), ['A', 'B', 'C', 'Choice', 'D']);
await gu.undo();
assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await elementCount('Section'), 2);
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D', 'E']);
assert.deepEqual(await hiddenColumns(), ['Choice']);
await revert();
@ -1243,12 +1367,9 @@ describe('FormView', function() {
const session = await gu.session().teamSite.login();
docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi();
await driver.executeScript(createClipboardTextArea);
});
after(async function() {
await driver.executeScript(removeClipboardTextArea);
});
gu.withClipboardTextArea();
it('can submit a form', async function() {
// A bug was preventing this by forcing a login redirect from the public form URL.
@ -1309,8 +1430,8 @@ function questionType(label: string) {
return question(label).find('.test-forms-type').value();
}
function plusButton() {
return element('plus');
function plusButton(parent?: WebElement) {
return element('plus', parent);
}
function drops() {

@ -262,7 +262,8 @@ describe('GridViewNewColumnMenu', function () {
// Wait for the side panel animation.
await gu.waitForSidePanel();
//check if right menu is opened on column section
assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed());
await gu.waitForSidePanel();
assert.isTrue(await driver.find('.test-right-tab-field').isDisplayed());
await gu.toggleSidePanel("right", "close");
await gu.undo(1);
});

@ -10,14 +10,40 @@ describe('ReferenceList', function() {
before(async function() {
session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'Favorite_Films.grist');
});
describe('other', function() {
it('allows to delete document with self reference', async function() {
const docId = await session.tempNewDoc(cleanup);
await gu.sendActions([
['AddEmptyTable', 'Table2'],
['ModifyColumn', 'Table1', 'B', {type: 'RefList:Table1'}],
['AddRecord', 'Table1', null, {A: 'a'}],
['AddRecord', 'Table1', null, {A: 'b', B: ["L", 1]}],
['AddRecord', 'Table1', null, {A: 'c', B: ["L", 2]}],
]);
await gu.toggleSidePanel('right');
await driver.find(".test-right-tab-pagewidget").click();
await driver.find('.test-config-data').click();
// Now try to delete the table.
await gu.removeTable('Table1');
await gu.checkForErrors();
// Make sure table is deleted. Previously it ended with an engine error
// in the 'a' row which has NULL instead of a list of ids.
const api = session.createHomeApi().getDocAPI(docId);
const tables = await api.getRows('_grist_Tables');
assert.deepEqual(tables.tableId, ['Table2']);
});
});
describe('transforms', function() {
before(async function() {
await session.tempDoc(cleanup, 'Favorite_Films.grist');
await gu.toggleSidePanel('right');
await driver.find(".test-right-tab-pagewidget").click();
await driver.find('.test-config-data').click();
});
afterEach(() => gu.checkForErrors());
it('should correctly transform references to reference lists', async function() {

@ -31,7 +31,8 @@ import { GristWebDriverUtils, PageWidgetPickerOptions,
WindowDimensions as WindowDimensionsBase } from 'test/nbrowser/gristWebDriverUtils';
import { HomeUtil } from 'test/nbrowser/homeUtil';
import { server } from 'test/nbrowser/testServer';
import { Cleanup } from 'test/nbrowser/testUtils';
import type { Cleanup } from 'test/nbrowser/testUtils';
import { fetchScreenshotAndLogs } from 'test/nbrowser/webdriverUtils';
import * as testUtils from 'test/server/testUtils';
import type { AssertionError } from 'assert';
import axios from 'axios';
@ -1016,6 +1017,13 @@ export async function sendActions(actions: UserAction[]) {
await driver.manage().setTimeouts({
script: 1000 * 2, /* 2 seconds, default is 0.5s */
});
// Make quick test that we have a list of actions not just a single action, by checking
// if the first element is an array.
if (actions.length && !Array.isArray(actions[0])) {
throw new Error('actions argument should be a list of actions, not a single action');
}
const result = await driver.executeAsyncScript(`
const done = arguments[arguments.length - 1];
const prom = gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
@ -1060,6 +1068,14 @@ export async function hideBanners() {
document.head.appendChild(style);`);
}
export async function assertBannerText(text: string | null) {
if (text === null) {
assert.isFalse(await driver.find('.test-banner').isPresent());
} else {
assert.equal(await driver.findWait('.test-doc-usage-banner-text', 2000).getText(), text);
}
}
/**
* Returns the left-panel item for the given page, given by a full string name, or a RegExp.
* You may simply click it to switch to that page.
@ -2819,23 +2835,34 @@ export async function getEnabledOptions(): Promise<SortOption[]> {
* Runs action in a separate tab, closing the tab after.
* In case of an error tab is not closed, consider using cleanupExtraWindows
* on whole test suite if needed.
*
* If {test: this.test} is given in options, we will additionally record a screenshot and driver
* logs, named using the test name, before opening the new tab, and before and after closing it.
*/
export async function onNewTab(action: () => Promise<void>) {
export async function onNewTab(action: () => Promise<void>, options?: {test?: Mocha.Runnable}) {
const currentTab = await driver.getWindowHandle();
await driver.executeScript("window.open('about:blank', '_blank')");
const tabs = await driver.getAllWindowHandles();
const newTab = tabs[tabs.length - 1];
const test = options?.test;
if (test) { await fetchScreenshotAndLogs(test); }
await driver.switchTo().window(newTab);
try {
await action();
} catch (e) {
console.warn("onNewTab cleaning up tab after error", e);
throw e;
} finally {
if (test) { await fetchScreenshotAndLogs(test); }
const newCurrentTab = await driver.getWindowHandle();
if (newCurrentTab === newTab) {
await driver.close();
await driver.switchTo().window(currentTab);
console.log("onNewTab returned to original tab");
} else {
console.log("onNewTab not cleaning up because is not on expected tab");
}
if (test) { await fetchScreenshotAndLogs(test); }
}
}
@ -3442,6 +3469,52 @@ export async function switchToWindow(target: string) {
}
}
/**
* Creates a temporary textarea to the document for pasting the contents of
* the clipboard.
*/
export async function createClipboardTextArea() {
function createTextArea() {
const textArea = window.document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.top = '0';
textArea.style.height = '2rem';
textArea.style.width = '16rem';
textArea.id = 'clipboardText';
window.document.body.appendChild(textArea);
}
await driver.executeScript(createTextArea);
}
/**
* Removes the temporary textarea added by `createClipboardTextArea`.
*/
export async function removeClipboardTextArea() {
function removeTextArea() {
const textArea = window.document.getElementById('clipboardText');
if (textArea) {
window.document.body.removeChild(textArea);
}
}
await driver.executeScript(removeTextArea);
}
/**
* Sets up a temporary textarea for pasting the contents of the clipboard,
* removing it after all tests have run.
*/
export function withClipboardTextArea() {
before(async function() {
await createClipboardTextArea();
});
after(async function() {
await removeClipboardTextArea();
});
}
/*
* Returns an instance of `LockableClipboard`, making sure to unlock it after
* each test.

@ -20,8 +20,6 @@ import {addToRepl, assert, driver, enableDebugCapture, ITimeouts,
Key, setOptionsModifyFunc, useServer} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server} from 'test/nbrowser/testServer';
import * as path from 'path';
import * as fs from 'fs/promises';
// Exports the server object with useful methods such as getHost(), waitServerReady(),
// simulateLogin(), etc.
@ -317,39 +315,3 @@ export function setupRequirement(options: TestSuiteOptions) {
});
return cleanup;
}
export async function withDriverLogging(
test: Mocha.Runnable|undefined, periodMs: number, timeoutMs: number,
callback: () => Promise<void>
) {
const dir = process.env.MOCHA_WEBDRIVER_LOGDIR!;
assert.isOk(dir, "driverLogging: MOCHA_WEBDRIVER_LOGDIR not set");
const testName = test?.file ? path.basename(test.file, path.extname(test.file)) : "unnamed";
const logPath = path.resolve(dir, `${testName}-driverLogging.log`);
await fs.mkdir(dir, {recursive: true});
let running = false;
async function repeat() {
if (running) {
console.log("driverLogging: skipping because previous repeat still running");
return;
}
running = true;
try {
await driver.saveScreenshot(`${testName}-driverLoggingScreenshot-{N}.png`);
const messages = await driver.fetchLogs('driver');
await fs.appendFile(logPath, messages.join("\n") + "\n");
} finally {
running = false;
}
}
const periodic = setInterval(repeat, periodMs);
const timeout = setTimeout(() => clearInterval(periodic), timeoutMs);
try {
return await callback();
} finally {
clearInterval(periodic);
clearTimeout(timeout);
}
}

@ -0,0 +1,44 @@
import log from 'app/server/lib/log';
import {assert, driver} from 'mocha-webdriver';
import * as path from 'path';
import * as fs from 'fs/promises';
export async function fetchScreenshotAndLogs(test: Mocha.Runnable|undefined) {
const dir = process.env.MOCHA_WEBDRIVER_LOGDIR!;
assert.isOk(dir, "driverLogging: MOCHA_WEBDRIVER_LOGDIR not set");
const testName = test?.file ? path.basename(test.file, path.extname(test.file)) : "unnamed";
const logPath = path.resolve(dir, `${testName}-driverLogging.log`);
await fs.mkdir(dir, {recursive: true});
await driver.saveScreenshot(`${testName}-driverLoggingScreenshot-{N}.png`);
const messages = await driver.fetchLogs('driver');
await fs.appendFile(logPath, messages.join("\n") + "\n");
}
export async function withDriverLogging(
test: Mocha.Runnable|undefined, periodMs: number, timeoutMs: number,
callback: () => Promise<void>
) {
let running = false;
async function repeat() {
if (running) {
log.warn("driverLogging: skipping because previous repeat still running");
return;
}
running = true;
try {
await fetchScreenshotAndLogs(test);
} finally {
running = false;
}
}
const periodic = setInterval(repeat, periodMs);
const timeout = setTimeout(() => clearInterval(periodic), timeoutMs);
try {
return await callback();
} finally {
clearInterval(periodic);
clearTimeout(timeout);
}
}

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# This runs browser tests with the server started using docker, to
# catch any configuration problems.

Loading…
Cancel
Save