(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() { public buildDom() {
return cssOuter( return cssOuter(
dom('div', this.gristDoc.behavioralPromptsManager.attachTip('accessRules', { dom('div', this.gristDoc.behavioralPromptsManager.attachPopup('accessRules', {
hideArrow: true, hideArrow: true,
})), })),
cssAddTableRow( 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 {logTelemetryEvent} from 'app/client/lib/telemetry';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {getUserPrefObs} from 'app/client/models/UserPrefs'; 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 {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, Observable} from 'grainjs'; 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 { export interface ShowPopupOptions {
/** Defaults to `false`. */ /** Defaults to `false`. Only applies to "tip" popups. */
hideArrow?: boolean; hideArrow?: boolean;
popupOptions?: IPopupOptions; popupOptions?: IPopupOptions;
onDispose?(): void; 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; isDisabled?(): boolean;
} }
interface QueuedTip { interface QueuedPopup {
prompt: BehavioralPrompt; prompt: BehavioralPrompt;
refElement: Element; 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 { export class BehavioralPromptsManager extends Disposable {
private _isDisabled: boolean = false; private _isDisabled: boolean = false;
@ -48,37 +50,39 @@ export class BehavioralPromptsManager extends Disposable {
private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts', private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts',
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>; { 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); const {dismissedTips} = use(this._prefs);
return new Set(dismissedTips.filter(BehavioralPrompt.guard)); return new Set(dismissedTips.filter(BehavioralPrompt.guard));
}); });
private _queuedTips: QueuedTip[] = []; private _queuedPopups: QueuedPopup[] = [];
private _activePopupCtl: PopupControl<IPopupOptions>;
constructor(private _appModel: AppModel) { constructor(private _appModel: AppModel) {
super(); super();
} }
public showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions = {}) { public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) {
this._queueTip(refElement, prompt, options); this._queuePopup(refElement, prompt, options);
} }
public attachTip(prompt: BehavioralPrompt, options: AttachTipOptions = {}) { public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) {
return (element: Element) => { return (element: Element) => {
if (options.isDisabled?.()) { return; } if (options.isDisabled?.()) { return; }
this._queueTip(element, prompt, options); this._queuePopup(element, prompt, options);
}; };
} }
public hasSeenTip(prompt: BehavioralPrompt) { public hasSeenPopup(prompt: BehavioralPrompt) {
return this._dismissedTips.get().has(prompt); return this._dismissedPopups.get().has(prompt);
} }
public shouldShowTip(prompt: BehavioralPrompt): boolean { public shouldShowPopup(prompt: BehavioralPrompt): boolean {
if (this._isDisabled) { return false; } 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, // 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. // but will require communication in advance to avoid disrupting users.
const {deploymentType, features} = getGristConfig(); const {deploymentType, features} = getGristConfig();
@ -91,22 +95,35 @@ export class BehavioralPromptsManager extends Disposable {
} }
const { const {
showContext = 'desktop', popupType,
showDeploymentTypes, audience = 'everyone',
deviceType = 'desktop',
deploymentTypes,
forceShow = false, forceShow = false,
} = GristBehavioralPrompts[prompt]; } = GristBehavioralPrompts[prompt];
if ( if (
showDeploymentTypes !== '*' && (audience === 'anonymous-users' && this._appModel.currentValidUser) ||
(!deploymentType || !showDeploymentTypes.includes(deploymentType)) (audience === 'signed-in-users' && !this._appModel.currentValidUser)
) { ) {
return false; return false;
} }
const context = isNarrowScreen() ? 'mobile' : 'desktop'; if (
if (showContext !== '*' && showContext !== context) { return false; } 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() { public enable() {
@ -115,6 +132,12 @@ export class BehavioralPromptsManager extends Disposable {
public disable() { public disable() {
this._isDisabled = true; this._isDisabled = true;
this._removeQueuedPopups();
this._removeActivePopup();
}
public isDisabled() {
return this._isDisabled;
} }
public reset() { public reset() {
@ -122,58 +145,70 @@ export class BehavioralPromptsManager extends Disposable {
this.enable(); this.enable();
} }
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) { private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
if (!this.shouldShowTip(prompt)) { return; } if (!this.shouldShowPopup(prompt)) { return; }
this._queuedTips.push({prompt, refElement, options}); this._queuedPopups.push({prompt, refElement, options});
if (this._queuedTips.length > 1) { if (this._queuedPopups.length > 1) {
// If we're already showing a tip, wait for that one to be dismissed, which will // 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. // cause the next one in the queue to be shown.
return; 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 = () => { const close = () => {
if (!ctl.isDisposed()) { if (!ctl.isDisposed()) {
ctl.close(); 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.onElem(refElement, 'click', () => close());
dom.onDisposeElem(refElement, () => close()); dom.onDisposeElem(refElement, () => close());
logTelemetryEvent('viewedTip', {full: {tipName: prompt}}); logTelemetryEvent('viewedTip', {full: {tipName: prompt}});
} }
private _showNextQueuedTip() { private _showNextQueuedPopup() {
this._queuedTips.shift(); this._queuedPopups.shift();
if (this._queuedTips.length !== 0) { if (this._queuedPopups.length !== 0) {
const [nextTip] = this._queuedTips; const [nextPopup] = this._queuedPopups;
const {refElement, prompt, options} = nextTip; const {refElement, prompt, options} = nextPopup;
this._showTip(refElement, prompt, options); this._showPopup(refElement, prompt, options);
} }
} }
private _markAsSeen(prompt: BehavioralPrompt) { private _markAsSeen(prompt: BehavioralPrompt) {
if (this._isDisabled) { return; }
const {dismissedTips} = this._prefs.get(); const {dismissedTips} = this._prefs.get();
const newDismissedTips = new Set(dismissedTips); const newDismissedTips = new Set(dismissedTips);
newDismissedTips.add(prompt); newDismissedTips.add(prompt);
@ -181,7 +216,21 @@ export class BehavioralPromptsManager extends Disposable {
} }
private _dontShowTips() { private _dontShowTips() {
if (this._isDisabled) { return; }
this._prefs.set({...this._prefs.get(), dontShowTips: true}); 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 {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu'; import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model'; import {BoxModel} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles'; import * as style from 'app/client/components/Forms/styles';
@ -86,6 +87,25 @@ export class ColumnsModel extends BoxModel {
); );
return buildEditor({ box: this, content }); 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 { 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) => { dom.on('click', async (_event, element) => {
try { try {
this._copyingLink.set(true); this._copyingLink.set(true);
const share = this._pageShare.get(); const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
if (!share) { "text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
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(),
},
}); });
await copyToClipboard(url); await copyToClipboard(data);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch(ex) { } catch (ex) {
if (ex.code === 'AUTH_NO_OWNER') { 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 { } finally {
this._copyingLink.set(false); 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() { private _buildSwitcherMessage() {
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => { return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage( return style.cssSwitcherMessage(

View File

@ -1,5 +1,6 @@
import * as style from './styles'; import * as style from './styles';
import {buildEditor} from 'app/client/components/Forms/Editor'; 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 {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model'; import {BoxModel} from 'app/client/components/Forms/Model';
import {makeTestId} from 'app/client/lib/domUtils'; import {makeTestId} from 'app/client/lib/domUtils';
@ -72,6 +73,25 @@ export class SectionModel extends BoxModel {
return place(dropped); 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', ` const cssSectionItems = styled('div.hover_border', `

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ export class RawDataPage extends Disposable {
public buildDom() { public buildDom() {
return cssContainer( return cssContainer(
cssPage( cssPage(
dom('div', this._gristDoc.behavioralPromptsManager.attachTip('rawDataPage', {hideArrow: true})), dom('div', this._gristDoc.behavioralPromptsManager.attachPopup('rawDataPage', {hideArrow: true})),
dom('div', dom('div',
dom.create(DataTables, this._gristDoc), dom.create(DataTables, this._gristDoc),
dom.create(DocumentUsage, this._gristDoc.docPageModel) 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 {UserAction} from 'app/common/DocActions';
import {Computed, dom, fromKo, Observable} from 'grainjs'; import {Computed, dom, fromKo, Observable} from 'grainjs';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {WidgetType} from 'app/common/widgetTypes';
const t = makeT('TypeTransform'); const t = makeT('TypeTransform');
@ -30,6 +31,7 @@ const t = makeT('TypeTransform');
export class TypeTransform extends ColumnTransform { export class TypeTransform extends ColumnTransform {
private _reviseTypeChange = Observable.create(this, false); private _reviseTypeChange = Observable.create(this, false);
private _transformWidget: Computed<NewAbstractWidget|null>; private _transformWidget: Computed<NewAbstractWidget|null>;
private _isFormWidget: Computed<boolean>;
private _convertColumn: ColumnRec; // Set in prepare() private _convertColumn: ColumnRec; // Set in prepare()
constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) { constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {
@ -41,6 +43,8 @@ export class TypeTransform extends ColumnTransform {
this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => { this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => {
return use(this.origColumn.isTransforming) ? widget : null; 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); this._reviseTypeChange.set(false);
return dom('div', return dom('div',
testId('type-transform-top'), 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.maybe(this._reviseTypeChange, () =>
dom('div.transform_editor', this.buildEditorDom(), dom('div.transform_editor', this.buildEditorDom(),
testId("type-transform-formula") testId("type-transform-formula")

View File

@ -139,7 +139,7 @@ export function reportUndo(
} }
} }
export interface ShowBehavioralPromptOptions { export interface ShowTipPopupOptions {
onClose: (dontShowTips: boolean) => void; onClose: (dontShowTips: boolean) => void;
/** Defaults to false. */ /** Defaults to false. */
hideArrow?: boolean; hideArrow?: boolean;
@ -148,11 +148,11 @@ export interface ShowBehavioralPromptOptions {
popupOptions?: IPopupOptions; popupOptions?: IPopupOptions;
} }
export function showBehavioralPrompt( export function showTipPopup(
refElement: Element, refElement: Element,
title: string, title: string,
content: DomContents, content: DomContents,
options: ShowBehavioralPromptOptions options: ShowTipPopupOptions
) { ) {
const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options; const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options;
const arrow = hideArrow ? null : buildArrow(); const arrow = hideArrow ? null : buildArrow();
@ -196,22 +196,7 @@ export function showBehavioralPrompt(
), ),
), ),
], ],
merge(popupOptions, { merge({}, defaultPopupOptions, popupOptions),
modifiers: {
...(arrow ? {arrow: {element: arrow}}: {}),
offset: {
offset: '0,12',
},
preventOverflow: {
boundariesElement: 'window',
padding: 32,
},
computeStyle: {
// GPU acceleration makes text look blurry.
gpuAcceleration: false,
},
}
})
); );
dom.onDisposeElem(refElement, () => { dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) { if (!tooltip.isDisposed()) {
@ -221,6 +206,64 @@ export function showBehavioralPrompt(
return tooltip; 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() { function buildArrow() {
return cssArrowContainer( return cssArrowContainer(
svg('svg', svg('svg',
@ -365,10 +408,18 @@ const cssBehavioralPromptModal = styled('div', `
} }
`); `);
const cssNewsPopupModal = cssBehavioralPromptModal;
const cssBehavioralPromptContainer = styled(cssTheme, ` const cssBehavioralPromptContainer = styled(cssTheme, `
line-height: 18px; line-height: 18px;
`); `);
const cssNewsPopupContainer = styled('div', `
background: linear-gradient(to right, #29a3a3, #16a772);
color: white;
border-radius: 4px;
`);
const cssBehavioralPromptHeader = styled('div', ` const cssBehavioralPromptHeader = styled('div', `
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -383,6 +434,12 @@ const cssBehavioralPromptBody = styled('div', `
padding: 16px; padding: 16px;
`); `);
const cssNewsPopupBody = styled('div', `
font-size: 14px;
line-height: 23px;
padding: 16px;
`);
const cssHeaderIconAndText = styled('div', ` const cssHeaderIconAndText = styled('div', `
display: flex; display: flex;
align-items: center; align-items: center;
@ -405,6 +462,27 @@ const cssBehavioralPromptTitle = styled('div', `
line-height: 32px; 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, ` const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `
line-height: normal; 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. // "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}` stretchToSelector: `.${cssAddNewButton.className}`
}), }),
activeDoc.behavioralPromptsManager.attachPopup('formsAreHere', {
popupOptions: {
placement: 'right',
},
}),
testId('dp-add-new'), testId('dp-add-new'),
dom.cls('tour-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))); wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0)));
public readonly shouldShowAddNewTip = Observable.create(this, 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); private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
import {basicButtonLink} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; import {commonUrls, GristDeploymentType} from 'app/common/gristUrls';
@ -28,6 +29,17 @@ const cssIcon = styled(icon, `
width: 18px; 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 = export type Tooltip =
| 'dataSize' | 'dataSize'
| 'setTriggerFormula' | 'setTriggerFormula'
@ -126,11 +138,14 @@ see or edit which parts of your document.')
}; };
export interface BehavioralPromptContent { export interface BehavioralPromptContent {
popupType: 'tip' | 'news';
title: () => string; title: () => string;
content: (...domArgs: DomElementArg[]) => DomContents; content: (...domArgs: DomElementArg[]) => DomContents;
showDeploymentTypes: GristDeploymentType[] | '*'; deploymentTypes: GristDeploymentType[] | 'all';
/** Defaults to `everyone`. */
audience?: 'signed-in-users' | 'anonymous-users' | 'everyone';
/** Defaults to `desktop`. */ /** Defaults to `desktop`. */
showContext?: 'mobile' | 'desktop' | '*'; deviceType?: 'mobile' | 'desktop' | 'all';
/** Defaults to `false`. */ /** Defaults to `false`. */
hideDontShowTips?: boolean; hideDontShowTips?: boolean;
/** Defaults to `false`. */ /** Defaults to `false`. */
@ -141,6 +156,7 @@ export interface BehavioralPromptContent {
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = { export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
referenceColumns: { referenceColumns: {
popupType: 'tip',
title: () => t('Reference Columns'), title: () => t('Reference Columns'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Reference columns are the key to {{relational}} data in Grist.', { dom('div', t('Reference columns are the key to {{relational}} data in Grist.', {
@ -152,9 +168,10 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
), ),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
referenceColumnsConfig: { referenceColumnsConfig: {
popupType: 'tip',
title: () => t('Reference Columns'), title: () => t('Reference Columns'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Select the table to link to.')), 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, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
rawDataPage: { rawDataPage: {
popupType: 'tip',
title: () => t('Raw Data page'), title: () => t('Raw Data page'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('The Raw Data page lists all data tables in your document, \ 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.'))), dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
accessRules: { accessRules: {
popupType: 'tip',
title: () => t('Access Rules'), title: () => t('Access Rules'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Access rules give you the power to create nuanced rules \ 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.'))), dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
filterButtons: { filterButtons: {
popupType: 'tip',
title: () => t('Pinning Filters'), title: () => t('Pinning Filters'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Pinned filters are displayed as buttons above the widget.')), 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.'))), dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
nestedFiltering: { nestedFiltering: {
popupType: 'tip',
title: () => t('Nested Filtering'), title: () => t('Nested Filtering'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('You can filter by more than one column.')), 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.')), dom('div', t('Only those rows will appear which match all of the filters.')),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
pageWidgetPicker: { pageWidgetPicker: {
popupType: 'tip',
title: () => t('Selecting Data'), title: () => t('Selecting Data'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Select the table containing the data to show.')), 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.')), dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
pageWidgetPickerSelectBy: { pageWidgetPickerSelectBy: {
popupType: 'tip',
title: () => t('Linking Widgets'), title: () => t('Linking Widgets'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Link your new widget to an existing widget on this page.')), 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.'))), dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
editCardLayout: { editCardLayout: {
popupType: 'tip',
title: () => t('Editing Card Layout'), title: () => t('Editing Card Layout'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Rearrange the fields in your card by dragging and resizing cells.')), 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, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
addNew: { addNew: {
popupType: 'tip',
title: () => t('Add New'), title: () => t('Add New'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')), dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
rickRow: { rickRow: {
popupType: 'tip',
title: () => t('Anchor Links'), title: () => t('Anchor Links'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', dom('div',
@ -258,13 +284,14 @@ to determine who can see or edit which parts of your document.')),
), ),
...args, ...args,
), ),
showDeploymentTypes: '*', deploymentTypes: 'all',
showContext: '*', deviceType: 'all',
hideDontShowTips: true, hideDontShowTips: true,
forceShow: true, forceShow: true,
markAsSeen: false, markAsSeen: false,
}, },
customURL: { customURL: {
popupType: 'tip',
title: () => t('Custom Widgets'), title: () => t('Custom Widgets'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', 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.'))), dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
...args, ...args,
), ),
showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
}, },
calendarConfig: { calendarConfig: {
popupType: 'tip',
title: () => t('Calendar'), title: () => t('Calendar'),
content: (...args: DomElementArg[]) => cssTooltipContent( content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t("To configure your calendar, select columns for start/end dates and event titles. \ 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.'))), dom('div', cssLink({href: commonUrls.helpCalendarWidget, target: '_blank'}, t('Learn more.'))),
...args, ...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. * 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 { createPopper, Placement } from '@popperjs/core';
import { FocusLayer } from 'app/client/lib/FocusLayer'; import { FocusLayer } from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
@ -74,18 +74,34 @@ export interface IOnBoardingMsg {
urlState?: IGristUrlState; 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 // There should only be one tour at a time. Use a holder to dispose the previous tour when
// starting a new one. // starting a new one.
const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null); const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null);
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) { export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) {
const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB); const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.onDispose(() => isTourActiveObs().set(false));
ctl.start().catch(reportError); ctl.start().catch(reportError);
isTourActiveObs().set(true);
} }
// Returns whether some tour is currently active. // Returns whether some tour is currently active.
export function isTourActive(): boolean { export function isTourActive(): boolean {
return !tourSingleton.isEmpty(); return isTourActiveObs().get();
} }
class OnBoardingError extends Error { class OnBoardingError extends Error {

View File

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

View File

@ -1013,16 +1013,7 @@ export class RightPanel extends Disposable {
return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
// Field config. // Field config.
dom.maybeOwned(selectedField, (scope, field) => { dom.maybe(selectedField, (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;
};
const fieldTitle = field.widgetOptionsJson.prop('question'); const fieldTitle = field.widgetOptionsJson.prop('question');
return [ return [
@ -1063,21 +1054,10 @@ export class RightPanel extends Disposable {
// cssSection( // cssSection(
// builder.buildSelectWidgetDom(), // builder.buildSelectWidgetDom(),
// ), // ),
dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [ cssSection(
cssSection( builder.buildFormConfigDom(),
builder.buildConfigDom(), ),
),
]),
]), ]),
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 {logTelemetryEvent} from 'app/client/lib/telemetry';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; 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 {icon} from 'app/client/ui2018/icons';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {dom, styled} from 'grainjs'; import {dom, styled} from 'grainjs';
import { makeT } from '../lib/localization';
const t = makeT('WelcomeCoachingCall'); const t = makeT('WelcomeCoachingCall');
@ -17,7 +17,7 @@ export function shouldShowWelcomeCoachingCall(appModel: AppModel) {
// Defer showing coaching call until Add New tip is dismissed. // Defer showing coaching call until Add New tip is dismissed.
const {behavioralPromptsManager, dismissedWelcomePopups} = appModel; 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'); const popup = dismissedWelcomePopups.get().find(p => p.id === 'coachingCall');
return ( return (

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import { SingleCell } from 'app/common/TableData';
import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {UploadResult} from 'app/common/uploads'; import {UploadResult} from 'app/common/uploads';
import { GristObjCode } from 'app/plugin/GristData'; 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'; 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 options = this.field.config.options;
const height = options.prop('height'); const height = options.prop('height');
const inputRange = input( const inputRange = input(

View File

@ -9,8 +9,7 @@ import {icon} from 'app/client/ui2018/icons';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken'; import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {WidgetType} from 'app/common/widgetTypes'; import {Computed, dom, styled} from 'grainjs';
import {Computed, dom, styled, UseCB} from 'grainjs';
export type IChoiceOptions = Style export type IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>; export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
@ -75,37 +74,9 @@ export class ChoiceTextBox extends NTextBox {
} }
public buildConfigDom() { 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 [ return [
dom.maybe(notForm, () => super.buildConfigDom()), super.buildConfigDom(),
cssLabel(t('CHOICES')), this._buildChoicesConfigDom(),
cssRow(
dom.autoDispose(disabled),
dom.autoDispose(mixed),
dom.create(
ChoiceListEntry,
this._choiceValues,
this._choiceOptionsByName,
this.save.bind(this),
disabled,
mixed
)
)
]; ];
} }
@ -113,6 +84,19 @@ export class ChoiceTextBox extends NTextBox {
return this.buildConfigDom(); return this.buildConfigDom();
} }
public buildFormConfigDom() {
return [
this._buildChoicesConfigDom(),
super.buildFormConfigDom(),
];
}
public buildFormTransformConfigDom() {
return [
this._buildChoicesConfigDom(),
];
}
protected getChoiceValuesSet(): Computed<Set<string>> { protected getChoiceValuesSet(): Computed<Set<string>> {
return this._choiceValuesSet; return this._choiceValuesSet;
} }
@ -128,6 +112,35 @@ export class ChoiceTextBox extends NTextBox {
}; };
return this.field.config.updateChoices(renames, options); 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 // 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 kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget'); var AbstractWidget = require('./AbstractWidget');
const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {fromKoSave} = require('app/client/lib/fromKoSave'); const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect'); 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 {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 {select} = require('app/client/ui2018/menus');
const {dateFormatOptions} = require('app/common/parseDate'); const {dateFormatOptions} = require('app/common/parseDate');
@ -79,6 +80,12 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
return this.buildDateConfigDom(); return this.buildDateConfigDom();
}; };
DateTextBox.prototype.buildFormConfigDom = function() {
return [
gdom.create(FieldRulesConfig, this.field),
];
};
DateTextBox.prototype.buildDom = function(row) { DateTextBox.prototype.buildDom = function(row) {
let value = row[this.field.colId()]; let value = row[this.field.colId()];
return dom('div.field_clip', return dom('div.field_clip',

View File

@ -305,7 +305,7 @@ export class FieldBuilder extends Disposable {
} }
if (op.label === 'Reference') { if (op.label === 'Reference') {
return this.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', { return this.gristDoc.behavioralPromptsManager.attachPopup('referenceColumns', {
popupOptions: { popupOptions: {
attach: `.${cssTypeSelectMenu.className}`, attach: `.${cssTypeSelectMenu.className}`,
placement: 'left-start', placement: 'left-start',
@ -412,7 +412,7 @@ export class FieldBuilder extends Disposable {
return [ return [
cssLabel(t('DATA FROM TABLE'), cssLabel(t('DATA FROM TABLE'),
kd.maybe(this._showRefConfigPopup, () => { kd.maybe(this._showRefConfigPopup, () => {
return dom('div', this.gristDoc.behavioralPromptsManager.attachTip( return dom('div', this.gristDoc.behavioralPromptsManager.attachPopup(
'referenceColumnsConfig', 'referenceColumnsConfig',
{ {
onDispose: () => this._showRefConfigPopup(false), 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. * 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 { fromKoSave } from 'app/client/lib/fromKoSave';
import { DataRowModel } from 'app/client/models/DataRowModel'; import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
@ -51,10 +52,16 @@ export class NTextBox extends NewAbstractWidget {
[{value: true, icon: 'Wrap'}], [{value: true, icon: 'Wrap'}],
toggle, toggle,
cssButtonSelect.cls('-disabled', wrapDisabled), 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); return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);
} }
public buildFormConfigDom(): DomContents {
return null;
}
public buildFormTransformConfigDom(): DomContents {
return null;
}
/** /**
* Builds the data cell DOM. * Builds the data cell DOM.
* @param {DataRowModel} row - The rowModel object. * @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 {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes'; import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {WidgetType} from 'app/common/widgetTypes';
import {UIRowId} from 'app/plugin/GristAPI'; import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled, UseCB} from 'grainjs'; import {Computed, dom, styled} from 'grainjs';
const t = makeT('Reference'); const t = makeT('Reference');
@ -49,16 +48,10 @@ export class Reference extends NTextBox {
} }
public buildConfigDom() { 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 [ return [
this.buildTransformConfigDom(), this.buildTransformConfigDom(),
dom.maybe(notForm, () => [ cssLabel(t('CELL FORMAT')),
cssLabel(t('CELL FORMAT')), super.buildConfigDom(),
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) { public buildDom(row: DataRowModel) {
// Note: we require 2 observables here because changes to the cell value (reference id) // 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` // 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 * as commands from 'app/client/components/commands';
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { DataRowModel } from 'app/client/models/DataRowModel'; import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { KoSaveableObservable } from 'app/client/models/modelUtil'; import { KoSaveableObservable } from 'app/client/models/modelUtil';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { theme } from 'app/client/ui2018/cssVars'; 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. * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
*/ */
abstract class ToggleBase extends NewAbstractWidget { abstract class ToggleBase extends NewAbstractWidget {
public buildFormConfigDom(): DomContents {
return [
dom.create(FieldRulesConfig, this.field),
];
}
protected _addClickEventHandlers(row: DataRowModel) { protected _addClickEventHandlers(row: DataRowModel) {
return [ return [
dom.on('click', (event) => { dom.on('click', (event) => {

View File

@ -286,7 +286,7 @@ class Bool extends BaseQuestion {
const label = field.question ? field.question : field.colId; const label = field.question ? field.question : field.colId;
return ` return `
<label class='grist-switch ${requiredLabel}'> <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-widget_switch grist-switch_transition">
<div class="grist-switch_slider"></div> <div class="grist-switch_slider"></div>
<div class="grist-switch_circle"></div> <div class="grist-switch_circle"></div>

View File

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

View File

@ -94,6 +94,7 @@ export const commonUrls = {
functions: 'https://support.getgrist.com/functions', functions: 'https://support.getgrist.com/functions',
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
formulas: 'https://support.getgrist.com/formulas', 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', basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png', 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 {DocData} from 'app/common/DocData';
import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes"; import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes";
import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms"; 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 {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema"; import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc'; import {SortFunc} from 'app/common/SortFunc';
@ -1538,7 +1538,8 @@ export class DocWorkerApi {
CONTENT: html, CONTENT: html,
SUCCESS_TEXT: box.successText || 'Thank you! Your response has been recorded.', SUCCESS_TEXT: box.successText || 'Thank you! Your response has been recorded.',
SUCCESS_URL: redirectUrl, 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', { this._grist.getTelemetry().logEvent(req, 'visitedForm', {
full: { full: {

View File

@ -24,7 +24,7 @@ body {
background-color: #f7f7f7; background-color: #f7f7f7;
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
padding-top: 52px; padding: 52px 0px 52px 0px;
font-size: 15px; font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif, font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
@ -61,7 +61,7 @@ body {
@media screen and (max-width: 600px) { @media screen and (max-width: 600px) {
.grist-form-container { .grist-form-container {
padding-top: 20px; padding: 20px 0px 20px 0px;
} }
.grist-form { .grist-form {
@ -89,6 +89,7 @@ body {
.grist-form input[type="date"], .grist-form input[type="date"],
.grist-form input[type="datetime-local"], .grist-form input[type="datetime-local"],
.grist-form input[type="number"] { .grist-form input[type="number"] {
height: 27px;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid var(--dark-gray); border: 1px solid var(--dark-gray);
border-radius: 3px; border-radius: 3px;
@ -164,6 +165,7 @@ body {
outline-width: 1px; outline-width: 1px;
background: white; background: white;
line-height: inherit; line-height: inherit;
height: 27px;
flex: auto; flex: auto;
width: 100%; width: 100%;
} }
@ -249,7 +251,6 @@ body {
} }
.grist-power-by { .grist-power-by {
margin-top: 24px;
color: #494949; color: #494949;
font-size: 13px; font-size: 13px;
font-style: normal; font-style: normal;
@ -258,10 +259,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-top: 1px solid var(--dark-gray); padding-left: 10px;
padding: 10px; padding-right: 10px;
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
} }
.grist-power-by a { .grist-power-by a {
@ -302,15 +301,6 @@ body {
margin-left: 4px; margin-left: 4px;
} }
/**
* When an empty value is selected, show the placeholder in italic gray.
* The css is: every select that has an empty option selected, and is not active (so not open).
*/
.grist-form select:has(option[value='']:checked):not(:active) {
font-style: italic;
color: var(--light-gray, #bfbfbf);
}
/* Markdown reset */ /* Markdown reset */
.grist-form h1, .grist-form h1,
@ -364,7 +354,7 @@ body {
.grist-switch { .grist-switch {
cursor: pointer; cursor: pointer;
display: flex; display: inline-flex;
align-items: center; align-items: center;
} }
.grist-switch input[type='checkbox']::after { .grist-switch input[type='checkbox']::after {
@ -451,8 +441,10 @@ input:checked + .grist-switch_transition > .grist-switch_circle {
} }
.grist-form-confirm-image { .grist-form-confirm-image {
width: 250px; width: 100%;
height: 215px; height: 100%;
max-width: 250px;
max-height: 215px;
} }
.grist-form-confirm-text { .grist-form-confirm-text {
@ -491,27 +483,29 @@ input:checked + .grist-switch_transition > .grist-switch_circle {
cursor: pointer; cursor: pointer;
} }
.grist-form-footer,
.grist-form-confirm-footer { .grist-form-confirm-footer {
border-top: 1px solid var(--dark-gray); border-top: 1px solid var(--dark-gray);
padding: 8px 16px; padding: 8px 16px;
}
.grist-form-footer {
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
}
.grist-form-confirm-footer {
width: 100%; width: 100%;
} }
.grist-form-confirm-footer .grist-power-by { .grist-form-build-form-link-container {
margin-top: 0px;
padding-top: 0px;
padding-bottom: 0px;
border-top: none;
}
.grist-form-confirm-build-form {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 8px; margin-top: 8px;
} }
.grist-form-confirm-build-form-link { .grist-form-build-form-link {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -31,11 +31,19 @@
data-grist-success-url="{{ SUCCESS_URL }}" data-grist-success-url="{{ SUCCESS_URL }}"
> >
{{ dompurify CONTENT }} {{ dompurify CONTENT }}
<div class="grist-power-by"> <div class='grist-form-footer'>
<a href="https://getgrist.com" target="_blank"> <div class="grist-power-by">
<div>Powered by</div> <a href="{{ FORMS_LANDING_PAGE_URL }}" target="_blank">
<div class="grist-logo"></div> <div>Powered by</div>
<div class="grist-logo"></div>
</a>
</div>
<div class='grist-form-build-form-link-container'>
<a class='grist-form-build-form-link' href="{{ FORMS_LANDING_PAGE_URL }}" target="_blank">
Build your own form
<div class="grist-form-icon grist-form-icon-expand"></div>
</a> </a>
</div>
</div> </div>
</form> </form>
@ -59,13 +67,13 @@
</div> </div>
<div class='grist-form-confirm-footer'> <div class='grist-form-confirm-footer'>
<div class="grist-power-by"> <div class="grist-power-by">
<a href="https://www.getgrist.com" target="_blank"> <a href="https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer" target="_blank">
<div>Powered by</div> <div>Powered by</div>
<div class="grist-logo"></div> <div class="grist-logo"></div>
</a> </a>
</div> </div>
<div class='grist-form-confirm-build-form'> <div class='grist-form-build-form-link-container'>
<a class='grist-form-confirm-build-form-link' href="https://www.getgrist.com" target="_blank"> <a class='grist-form-build-form-link' href="https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer" target="_blank">
Build your own form Build your own form
<div class="grist-form-icon grist-form-icon-expand"></div> <div class="grist-form-icon grist-form-icon-expand"></div>
</a> </a>

View File

@ -0,0 +1,268 @@
import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('BehavioralPrompts', function() {
this.timeout(20000);
const cleanup = setupTestSuite();
let session: gu.Session;
let docId: string;
before(async () => {
session = await gu.session().user('user1').login({showTips: true});
await gu.dismissCoachingCall();
docId = await session.tempNewDoc(cleanup, 'BehavioralPrompts');
});
afterEach(() => gu.checkForErrors());
describe('when helpCenter is hidden', function() {
gu.withEnvironmentSnapshot({'GRIST_HIDE_UI_ELEMENTS': 'helpCenter'});
before(async () => {
const sessionNoHelpCenter = await gu.session().user('user3').login({
isFirstLogin: false,
freshAccount: true,
showTips: true,
});
await gu.dismissCoachingCall();
await sessionNoHelpCenter.tempNewDoc(cleanup, 'BehavioralPromptsNoHelpCenter');
});
it('should not be shown', async function() {
await assertPromptTitle(null);
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
await assertPromptTitle(null);
});
});
it('should show an announcement for forms', async function() {
await assertPromptTitle('Forms are here!');
await gu.dismissBehavioralPrompts();
});
describe('when anonymous', function() {
before(async () => {
const anonymousSession = await gu.session().anon.login({
showTips: true,
});
await anonymousSession.loadDocMenu('/');
await driver.find('.test-intro-create-doc').click();
await gu.waitForDocToLoad();
});
it('should not shown an announcement for forms', async function() {
await assertPromptTitle(null);
});
});
it('should be shown when the column type select menu is opened', async function() {
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
await assertPromptTitle('Reference Columns');
});
it('should be temporarily dismissed on click-away', async function() {
await gu.getCell({col: 'A', rowNum: 1}).click();
await assertPromptTitle(null);
});
it('should be shown again the next time the menu is opened', async function() {
await driver.find('.test-fbuilder-type-select').click();
await assertPromptTitle('Reference Columns');
});
it('should be permanently dismissed when "Got it" is clicked', async function() {
await gu.dismissBehavioralPrompts();
await assertPromptTitle(null);
// Refresh the page and make sure the prompt isn't shown again.
await session.loadDoc(`/doc/${docId}`);
await driver.find('.test-fbuilder-type-select').click();
await assertPromptTitle(null);
await gu.sendKeys(Key.ESCAPE);
});
it('should be shown after selecting a reference column type', async function() {
await gu.setType(/Reference$/);
await assertPromptTitle('Reference Columns');
await gu.undo();
});
it('should be shown after selecting a reference list column type', async function() {
await gu.setType(/Reference List$/);
await assertPromptTitle('Reference Columns');
});
it('should be shown when opening the Raw Data page', async function() {
await driver.find('.test-tools-raw').click();
await assertPromptTitle('Raw Data page');
});
it('should be shown when opening the Access Rules page', async function() {
await driver.find('.test-tools-access-rules').click();
await assertPromptTitle('Access Rules');
});
it('should be shown when opening the filter menu', async function() {
await gu.openPage('Table1');
await gu.openColumnMenu('A', 'Filter');
await assertPromptTitle('Pinning Filters');
await gu.dismissBehavioralPrompts();
});
it('should be shown when adding a second pinned filter', async function() {
await driver.find('.test-filter-menu-apply-btn').click();
await assertPromptTitle(null);
await gu.openColumnMenu('B', 'Filter');
await driver.find('.test-filter-menu-apply-btn').click();
await assertPromptTitle('Nested Filtering');
});
it('should be shown when opening the page widget picker', async function() {
await gu.openAddWidgetToPage();
await assertPromptTitle('Selecting Data');
await gu.dismissBehavioralPrompts();
});
it('should be shown when select by is an available option', async function() {
await driver.findContent('.test-wselect-table', /Table1/).click();
await assertPromptTitle('Linking Widgets');
await gu.dismissBehavioralPrompts();
});
it('should be shown when adding a card widget', async function() {
await gu.selectWidget('Card', /Table1/);
await assertPromptTitle('Editing Card Layout');
});
it('should not be shown when adding a non-card widget', async function() {
await gu.addNewPage('Table', /Table1/);
await assertPromptTitle(null);
});
it('should be shown when adding a card list widget', async function() {
await gu.addNewPage('Card List', /Table1/);
await assertPromptTitle('Editing Card Layout');
});
it('should be shown after adding custom view as a new page', async function() {
await gu.addNewPage('Custom', 'Table1');
await assertPromptTitle('Custom Widgets');
await gu.undo();
});
it('should be shown after adding custom section', async function() {
await gu.addNewSection('Custom', 'Table1');
await assertPromptTitle('Custom Widgets');
await gu.undo();
});
describe('for the Add New button', function() {
it('should not be shown if site is empty', async function() {
session = await gu.session().user('user4').login({showTips: true});
await gu.dismissCoachingCall();
await driver.navigate().refresh();
await gu.loadDocMenu('/');
await assertPromptTitle(null);
});
it('should be shown if site has documents', async function() {
await session.tempNewDoc(cleanup, 'BehavioralPromptsAddNew');
await session.loadDocMenu('/');
await assertPromptTitle('Add New');
});
it('should not be shown on the Trash page', async function() {
// Load /p/trash and check that tip isn't initially shown.
await session.loadDocMenu('/p/trash');
await assertPromptTitle(null);
});
it('should only be shown once each visit to the doc menu', async function() {
// Navigate to another page without reloading; the tip should now be shown.
await driver.find('.test-dm-all-docs').click();
await gu.waitForDocMenuToLoad();
await assertPromptTitle('Add New');
// Navigate to another page; the tip should no longer be shown.
await driver.findContent('.test-dm-workspace', /Home/).click();
await gu.waitForDocMenuToLoad();
await assertPromptTitle(null);
});
});
it(`should stop showing tips if "Don't show tips" is checked`, async function() {
// Log in as a new user who hasn't seen any tips yet.
session = await gu.session().user('user2').login({showTips: true});
docId = await session.tempNewDoc(cleanup, 'BehavioralPromptsDontShowTips');
await gu.loadDoc(`/doc/${docId}`);
// Check "Don't show tips" in the Reference Columns tip and dismiss it.
await gu.setType(/Reference$/);
await gu.scrollPanel(false);
await driver.findWait('.test-behavioral-prompt-dont-show-tips', 1000).click();
await gu.dismissBehavioralPrompts();
// Now visit Raw Data and check that its tip isn't shown.
await driver.find('.test-tools-raw').click();
await assertPromptTitle(null);
});
describe('when welcome tour is active', function() {
before(async () => {
const welcomeTourSession = await gu.session().user('user3').login({
isFirstLogin: false,
freshAccount: true,
showTips: true,
});
await welcomeTourSession.tempNewDoc(cleanup, 'BehavioralPromptsWelcomeTour');
});
it('should not be shown', async function() {
assert.isTrue(await driver.find('.test-onboarding-close').isDisplayed());
// The forms announcement is normally shown here.
await assertPromptTitle(null);
});
});
describe('when in a tutorial', function() {
gu.withEnvironmentSnapshot({'GRIST_UI_FEATURES': 'tutorials'});
before(async () => {
const tutorialSession = await gu.session().user('user3').login({
showTips: true,
});
const doc = await tutorialSession.tempDoc(cleanup, 'DocTutorial.grist', {load: false});
const api = tutorialSession.createHomeApi();
await api.updateDoc(doc.id, {type: 'tutorial'});
await tutorialSession.loadDoc(`/doc/${doc.id}`);
});
it('should not be shown', async function() {
// The forms announcement is normally shown here.
await assertPromptTitle(null);
await driver.find('.test-floating-popup-minimize-maximize').click();
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
await assertPromptTitle(null);
});
});
});
async function assertPromptTitle(title: string | null) {
if (title === null) {
await gu.waitToPass(async () => {
assert.equal(await driver.find('.test-behavioral-prompt').isPresent(), false);
});
} else {
await gu.waitToPass(async () => {
assert.equal(await driver.find('.test-behavioral-prompt-title').getText(), title);
});
}
}

View File

@ -898,6 +898,20 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await element('Section', 1).element('label', 4).getText(), '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.
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(), []);
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.deepEqual(await hiddenColumns(), ['Choice']);
await revert(); await revert();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']); assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
}); });
@ -981,6 +995,31 @@ describe('FormView', function() {
assert.equal(await element('column', 2).element('label').getText(), 'D'); assert.equal(await element('column', 2).element('label').getText(), 'D');
assert.equal(await element('column', 3).type(), 'Placeholder'); assert.equal(await element('column', 3).type(), 'Placeholder');
// Add a second question column.
await element('Columns').element(`Placeholder`, 1).click();
await clickMenu('Text');
await gu.waitForServer();
// Delete the column and make sure both questions get deleted.
await element('Columns').element('Field', 1).click();
await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
await gu.openWidgetPanel();
assert.deepEqual(await hiddenColumns(), ['Choice', 'D', 'E']);
// Undo and check everything reverted correctly.
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'E', 'D']);
assert.equal(await elementCount('column'), 3);
assert.equal(await element('column', 1).type(), 'Field');
assert.equal(await element('column', 1).element('label').getText(), 'E');
assert.equal(await element('column', 2).type(), 'Field');
assert.equal(await element('column', 2).element('label').getText(), 'D');
assert.equal(await element('column', 3).type(), 'Placeholder');
assert.deepEqual(await hiddenColumns(), ['Choice']);
await gu.undo();
// There was a bug with paragraph and columns. // There was a bug with paragraph and columns.
// Add a paragraph to first placeholder. // Add a paragraph to first placeholder.
await element('Columns').element(`Placeholder`, 1).click(); await element('Columns').element(`Placeholder`, 1).click();

View File

@ -17,6 +17,7 @@ describe('GridViewNewColumnMenu', function () {
session = await gu.session().login({showTips:true}); session = await gu.session().login({showTips:true});
api = session.createHomeApi(); api = session.createHomeApi();
docId = await session.tempNewDoc(cleanup, 'ColumnMenu'); docId = await session.tempNewDoc(cleanup, 'ColumnMenu');
await gu.dismissBehavioralPrompts();
// Add a table that will be used for lookups. // Add a table that will be used for lookups.
await gu.sendActions([ await gu.sendActions([
@ -77,6 +78,7 @@ describe('GridViewNewColumnMenu', function () {
it('should show rename menu after a new column click', async function () { it('should show rename menu after a new column click', async function () {
await clickAddColumn(); await clickAddColumn();
await driver.findWait('.test-new-columns-menu-add-new', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-new-columns-menu-add-new', STANDARD_WAITING_TIME).click();
await gu.waitForServer();
await driver.findWait('.test-column-title-popup', STANDARD_WAITING_TIME, 'rename menu is not present'); await driver.findWait('.test-column-title-popup', STANDARD_WAITING_TIME, 'rename menu is not present');
await closeAddColumnMenu(); await closeAddColumnMenu();
}); });
@ -204,29 +206,28 @@ describe('GridViewNewColumnMenu', function () {
await driver.findWait('.test-new-columns-menu-add-with-type-submenu', STANDARD_WAITING_TIME); await driver.findWait('.test-new-columns-menu-add-with-type-submenu', STANDARD_WAITING_TIME);
// popup should not be showed // popup should not be showed
assert.isFalse(await driver.find('.test-behavioral-prompt').isPresent()); assert.isFalse(await driver.find('.test-behavioral-prompt').isPresent());
await gu.disableTips(session.email);
await closeAddColumnMenu(); await closeAddColumnMenu();
}); });
for (const option of optionsToBeDisplayed) { for (const option of optionsToBeDisplayed) {
it(`should allow to select column type ${option.type}`, async function () { it(`should allow to select column type ${option.type}`, async function () {
// open add new colum menu // open add new colum menu
await clickAddColumn(); await clickAddColumn();
// select "Add Column With type" option // select "Add Column With type" option
await driver.findWait('.test-new-columns-menu-add-with-type', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-new-columns-menu-add-with-type', STANDARD_WAITING_TIME).click();
// wait for submenu to appear // wait for submenu to appear
await driver.findWait('.test-new-columns-menu-add-with-type-submenu', STANDARD_WAITING_TIME); await driver.findWait('.test-new-columns-menu-add-with-type-submenu', STANDARD_WAITING_TIME);
// check if it is present in the menu // check if it is present in the menu
const element = await driver.findWait( const element = await driver.findWait(
`.test-new-columns-menu-add-${option.testClass}`.toLowerCase(), `.test-new-columns-menu-add-${option.testClass}`.toLowerCase(),
100, 100,
`${option.type} option is not present`); `${option.type} option is not present`);
// click on the option and check if column is added with a proper type // click on the option and check if column is added with a proper type
await element.click(); await element.click();
await gu.waitForServer();//discard rename menu await gu.waitForServer();
//discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
//check if new column is present //check if new column is present
await gu.selectColumn('D'); await gu.selectColumn('D');
await gu.openColumnPanel(); await gu.openColumnPanel();
const type = await gu.getType(); const type = await gu.getType();
@ -255,14 +256,11 @@ describe('GridViewNewColumnMenu', function () {
`${optionsTriggeringMenu.type} option is not present`); `${optionsTriggeringMenu.type} option is not present`);
// click on the option and check if column is added with a proper type // click on the option and check if column is added with a proper type
await element.click(); await element.click();
await gu.waitForServer();
//discard rename menu //discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
await gu.waitForServer(); //check if right menu is opened on column section
//check if left menu is opened on column section
assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed()); assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed());
await gu.disableTips(session.email);
await gu.dismissBehavioralPrompts();
await gu.toggleSidePanel("right", "close"); await gu.toggleSidePanel("right", "close");
await gu.undo(1); await gu.undo(1);
}); });
@ -287,9 +285,9 @@ describe('GridViewNewColumnMenu', function () {
`${optionsTriggeringMenu.type} option is not present`); `${optionsTriggeringMenu.type} option is not present`);
// click on the option and check if column is added with a proper type // click on the option and check if column is added with a proper type
await element.click(); await element.click();
await gu.waitForServer();
//discard rename menu //discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
await gu.waitForServer();
//check if referenceColumnsConfig is present //check if referenceColumnsConfig is present
await gu.waitToPass(async ()=> assert.isTrue( await gu.waitToPass(async ()=> assert.isTrue(
await driver.findContentWait( await driver.findContentWait(
@ -299,8 +297,6 @@ describe('GridViewNewColumnMenu', function () {
).isDisplayed() ).isDisplayed()
), 5000); ), 5000);
await gu.dismissBehavioralPrompts(); await gu.dismissBehavioralPrompts();
await gu.disableTips(session.email);
await gu.toggleSidePanel("right", "close"); await gu.toggleSidePanel("right", "close");
await gu.undo(1); await gu.undo(1);
}); });
@ -328,14 +324,11 @@ describe('GridViewNewColumnMenu', function () {
`${optionsTriggeringMenu.type} option is not present`); `${optionsTriggeringMenu.type} option is not present`);
// click on the option and check if column is added with a proper type // click on the option and check if column is added with a proper type
await element.click(); await element.click();
await gu.waitForServer();
//discard rename menu //discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
await gu.waitForServer(); //check if right menu is opened on column section
//check if left menu is opened on column section
assert.isFalse(await driver.find('.test-right-tab-field').isPresent()); assert.isFalse(await driver.find('.test-right-tab-field').isPresent());
await gu.disableTips(session.email);
await gu.dismissBehavioralPrompts();
await gu.toggleSidePanel("right", "close"); await gu.toggleSidePanel("right", "close");
await gu.undo(1); await gu.undo(1);
}); });
@ -381,10 +374,10 @@ describe('GridViewNewColumnMenu', function () {
await clickAddColumn(); await clickAddColumn();
// select "create formula column" option // select "create formula column" option
await driver.findWait('.test-new-columns-menu-add-formula', STANDARD_WAITING_TIME).click(); await driver.findWait('.test-new-columns-menu-add-formula', STANDARD_WAITING_TIME).click();
// there should not be a rename poup
assert.isFalse(await driver.find('test-column-title-popup').isPresent());
//check if new column is present //check if new column is present
await gu.waitForServer(); await gu.waitForServer();
// there should not be a rename poup
assert.isFalse(await driver.find('test-column-title-popup').isPresent());
// check if editor popup is opened // check if editor popup is opened
await driver.findWait('.test-floating-editor-popup', 200, 'Editor popup is not present'); await driver.findWait('.test-floating-editor-popup', 200, 'Editor popup is not present');
// write some formula // write some formula