(core) Add custom widget gallery

Summary:
Custom widgets are now shown in a gallery.

The gallery is automatically opened when a new custom widget is
added to a page.

Descriptions, authors, and update times are pulled from the widget
manifest.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4309
This commit is contained in:
George Gevoian
2024-08-13 19:21:48 -04:00
parent a16d76d25d
commit e70c294e3d
32 changed files with 1672 additions and 785 deletions

View File

@@ -42,6 +42,7 @@ import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
import TableModel from 'app/client/models/TableModel';
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {App} from 'app/client/ui/App';
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial';
@@ -138,6 +139,13 @@ interface PopupSectionOptions {
close: () => void;
}
interface AddSectionOptions {
/** If focus should move to the new section. Defaults to `true`. */
focus?: boolean;
/** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */
popups?: boolean;
}
export class GristDoc extends DisposableWithEvents {
public docModel: DocModel;
public viewModel: ViewRec;
@@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
/**
* Adds a view section described by val to the current page.
*/
public async addWidgetToPage(val: IPageWidget) {
const docData = this.docModel.docData;
const viewName = this.viewModel.name.peek();
public async addWidgetToPage(widget: IPageWidget) {
const {table, type} = widget;
let tableId: string | null | undefined;
if (val.table === 'New Table') {
if (table === 'New Table') {
tableId = await this._promptForName();
if (tableId === undefined) {
return;
}
}
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (val.link !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (type === 'custom') {
return showCustomWidgetGallery(this, {
addWidget: () => this._addWidgetToPage(widget, tableId),
});
}
const res: {sectionRef: number} = await docData.bundleActions(
const viewName = this.viewModel.name.peek();
const {sectionRef} = await this.docData.bundleActions(
t("Added new linked section to view {{viewName}}", {viewName}),
() => this.addWidgetToPageImpl(val, tableId ?? null)
() => this._addWidgetToPage(widget, tableId ?? null)
);
// The newly-added section should be given focus.
this.viewModel.activeSectionId(res.sectionRef);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
return res.sectionRef;
return sectionRef;
}
public async onCreateForm() {
@@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
commands.allCommands.expandSection.run();
}
/**
* The actual implementation of addWidgetToPage
*/
public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
const viewRef = this.activeViewId.get();
const tableRef = val.table === 'New Table' ? 0 : val.table;
const result = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
);
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(result.sectionRef);
}
await this.saveLink(val.link, result.sectionRef);
return result;
}
/**
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
*/
public async addNewPage(val: IPageWidget) {
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
logTelemetryEvent('addedWidget', {
full: {
docIdDigest: this.docId(),
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
},
});
let viewRef: IDocPage;
let sectionRef: number | undefined;
await this.docData.bundleActions('Add new page', async () => {
if (val.table === 'New Table') {
const name = await this._promptForName();
if (name === undefined) {
return;
}
if (val.type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
viewRef = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', /* new table */0, 0, val.type, null, name]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
}
} else {
const result = await this.docData.sendAction(
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
if (val.type === 'chart') {
await this._ensureOneNumericSeries(sectionRef!);
}
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(sectionRef!);
}
});
await this.openDocPage(viewRef!);
if (sectionRef) {
// The newly-added section should be given focus.
this.viewModel.activeSectionId(sectionRef);
const {table, type} = val;
let tableId: string | null | undefined;
if (table === 'New Table') {
tableId = await this._promptForName();
if (tableId === undefined) { return; }
}
if (type === 'custom') {
return showCustomWidgetGallery(this, {
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
viewRef: number;
sectionRef: number;
}>,
});
}
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
const {sectionRef, viewRef} = await this.docData.bundleActions(
'Add new page',
() => this._addPage(val, tableId ?? null)
);
await this._focus({sectionRef, viewRef});
this._showNewWidgetPopups(type);
}
/**
@@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
return values;
}
private async _addWidgetToPage(
widget: IPageWidget,
tableId: string | null = null,
{focus = true, popups = true}: AddSectionOptions= {}
) {
const {columns, link, summarize, table, type} = widget;
const viewRef = this.activeViewId.get();
const tableRef = table === 'New Table' ? 0 : table;
const result: {viewRef: number, sectionRef: number} = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, type, summarize ? columns : null, tableId]
);
if (type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
}
if (type === 'form') {
await this._setDefaultFormLayoutSpec(result.sectionRef);
}
await this.saveLink(link, result.sectionRef);
const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (link !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
}
if (focus) { await this._focus({sectionRef: result.sectionRef}); }
if (popups) { this._showNewWidgetPopups(type); }
return result;
}
private async _addPage(
widget: IPageWidget,
tableId: string | null = null,
{focus = true, popups = true}: AddSectionOptions = {}
) {
const {columns, summarize, table, type} = widget;
let viewRef: number;
let sectionRef: number | undefined;
if (table === 'New Table') {
if (type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', tableId]);
viewRef = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', 0, 0, type, null, tableId]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
}
} else {
const result = await this.docData.sendAction(
['CreateViewSection', table, 0, type, summarize ? columns : null, null]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
if (type === 'chart') {
await this._ensureOneNumericSeries(sectionRef!);
}
}
if (type === 'form') {
await this._setDefaultFormLayoutSpec(sectionRef!);
}
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
logTelemetryEvent('addedWidget', {
full: {
docIdDigest: this.docId(),
widgetType: getTelemetryWidgetTypeFromPageWidget(widget),
},
});
if (focus) { await this._focus({viewRef, sectionRef}); }
if (popups) { this._showNewWidgetPopups(type); }
return {viewRef, sectionRef};
}
private async _focus({viewRef, sectionRef}: {viewRef?: number, sectionRef?: number}) {
if (viewRef) { await this.openDocPage(viewRef); }
if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }
}
private _showNewWidgetPopups(type: IWidgetType) {
this._maybeShowEditCardLayoutTip(type).catch(reportError);
if (AttachedCustomWidgets.guard(type)) {
this._handleNewAttachedCustomWidget(type).catch(reportError);
}
}
/**
* Opens popup with a section data (used by Raw Data view).
*/
@@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
const sectionId = section.id();
// create a new section
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
// update section name
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);

View File

@@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
// Appends access level to query string.
private _urlWithAccess(url: string) {
if (!url) {
if (!url) { return url; }
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (e) {
console.error(e);
return url;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.