(core) Forms post-release fixes and improvements

Summary:
Fixes misc. bugs with forms, updates Grist URLs on static form pages to link
to the new forms marketing page, and adds a forms announcement popup that's
shown next to the Add New button within a document.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4185
This commit is contained in:
George Gevoian
2024-02-14 16:18:09 -05:00
parent b8f32d1784
commit cd339ce7cb
43 changed files with 957 additions and 302 deletions

View File

@@ -354,7 +354,7 @@ export class AccessRules extends Disposable {
public buildDom() {
return cssOuter(
dom('div', this.gristDoc.behavioralPromptsManager.attachTip('accessRules', {
dom('div', this.gristDoc.behavioralPromptsManager.attachPopup('accessRules', {
hideArrow: true,
})),
cssAddTableRow(

View File

@@ -1,4 +1,4 @@
import {showBehavioralPrompt} from 'app/client/components/modals';
import {showNewsPopup, showTipPopup} from 'app/client/components/modals';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {AppModel} from 'app/client/models/AppModel';
import {getUserPrefObs} from 'app/client/models/UserPrefs';
@@ -7,40 +7,42 @@ import {isNarrowScreen} from 'app/client/ui2018/cssVars';
import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, Observable} from 'grainjs';
import {IPopupOptions} from 'popweasel';
import {IPopupOptions, PopupControl} from 'popweasel';
/**
* Options for showing a tip.
* Options for showing a popup.
*/
export interface ShowTipOptions {
/** Defaults to `false`. */
export interface ShowPopupOptions {
/** Defaults to `false`. Only applies to "tip" popups. */
hideArrow?: boolean;
popupOptions?: IPopupOptions;
onDispose?(): void;
}
/**
* Options for attaching a tip to a DOM element.
* Options for attaching a popup to a DOM element.
*/
export interface AttachTipOptions extends ShowTipOptions {
export interface AttachPopupOptions extends ShowPopupOptions {
/**
* Optional callback that should return true if the tip should be disabled.
* Optional callback that should return true if the popup should be disabled.
*
* If omitted, the tip is enabled.
* If omitted, the popup is enabled.
*/
isDisabled?(): boolean;
}
interface QueuedTip {
interface QueuedPopup {
prompt: BehavioralPrompt;
refElement: Element;
options: ShowTipOptions;
options: ShowPopupOptions;
}
/**
* Manages tips that are shown the first time a user performs some action.
* Manages popups for product announcements and tips.
*
* Tips are shown in the order that they are attached.
* Popups are shown in the order that they are attached, with at most one popup
* visible at any point in time. Popups that aren't visible are queued until all
* preceding popups have been dismissed.
*/
export class BehavioralPromptsManager extends Disposable {
private _isDisabled: boolean = false;
@@ -48,37 +50,39 @@ export class BehavioralPromptsManager extends Disposable {
private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts',
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;
private _dismissedTips: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
private _dismissedPopups: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
const {dismissedTips} = use(this._prefs);
return new Set(dismissedTips.filter(BehavioralPrompt.guard));
});
private _queuedTips: QueuedTip[] = [];
private _queuedPopups: QueuedPopup[] = [];
private _activePopupCtl: PopupControl<IPopupOptions>;
constructor(private _appModel: AppModel) {
super();
}
public showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions = {}) {
this._queueTip(refElement, prompt, options);
public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) {
this._queuePopup(refElement, prompt, options);
}
public attachTip(prompt: BehavioralPrompt, options: AttachTipOptions = {}) {
public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) {
return (element: Element) => {
if (options.isDisabled?.()) { return; }
this._queueTip(element, prompt, options);
this._queuePopup(element, prompt, options);
};
}
public hasSeenTip(prompt: BehavioralPrompt) {
return this._dismissedTips.get().has(prompt);
public hasSeenPopup(prompt: BehavioralPrompt) {
return this._dismissedPopups.get().has(prompt);
}
public shouldShowTip(prompt: BehavioralPrompt): boolean {
public shouldShowPopup(prompt: BehavioralPrompt): boolean {
if (this._isDisabled) { return false; }
// For non-SaaS flavors of Grist, don't show tips if the Help Center is explicitly
// For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly
// disabled. A separate opt-out feature could be added down the road for more granularity,
// but will require communication in advance to avoid disrupting users.
const {deploymentType, features} = getGristConfig();
@@ -91,22 +95,35 @@ export class BehavioralPromptsManager extends Disposable {
}
const {
showContext = 'desktop',
showDeploymentTypes,
popupType,
audience = 'everyone',
deviceType = 'desktop',
deploymentTypes,
forceShow = false,
} = GristBehavioralPrompts[prompt];
if (
showDeploymentTypes !== '*' &&
(!deploymentType || !showDeploymentTypes.includes(deploymentType))
(audience === 'anonymous-users' && this._appModel.currentValidUser) ||
(audience === 'signed-in-users' && !this._appModel.currentValidUser)
) {
return false;
}
const context = isNarrowScreen() ? 'mobile' : 'desktop';
if (showContext !== '*' && showContext !== context) { return false; }
if (
deploymentTypes !== 'all' &&
(!deploymentType || !deploymentTypes.includes(deploymentType))
) {
return false;
}
return forceShow || (!this._prefs.get().dontShowTips && !this.hasSeenTip(prompt));
const currentDeviceType = isNarrowScreen() ? 'mobile' : 'desktop';
if (deviceType !== 'all' && deviceType !== currentDeviceType) { return false; }
return (
forceShow ||
(popupType === 'news' && !this.hasSeenPopup(prompt)) ||
(!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt))
);
}
public enable() {
@@ -115,6 +132,12 @@ export class BehavioralPromptsManager extends Disposable {
public disable() {
this._isDisabled = true;
this._removeQueuedPopups();
this._removeActivePopup();
}
public isDisabled() {
return this._isDisabled;
}
public reset() {
@@ -122,58 +145,70 @@ export class BehavioralPromptsManager extends Disposable {
this.enable();
}
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) {
if (!this.shouldShowTip(prompt)) { return; }
private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
if (!this.shouldShowPopup(prompt)) { return; }
this._queuedTips.push({prompt, refElement, options});
if (this._queuedTips.length > 1) {
// If we're already showing a tip, wait for that one to be dismissed, which will
this._queuedPopups.push({prompt, refElement, options});
if (this._queuedPopups.length > 1) {
// If we're already showing a popup, wait for that one to be dismissed, which will
// cause the next one in the queue to be shown.
return;
}
this._showTip(refElement, prompt, options);
this._showPopup(refElement, prompt, options);
}
private _showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) {
private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
const {hideArrow, onDispose, popupOptions} = options;
const {popupType, title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt];
let ctl: PopupControl<IPopupOptions>;
if (popupType === 'news') {
ctl = showNewsPopup(refElement, title(), content(), {
popupOptions,
});
ctl.onDispose(() => { if (markAsSeen) { this._markAsSeen(prompt); } });
} else if (popupType === 'tip') {
ctl = showTipPopup(refElement, title(), content(), {
onClose: (dontShowTips) => {
if (dontShowTips) { this._dontShowTips(); }
if (markAsSeen) { this._markAsSeen(prompt); }
},
hideArrow,
popupOptions,
hideDontShowTips,
});
} else {
throw new Error(`BehavioralPromptsManager received unknown popup type: ${popupType}`);
}
this._activePopupCtl = ctl;
ctl.onDispose(() => {
onDispose?.();
this._showNextQueuedPopup();
});
const close = () => {
if (!ctl.isDisposed()) {
ctl.close();
}
};
const {hideArrow, onDispose, popupOptions} = options;
const {title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt];
const ctl = showBehavioralPrompt(refElement, title(), content(), {
onClose: (dontShowTips) => {
if (dontShowTips) { this._dontShowTips(); }
if (markAsSeen) { this._markAsSeen(prompt); }
},
hideArrow,
popupOptions,
hideDontShowTips,
});
ctl.onDispose(() => {
onDispose?.();
this._showNextQueuedTip();
});
dom.onElem(refElement, 'click', () => close());
dom.onDisposeElem(refElement, () => close());
logTelemetryEvent('viewedTip', {full: {tipName: prompt}});
}
private _showNextQueuedTip() {
this._queuedTips.shift();
if (this._queuedTips.length !== 0) {
const [nextTip] = this._queuedTips;
const {refElement, prompt, options} = nextTip;
this._showTip(refElement, prompt, options);
private _showNextQueuedPopup() {
this._queuedPopups.shift();
if (this._queuedPopups.length !== 0) {
const [nextPopup] = this._queuedPopups;
const {refElement, prompt, options} = nextPopup;
this._showPopup(refElement, prompt, options);
}
}
private _markAsSeen(prompt: BehavioralPrompt) {
if (this._isDisabled) { return; }
const {dismissedTips} = this._prefs.get();
const newDismissedTips = new Set(dismissedTips);
newDismissedTips.add(prompt);
@@ -181,7 +216,21 @@ export class BehavioralPromptsManager extends Disposable {
}
private _dontShowTips() {
if (this._isDisabled) { return; }
this._prefs.set({...this._prefs.get(), dontShowTips: true});
this._queuedTips = [];
this._queuedPopups = this._queuedPopups.filter(({prompt}) => {
return GristBehavioralPrompts[prompt].popupType !== 'tip';
});
}
private _removeActivePopup() {
if (this._activePopupCtl && !this._activePopupCtl.isDisposed()) {
this._activePopupCtl.close();
}
}
private _removeQueuedPopups() {
this._queuedPopups = [];
}
}

View File

@@ -1,4 +1,5 @@
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
@@ -86,6 +87,25 @@ export class ColumnsModel extends BoxModel {
);
return buildEditor({ box: this, content });
}
public async deleteSelf(): Promise<void> {
// Prepare all the fields that are children of this column for removal.
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 () => {
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
});
}
}
export class PlaceholderModel extends BoxModel {

View File

@@ -0,0 +1,30 @@
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {testId} from 'app/client/ui2018/cssVars';
import {Disposable} from 'grainjs';
const t = makeT('FormConfig');
export class FieldRulesConfig extends Disposable {
constructor(private _field: ViewFieldRec) {
super();
}
public buildDom() {
const requiredField: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
return [
cssSeparator(),
cssLabel(t('Field rules')),
cssRow(labeledSquareCheckbox(
fromKoSave(requiredField),
t('Required field'),
testId('field-required'),
)),
];
}
}

View File

@@ -644,28 +644,14 @@ export class FormView extends Disposable {
dom.on('click', async (_event, element) => {
try {
this._copyingLink.set(true);
const share = this._pageShare.get();
if (!share) {
throw new Error('Unable to copy link: form is not published');
}
const remoteShare = await this.gristDoc.docComm.getShare(share.linkId());
if (!remoteShare) {
throw new Error('Unable to copy link: form is not published');
}
const url = urlState().makeUrl({
doc: undefined,
form: {
shareKey: remoteShare.key,
vsId: this.viewSection.id(),
},
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
"text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
});
await copyToClipboard(url);
await copyToClipboard(data);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch(ex) {
} catch (ex) {
if (ex.code === 'AUTH_NO_OWNER') {
throw new Error('Publishing form is only available to owners');
throw new Error('Sharing a form is only available to owners');
}
} finally {
this._copyingLink.set(false);
@@ -693,6 +679,26 @@ export class FormView extends Disposable {
);
}
private async _getFormLink() {
const share = this._pageShare.get();
if (!share) {
throw new Error('Unable to get form link: form is not published');
}
const remoteShare = await this.gristDoc.docComm.getShare(share.linkId());
if (!remoteShare) {
throw new Error('Unable to get form link: form is not published');
}
return urlState().makeUrl({
doc: undefined,
form: {
shareKey: remoteShare.key,
vsId: this.viewSection.id(),
},
});
}
private _buildSwitcherMessage() {
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage(

View File

@@ -1,5 +1,6 @@
import * as style from './styles';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import {makeTestId} from 'app/client/lib/domUtils';
@@ -72,6 +73,25 @@ export class SectionModel extends BoxModel {
return place(dropped);
}
public async deleteSelf(): Promise<void> {
// Prepare all the fields that are children of this section for removal.
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 () => {
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
});
}
}
const cssSectionItems = styled('div.hover_border', `

View File

@@ -150,7 +150,7 @@ export class UnmappedFieldsConfig extends Disposable {
allCommands.showColumns.run([column.colId.peek()]);
}),
),
squareCheckbox(props.selected),
cssSquareCheckbox(props.selected),
),
);
}
@@ -171,7 +171,7 @@ export class UnmappedFieldsConfig extends Disposable {
allCommands.hideFields.run([column.colId.peek()]);
}),
),
squareCheckbox(props.selected),
cssSquareCheckbox(props.selected),
),
);
}
@@ -272,3 +272,7 @@ const cssHeader = styled(cssRow, `
line-height: 1em;
}
`);
const cssSquareCheckbox = styled(squareCheckbox, `
flex-shrink: 0;
`);

View File

@@ -147,7 +147,7 @@ export const cssRenderedLabel = styled('div', `
cursor: pointer;
min-height: 16px;
color: ${colors.darkText};
color: ${theme.mediumText};
font-size: 11px;
line-height: 16px;
font-weight: 700;
@@ -213,6 +213,7 @@ export const cssDesc = styled('div', `
export const cssInput = styled('input', `
background-color: ${theme.inputDisabledBg};
font-size: inherit;
height: 27px;
padding: 4px 8px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
@@ -232,6 +233,7 @@ export const cssSelect = styled('select', `
width: 100%;
background-color: ${theme.inputDisabledBg};
font-size: inherit;
height: 27px;
padding: 4px 8px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;

View File

@@ -45,7 +45,7 @@ import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {isTourActive, isTourActiveObs} from "app/client/ui/OnBoardingPopups";
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage';
@@ -299,14 +299,12 @@ export class GristDoc extends DisposableWithEvents {
}
}));
// Subscribe to URL state, and navigate to anchor or open a popup if necessary.
this.autoDispose(subscribe(urlState().state, async (use, state) => {
if (!state.hash) {
return;
}
try {
if (state.hash.popup || state.hash.recordCard) {
await this._openPopup(state.hash);
@@ -343,7 +341,7 @@ export class GristDoc extends DisposableWithEvents {
return;
}
this.behavioralPromptsManager.showTip(cursor, 'rickRow', {
this.behavioralPromptsManager.showPopup(cursor, 'rickRow', {
onDispose: () => this._playRickRollVideo(),
});
})
@@ -356,9 +354,25 @@ export class GristDoc extends DisposableWithEvents {
}
}));
if (this.docModel.isTutorial()) {
this.behavioralPromptsManager.disable();
}
this.autoDispose(subscribe(
urlState().state,
isTourActiveObs(),
fromKo(this.docModel.isTutorial),
(_use, state, hasActiveTour, isTutorial) => {
// Tours and tutorials can interfere with in-product tips and announcements.
const hasPendingDocTour = state.docTour || this._shouldAutoStartDocTour();
const hasPendingWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();
const isPopupManagerDisabled = this.behavioralPromptsManager.isDisabled();
if (
(hasPendingDocTour || hasPendingWelcomeTour || hasActiveTour || isTutorial) &&
!isPopupManagerDisabled
) {
this.behavioralPromptsManager.disable();
} else if (isPopupManagerDisabled) {
this.behavioralPromptsManager.enable();
}
}
));
let isStartingTourOrTutorial = false;
this.autoDispose(subscribe(urlState().state, async (_use, state) => {
@@ -1611,7 +1625,7 @@ export class GristDoc extends DisposableWithEvents {
// Don't show the tip if a non-card widget was selected.
!['single', 'detail'].includes(selectedWidgetType) ||
// Or if we shouldn't see the tip.
!this.behavioralPromptsManager.shouldShowTip('editCardLayout')
!this.behavioralPromptsManager.shouldShowPopup('editCardLayout')
) {
return;
}
@@ -1627,7 +1641,7 @@ export class GristDoc extends DisposableWithEvents {
throw new Error('GristDoc failed to find edit card layout button');
}
this.behavioralPromptsManager.showTip(editLayoutButton, 'editCardLayout', {
this.behavioralPromptsManager.showPopup(editLayoutButton, 'editCardLayout', {
popupOptions: {
placement: 'left-start',
}
@@ -1637,7 +1651,7 @@ export class GristDoc extends DisposableWithEvents {
private async _handleNewAttachedCustomWidget(widget: IAttachedCustomWidget) {
switch (widget) {
case 'custom.calendar': {
if (this.behavioralPromptsManager.shouldShowTip('calendarConfig')) {
if (this.behavioralPromptsManager.shouldShowPopup('calendarConfig')) {
// Open the right panel to the calendar subtab.
commands.allCommands.viewTabOpen.run();

View File

@@ -46,7 +46,7 @@ export class RawDataPage extends Disposable {
public buildDom() {
return cssContainer(
cssPage(
dom('div', this._gristDoc.behavioralPromptsManager.attachTip('rawDataPage', {hideArrow: true})),
dom('div', this._gristDoc.behavioralPromptsManager.attachPopup('rawDataPage', {hideArrow: true})),
dom('div',
dom.create(DataTables, this._gristDoc),
dom.create(DocumentUsage, this._gristDoc.docPageModel)

View File

@@ -18,6 +18,7 @@ import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {UserAction} from 'app/common/DocActions';
import {Computed, dom, fromKo, Observable} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import {WidgetType} from 'app/common/widgetTypes';
const t = makeT('TypeTransform');
@@ -30,6 +31,7 @@ const t = makeT('TypeTransform');
export class TypeTransform extends ColumnTransform {
private _reviseTypeChange = Observable.create(this, false);
private _transformWidget: Computed<NewAbstractWidget|null>;
private _isFormWidget: Computed<boolean>;
private _convertColumn: ColumnRec; // Set in prepare()
constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {
@@ -41,6 +43,8 @@ export class TypeTransform extends ColumnTransform {
this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => {
return use(this.origColumn.isTransforming) ? widget : null;
});
this._isFormWidget = Computed.create(this, use => use(use(this.field.viewSection).parentKey) === WidgetType.Form);
}
/**
@@ -52,7 +56,16 @@ export class TypeTransform extends ColumnTransform {
this._reviseTypeChange.set(false);
return dom('div',
testId('type-transform-top'),
dom.maybe(this._transformWidget, transformWidget => transformWidget.buildTransformConfigDom()),
dom.domComputed(use => {
const transformWidget = use(this._transformWidget);
if (!transformWidget) { return null; }
if (use(this._isFormWidget)) {
return transformWidget.buildFormTransformConfigDom();
} else {
return transformWidget.buildTransformConfigDom();
}
}),
dom.maybe(this._reviseTypeChange, () =>
dom('div.transform_editor', this.buildEditorDom(),
testId("type-transform-formula")

View File

@@ -139,7 +139,7 @@ export function reportUndo(
}
}
export interface ShowBehavioralPromptOptions {
export interface ShowTipPopupOptions {
onClose: (dontShowTips: boolean) => void;
/** Defaults to false. */
hideArrow?: boolean;
@@ -148,11 +148,11 @@ export interface ShowBehavioralPromptOptions {
popupOptions?: IPopupOptions;
}
export function showBehavioralPrompt(
export function showTipPopup(
refElement: Element,
title: string,
content: DomContents,
options: ShowBehavioralPromptOptions
options: ShowTipPopupOptions
) {
const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options;
const arrow = hideArrow ? null : buildArrow();
@@ -196,22 +196,7 @@ export function showBehavioralPrompt(
),
),
],
merge(popupOptions, {
modifiers: {
...(arrow ? {arrow: {element: arrow}}: {}),
offset: {
offset: '0,12',
},
preventOverflow: {
boundariesElement: 'window',
padding: 32,
},
computeStyle: {
// GPU acceleration makes text look blurry.
gpuAcceleration: false,
},
}
})
merge({}, defaultPopupOptions, popupOptions),
);
dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) {
@@ -221,6 +206,64 @@ export function showBehavioralPrompt(
return tooltip;
}
export interface ShowNewsPopupOptions {
popupOptions?: IPopupOptions;
}
export function showNewsPopup(
refElement: Element,
title: string,
content: DomContents,
options: ShowNewsPopupOptions = {}
) {
const {popupOptions} = options;
const popup = modalTooltip(refElement,
(ctl) => [
cssNewsPopupModal.cls(''),
cssNewsPopupContainer(
testId('behavioral-prompt'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => { ctl.close(); },
Enter: () => { ctl.close(); },
}),
cssNewsPopupCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
testId('behavioral-prompt-dismiss'),
),
cssNewsPopupBody(
cssNewsPopupTitle(title, testId('behavioral-prompt-title')),
content,
),
),
],
merge({}, defaultPopupOptions, popupOptions),
);
dom.onDisposeElem(refElement, () => {
if (!popup.isDisposed()) {
popup.close();
}
});
return popup;
}
const defaultPopupOptions = {
modifiers: {
offset: {
offset: '0,12',
},
preventOverflow: {
boundariesElement: 'window',
padding: 32,
},
computeStyle: {
// GPU acceleration makes text look blurry.
gpuAcceleration: false,
},
}
};
function buildArrow() {
return cssArrowContainer(
svg('svg',
@@ -365,10 +408,18 @@ const cssBehavioralPromptModal = styled('div', `
}
`);
const cssNewsPopupModal = cssBehavioralPromptModal;
const cssBehavioralPromptContainer = styled(cssTheme, `
line-height: 18px;
`);
const cssNewsPopupContainer = styled('div', `
background: linear-gradient(to right, #29a3a3, #16a772);
color: white;
border-radius: 4px;
`);
const cssBehavioralPromptHeader = styled('div', `
display: flex;
justify-content: center;
@@ -383,6 +434,12 @@ const cssBehavioralPromptBody = styled('div', `
padding: 16px;
`);
const cssNewsPopupBody = styled('div', `
font-size: 14px;
line-height: 23px;
padding: 16px;
`);
const cssHeaderIconAndText = styled('div', `
display: flex;
align-items: center;
@@ -405,6 +462,27 @@ const cssBehavioralPromptTitle = styled('div', `
line-height: 32px;
`);
const cssNewsPopupTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssNewsPopupCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: white;
&:hover {
background-color: ${theme.hover};
}
`);
const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `
line-height: normal;
`);

View File

@@ -221,6 +221,11 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}`
}),
activeDoc.behavioralPromptsManager.attachPopup('formsAreHere', {
popupOptions: {
placement: 'right',
},
}),
testId('dp-add-new'),
dom.cls('tour-add-new'),
),

View File

@@ -158,7 +158,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0)));
public readonly shouldShowAddNewTip = Observable.create(this,
!this._app.behavioralPromptsManager.hasSeenTip('addNew'));
!this._app.behavioralPromptsManager.hasSeenPopup('addNew'));
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);

View File

@@ -39,7 +39,7 @@ function showAddNewTip(home: HomeModel): void {
return;
}
home.app.behavioralPromptsManager.showTip(addNewButton, 'addNew', {
home.app.behavioralPromptsManager.showPopup(addNewButton, 'addNew', {
popupOptions: {
placement: 'right-start',
},

View File

@@ -349,7 +349,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
icon('PinTilted'),
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
gristDoc.behavioralPromptsManager.attachTip('filterButtons', {
gristDoc.behavioralPromptsManager.attachPopup('filterButtons', {
popupOptions: {
attach: null,
placement: 'right',

View File

@@ -373,7 +373,7 @@ class CustomSectionConfigurationConfig extends Disposable{
switch (widgetUrl) {
// TODO: come up with a way to attach tips without hardcoding widget URLs.
case 'https://gristlabs.github.io/grist-widget/calendar/index.html': {
return this._gristDoc.behavioralPromptsManager.attachTip('calendarConfig', {
return this._gristDoc.behavioralPromptsManager.attachPopup('calendarConfig', {
popupOptions: {placement: 'left-start'},
});
}
@@ -600,7 +600,7 @@ export class CustomSectionConfig extends Disposable {
dom.attr('placeholder', t("Enter Custom URL")),
testId('url')
),
this._gristDoc.behavioralPromptsManager.attachTip('customURL', {
this._gristDoc.behavioralPromptsManager.attachPopup('customURL', {
popupOptions: {
placement: 'left-start',
},

View File

@@ -24,7 +24,7 @@ export function filterBar(
dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)),
dom.maybe(viewSection.showNestedFilteringPopup, () => {
return dom('div',
gristDoc.behavioralPromptsManager.attachTip('nestedFiltering', {
gristDoc.behavioralPromptsManager.attachPopup('nestedFiltering', {
onDispose: () => viewSection.showNestedFilteringPopup.set(false),
}),
);

View File

@@ -108,7 +108,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle
},
menuIcon(colType.icon as IconName),
colType.displayName === 'Reference'?
gridView.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', {
gridView.gristDoc.behavioralPromptsManager.attachPopup('referenceColumns', {
popupOptions: {
attach: `.${menuCssClass}`,
placement: 'left-start',

View File

@@ -1,6 +1,7 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
import {basicButtonLink} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls, GristDeploymentType} from 'app/common/gristUrls';
@@ -28,6 +29,17 @@ const cssIcon = styled(icon, `
width: 18px;
`);
const cssNewsPopupLearnMoreButton = styled(basicButtonLink, `
color: white;
border: 1px solid white;
padding: 3px;
&:hover, &:focus, &:visited {
color: white;
border-color: white;
}
`);
export type Tooltip =
| 'dataSize'
| 'setTriggerFormula'
@@ -126,11 +138,14 @@ see or edit which parts of your document.')
};
export interface BehavioralPromptContent {
popupType: 'tip' | 'news';
title: () => string;
content: (...domArgs: DomElementArg[]) => DomContents;
showDeploymentTypes: GristDeploymentType[] | '*';
deploymentTypes: GristDeploymentType[] | 'all';
/** Defaults to `everyone`. */
audience?: 'signed-in-users' | 'anonymous-users' | 'everyone';
/** Defaults to `desktop`. */
showContext?: 'mobile' | 'desktop' | '*';
deviceType?: 'mobile' | 'desktop' | 'all';
/** Defaults to `false`. */
hideDontShowTips?: boolean;
/** Defaults to `false`. */
@@ -141,6 +156,7 @@ export interface BehavioralPromptContent {
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
referenceColumns: {
popupType: 'tip',
title: () => t('Reference Columns'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Reference columns are the key to {{relational}} data in Grist.', {
@@ -152,9 +168,10 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
referenceColumnsConfig: {
popupType: 'tip',
title: () => t('Reference Columns'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Select the table to link to.')),
@@ -167,9 +184,10 @@ record in that table, but you may select which column from that record to show.'
),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
rawDataPage: {
popupType: 'tip',
title: () => t('Raw Data page'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('The Raw Data page lists all data tables in your document, \
@@ -177,9 +195,10 @@ including summary tables and tables not included in page layouts.')),
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
accessRules: {
popupType: 'tip',
title: () => t('Access Rules'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Access rules give you the power to create nuanced rules \
@@ -187,9 +206,10 @@ to determine who can see or edit which parts of your document.')),
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
filterButtons: {
popupType: 'tip',
title: () => t('Pinning Filters'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Pinned filters are displayed as buttons above the widget.')),
@@ -197,27 +217,30 @@ to determine who can see or edit which parts of your document.')),
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
nestedFiltering: {
popupType: 'tip',
title: () => t('Nested Filtering'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('You can filter by more than one column.')),
dom('div', t('Only those rows will appear which match all of the filters.')),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
pageWidgetPicker: {
popupType: 'tip',
title: () => t('Selecting Data'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Select the table containing the data to show.')),
dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
pageWidgetPickerSelectBy: {
popupType: 'tip',
title: () => t('Linking Widgets'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Link your new widget to an existing widget on this page.')),
@@ -225,9 +248,10 @@ to determine who can see or edit which parts of your document.')),
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
editCardLayout: {
popupType: 'tip',
title: () => t('Editing Card Layout'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Rearrange the fields in your card by dragging and resizing cells.')),
@@ -236,17 +260,19 @@ to determine who can see or edit which parts of your document.')),
})),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
addNew: {
popupType: 'tip',
title: () => t('Add New'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
rickRow: {
popupType: 'tip',
title: () => t('Anchor Links'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
@@ -258,13 +284,14 @@ to determine who can see or edit which parts of your document.')),
),
...args,
),
showDeploymentTypes: '*',
showContext: '*',
deploymentTypes: 'all',
deviceType: 'all',
hideDontShowTips: true,
forceShow: true,
markAsSeen: false,
},
customURL: {
popupType: 'tip',
title: () => t('Custom Widgets'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
@@ -275,9 +302,10 @@ to determine who can see or edit which parts of your document.')),
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
calendarConfig: {
popupType: 'tip',
title: () => t('Calendar'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t("To configure your calendar, select columns for start/end dates and event titles. \
@@ -287,6 +315,21 @@ data.")),
dom('div', cssLink({href: commonUrls.helpCalendarWidget, target: '_blank'}, t('Learn more.'))),
...args,
),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
formsAreHere: {
popupType: 'news',
audience: 'signed-in-users',
title: () => t('Forms are here!'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}', {
learnMoreButton: cssNewsPopupLearnMoreButton(t('Learn more'), {
href: commonUrls.forms,
target: '_blank',
}),
})),
...args,
),
deploymentTypes: ['saas', 'core', 'enterprise'],
},
};

View File

@@ -22,7 +22,7 @@
* the caller. Pass an `onFinishCB` to handle when a user dimiss the popups.
*/
import { Disposable, dom, DomElementArg, Holder, makeTestId, styled, svg } from "grainjs";
import { Disposable, dom, DomElementArg, Holder, makeTestId, Observable, styled, svg } from "grainjs";
import { createPopper, Placement } from '@popperjs/core';
import { FocusLayer } from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
@@ -74,18 +74,34 @@ export interface IOnBoardingMsg {
urlState?: IGristUrlState;
}
let _isTourActiveObs: Observable<boolean>|undefined;
// Returns a singleton observable for whether some tour is currently active.
//
// GristDoc subscribes to this observable in order to temporarily disable tips and other
// in-product popups from being shown while a tour is active.
export function isTourActiveObs(): Observable<boolean> {
if (!_isTourActiveObs) {
const obs = Observable.create<boolean>(null, false);
_isTourActiveObs = obs;
}
return _isTourActiveObs;
}
// There should only be one tour at a time. Use a holder to dispose the previous tour when
// starting a new one.
const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null);
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) {
const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.onDispose(() => isTourActiveObs().set(false));
ctl.start().catch(reportError);
isTourActiveObs().set(true);
}
// Returns whether some tour is currently active.
export function isTourActive(): boolean {
return !tourSingleton.isEmpty();
return isTourActiveObs().get();
}
class OnBoardingError extends Error {

View File

@@ -337,7 +337,7 @@ export class PageWidgetSelect extends Disposable {
cssIcon('TypeTable'), 'New Table',
// prevent the selection of 'New Table' if it is disabled
dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')),
this._behavioralPromptsManager.attachTip('pageWidgetPicker', {
this._behavioralPromptsManager.attachPopup('pageWidgetPicker', {
popupOptions: {
attach: null,
placement: 'right-start',
@@ -395,7 +395,7 @@ export class PageWidgetSelect extends Disposable {
),
'selectBy',
{popupOptions: {attach: null}, domArgs: [
this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', {
this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', {
popupOptions: {
attach: null,
placement: 'bottom',

View File

@@ -1013,16 +1013,7 @@ export class RightPanel extends Disposable {
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
// Field config.
dom.maybeOwned(selectedField, (scope, field) => {
const requiredField = field.widgetOptionsJson.prop('formRequired');
// V2 thing.
// const hiddenField = field.widgetOptionsJson.prop('formHidden');
const defaultField = field.widgetOptionsJson.prop('formDefault');
const toComputed = (obs: typeof defaultField) => {
const result = Computed.create(scope, (use) => use(obs));
result.onWrite(val => obs.setAndSave(val));
return result;
};
dom.maybe(selectedField, (field) => {
const fieldTitle = field.widgetOptionsJson.prop('question');
return [
@@ -1063,21 +1054,10 @@ export class RightPanel extends Disposable {
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [
cssSection(
builder.buildConfigDom(),
),
]),
cssSection(
builder.buildFormConfigDom(),
),
]),
cssSeparator(),
cssLabel(t("Field rules")),
cssRow(labeledSquareCheckbox(
toComputed(requiredField),
t("Required field"),
testId('field-required'),
)),
// V2 thing
// cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
];
}),

View File

@@ -1,3 +1,4 @@
import {makeT} from 'app/client/lib/localization';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {AppModel} from 'app/client/models/AppModel';
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
@@ -7,7 +8,6 @@ import {cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton,
import {icon} from 'app/client/ui2018/icons';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, styled} from 'grainjs';
import { makeT } from '../lib/localization';
const t = makeT('WelcomeCoachingCall');
@@ -17,7 +17,7 @@ export function shouldShowWelcomeCoachingCall(appModel: AppModel) {
// Defer showing coaching call until Add New tip is dismissed.
const {behavioralPromptsManager, dismissedWelcomePopups} = appModel;
if (behavioralPromptsManager.shouldShowTip('addNew')) { return false; }
if (behavioralPromptsManager.shouldShowPopup('addNew')) { return false; }
const popup = dismissedWelcomePopups.get().find(p => p.id === 'coachingCall');
return (

View File

@@ -7,9 +7,9 @@ import {pagePanels} from 'app/client/ui/PagePanels';
import {setUpPage} from 'app/client/ui/setUpPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
@@ -128,14 +128,14 @@ export function createFormNotFoundPage(message?: string) {
cssFormErrorFooter(
cssFormPoweredByGrist(
cssFormPoweredByGristLink(
{href: 'https://www.getgrist.com', target: '_blank'},
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
cssGristLogo(),
)
),
cssFormBuildForm(
cssFormBuildFormLink(
{href: 'https://www.getgrist.com', target: '_blank'},
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
@@ -227,10 +227,17 @@ const cssButtonWrap = styled('div', `
`);
const cssFormErrorPage = styled('div', `
--grist-form-padding: 48px;
min-height: 100%;
background-color: ${colors.lightGrey};
height: 100%;
width: 100%;
padding-top: 52px;
padding: 52px 0px 52px 0px;
overflow: auto;
@media ${mediaSmall} {
& {
padding: 20px 0px 20px 0px;
}
}
`);
const cssFormErrorContainer = styled('div', `
@@ -243,6 +250,7 @@ const cssFormError = styled('div', `
text-align: center;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
@@ -254,8 +262,10 @@ const cssFormErrorBody = styled('div', `
`);
const cssFormErrorImage = styled('img', `
width: 250px;
height: 281px;
width: 100%;
height: 100%;
max-width: 250px;
max-height: 281px;
`);
const cssFormErrorText = styled('div', `
@@ -282,8 +292,6 @@ const cssFormPoweredByGrist = styled('div', `
align-items: center;
justify-content: center;
padding: 0px 10px;
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
`);
const cssFormPoweredByGristLink = styled('a', `

View File

@@ -45,4 +45,12 @@ AbstractWidget.prototype.buildColorConfigDom = function(gristDoc) {
return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);
};
AbstractWidget.prototype.buildFormConfigDom = function() {
return null;
};
AbstractWidget.prototype.buildFormTransformConfigDom = function() {
return null;
};
module.exports = AbstractWidget;

View File

@@ -13,7 +13,7 @@ import { SingleCell } from 'app/common/TableData';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {UploadResult} from 'app/common/uploads';
import { GristObjCode } from 'app/plugin/GristData';
import {Computed, dom, fromKo, input, onElem, styled} from 'grainjs';
import {Computed, dom, DomContents, fromKo, input, onElem, styled} from 'grainjs';
import {extname} from 'path';
@@ -69,7 +69,7 @@ export class AttachmentsWidget extends NewAbstractWidget {
);
}
public buildConfigDom(): Element {
public buildConfigDom(): DomContents {
const options = this.field.config.options;
const height = options.prop('height');
const inputRange = input(

View File

@@ -9,8 +9,7 @@ import {icon} from 'app/client/ui2018/icons';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {WidgetType} from 'app/common/widgetTypes';
import {Computed, dom, styled, UseCB} from 'grainjs';
import {Computed, dom, styled} from 'grainjs';
export type IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
@@ -75,37 +74,9 @@ export class ChoiceTextBox extends NTextBox {
}
public buildConfigDom() {
const disabled = Computed.create(null,
use => use(this.field.disableModify)
|| use(use(this.field.column).disableEditData)
|| use(this.field.config.options.disabled('choices'))
);
const mixed = Computed.create(null,
use => !use(disabled)
&& (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions')))
);
// If we are on forms, we don't want to show alignment options.
const notForm = (use: UseCB) => {
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
};
return [
dom.maybe(notForm, () => super.buildConfigDom()),
cssLabel(t('CHOICES')),
cssRow(
dom.autoDispose(disabled),
dom.autoDispose(mixed),
dom.create(
ChoiceListEntry,
this._choiceValues,
this._choiceOptionsByName,
this.save.bind(this),
disabled,
mixed
)
)
super.buildConfigDom(),
this._buildChoicesConfigDom(),
];
}
@@ -113,6 +84,19 @@ export class ChoiceTextBox extends NTextBox {
return this.buildConfigDom();
}
public buildFormConfigDom() {
return [
this._buildChoicesConfigDom(),
super.buildFormConfigDom(),
];
}
public buildFormTransformConfigDom() {
return [
this._buildChoicesConfigDom(),
];
}
protected getChoiceValuesSet(): Computed<Set<string>> {
return this._choiceValuesSet;
}
@@ -128,6 +112,35 @@ export class ChoiceTextBox extends NTextBox {
};
return this.field.config.updateChoices(renames, options);
}
private _buildChoicesConfigDom() {
const disabled = Computed.create(null,
use => use(this.field.disableModify)
|| use(use(this.field.column).disableEditData)
|| use(this.field.config.options.disabled('choices'))
);
const mixed = Computed.create(null,
use => !use(disabled)
&& (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions')))
);
return [
cssLabel(t('CHOICES')),
cssRow(
dom.autoDispose(disabled),
dom.autoDispose(mixed),
dom.create(
ChoiceListEntry,
this._choiceValues,
this._choiceOptionsByName,
this.save.bind(this),
disabled,
mixed
)
)
];
}
}
// Converts a POJO containing choice options to an ES6 Map

View File

@@ -6,11 +6,12 @@ var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget');
const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {styled, fromKo} = require('grainjs');
const {dom: gdom, styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus');
const {dateFormatOptions} = require('app/common/parseDate');
@@ -79,6 +80,12 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
return this.buildDateConfigDom();
};
DateTextBox.prototype.buildFormConfigDom = function() {
return [
gdom.create(FieldRulesConfig, this.field),
];
};
DateTextBox.prototype.buildDom = function(row) {
let value = row[this.field.colId()];
return dom('div.field_clip',

View File

@@ -305,7 +305,7 @@ export class FieldBuilder extends Disposable {
}
if (op.label === 'Reference') {
return this.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', {
return this.gristDoc.behavioralPromptsManager.attachPopup('referenceColumns', {
popupOptions: {
attach: `.${cssTypeSelectMenu.className}`,
placement: 'left-start',
@@ -412,7 +412,7 @@ export class FieldBuilder extends Disposable {
return [
cssLabel(t('DATA FROM TABLE'),
kd.maybe(this._showRefConfigPopup, () => {
return dom('div', this.gristDoc.behavioralPromptsManager.attachTip(
return dom('div', this.gristDoc.behavioralPromptsManager.attachPopup(
'referenceColumnsConfig',
{
onDispose: () => this._showRefConfigPopup(false),
@@ -501,6 +501,14 @@ export class FieldBuilder extends Disposable {
);
}
public buildFormConfigDom() {
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildFormConfigDom())
)
);
}
/**
* Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
*/

View File

@@ -1,3 +1,4 @@
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
@@ -51,10 +52,16 @@ export class NTextBox extends NewAbstractWidget {
[{value: true, icon: 'Wrap'}],
toggle,
cssButtonSelect.cls('-disabled', wrapDisabled),
),
testId('tb-wrap-text')
)
)
),
testId('tb-wrap-text'),
),
),
];
}
public buildFormConfigDom(): DomContents {
return [
dom.create(FieldRulesConfig, this.field),
];
}

View File

@@ -76,6 +76,14 @@ export abstract class NewAbstractWidget extends Disposable {
return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);
}
public buildFormConfigDom(): DomContents {
return null;
}
public buildFormTransformConfigDom(): DomContents {
return null;
}
/**
* Builds the data cell DOM.
* @param {DataRowModel} row - The rowModel object.

View File

@@ -9,9 +9,8 @@ import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {WidgetType} from 'app/common/widgetTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled, UseCB} from 'grainjs';
import {Computed, dom, styled} from 'grainjs';
const t = makeT('Reference');
@@ -49,16 +48,10 @@ export class Reference extends NTextBox {
}
public buildConfigDom() {
// If we are on forms, we don't want to show alignment options.
const notForm = (use: UseCB) => {
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
};
return [
this.buildTransformConfigDom(),
dom.maybe(notForm, () => [
cssLabel(t('CELL FORMAT')),
super.buildConfigDom()
])
cssLabel(t('CELL FORMAT')),
super.buildConfigDom(),
];
}
@@ -76,6 +69,17 @@ export class Reference extends NTextBox {
];
}
public buildFormConfigDom() {
return [
this.buildTransformConfigDom(),
super.buildFormConfigDom(),
];
}
public buildFormTransformConfigDom() {
return this.buildTransformConfigDom();
}
public buildDom(row: DataRowModel) {
// Note: we require 2 observables here because changes to the cell value (reference id)
// and the display value (display column) are not bundled. This can cause `formattedValue`

View File

@@ -1,15 +1,22 @@
import * as commands from 'app/client/components/commands';
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { KoSaveableObservable } from 'app/client/models/modelUtil';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { theme } from 'app/client/ui2018/cssVars';
import { dom } from 'grainjs';
import { dom, DomContents } from 'grainjs';
/**
* ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
*/
abstract class ToggleBase extends NewAbstractWidget {
public buildFormConfigDom(): DomContents {
return [
dom.create(FieldRulesConfig, this.field),
];
}
protected _addClickEventHandlers(row: DataRowModel) {
return [
dom.on('click', (event) => {

View File

@@ -286,7 +286,7 @@ class Bool extends BaseQuestion {
const label = field.question ? field.question : field.colId;
return `
<label class='grist-switch ${requiredLabel}'>
<input type='checkbox' name='${this.name(field)}' value="1" ${required} />
<input type='checkbox' name='${this.name(field)}' value="1" ${required} />
<div class="grist-widget_switch grist-switch_transition">
<div class="grist-switch_slider"></div>
<div class="grist-switch_circle"></div>

View File

@@ -88,6 +88,7 @@ export const BehavioralPrompt = StringUnion(
'rickRow',
'customURL',
'calendarConfig',
'formsAreHere',
);
export type BehavioralPrompt = typeof BehavioralPrompt.type;

View File

@@ -94,6 +94,7 @@ export const commonUrls = {
functions: 'https://support.getgrist.com/functions',
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
formulas: 'https://support.getgrist.com/formulas',
forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',

View File

@@ -14,7 +14,7 @@ import {
import {DocData} from 'app/common/DocData';
import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes";
import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {buildUrlId, commonUrls, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
@@ -1538,7 +1538,8 @@ export class DocWorkerApi {
CONTENT: html,
SUCCESS_TEXT: box.successText || 'Thank you! Your response has been recorded.',
SUCCESS_URL: redirectUrl,
TITLE: `${section.title || tableName || tableId || 'Form'} - Grist`
TITLE: `${section.title || tableName || tableId || 'Form'} - Grist`,
FORMS_LANDING_PAGE_URL: commonUrls.forms,
});
this._grist.getTelemetry().logEvent(req, 'visitedForm', {
full: {