diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index 28086c83..3613dfb1 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -354,7 +354,7 @@ export class AccessRules extends Disposable { public buildDom() { return cssOuter( - dom('div', this.gristDoc.behavioralPromptsManager.attachTip('accessRules', { + dom('div', this.gristDoc.behavioralPromptsManager.attachPopup('accessRules', { hideArrow: true, })), cssAddTableRow( diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index 5b365597..d0799515 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -1,4 +1,4 @@ -import {showBehavioralPrompt} from 'app/client/components/modals'; +import {showNewsPopup, showTipPopup} from 'app/client/components/modals'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {AppModel} from 'app/client/models/AppModel'; import {getUserPrefObs} from 'app/client/models/UserPrefs'; @@ -7,40 +7,42 @@ import {isNarrowScreen} from 'app/client/ui2018/cssVars'; import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs'; import {getGristConfig} from 'app/common/urlUtils'; import {Computed, Disposable, dom, Observable} from 'grainjs'; -import {IPopupOptions} from 'popweasel'; +import {IPopupOptions, PopupControl} from 'popweasel'; /** - * Options for showing a tip. + * Options for showing a popup. */ -export interface ShowTipOptions { - /** Defaults to `false`. */ +export interface ShowPopupOptions { + /** Defaults to `false`. Only applies to "tip" popups. */ hideArrow?: boolean; popupOptions?: IPopupOptions; onDispose?(): void; } /** - * Options for attaching a tip to a DOM element. + * Options for attaching a popup to a DOM element. */ -export interface AttachTipOptions extends ShowTipOptions { +export interface AttachPopupOptions extends ShowPopupOptions { /** - * Optional callback that should return true if the tip should be disabled. + * Optional callback that should return true if the popup should be disabled. * - * If omitted, the tip is enabled. + * If omitted, the popup is enabled. */ isDisabled?(): boolean; } -interface QueuedTip { +interface QueuedPopup { prompt: BehavioralPrompt; refElement: Element; - options: ShowTipOptions; + options: ShowPopupOptions; } /** - * Manages tips that are shown the first time a user performs some action. + * Manages popups for product announcements and tips. * - * Tips are shown in the order that they are attached. + * Popups are shown in the order that they are attached, with at most one popup + * visible at any point in time. Popups that aren't visible are queued until all + * preceding popups have been dismissed. */ export class BehavioralPromptsManager extends Disposable { private _isDisabled: boolean = false; @@ -48,37 +50,39 @@ export class BehavioralPromptsManager extends Disposable { private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts', { defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable; - private _dismissedTips: Computed> = Computed.create(this, use => { + private _dismissedPopups: Computed> = Computed.create(this, use => { const {dismissedTips} = use(this._prefs); return new Set(dismissedTips.filter(BehavioralPrompt.guard)); }); - private _queuedTips: QueuedTip[] = []; + private _queuedPopups: QueuedPopup[] = []; + + private _activePopupCtl: PopupControl; constructor(private _appModel: AppModel) { super(); } - public showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions = {}) { - this._queueTip(refElement, prompt, options); + public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) { + this._queuePopup(refElement, prompt, options); } - public attachTip(prompt: BehavioralPrompt, options: AttachTipOptions = {}) { + public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) { return (element: Element) => { if (options.isDisabled?.()) { return; } - this._queueTip(element, prompt, options); + this._queuePopup(element, prompt, options); }; } - public hasSeenTip(prompt: BehavioralPrompt) { - return this._dismissedTips.get().has(prompt); + public hasSeenPopup(prompt: BehavioralPrompt) { + return this._dismissedPopups.get().has(prompt); } - public shouldShowTip(prompt: BehavioralPrompt): boolean { + public shouldShowPopup(prompt: BehavioralPrompt): boolean { if (this._isDisabled) { return false; } - // For non-SaaS flavors of Grist, don't show tips if the Help Center is explicitly + // For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly // disabled. A separate opt-out feature could be added down the road for more granularity, // but will require communication in advance to avoid disrupting users. const {deploymentType, features} = getGristConfig(); @@ -91,22 +95,35 @@ export class BehavioralPromptsManager extends Disposable { } const { - showContext = 'desktop', - showDeploymentTypes, + popupType, + audience = 'everyone', + deviceType = 'desktop', + deploymentTypes, forceShow = false, } = GristBehavioralPrompts[prompt]; if ( - showDeploymentTypes !== '*' && - (!deploymentType || !showDeploymentTypes.includes(deploymentType)) + (audience === 'anonymous-users' && this._appModel.currentValidUser) || + (audience === 'signed-in-users' && !this._appModel.currentValidUser) ) { return false; } - const context = isNarrowScreen() ? 'mobile' : 'desktop'; - if (showContext !== '*' && showContext !== context) { return false; } + if ( + deploymentTypes !== 'all' && + (!deploymentType || !deploymentTypes.includes(deploymentType)) + ) { + return false; + } - return forceShow || (!this._prefs.get().dontShowTips && !this.hasSeenTip(prompt)); + const currentDeviceType = isNarrowScreen() ? 'mobile' : 'desktop'; + if (deviceType !== 'all' && deviceType !== currentDeviceType) { return false; } + + return ( + forceShow || + (popupType === 'news' && !this.hasSeenPopup(prompt)) || + (!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt)) + ); } public enable() { @@ -115,6 +132,12 @@ export class BehavioralPromptsManager extends Disposable { public disable() { this._isDisabled = true; + this._removeQueuedPopups(); + this._removeActivePopup(); + } + + public isDisabled() { + return this._isDisabled; } public reset() { @@ -122,58 +145,70 @@ export class BehavioralPromptsManager extends Disposable { this.enable(); } - private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) { - if (!this.shouldShowTip(prompt)) { return; } + private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) { + if (!this.shouldShowPopup(prompt)) { return; } - this._queuedTips.push({prompt, refElement, options}); - if (this._queuedTips.length > 1) { - // If we're already showing a tip, wait for that one to be dismissed, which will + this._queuedPopups.push({prompt, refElement, options}); + if (this._queuedPopups.length > 1) { + // If we're already showing a popup, wait for that one to be dismissed, which will // cause the next one in the queue to be shown. return; } - this._showTip(refElement, prompt, options); + this._showPopup(refElement, prompt, options); } - private _showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) { + private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) { + const {hideArrow, onDispose, popupOptions} = options; + const {popupType, title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt]; + let ctl: PopupControl; + if (popupType === 'news') { + ctl = showNewsPopup(refElement, title(), content(), { + popupOptions, + }); + ctl.onDispose(() => { if (markAsSeen) { this._markAsSeen(prompt); } }); + } else if (popupType === 'tip') { + ctl = showTipPopup(refElement, title(), content(), { + onClose: (dontShowTips) => { + if (dontShowTips) { this._dontShowTips(); } + if (markAsSeen) { this._markAsSeen(prompt); } + }, + hideArrow, + popupOptions, + hideDontShowTips, + }); + } else { + throw new Error(`BehavioralPromptsManager received unknown popup type: ${popupType}`); + } + + this._activePopupCtl = ctl; + ctl.onDispose(() => { + onDispose?.(); + this._showNextQueuedPopup(); + }); const close = () => { if (!ctl.isDisposed()) { ctl.close(); } }; - - const {hideArrow, onDispose, popupOptions} = options; - const {title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt]; - const ctl = showBehavioralPrompt(refElement, title(), content(), { - onClose: (dontShowTips) => { - if (dontShowTips) { this._dontShowTips(); } - if (markAsSeen) { this._markAsSeen(prompt); } - }, - hideArrow, - popupOptions, - hideDontShowTips, - }); - - ctl.onDispose(() => { - onDispose?.(); - this._showNextQueuedTip(); - }); dom.onElem(refElement, 'click', () => close()); dom.onDisposeElem(refElement, () => close()); logTelemetryEvent('viewedTip', {full: {tipName: prompt}}); } - private _showNextQueuedTip() { - this._queuedTips.shift(); - if (this._queuedTips.length !== 0) { - const [nextTip] = this._queuedTips; - const {refElement, prompt, options} = nextTip; - this._showTip(refElement, prompt, options); + private _showNextQueuedPopup() { + this._queuedPopups.shift(); + if (this._queuedPopups.length !== 0) { + const [nextPopup] = this._queuedPopups; + const {refElement, prompt, options} = nextPopup; + this._showPopup(refElement, prompt, options); } } private _markAsSeen(prompt: BehavioralPrompt) { + if (this._isDisabled) { return; } + const {dismissedTips} = this._prefs.get(); const newDismissedTips = new Set(dismissedTips); newDismissedTips.add(prompt); @@ -181,7 +216,21 @@ export class BehavioralPromptsManager extends Disposable { } private _dontShowTips() { + if (this._isDisabled) { return; } + this._prefs.set({...this._prefs.get(), dontShowTips: true}); - this._queuedTips = []; + this._queuedPopups = this._queuedPopups.filter(({prompt}) => { + return GristBehavioralPrompts[prompt].popupType !== 'tip'; + }); + } + + private _removeActivePopup() { + if (this._activePopupCtl && !this._activePopupCtl.isDisposed()) { + this._activePopupCtl.close(); + } + } + + private _removeQueuedPopups() { + this._queuedPopups = []; } } diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index 723b7150..59fd43cb 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -1,4 +1,5 @@ import {buildEditor} from 'app/client/components/Forms/Editor'; +import {FieldModel} from 'app/client/components/Forms/Field'; import {buildMenu} from 'app/client/components/Forms/Menu'; import {BoxModel} from 'app/client/components/Forms/Model'; import * as style from 'app/client/components/Forms/styles'; @@ -86,6 +87,25 @@ export class ColumnsModel extends BoxModel { ); return buildEditor({ box: this, content }); } + + public async deleteSelf(): Promise { + // 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 { diff --git a/app/client/components/Forms/FormConfig.ts b/app/client/components/Forms/FormConfig.ts new file mode 100644 index 00000000..31c32db7 --- /dev/null +++ b/app/client/components/Forms/FormConfig.ts @@ -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 = this._field.widgetOptionsJson.prop('formRequired'); + + return [ + cssSeparator(), + cssLabel(t('Field rules')), + cssRow(labeledSquareCheckbox( + fromKoSave(requiredField), + t('Required field'), + testId('field-required'), + )), + ]; + } +} diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index d763c473..6a0b5ce5 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -644,28 +644,14 @@ export class FormView extends Disposable { dom.on('click', async (_event, element) => { try { this._copyingLink.set(true); - const share = this._pageShare.get(); - if (!share) { - throw new Error('Unable to copy link: form is not published'); - } - - const remoteShare = await this.gristDoc.docComm.getShare(share.linkId()); - if (!remoteShare) { - throw new Error('Unable to copy link: form is not published'); - } - - const url = urlState().makeUrl({ - doc: undefined, - form: { - shareKey: remoteShare.key, - vsId: this.viewSection.id(), - }, + const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({ + "text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})), }); - await copyToClipboard(url); + await copyToClipboard(data); showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); - } catch(ex) { + } catch (ex) { if (ex.code === 'AUTH_NO_OWNER') { - throw new Error('Publishing form is only available to owners'); + throw new Error('Sharing a form is only available to owners'); } } finally { this._copyingLink.set(false); @@ -693,6 +679,26 @@ export class FormView extends Disposable { ); } + private async _getFormLink() { + const share = this._pageShare.get(); + if (!share) { + throw new Error('Unable to get form link: form is not published'); + } + + const remoteShare = await this.gristDoc.docComm.getShare(share.linkId()); + if (!remoteShare) { + throw new Error('Unable to get form link: form is not published'); + } + + return urlState().makeUrl({ + doc: undefined, + form: { + shareKey: remoteShare.key, + vsId: this.viewSection.id(), + }, + }); + } + private _buildSwitcherMessage() { return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => { return style.cssSwitcherMessage( diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index adcc5899..ef9136ac 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -1,5 +1,6 @@ import * as style from './styles'; import {buildEditor} from 'app/client/components/Forms/Editor'; +import {FieldModel} from 'app/client/components/Forms/Field'; import {buildMenu} from 'app/client/components/Forms/Menu'; import {BoxModel} from 'app/client/components/Forms/Model'; import {makeTestId} from 'app/client/lib/domUtils'; @@ -72,6 +73,25 @@ export class SectionModel extends BoxModel { return place(dropped); } + + public async deleteSelf(): Promise { + // 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', ` diff --git a/app/client/components/Forms/UnmappedFieldsConfig.ts b/app/client/components/Forms/UnmappedFieldsConfig.ts index b022eb95..ec61feb5 100644 --- a/app/client/components/Forms/UnmappedFieldsConfig.ts +++ b/app/client/components/Forms/UnmappedFieldsConfig.ts @@ -150,7 +150,7 @@ export class UnmappedFieldsConfig extends Disposable { allCommands.showColumns.run([column.colId.peek()]); }), ), - squareCheckbox(props.selected), + cssSquareCheckbox(props.selected), ), ); } @@ -171,7 +171,7 @@ export class UnmappedFieldsConfig extends Disposable { allCommands.hideFields.run([column.colId.peek()]); }), ), - squareCheckbox(props.selected), + cssSquareCheckbox(props.selected), ), ); } @@ -272,3 +272,7 @@ const cssHeader = styled(cssRow, ` line-height: 1em; } `); + +const cssSquareCheckbox = styled(squareCheckbox, ` + flex-shrink: 0; +`); diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index 674e8877..c126b4de 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -147,7 +147,7 @@ export const cssRenderedLabel = styled('div', ` cursor: pointer; min-height: 16px; - color: ${colors.darkText}; + color: ${theme.mediumText}; font-size: 11px; line-height: 16px; font-weight: 700; @@ -213,6 +213,7 @@ export const cssDesc = styled('div', ` export const cssInput = styled('input', ` background-color: ${theme.inputDisabledBg}; font-size: inherit; + height: 27px; padding: 4px 8px; border: 1px solid ${theme.inputBorder}; border-radius: 3px; @@ -232,6 +233,7 @@ export const cssSelect = styled('select', ` width: 100%; background-color: ${theme.inputDisabledBg}; font-size: inherit; + height: 27px; padding: 4px 8px; border: 1px solid ${theme.inputBorder}; border-radius: 3px; diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 9bb943bf..5f6893fa 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -45,7 +45,7 @@ import {DocHistory} from 'app/client/ui/DocHistory'; import {startDocTour} from "app/client/ui/DocTour"; import {DocTutorial} from 'app/client/ui/DocTutorial'; import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; -import {isTourActive} from "app/client/ui/OnBoardingPopups"; +import {isTourActive, isTourActiveObs} from "app/client/ui/OnBoardingPopups"; import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy'; import {WebhookPage} from 'app/client/ui/WebhookPage'; @@ -299,14 +299,12 @@ export class GristDoc extends DisposableWithEvents { } })); - // Subscribe to URL state, and navigate to anchor or open a popup if necessary. this.autoDispose(subscribe(urlState().state, async (use, state) => { if (!state.hash) { return; } - try { if (state.hash.popup || state.hash.recordCard) { await this._openPopup(state.hash); @@ -343,7 +341,7 @@ export class GristDoc extends DisposableWithEvents { return; } - this.behavioralPromptsManager.showTip(cursor, 'rickRow', { + this.behavioralPromptsManager.showPopup(cursor, 'rickRow', { onDispose: () => this._playRickRollVideo(), }); }) @@ -356,9 +354,25 @@ export class GristDoc extends DisposableWithEvents { } })); - if (this.docModel.isTutorial()) { - this.behavioralPromptsManager.disable(); - } + this.autoDispose(subscribe( + urlState().state, + isTourActiveObs(), + fromKo(this.docModel.isTutorial), + (_use, state, hasActiveTour, isTutorial) => { + // Tours and tutorials can interfere with in-product tips and announcements. + const hasPendingDocTour = state.docTour || this._shouldAutoStartDocTour(); + const hasPendingWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour(); + const isPopupManagerDisabled = this.behavioralPromptsManager.isDisabled(); + if ( + (hasPendingDocTour || hasPendingWelcomeTour || hasActiveTour || isTutorial) && + !isPopupManagerDisabled + ) { + this.behavioralPromptsManager.disable(); + } else if (isPopupManagerDisabled) { + this.behavioralPromptsManager.enable(); + } + } + )); let isStartingTourOrTutorial = false; this.autoDispose(subscribe(urlState().state, async (_use, state) => { @@ -1611,7 +1625,7 @@ export class GristDoc extends DisposableWithEvents { // Don't show the tip if a non-card widget was selected. !['single', 'detail'].includes(selectedWidgetType) || // Or if we shouldn't see the tip. - !this.behavioralPromptsManager.shouldShowTip('editCardLayout') + !this.behavioralPromptsManager.shouldShowPopup('editCardLayout') ) { return; } @@ -1627,7 +1641,7 @@ export class GristDoc extends DisposableWithEvents { throw new Error('GristDoc failed to find edit card layout button'); } - this.behavioralPromptsManager.showTip(editLayoutButton, 'editCardLayout', { + this.behavioralPromptsManager.showPopup(editLayoutButton, 'editCardLayout', { popupOptions: { placement: 'left-start', } @@ -1637,7 +1651,7 @@ export class GristDoc extends DisposableWithEvents { private async _handleNewAttachedCustomWidget(widget: IAttachedCustomWidget) { switch (widget) { case 'custom.calendar': { - if (this.behavioralPromptsManager.shouldShowTip('calendarConfig')) { + if (this.behavioralPromptsManager.shouldShowPopup('calendarConfig')) { // Open the right panel to the calendar subtab. commands.allCommands.viewTabOpen.run(); diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index a38c57d6..ec6e6c00 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -46,7 +46,7 @@ export class RawDataPage extends Disposable { public buildDom() { return cssContainer( cssPage( - dom('div', this._gristDoc.behavioralPromptsManager.attachTip('rawDataPage', {hideArrow: true})), + dom('div', this._gristDoc.behavioralPromptsManager.attachPopup('rawDataPage', {hideArrow: true})), dom('div', dom.create(DataTables, this._gristDoc), dom.create(DocumentUsage, this._gristDoc.docPageModel) diff --git a/app/client/components/TypeTransform.ts b/app/client/components/TypeTransform.ts index 1e1d04b8..c47fcd86 100644 --- a/app/client/components/TypeTransform.ts +++ b/app/client/components/TypeTransform.ts @@ -18,6 +18,7 @@ import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; import {UserAction} from 'app/common/DocActions'; import {Computed, dom, fromKo, Observable} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; +import {WidgetType} from 'app/common/widgetTypes'; const t = makeT('TypeTransform'); @@ -30,6 +31,7 @@ const t = makeT('TypeTransform'); export class TypeTransform extends ColumnTransform { private _reviseTypeChange = Observable.create(this, false); private _transformWidget: Computed; + private _isFormWidget: Computed; private _convertColumn: ColumnRec; // Set in prepare() constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) { @@ -41,6 +43,8 @@ export class TypeTransform extends ColumnTransform { this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => { return use(this.origColumn.isTransforming) ? widget : null; }); + + this._isFormWidget = Computed.create(this, use => use(use(this.field.viewSection).parentKey) === WidgetType.Form); } /** @@ -52,7 +56,16 @@ export class TypeTransform extends ColumnTransform { this._reviseTypeChange.set(false); return dom('div', testId('type-transform-top'), - dom.maybe(this._transformWidget, transformWidget => transformWidget.buildTransformConfigDom()), + dom.domComputed(use => { + const transformWidget = use(this._transformWidget); + if (!transformWidget) { return null; } + + if (use(this._isFormWidget)) { + return transformWidget.buildFormTransformConfigDom(); + } else { + return transformWidget.buildTransformConfigDom(); + } + }), dom.maybe(this._reviseTypeChange, () => dom('div.transform_editor', this.buildEditorDom(), testId("type-transform-formula") diff --git a/app/client/components/modals.ts b/app/client/components/modals.ts index ad319755..25c2042f 100644 --- a/app/client/components/modals.ts +++ b/app/client/components/modals.ts @@ -139,7 +139,7 @@ export function reportUndo( } } -export interface ShowBehavioralPromptOptions { +export interface ShowTipPopupOptions { onClose: (dontShowTips: boolean) => void; /** Defaults to false. */ hideArrow?: boolean; @@ -148,11 +148,11 @@ export interface ShowBehavioralPromptOptions { popupOptions?: IPopupOptions; } -export function showBehavioralPrompt( +export function showTipPopup( refElement: Element, title: string, content: DomContents, - options: ShowBehavioralPromptOptions + options: ShowTipPopupOptions ) { const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options; const arrow = hideArrow ? null : buildArrow(); @@ -196,22 +196,7 @@ export function showBehavioralPrompt( ), ), ], - merge(popupOptions, { - modifiers: { - ...(arrow ? {arrow: {element: arrow}}: {}), - offset: { - offset: '0,12', - }, - preventOverflow: { - boundariesElement: 'window', - padding: 32, - }, - computeStyle: { - // GPU acceleration makes text look blurry. - gpuAcceleration: false, - }, - } - }) + merge({}, defaultPopupOptions, popupOptions), ); dom.onDisposeElem(refElement, () => { if (!tooltip.isDisposed()) { @@ -221,6 +206,64 @@ export function showBehavioralPrompt( return tooltip; } +export interface ShowNewsPopupOptions { + popupOptions?: IPopupOptions; +} + +export function showNewsPopup( + refElement: Element, + title: string, + content: DomContents, + options: ShowNewsPopupOptions = {} +) { + const {popupOptions} = options; + const popup = modalTooltip(refElement, + (ctl) => [ + cssNewsPopupModal.cls(''), + cssNewsPopupContainer( + testId('behavioral-prompt'), + elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); }, + dom.onKeyDown({ + Escape: () => { ctl.close(); }, + Enter: () => { ctl.close(); }, + }), + cssNewsPopupCloseButton( + icon('CrossBig'), + dom.on('click', () => ctl.close()), + testId('behavioral-prompt-dismiss'), + ), + cssNewsPopupBody( + cssNewsPopupTitle(title, testId('behavioral-prompt-title')), + content, + ), + ), + ], + merge({}, defaultPopupOptions, popupOptions), + ); + dom.onDisposeElem(refElement, () => { + if (!popup.isDisposed()) { + popup.close(); + } + }); + return popup; +} + +const defaultPopupOptions = { + modifiers: { + offset: { + offset: '0,12', + }, + preventOverflow: { + boundariesElement: 'window', + padding: 32, + }, + computeStyle: { + // GPU acceleration makes text look blurry. + gpuAcceleration: false, + }, + } +}; + function buildArrow() { return cssArrowContainer( svg('svg', @@ -365,10 +408,18 @@ const cssBehavioralPromptModal = styled('div', ` } `); +const cssNewsPopupModal = cssBehavioralPromptModal; + const cssBehavioralPromptContainer = styled(cssTheme, ` line-height: 18px; `); +const cssNewsPopupContainer = styled('div', ` + background: linear-gradient(to right, #29a3a3, #16a772); + color: white; + border-radius: 4px; +`); + const cssBehavioralPromptHeader = styled('div', ` display: flex; justify-content: center; @@ -383,6 +434,12 @@ const cssBehavioralPromptBody = styled('div', ` padding: 16px; `); +const cssNewsPopupBody = styled('div', ` + font-size: 14px; + line-height: 23px; + padding: 16px; +`); + const cssHeaderIconAndText = styled('div', ` display: flex; align-items: center; @@ -405,6 +462,27 @@ const cssBehavioralPromptTitle = styled('div', ` line-height: 32px; `); +const cssNewsPopupTitle = styled('div', ` + font-size: ${vars.xxxlargeFontSize}; + font-weight: ${vars.headerControlTextWeight}; + margin: 0 0 16px 0; + line-height: 32px; +`); + +const cssNewsPopupCloseButton = styled('div', ` + position: absolute; + top: 8px; + right: 8px; + padding: 4px; + border-radius: 4px; + cursor: pointer; + --icon-color: white; + + &:hover { + background-color: ${theme.hover}; + } +`); + const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, ` line-height: normal; `); diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 2fb0a91f..8034bd57 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -221,6 +221,11 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` }), + activeDoc.behavioralPromptsManager.attachPopup('formsAreHere', { + popupOptions: { + placement: 'right', + }, + }), testId('dp-add-new'), dom.cls('tour-add-new'), ), diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 0c61a54f..ee62e38e 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -158,7 +158,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0))); public readonly shouldShowAddNewTip = Observable.create(this, - !this._app.behavioralPromptsManager.hasSeenTip('addNew')); + !this._app.behavioralPromptsManager.hasSeenPopup('addNew')); private _userOrgPrefs = Observable.create(this, this._app.currentOrg?.userOrgPrefs); diff --git a/app/client/ui/AddNewTip.ts b/app/client/ui/AddNewTip.ts index 19871357..43ae62dd 100644 --- a/app/client/ui/AddNewTip.ts +++ b/app/client/ui/AddNewTip.ts @@ -39,7 +39,7 @@ function showAddNewTip(home: HomeModel): void { return; } - home.app.behavioralPromptsManager.showTip(addNewButton, 'addNew', { + home.app.behavioralPromptsManager.showPopup(addNewButton, 'addNew', { popupOptions: { placement: 'right-start', }, diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index b3b3879b..6709c398 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -349,7 +349,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio icon('PinTilted'), cssPinButton.cls('-pinned', model.filterInfo.isPinned), dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())), - gristDoc.behavioralPromptsManager.attachTip('filterButtons', { + gristDoc.behavioralPromptsManager.attachPopup('filterButtons', { popupOptions: { attach: null, placement: 'right', diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index dc8dd7af..b05fc6bf 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -373,7 +373,7 @@ class CustomSectionConfigurationConfig extends Disposable{ switch (widgetUrl) { // TODO: come up with a way to attach tips without hardcoding widget URLs. case 'https://gristlabs.github.io/grist-widget/calendar/index.html': { - return this._gristDoc.behavioralPromptsManager.attachTip('calendarConfig', { + return this._gristDoc.behavioralPromptsManager.attachPopup('calendarConfig', { popupOptions: {placement: 'left-start'}, }); } @@ -600,7 +600,7 @@ export class CustomSectionConfig extends Disposable { dom.attr('placeholder', t("Enter Custom URL")), testId('url') ), - this._gristDoc.behavioralPromptsManager.attachTip('customURL', { + this._gristDoc.behavioralPromptsManager.attachPopup('customURL', { popupOptions: { placement: 'left-start', }, diff --git a/app/client/ui/FilterBar.ts b/app/client/ui/FilterBar.ts index fa334c81..3459eba8 100644 --- a/app/client/ui/FilterBar.ts +++ b/app/client/ui/FilterBar.ts @@ -24,7 +24,7 @@ export function filterBar( dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)), dom.maybe(viewSection.showNestedFilteringPopup, () => { return dom('div', - gristDoc.behavioralPromptsManager.attachTip('nestedFiltering', { + gristDoc.behavioralPromptsManager.attachPopup('nestedFiltering', { onDispose: () => viewSection.showNestedFilteringPopup.set(false), }), ); diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index 33298075..1b77dd43 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -108,7 +108,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle }, menuIcon(colType.icon as IconName), colType.displayName === 'Reference'? - gridView.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', { + gridView.gristDoc.behavioralPromptsManager.attachPopup('referenceColumns', { popupOptions: { attach: `.${menuCssClass}`, placement: 'left-start', diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 0737a2b3..f1a7f18c 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -1,6 +1,7 @@ import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; +import {basicButtonLink} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; @@ -28,6 +29,17 @@ const cssIcon = styled(icon, ` width: 18px; `); +const cssNewsPopupLearnMoreButton = styled(basicButtonLink, ` + color: white; + border: 1px solid white; + padding: 3px; + + &:hover, &:focus, &:visited { + color: white; + border-color: white; + } +`); + export type Tooltip = | 'dataSize' | 'setTriggerFormula' @@ -126,11 +138,14 @@ see or edit which parts of your document.') }; export interface BehavioralPromptContent { + popupType: 'tip' | 'news'; title: () => string; content: (...domArgs: DomElementArg[]) => DomContents; - showDeploymentTypes: GristDeploymentType[] | '*'; + deploymentTypes: GristDeploymentType[] | 'all'; + /** Defaults to `everyone`. */ + audience?: 'signed-in-users' | 'anonymous-users' | 'everyone'; /** Defaults to `desktop`. */ - showContext?: 'mobile' | 'desktop' | '*'; + deviceType?: 'mobile' | 'desktop' | 'all'; /** Defaults to `false`. */ hideDontShowTips?: boolean; /** Defaults to `false`. */ @@ -141,6 +156,7 @@ export interface BehavioralPromptContent { export const GristBehavioralPrompts: Record = { referenceColumns: { + popupType: 'tip', title: () => t('Reference Columns'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Reference columns are the key to {{relational}} data in Grist.', { @@ -152,9 +168,10 @@ export const GristBehavioralPrompts: Record t('Reference Columns'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Select the table to link to.')), @@ -167,9 +184,10 @@ record in that table, but you may select which column from that record to show.' ), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, rawDataPage: { + popupType: 'tip', title: () => t('Raw Data page'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('The Raw Data page lists all data tables in your document, \ @@ -177,9 +195,10 @@ including summary tables and tables not included in page layouts.')), dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, accessRules: { + popupType: 'tip', title: () => t('Access Rules'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Access rules give you the power to create nuanced rules \ @@ -187,9 +206,10 @@ to determine who can see or edit which parts of your document.')), dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, filterButtons: { + popupType: 'tip', title: () => t('Pinning Filters'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Pinned filters are displayed as buttons above the widget.')), @@ -197,27 +217,30 @@ to determine who can see or edit which parts of your document.')), dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, nestedFiltering: { + popupType: 'tip', title: () => t('Nested Filtering'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('You can filter by more than one column.')), dom('div', t('Only those rows will appear which match all of the filters.')), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, pageWidgetPicker: { + popupType: 'tip', title: () => t('Selecting Data'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Select the table containing the data to show.')), dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, pageWidgetPickerSelectBy: { + popupType: 'tip', title: () => t('Linking Widgets'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Link your new widget to an existing widget on this page.')), @@ -225,9 +248,10 @@ to determine who can see or edit which parts of your document.')), dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, editCardLayout: { + popupType: 'tip', title: () => t('Editing Card Layout'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Rearrange the fields in your card by dragging and resizing cells.')), @@ -236,17 +260,19 @@ to determine who can see or edit which parts of your document.')), })), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, addNew: { + popupType: 'tip', title: () => t('Add New'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, rickRow: { + popupType: 'tip', title: () => t('Anchor Links'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', @@ -258,13 +284,14 @@ to determine who can see or edit which parts of your document.')), ), ...args, ), - showDeploymentTypes: '*', - showContext: '*', + deploymentTypes: 'all', + deviceType: 'all', hideDontShowTips: true, forceShow: true, markAsSeen: false, }, customURL: { + popupType: 'tip', title: () => t('Custom Widgets'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', @@ -275,9 +302,10 @@ to determine who can see or edit which parts of your document.')), dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], }, calendarConfig: { + popupType: 'tip', title: () => t('Calendar'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t("To configure your calendar, select columns for start/end dates and event titles. \ @@ -287,6 +315,21 @@ data.")), dom('div', cssLink({href: commonUrls.helpCalendarWidget, target: '_blank'}, t('Learn more.'))), ...args, ), - showDeploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + deploymentTypes: ['saas', 'core', 'enterprise', 'electron'], + }, + formsAreHere: { + popupType: 'news', + audience: 'signed-in-users', + title: () => t('Forms are here!'), + content: (...args: DomElementArg[]) => cssTooltipContent( + dom('div', t('Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}', { + learnMoreButton: cssNewsPopupLearnMoreButton(t('Learn more'), { + href: commonUrls.forms, + target: '_blank', + }), + })), + ...args, + ), + deploymentTypes: ['saas', 'core', 'enterprise'], }, }; diff --git a/app/client/ui/OnBoardingPopups.ts b/app/client/ui/OnBoardingPopups.ts index b00e1ac0..733a976d 100644 --- a/app/client/ui/OnBoardingPopups.ts +++ b/app/client/ui/OnBoardingPopups.ts @@ -22,7 +22,7 @@ * the caller. Pass an `onFinishCB` to handle when a user dimiss the popups. */ -import { Disposable, dom, DomElementArg, Holder, makeTestId, styled, svg } from "grainjs"; +import { Disposable, dom, DomElementArg, Holder, makeTestId, Observable, styled, svg } from "grainjs"; import { createPopper, Placement } from '@popperjs/core'; import { FocusLayer } from 'app/client/lib/FocusLayer'; import {makeT} from 'app/client/lib/localization'; @@ -74,18 +74,34 @@ export interface IOnBoardingMsg { urlState?: IGristUrlState; } +let _isTourActiveObs: Observable|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 { + if (!_isTourActiveObs) { + const obs = Observable.create(null, false); + _isTourActiveObs = obs; + } + return _isTourActiveObs; +} + // There should only be one tour at a time. Use a holder to dispose the previous tour when // starting a new one. const tourSingleton = Holder.create(null); export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) { const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB); + ctl.onDispose(() => isTourActiveObs().set(false)); ctl.start().catch(reportError); + isTourActiveObs().set(true); } // Returns whether some tour is currently active. export function isTourActive(): boolean { - return !tourSingleton.isEmpty(); + return isTourActiveObs().get(); } class OnBoardingError extends Error { diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 954d5f6d..9a6678e8 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -337,7 +337,7 @@ export class PageWidgetSelect extends Disposable { cssIcon('TypeTable'), 'New Table', // prevent the selection of 'New Table' if it is disabled dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')), - this._behavioralPromptsManager.attachTip('pageWidgetPicker', { + this._behavioralPromptsManager.attachPopup('pageWidgetPicker', { popupOptions: { attach: null, placement: 'right-start', @@ -395,7 +395,7 @@ export class PageWidgetSelect extends Disposable { ), 'selectBy', {popupOptions: {attach: null}, domArgs: [ - this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', { + this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', { popupOptions: { attach: null, placement: 'bottom', diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 961f28cd..7f41d7f0 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -1013,16 +1013,7 @@ export class RightPanel extends Disposable { return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( // Field config. - dom.maybeOwned(selectedField, (scope, field) => { - const requiredField = field.widgetOptionsJson.prop('formRequired'); - // V2 thing. - // const hiddenField = field.widgetOptionsJson.prop('formHidden'); - const defaultField = field.widgetOptionsJson.prop('formDefault'); - const toComputed = (obs: typeof defaultField) => { - const result = Computed.create(scope, (use) => use(obs)); - result.onWrite(val => obs.setAndSave(val)); - return result; - }; + dom.maybe(selectedField, (field) => { const fieldTitle = field.widgetOptionsJson.prop('question'); return [ @@ -1063,21 +1054,10 @@ export class RightPanel extends Disposable { // cssSection( // builder.buildSelectWidgetDom(), // ), - dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [ - cssSection( - builder.buildConfigDom(), - ), - ]), + cssSection( + builder.buildFormConfigDom(), + ), ]), - cssSeparator(), - cssLabel(t("Field rules")), - cssRow(labeledSquareCheckbox( - toComputed(requiredField), - t("Required field"), - testId('field-required'), - )), - // V2 thing - // cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),), ]; }), diff --git a/app/client/ui/WelcomeCoachingCall.ts b/app/client/ui/WelcomeCoachingCall.ts index b784297c..2aa91818 100644 --- a/app/client/ui/WelcomeCoachingCall.ts +++ b/app/client/ui/WelcomeCoachingCall.ts @@ -1,3 +1,4 @@ +import {makeT} from 'app/client/lib/localization'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {AppModel} from 'app/client/models/AppModel'; import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; @@ -7,7 +8,6 @@ import {cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton, import {icon} from 'app/client/ui2018/icons'; import {getGristConfig} from 'app/common/urlUtils'; import {dom, styled} from 'grainjs'; -import { makeT } from '../lib/localization'; const t = makeT('WelcomeCoachingCall'); @@ -17,7 +17,7 @@ export function shouldShowWelcomeCoachingCall(appModel: AppModel) { // Defer showing coaching call until Add New tip is dismissed. const {behavioralPromptsManager, dismissedWelcomePopups} = appModel; - if (behavioralPromptsManager.shouldShowTip('addNew')) { return false; } + if (behavioralPromptsManager.shouldShowPopup('addNew')) { return false; } const popup = dismissedWelcomePopups.get().find(p => p.id === 'coachingCall'); return ( diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index 94a2ff20..a9c9d146 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -7,9 +7,9 @@ import {pagePanels} from 'app/client/ui/PagePanels'; import {setUpPage} from 'app/client/ui/setUpPage'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, theme, vars} from 'app/client/ui2018/cssVars'; +import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {getPageTitleSuffix} from 'app/common/gristUrls'; +import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs'; @@ -128,14 +128,14 @@ export function createFormNotFoundPage(message?: string) { cssFormErrorFooter( cssFormPoweredByGrist( cssFormPoweredByGristLink( - {href: 'https://www.getgrist.com', target: '_blank'}, + {href: commonUrls.forms, target: '_blank'}, t('Powered by'), cssGristLogo(), ) ), cssFormBuildForm( cssFormBuildFormLink( - {href: 'https://www.getgrist.com', target: '_blank'}, + {href: commonUrls.forms, target: '_blank'}, t('Build your own form'), icon('Expand'), ), @@ -227,10 +227,17 @@ const cssButtonWrap = styled('div', ` `); const cssFormErrorPage = styled('div', ` - --grist-form-padding: 48px; - min-height: 100%; + background-color: ${colors.lightGrey}; + height: 100%; width: 100%; - padding-top: 52px; + padding: 52px 0px 52px 0px; + overflow: auto; + + @media ${mediaSmall} { + & { + padding: 20px 0px 20px 0px; + } + } `); const cssFormErrorContainer = styled('div', ` @@ -243,6 +250,7 @@ const cssFormError = styled('div', ` text-align: center; flex-direction: column; align-items: center; + background-color: white; border: 1px solid ${colors.darkGrey}; border-radius: 3px; max-width: 600px; @@ -254,8 +262,10 @@ const cssFormErrorBody = styled('div', ` `); const cssFormErrorImage = styled('img', ` - width: 250px; - height: 281px; + width: 100%; + height: 100%; + max-width: 250px; + max-height: 281px; `); const cssFormErrorText = styled('div', ` @@ -282,8 +292,6 @@ const cssFormPoweredByGrist = styled('div', ` align-items: center; justify-content: center; padding: 0px 10px; - margin-left: calc(-1 * var(--grist-form-padding)); - margin-right: calc(-1 * var(--grist-form-padding)); `); const cssFormPoweredByGristLink = styled('a', ` diff --git a/app/client/widgets/AbstractWidget.js b/app/client/widgets/AbstractWidget.js index 5a529334..b7a1c23b 100644 --- a/app/client/widgets/AbstractWidget.js +++ b/app/client/widgets/AbstractWidget.js @@ -45,4 +45,12 @@ AbstractWidget.prototype.buildColorConfigDom = function(gristDoc) { return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor); }; +AbstractWidget.prototype.buildFormConfigDom = function() { + return null; +}; + +AbstractWidget.prototype.buildFormTransformConfigDom = function() { + return null; +}; + module.exports = AbstractWidget; diff --git a/app/client/widgets/AttachmentsWidget.ts b/app/client/widgets/AttachmentsWidget.ts index 153195f1..b013f772 100644 --- a/app/client/widgets/AttachmentsWidget.ts +++ b/app/client/widgets/AttachmentsWidget.ts @@ -13,7 +13,7 @@ import { SingleCell } from 'app/common/TableData'; import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {UploadResult} from 'app/common/uploads'; import { GristObjCode } from 'app/plugin/GristData'; -import {Computed, dom, fromKo, input, onElem, styled} from 'grainjs'; +import {Computed, dom, DomContents, fromKo, input, onElem, styled} from 'grainjs'; import {extname} from 'path'; @@ -69,7 +69,7 @@ export class AttachmentsWidget extends NewAbstractWidget { ); } - public buildConfigDom(): Element { + public buildConfigDom(): DomContents { const options = this.field.config.options; const height = options.prop('height'); const inputRange = input( diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index 75327d02..e41753e1 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -9,8 +9,7 @@ import {icon} from 'app/client/ui2018/icons'; import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken'; import {NTextBox} from 'app/client/widgets/NTextBox'; -import {WidgetType} from 'app/common/widgetTypes'; -import {Computed, dom, styled, UseCB} from 'grainjs'; +import {Computed, dom, styled} from 'grainjs'; export type IChoiceOptions = Style export type ChoiceOptions = Record; @@ -75,37 +74,9 @@ export class ChoiceTextBox extends NTextBox { } public buildConfigDom() { - const disabled = Computed.create(null, - use => use(this.field.disableModify) - || use(use(this.field.column).disableEditData) - || use(this.field.config.options.disabled('choices')) - ); - - const mixed = Computed.create(null, - use => !use(disabled) - && (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions'))) - ); - - // If we are on forms, we don't want to show alignment options. - const notForm = (use: UseCB) => { - return use(use(this.field.viewSection).parentKey) !== WidgetType.Form; - }; - return [ - dom.maybe(notForm, () => super.buildConfigDom()), - cssLabel(t('CHOICES')), - cssRow( - dom.autoDispose(disabled), - dom.autoDispose(mixed), - dom.create( - ChoiceListEntry, - this._choiceValues, - this._choiceOptionsByName, - this.save.bind(this), - disabled, - mixed - ) - ) + super.buildConfigDom(), + this._buildChoicesConfigDom(), ]; } @@ -113,6 +84,19 @@ export class ChoiceTextBox extends NTextBox { return this.buildConfigDom(); } + public buildFormConfigDom() { + return [ + this._buildChoicesConfigDom(), + super.buildFormConfigDom(), + ]; + } + + public buildFormTransformConfigDom() { + return [ + this._buildChoicesConfigDom(), + ]; + } + protected getChoiceValuesSet(): Computed> { return this._choiceValuesSet; } @@ -128,6 +112,35 @@ export class ChoiceTextBox extends NTextBox { }; return this.field.config.updateChoices(renames, options); } + + private _buildChoicesConfigDom() { + const disabled = Computed.create(null, + use => use(this.field.disableModify) + || use(use(this.field.column).disableEditData) + || use(this.field.config.options.disabled('choices')) + ); + + const mixed = Computed.create(null, + use => !use(disabled) + && (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions'))) + ); + + return [ + cssLabel(t('CHOICES')), + cssRow( + dom.autoDispose(disabled), + dom.autoDispose(mixed), + dom.create( + ChoiceListEntry, + this._choiceValues, + this._choiceOptionsByName, + this.save.bind(this), + disabled, + mixed + ) + ) + ]; + } } // Converts a POJO containing choice options to an ES6 Map diff --git a/app/client/widgets/DateTextBox.js b/app/client/widgets/DateTextBox.js index 388bfae8..a378a0f4 100644 --- a/app/client/widgets/DateTextBox.js +++ b/app/client/widgets/DateTextBox.js @@ -6,11 +6,12 @@ var kd = require('../lib/koDom'); var kf = require('../lib/koForm'); var AbstractWidget = require('./AbstractWidget'); +const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig'); const {fromKoSave} = require('app/client/lib/fromKoSave'); const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect'); -const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles'); +const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles'); const {cssTextInput} = require("app/client/ui2018/editableLabel"); -const {styled, fromKo} = require('grainjs'); +const {dom: gdom, styled, fromKo} = require('grainjs'); const {select} = require('app/client/ui2018/menus'); const {dateFormatOptions} = require('app/common/parseDate'); @@ -79,6 +80,12 @@ DateTextBox.prototype.buildTransformConfigDom = function() { return this.buildDateConfigDom(); }; +DateTextBox.prototype.buildFormConfigDom = function() { + return [ + gdom.create(FieldRulesConfig, this.field), + ]; +}; + DateTextBox.prototype.buildDom = function(row) { let value = row[this.field.colId()]; return dom('div.field_clip', diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index ec9496ca..1ca94636 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -305,7 +305,7 @@ export class FieldBuilder extends Disposable { } if (op.label === 'Reference') { - return this.gristDoc.behavioralPromptsManager.attachTip('referenceColumns', { + return this.gristDoc.behavioralPromptsManager.attachPopup('referenceColumns', { popupOptions: { attach: `.${cssTypeSelectMenu.className}`, placement: 'left-start', @@ -412,7 +412,7 @@ export class FieldBuilder extends Disposable { return [ cssLabel(t('DATA FROM TABLE'), kd.maybe(this._showRefConfigPopup, () => { - return dom('div', this.gristDoc.behavioralPromptsManager.attachTip( + return dom('div', this.gristDoc.behavioralPromptsManager.attachPopup( 'referenceColumnsConfig', { onDispose: () => this._showRefConfigPopup(false), @@ -501,6 +501,14 @@ export class FieldBuilder extends Disposable { ); } + public buildFormConfigDom() { + return dom('div', + kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) => + dom('div', widget.buildFormConfigDom()) + ) + ); + } + /** * Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl. */ diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index 12f7a780..c756ef0b 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -1,3 +1,4 @@ +import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; import { fromKoSave } from 'app/client/lib/fromKoSave'; import { DataRowModel } from 'app/client/models/DataRowModel'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; @@ -51,10 +52,16 @@ export class NTextBox extends NewAbstractWidget { [{value: true, icon: 'Wrap'}], toggle, cssButtonSelect.cls('-disabled', wrapDisabled), - ), - testId('tb-wrap-text') - ) - ) + ), + testId('tb-wrap-text'), + ), + ), + ]; + } + + public buildFormConfigDom(): DomContents { + return [ + dom.create(FieldRulesConfig, this.field), ]; } diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index 85a8c3fe..86812e20 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -76,6 +76,14 @@ export abstract class NewAbstractWidget extends Disposable { return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor); } + public buildFormConfigDom(): DomContents { + return null; + } + + public buildFormTransformConfigDom(): DomContents { + return null; + } + /** * Builds the data cell DOM. * @param {DataRowModel} row - The rowModel object. diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index 35572103..38529bf8 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -9,9 +9,8 @@ import {icon} from 'app/client/ui2018/icons'; import {IOptionFull, select} from 'app/client/ui2018/menus'; import {NTextBox} from 'app/client/widgets/NTextBox'; import {isFullReferencingType, isVersions} from 'app/common/gristTypes'; -import {WidgetType} from 'app/common/widgetTypes'; import {UIRowId} from 'app/plugin/GristAPI'; -import {Computed, dom, styled, UseCB} from 'grainjs'; +import {Computed, dom, styled} from 'grainjs'; const t = makeT('Reference'); @@ -49,16 +48,10 @@ export class Reference extends NTextBox { } public buildConfigDom() { - // If we are on forms, we don't want to show alignment options. - const notForm = (use: UseCB) => { - return use(use(this.field.viewSection).parentKey) !== WidgetType.Form; - }; return [ this.buildTransformConfigDom(), - dom.maybe(notForm, () => [ - cssLabel(t('CELL FORMAT')), - super.buildConfigDom() - ]) + cssLabel(t('CELL FORMAT')), + super.buildConfigDom(), ]; } @@ -76,6 +69,17 @@ export class Reference extends NTextBox { ]; } + public buildFormConfigDom() { + return [ + this.buildTransformConfigDom(), + super.buildFormConfigDom(), + ]; + } + + public buildFormTransformConfigDom() { + return this.buildTransformConfigDom(); + } + public buildDom(row: DataRowModel) { // Note: we require 2 observables here because changes to the cell value (reference id) // and the display value (display column) are not bundled. This can cause `formattedValue` diff --git a/app/client/widgets/Toggle.ts b/app/client/widgets/Toggle.ts index 164f8324..5a6f440d 100644 --- a/app/client/widgets/Toggle.ts +++ b/app/client/widgets/Toggle.ts @@ -1,15 +1,22 @@ import * as commands from 'app/client/components/commands'; +import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; import { DataRowModel } from 'app/client/models/DataRowModel'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; import { KoSaveableObservable } from 'app/client/models/modelUtil'; import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; import { theme } from 'app/client/ui2018/cssVars'; -import { dom } from 'grainjs'; +import { dom, DomContents } from 'grainjs'; /** * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch. */ abstract class ToggleBase extends NewAbstractWidget { + public buildFormConfigDom(): DomContents { + return [ + dom.create(FieldRulesConfig, this.field), + ]; + } + protected _addClickEventHandlers(row: DataRowModel) { return [ dom.on('click', (event) => { diff --git a/app/common/Forms.ts b/app/common/Forms.ts index bb453455..39824cb3 100644 --- a/app/common/Forms.ts +++ b/app/common/Forms.ts @@ -286,7 +286,7 @@ class Bool extends BaseQuestion { const label = field.question ? field.question : field.colId; return `