Skip to content

Commit ad9744b

Browse files
committed
(ui) "new record button" experimental feature
- add logic to enable the toggling of "experiments" via URL search params - first experiment of this sort is a "new record button" that is visible at the bottom left of a grid or detail view.
1 parent fdbf73b commit ad9744b

10 files changed

+302
-1
lines changed

app/client/components/DetailView.js

+4
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,10 @@ DetailView.prototype.buildTitleControls = function() {
419419
);
420420
};
421421

422+
DetailView.prototype.onNewRecordRequest = function() {
423+
let addRowIndex = this.viewData.getRowIndex('new');
424+
this.cursor.rowIndex(addRowIndex);
425+
};
422426

423427
/** @inheritdoc */
424428
DetailView.prototype.onResize = function() {

app/client/components/GridView.js

+4
Original file line numberDiff line numberDiff line change
@@ -1570,6 +1570,10 @@ GridView.prototype.buildDom = function() {
15701570
}
15711571
};
15721572

1573+
GridView.prototype.onNewRecordRequest = function() {
1574+
this.insertRow();
1575+
};
1576+
15731577
/** @inheritdoc */
15741578
GridView.prototype.onResize = function() {
15751579
const activeFieldBuilder = this.activeFieldBuilder();

app/client/components/buildViewSectionDom.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {getWidgetTypes} from "app/client/ui/widgetTypesMap";
1414
import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs';
1515
import {defaultMenuOptions} from 'popweasel';
1616
import {undef} from 'app/common/gutil';
17+
import {newRecordButton} from 'app/client/ui/NewRecordButton';
1718

1819
const t = makeT('ViewSection');
1920

@@ -88,6 +89,7 @@ export function buildViewSectionDom(options: {
8889
if (!use(vs.linkSrcSectionRef)) { return null; }
8990
return use(use(vs.linkSrcSection).titleDef);
9091
});
92+
const enableNewRecordButton = gristDoc.app.experiments.isEnabled('newRecordButton');
9193
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
9294
testId(`viewlayout-section-${sectionRowId}`),
9395
dom.autoDispose(selectedBySectionTitle),
@@ -131,7 +133,13 @@ export function buildViewSectionDom(options: {
131133
dom('div.viewsection_truncated', t('Not all data is shown'))
132134
),
133135
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
134-
viewInstance.viewPane
136+
viewInstance.viewPane,
137+
enableNewRecordButton
138+
? dom.domComputed(use =>
139+
(enableNewRecordButton && use(viewInstance.viewSection.hasFocus) && use(viewInstance.enableAddRow)),
140+
(showNewRecordButton) => showNewRecordButton ? newRecordButton(viewInstance) : null
141+
)
142+
: null
135143
),
136144
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
137145
]),

app/client/declarations.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ declare module "app/client/components/BaseView" {
6363
public tableModel: DataTableModel;
6464
public selectionSummary?: SelectionSummary;
6565
public currentEditingColumnIndex: ko.Observable<number>;
66+
public enableAddRow: ko.Computed<boolean>;
6667

6768
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
6869
public setCursorPos(cursorPos: CursorPos): void;
@@ -79,6 +80,7 @@ declare module "app/client/components/BaseView" {
7980
public getAnchorLinkForSection(sectionId: number): IGristUrlState;
8081
public viewSelectedRecordAsCard(): void;
8182
public isRecordCardDisabled(): boolean;
83+
public onNewRecordRequest?(): void;
8284
}
8385
export = BaseView;
8486
}

app/client/ui/App.ts

+7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
1313
import {setUpErrorHandling} from 'app/client/models/errors';
1414
import {createAppUI} from 'app/client/ui/AppUI';
1515
import {addViewportTag} from 'app/client/ui/viewport';
16+
import {Experiments} from 'app/client/ui/Experiments';
1617
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
1718
import {attachTheme} from 'app/client/ui2018/theme';
1819
import {BaseAPI} from 'app/common/BaseAPI';
@@ -41,6 +42,7 @@ export class App extends DisposableWithEvents {
4142
public comm = this.autoDispose(Comm.create(this._checkError.bind(this)));
4243
public clientScope: ClientScope;
4344
public features: ko.Computed<ISupportedFeatures>;
45+
public experiments: Experiments;
4446
public topAppModel: TopAppModel; // Exposed because used by test/nbrowser/gristUtils.
4547

4648
private _settings: ko.Observable<{features?: ISupportedFeatures}>;
@@ -189,6 +191,11 @@ export class App extends DisposableWithEvents {
189191
attachTheme();
190192
addViewportTag();
191193
this.autoDispose(createAppUI(this.topAppModel, this));
194+
195+
this.experiments = this.autoDispose(Experiments.create(this, this));
196+
if (this.experiments.isRequested()) {
197+
this.experiments.showModal(this.experiments.getCurrentRequest()!);
198+
}
192199
}
193200

194201
// We want to test errors from Selenium, but errors we can trigger using driver.executeScript()

app/client/ui/Experiments.ts

+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { Disposable, dom, Observable, styled } from 'grainjs';
2+
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
3+
import { testId } from 'app/client/lib/dom';
4+
import { getStorage } from 'app/client/lib/storage';
5+
import { confirmModal, cssModalBody, cssModalButtons, cssModalTitle, modal } from 'app/client/ui2018/modals';
6+
import { bigPrimaryButton } from 'app/client/ui2018/buttons';
7+
import { cssLink } from 'app/client/ui2018/links';
8+
import { makeT } from 'app/client/lib/localization';
9+
import { App } from 'app/client/ui/App';
10+
11+
const t = makeT('Experiments');
12+
13+
const G = getBrowserGlobals('document', 'window');
14+
15+
const EXPERIMENTS = {
16+
newRecordButton: () => t('New record button'),
17+
};
18+
19+
type Experiment = keyof typeof EXPERIMENTS;
20+
21+
const EXPERIMENT_URL_PARAM = 'experiment';
22+
23+
export class Experiments extends Disposable {
24+
private _app: App;
25+
26+
constructor(app: App) {
27+
super();
28+
this._app = app;
29+
}
30+
31+
public isEnabled(experiment: Experiment) {
32+
const experimentState = this._getExperimentState(experiment);
33+
return experimentState.enabled;
34+
}
35+
36+
/**
37+
* Returns whether or not the user wants to show the experiments modal.
38+
*/
39+
public isRequested() {
40+
const urlExperiment = this.getCurrentRequest();
41+
return urlExperiment && this._isSupported(urlExperiment);
42+
}
43+
44+
/**
45+
* Returns the experiment that the user wants to show the modal for.
46+
*/
47+
public getCurrentRequest() {
48+
const searchParams = new URLSearchParams(G.window.location.search);
49+
return searchParams.get(EXPERIMENT_URL_PARAM);
50+
}
51+
52+
/**
53+
* Shows the modal for the given experiment, allowing the user to enable or disable it.
54+
*/
55+
public showModal(experiment: string) {
56+
if (!this._isSupported(experiment)) {
57+
return;
58+
}
59+
60+
// if the app is not initialized, wait and retry, we need the current user info
61+
const appObs = this._app.topAppModel.appObs;
62+
if (appObs.get() === null) {
63+
appObs.addListener((app, prevApp) => {
64+
if (app && prevApp === null) {
65+
this.showModal(experiment);
66+
}
67+
});
68+
return;
69+
}
70+
71+
const experimentState = this._getExperimentState(experiment);
72+
73+
const alreadyEnabled = experimentState.enabled;
74+
75+
const experimentLabel = EXPERIMENTS[experiment as keyof typeof EXPERIMENTS]();
76+
const hasConfirmedModal = Observable.create(this, false);
77+
const urlBlock = `
78+
<a class="${cssLink.className}" href="${window.location.href}">
79+
${window.location.href}
80+
</a>
81+
`;
82+
83+
if (!hasConfirmedModal.get()) {
84+
confirmModal(
85+
t('Experimental feature'),
86+
alreadyEnabled ? t('Disable feature') : t('Enable feature'),
87+
() => {
88+
this._setExperimentState(experiment, !alreadyEnabled);
89+
hasConfirmedModal.set(true);
90+
},
91+
{
92+
explanation: cssWrapper(
93+
cssWrapper((el) => {
94+
el.innerHTML = t(
95+
alreadyEnabled
96+
? 'You are about to disable this experimental feature: {{- experiment}}'
97+
: 'You are about to enable this experimental feature: {{- experiment}}',
98+
{experiment: `
99+
<strong>
100+
${experimentLabel}
101+
</strong>`}
102+
);
103+
}),
104+
!alreadyEnabled ? dom('p', t('This feature is experimental and may not work as expected.')) : null,
105+
cssWrapper((el) => {
106+
el.innerHTML = t(alreadyEnabled
107+
? 'To start using it again, you can visit this URL at any time: {{- url}}'
108+
: 'Visit this URL at any time to stop using this feature: {{- url}}',
109+
{ url: urlBlock }
110+
);
111+
}),
112+
)
113+
}
114+
);
115+
}
116+
117+
// If the user just confirmed the feature toggle, feedback the change and force a page reload
118+
hasConfirmedModal.addListener((val) => {
119+
if (!val) {
120+
return;
121+
}
122+
modal(
123+
(ctl, owner) => {
124+
return [
125+
cssModalTitle(t('Experimental feature'), testId('modal-title')),
126+
cssModalBody(dom('div',
127+
dom('p', (el) => {
128+
el.innerHTML = t(
129+
alreadyEnabled ? '{{- experiment}} disabled.' : '{{- experiment}} enabled.',
130+
{experiment: `<strong>${experimentLabel}</strong>`}
131+
);
132+
})
133+
)),
134+
cssModalButtons(
135+
bigPrimaryButton(t('Reload the page'), dom.on('click', () => {
136+
const url = new URL(window.location.href);
137+
url.searchParams.delete(EXPERIMENT_URL_PARAM);
138+
window.location.href = url.toString();
139+
})),
140+
),
141+
];
142+
},
143+
{
144+
noEscapeKey: true,
145+
noClickAway: true,
146+
}
147+
);
148+
});
149+
}
150+
151+
private _isSupported(experiment: string) {
152+
return !!(EXPERIMENTS[experiment as keyof typeof EXPERIMENTS]);
153+
}
154+
155+
private _getExperimentState(experiment: string) {
156+
const localStorage = getStorage();
157+
const storageKey = this._getStorageKey(experiment);
158+
159+
const experimentState = localStorage.getItem(storageKey)
160+
? JSON.parse(localStorage.getItem(storageKey)!)
161+
: {enabled: false, timestamp: null};
162+
163+
return experimentState;
164+
}
165+
166+
private _setExperimentState(experiment: string, enabled: boolean) {
167+
const localStorage = getStorage();
168+
const storageKey = this._getStorageKey(experiment);
169+
localStorage.setItem(storageKey, JSON.stringify({enabled, timestamp: Date.now()}));
170+
}
171+
172+
private _getStorageKey(experiment: string) {
173+
const userId = this._app.topAppModel.appObs.get()?.currentUser?.id || 0;
174+
return `u=${userId}:experiment=${experiment}`;
175+
}
176+
}
177+
178+
const cssWrapper = styled('div', `
179+
display: flex;
180+
flex-direction: column;
181+
gap: 1rem;
182+
& > p {
183+
margin-bottom: 0;
184+
}
185+
`);

app/client/ui/NewRecordButton.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {dom, styled} from 'grainjs';
2+
import {testId, vars} from 'app/client/ui2018/cssVars';
3+
import {makeT} from 'app/client/lib/localization';
4+
import {primaryButton} from 'app/client/ui2018/buttons';
5+
import {iconSpan} from 'app/client/ui2018/icons';
6+
import BaseView from 'app/client/components/BaseView';
7+
8+
const t = makeT('NewRecordButton');
9+
10+
const translationStrings = {
11+
'record': 'New record',
12+
'single': 'New card',
13+
};
14+
15+
/**
16+
* "New Record" button for the given view that inserts a new record at the end on click.
17+
*
18+
* Note that each view has its own implementation of how to "create a new record"
19+
* via the `onNewRecordRequest` method.
20+
*
21+
* Appears in the bottom-left corner of its parent element.
22+
*/
23+
export function newRecordButton(view: BaseView) {
24+
const viewType = view.viewSection.parentKey.peek();
25+
26+
const translationString = translationStrings[viewType as keyof typeof translationStrings]
27+
|| 'New record';
28+
return cssNewRecordButton(
29+
iconSpan('Plus'),
30+
dom('span', t(translationString)),
31+
dom.on('click', () => {
32+
if (view.onNewRecordRequest) {
33+
view.onNewRecordRequest();
34+
}
35+
}),
36+
testId('new-record-button')
37+
);
38+
}
39+
40+
const cssNewRecordButton = styled(primaryButton, `
41+
position: absolute;
42+
bottom: -12px;
43+
left: -12px;
44+
z-index: ${vars.newRecordButtonZIndex};
45+
display: flex;
46+
align-items: center;
47+
gap: 6px;
48+
49+
/* 16px on the plus icon is blurry, 17px is sharp, needs more test. */
50+
& > span:first-child {
51+
width: 17px;
52+
height: 17px;
53+
}
54+
`);

app/client/ui2018/cssVars.ts

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export const vars = {
136136
stickyHeaderZIndex: new CustomProp('sticky-header-z-index', '20'),
137137
insertColumnLineZIndex: new CustomProp('insert-column-line-z-index', '20'),
138138
emojiPickerZIndex: new CustomProp('modal-z-index', '20'),
139+
newRecordButtonZIndex: new CustomProp('new-record-button-z-index', '30'),
139140
popupSectionBackdropZIndex: new CustomProp('popup-section-backdrop-z-index', '100'),
140141
menuZIndex: new CustomProp('menu-z-index', '999'),
141142
modalZIndex: new CustomProp('modal-z-index', '999'),

static/locales/en.client.json

+18
Original file line numberDiff line numberDiff line change
@@ -1948,5 +1948,23 @@
19481948
"Have you **reviewed the code** at this URL?": "Have you **reviewed the code** at this URL?",
19491949
"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.": "If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.",
19501950
"I confirm that I understand these warnings and accept the risks": "I confirm that I understand these warnings and accept the risks"
1951+
},
1952+
"NewRecordButton": {
1953+
"New card": "New card",
1954+
"New record": "New record"
1955+
},
1956+
"Experiments": {
1957+
"{{- experiment}} disabled.": "{{- experiment}} disabled.",
1958+
"{{- experiment}} enabled.": "{{- experiment}} enabled.",
1959+
"Disable feature": "Disable feature",
1960+
"Enable feature": "Enable feature",
1961+
"Experimental feature": "Experimental feature",
1962+
"New record button": "New record button",
1963+
"Reload the page": "Reload the page",
1964+
"This feature is experimental and may not work as expected.": "This feature is experimental and may not work as expected.",
1965+
"To start using it again, you can visit this URL at any time: {{- url}}": "To start using it again, you can visit this URL at any time: {{- url}}",
1966+
"Visit this URL at any time to stop using this feature: {{- url}}": "Visit this URL at any time to stop using this feature: {{- url}}",
1967+
"You are about to disable this experimental feature: {{- experiment}}": "You are about to disable this experimental feature: {{- experiment}}",
1968+
"You are about to enable this experimental feature: {{- experiment}}": "You are about to enable this experimental feature: {{- experiment}}"
19511969
}
19521970
}

static/locales/fr.client.json

+18
Original file line numberDiff line numberDiff line change
@@ -1946,5 +1946,23 @@
19461946
"Have you **reviewed the code** at this URL?": "Le cas échéant avez-vous pu **auditer le code derrière ce lien** ?",
19471947
"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.": "En cas de doute, vous pouvez transférer le lien aux équipes techniques responsable de votre instance.",
19481948
"I confirm that I understand these warnings and accept the risks": "j’ai bien lu ces recommandations et je confirme vouloir ajouter cette vue"
1949+
},
1950+
"NewRecordButton": {
1951+
"New card": "Nouvelle fiche",
1952+
"New record": "Nouvelle ligne"
1953+
},
1954+
"Experiments": {
1955+
"{{- experiment}} disabled.": "{{- experiment}} : désactivé.",
1956+
"{{- experiment}} enabled.": "{{- experiment}} : activé.",
1957+
"Disable feature": "Désactiver la fonctionnalité",
1958+
"Enable feature": "Activer la fonctionnalité",
1959+
"Experimental feature": "Fonctionnalité expérimentale",
1960+
"New record button": "Bouton Nouvelle Ligne",
1961+
"Reload the page": "Recharger la page",
1962+
"This feature is experimental and may not work as expected.": "Cette fonctionnalité est expérimentale et peut ne pas fonctionner comme prévu.",
1963+
"To start using it again, you can visit this URL at any time: {{- url}}": "Pour la réactiver, vous pouvez visiter cette URL à tout moment : {{- url}}",
1964+
"Visit this URL at any time to stop using this feature: {{- url}}": "Visitez cette URL à tout moment à l'avenir pour la désactiver : {{- url}}",
1965+
"You are about to disable this experimental feature: {{- experiment}}": "Vous êtes sur le point de désactiver cette fonctionnalité expérimentale : {{- experiment}}",
1966+
"You are about to enable this experimental feature: {{- experiment}}": "Vous êtes sur le point d'activer cette fonctionnalité expérimentale : {{- experiment}}"
19491967
}
19501968
}

0 commit comments

Comments
 (0)