mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -1,4 +1,4 @@
|
||||
import {showBehavioralPrompt} from 'app/client/components/modals';
|
||||
import {showNewsPopup, showTipPopup} from 'app/client/components/modals';
|
||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
||||
@@ -7,40 +7,42 @@ import {isNarrowScreen} from 'app/client/ui2018/cssVars';
|
||||
import {BehavioralPrompt, BehavioralPromptPrefs} from 'app/common/Prefs';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, Disposable, dom, Observable} from 'grainjs';
|
||||
import {IPopupOptions} from 'popweasel';
|
||||
import {IPopupOptions, PopupControl} from 'popweasel';
|
||||
|
||||
/**
|
||||
* Options for showing a tip.
|
||||
* Options for showing a popup.
|
||||
*/
|
||||
export interface ShowTipOptions {
|
||||
/** Defaults to `false`. */
|
||||
export interface ShowPopupOptions {
|
||||
/** Defaults to `false`. Only applies to "tip" popups. */
|
||||
hideArrow?: boolean;
|
||||
popupOptions?: IPopupOptions;
|
||||
onDispose?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for attaching a tip to a DOM element.
|
||||
* Options for attaching a popup to a DOM element.
|
||||
*/
|
||||
export interface AttachTipOptions extends ShowTipOptions {
|
||||
export interface AttachPopupOptions extends ShowPopupOptions {
|
||||
/**
|
||||
* Optional callback that should return true if the tip should be disabled.
|
||||
* Optional callback that should return true if the popup should be disabled.
|
||||
*
|
||||
* If omitted, the tip is enabled.
|
||||
* If omitted, the popup is enabled.
|
||||
*/
|
||||
isDisabled?(): boolean;
|
||||
}
|
||||
|
||||
interface QueuedTip {
|
||||
interface QueuedPopup {
|
||||
prompt: BehavioralPrompt;
|
||||
refElement: Element;
|
||||
options: ShowTipOptions;
|
||||
options: ShowPopupOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages tips that are shown the first time a user performs some action.
|
||||
* Manages popups for product announcements and tips.
|
||||
*
|
||||
* Tips are shown in the order that they are attached.
|
||||
* Popups are shown in the order that they are attached, with at most one popup
|
||||
* visible at any point in time. Popups that aren't visible are queued until all
|
||||
* preceding popups have been dismissed.
|
||||
*/
|
||||
export class BehavioralPromptsManager extends Disposable {
|
||||
private _isDisabled: boolean = false;
|
||||
@@ -48,37 +50,39 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, 'behavioralPrompts',
|
||||
{ defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;
|
||||
|
||||
private _dismissedTips: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
|
||||
private _dismissedPopups: Computed<Set<BehavioralPrompt>> = Computed.create(this, use => {
|
||||
const {dismissedTips} = use(this._prefs);
|
||||
return new Set(dismissedTips.filter(BehavioralPrompt.guard));
|
||||
});
|
||||
|
||||
private _queuedTips: QueuedTip[] = [];
|
||||
private _queuedPopups: QueuedPopup[] = [];
|
||||
|
||||
private _activePopupCtl: PopupControl<IPopupOptions>;
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
}
|
||||
|
||||
public showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions = {}) {
|
||||
this._queueTip(refElement, prompt, options);
|
||||
public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) {
|
||||
this._queuePopup(refElement, prompt, options);
|
||||
}
|
||||
|
||||
public attachTip(prompt: BehavioralPrompt, options: AttachTipOptions = {}) {
|
||||
public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) {
|
||||
return (element: Element) => {
|
||||
if (options.isDisabled?.()) { return; }
|
||||
|
||||
this._queueTip(element, prompt, options);
|
||||
this._queuePopup(element, prompt, options);
|
||||
};
|
||||
}
|
||||
|
||||
public hasSeenTip(prompt: BehavioralPrompt) {
|
||||
return this._dismissedTips.get().has(prompt);
|
||||
public hasSeenPopup(prompt: BehavioralPrompt) {
|
||||
return this._dismissedPopups.get().has(prompt);
|
||||
}
|
||||
|
||||
public shouldShowTip(prompt: BehavioralPrompt): boolean {
|
||||
public shouldShowPopup(prompt: BehavioralPrompt): boolean {
|
||||
if (this._isDisabled) { return false; }
|
||||
|
||||
// For non-SaaS flavors of Grist, don't show tips if the Help Center is explicitly
|
||||
// For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly
|
||||
// disabled. A separate opt-out feature could be added down the road for more granularity,
|
||||
// but will require communication in advance to avoid disrupting users.
|
||||
const {deploymentType, features} = getGristConfig();
|
||||
@@ -91,22 +95,35 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
}
|
||||
|
||||
const {
|
||||
showContext = 'desktop',
|
||||
showDeploymentTypes,
|
||||
popupType,
|
||||
audience = 'everyone',
|
||||
deviceType = 'desktop',
|
||||
deploymentTypes,
|
||||
forceShow = false,
|
||||
} = GristBehavioralPrompts[prompt];
|
||||
|
||||
if (
|
||||
showDeploymentTypes !== '*' &&
|
||||
(!deploymentType || !showDeploymentTypes.includes(deploymentType))
|
||||
(audience === 'anonymous-users' && this._appModel.currentValidUser) ||
|
||||
(audience === 'signed-in-users' && !this._appModel.currentValidUser)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const context = isNarrowScreen() ? 'mobile' : 'desktop';
|
||||
if (showContext !== '*' && showContext !== context) { return false; }
|
||||
if (
|
||||
deploymentTypes !== 'all' &&
|
||||
(!deploymentType || !deploymentTypes.includes(deploymentType))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return forceShow || (!this._prefs.get().dontShowTips && !this.hasSeenTip(prompt));
|
||||
const currentDeviceType = isNarrowScreen() ? 'mobile' : 'desktop';
|
||||
if (deviceType !== 'all' && deviceType !== currentDeviceType) { return false; }
|
||||
|
||||
return (
|
||||
forceShow ||
|
||||
(popupType === 'news' && !this.hasSeenPopup(prompt)) ||
|
||||
(!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt))
|
||||
);
|
||||
}
|
||||
|
||||
public enable() {
|
||||
@@ -115,6 +132,12 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
|
||||
public disable() {
|
||||
this._isDisabled = true;
|
||||
this._removeQueuedPopups();
|
||||
this._removeActivePopup();
|
||||
}
|
||||
|
||||
public isDisabled() {
|
||||
return this._isDisabled;
|
||||
}
|
||||
|
||||
public reset() {
|
||||
@@ -122,58 +145,70 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
this.enable();
|
||||
}
|
||||
|
||||
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) {
|
||||
if (!this.shouldShowTip(prompt)) { return; }
|
||||
private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
|
||||
if (!this.shouldShowPopup(prompt)) { return; }
|
||||
|
||||
this._queuedTips.push({prompt, refElement, options});
|
||||
if (this._queuedTips.length > 1) {
|
||||
// If we're already showing a tip, wait for that one to be dismissed, which will
|
||||
this._queuedPopups.push({prompt, refElement, options});
|
||||
if (this._queuedPopups.length > 1) {
|
||||
// If we're already showing a popup, wait for that one to be dismissed, which will
|
||||
// cause the next one in the queue to be shown.
|
||||
return;
|
||||
}
|
||||
|
||||
this._showTip(refElement, prompt, options);
|
||||
this._showPopup(refElement, prompt, options);
|
||||
}
|
||||
|
||||
private _showTip(refElement: Element, prompt: BehavioralPrompt, options: ShowTipOptions) {
|
||||
private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {
|
||||
const {hideArrow, onDispose, popupOptions} = options;
|
||||
const {popupType, title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt];
|
||||
let ctl: PopupControl<IPopupOptions>;
|
||||
if (popupType === 'news') {
|
||||
ctl = showNewsPopup(refElement, title(), content(), {
|
||||
popupOptions,
|
||||
});
|
||||
ctl.onDispose(() => { if (markAsSeen) { this._markAsSeen(prompt); } });
|
||||
} else if (popupType === 'tip') {
|
||||
ctl = showTipPopup(refElement, title(), content(), {
|
||||
onClose: (dontShowTips) => {
|
||||
if (dontShowTips) { this._dontShowTips(); }
|
||||
if (markAsSeen) { this._markAsSeen(prompt); }
|
||||
},
|
||||
hideArrow,
|
||||
popupOptions,
|
||||
hideDontShowTips,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`BehavioralPromptsManager received unknown popup type: ${popupType}`);
|
||||
}
|
||||
|
||||
this._activePopupCtl = ctl;
|
||||
ctl.onDispose(() => {
|
||||
onDispose?.();
|
||||
this._showNextQueuedPopup();
|
||||
});
|
||||
const close = () => {
|
||||
if (!ctl.isDisposed()) {
|
||||
ctl.close();
|
||||
}
|
||||
};
|
||||
|
||||
const {hideArrow, onDispose, popupOptions} = options;
|
||||
const {title, content, hideDontShowTips = false, markAsSeen = true} = GristBehavioralPrompts[prompt];
|
||||
const ctl = showBehavioralPrompt(refElement, title(), content(), {
|
||||
onClose: (dontShowTips) => {
|
||||
if (dontShowTips) { this._dontShowTips(); }
|
||||
if (markAsSeen) { this._markAsSeen(prompt); }
|
||||
},
|
||||
hideArrow,
|
||||
popupOptions,
|
||||
hideDontShowTips,
|
||||
});
|
||||
|
||||
ctl.onDispose(() => {
|
||||
onDispose?.();
|
||||
this._showNextQueuedTip();
|
||||
});
|
||||
dom.onElem(refElement, 'click', () => close());
|
||||
dom.onDisposeElem(refElement, () => close());
|
||||
|
||||
logTelemetryEvent('viewedTip', {full: {tipName: prompt}});
|
||||
}
|
||||
|
||||
private _showNextQueuedTip() {
|
||||
this._queuedTips.shift();
|
||||
if (this._queuedTips.length !== 0) {
|
||||
const [nextTip] = this._queuedTips;
|
||||
const {refElement, prompt, options} = nextTip;
|
||||
this._showTip(refElement, prompt, options);
|
||||
private _showNextQueuedPopup() {
|
||||
this._queuedPopups.shift();
|
||||
if (this._queuedPopups.length !== 0) {
|
||||
const [nextPopup] = this._queuedPopups;
|
||||
const {refElement, prompt, options} = nextPopup;
|
||||
this._showPopup(refElement, prompt, options);
|
||||
}
|
||||
}
|
||||
|
||||
private _markAsSeen(prompt: BehavioralPrompt) {
|
||||
if (this._isDisabled) { return; }
|
||||
|
||||
const {dismissedTips} = this._prefs.get();
|
||||
const newDismissedTips = new Set(dismissedTips);
|
||||
newDismissedTips.add(prompt);
|
||||
@@ -181,7 +216,21 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
}
|
||||
|
||||
private _dontShowTips() {
|
||||
if (this._isDisabled) { return; }
|
||||
|
||||
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
||||
this._queuedTips = [];
|
||||
this._queuedPopups = this._queuedPopups.filter(({prompt}) => {
|
||||
return GristBehavioralPrompts[prompt].popupType !== 'tip';
|
||||
});
|
||||
}
|
||||
|
||||
private _removeActivePopup() {
|
||||
if (this._activePopupCtl && !this._activePopupCtl.isDisposed()) {
|
||||
this._activePopupCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _removeQueuedPopups() {
|
||||
this._queuedPopups = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
@@ -86,6 +87,25 @@ export class ColumnsModel extends BoxModel {
|
||||
);
|
||||
return buildEditor({ box: this, content });
|
||||
}
|
||||
|
||||
public async deleteSelf(): Promise<void> {
|
||||
// Prepare all the fields that are children of this column for removal.
|
||||
const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
|
||||
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
|
||||
|
||||
// Remove each child of this column from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this column from the layout.
|
||||
this.removeSelf();
|
||||
|
||||
// Finally, remove the fields and save the changes to the layout.
|
||||
await this.parent?.save(async () => {
|
||||
if (fieldIdsToRemove.length > 0) {
|
||||
await this.view.viewSection.removeField(fieldIdsToRemove);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PlaceholderModel extends BoxModel {
|
||||
|
||||
30
app/client/components/Forms/FormConfig.ts
Normal file
30
app/client/components/Forms/FormConfig.ts
Normal 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'),
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as style from './styles';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
@@ -72,6 +73,25 @@ export class SectionModel extends BoxModel {
|
||||
|
||||
return place(dropped);
|
||||
}
|
||||
|
||||
public async deleteSelf(): Promise<void> {
|
||||
// Prepare all the fields that are children of this section for removal.
|
||||
const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];
|
||||
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
|
||||
|
||||
// Remove each child of this section from the layout.
|
||||
this.children.get().forEach(child => { child.removeSelf(); });
|
||||
|
||||
// Remove this section from the layout.
|
||||
this.removeSelf();
|
||||
|
||||
// Finally, remove the fields and save the changes to the layout.
|
||||
await this.parent?.save(async () => {
|
||||
if (fieldIdsToRemove.length > 0) {
|
||||
await this.view.viewSection.removeField(fieldIdsToRemove);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cssSectionItems = styled('div.hover_border', `
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
|
||||
import {UserAction} from 'app/common/DocActions';
|
||||
import {Computed, dom, fromKo, Observable} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {WidgetType} from 'app/common/widgetTypes';
|
||||
|
||||
const t = makeT('TypeTransform');
|
||||
|
||||
@@ -30,6 +31,7 @@ const t = makeT('TypeTransform');
|
||||
export class TypeTransform extends ColumnTransform {
|
||||
private _reviseTypeChange = Observable.create(this, false);
|
||||
private _transformWidget: Computed<NewAbstractWidget|null>;
|
||||
private _isFormWidget: Computed<boolean>;
|
||||
private _convertColumn: ColumnRec; // Set in prepare()
|
||||
|
||||
constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {
|
||||
@@ -41,6 +43,8 @@ export class TypeTransform extends ColumnTransform {
|
||||
this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => {
|
||||
return use(this.origColumn.isTransforming) ? widget : null;
|
||||
});
|
||||
|
||||
this._isFormWidget = Computed.create(this, use => use(use(this.field.viewSection).parentKey) === WidgetType.Form);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +56,16 @@ export class TypeTransform extends ColumnTransform {
|
||||
this._reviseTypeChange.set(false);
|
||||
return dom('div',
|
||||
testId('type-transform-top'),
|
||||
dom.maybe(this._transformWidget, transformWidget => transformWidget.buildTransformConfigDom()),
|
||||
dom.domComputed(use => {
|
||||
const transformWidget = use(this._transformWidget);
|
||||
if (!transformWidget) { return null; }
|
||||
|
||||
if (use(this._isFormWidget)) {
|
||||
return transformWidget.buildFormTransformConfigDom();
|
||||
} else {
|
||||
return transformWidget.buildTransformConfigDom();
|
||||
}
|
||||
}),
|
||||
dom.maybe(this._reviseTypeChange, () =>
|
||||
dom('div.transform_editor', this.buildEditorDom(),
|
||||
testId("type-transform-formula")
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user