mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Widget options api
Summary: Adding configuration options for CustomWidgets. Custom widgets can now store options (in JSON) in viewSection metadata. Changes in grist-plugin-api: - Adding onOptions handler, that will be invoked when the widget is ready and when the configuration is changed - Adding WidgetAPI - new API to read and save a configuration for widget. Changes in Grist: - Rewriting CustomView code, and extracting code that is responsible for showing the iframe and registering Rpc. - Adding Open Configuration button to Widget section in the Creator panel and in the section menu. - Custom Widgets can implement "configure" method, to show configuration screen when requested. Test Plan: Browser tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3185
This commit is contained in:
@@ -1,24 +1,23 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssLabel, cssRow, cssTextInput} from 'app/client/ui/RightPanel';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {IOptionFull, select} from 'app/client/ui2018/menus';
|
||||
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
|
||||
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {nativeCompare} from 'app/common/gutil';
|
||||
import {UserAPI} from 'app/common/UserAPI';
|
||||
import {bundleChanges, Computed, Disposable, dom,
|
||||
makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
// Custom URL widget id - used as mock id for selectbox.
|
||||
const CUSTOM_ID = "custom";
|
||||
const CUSTOM_ID = 'custom';
|
||||
const testId = makeTestId('test-config-widget-');
|
||||
|
||||
|
||||
/**
|
||||
* Custom Widget section.
|
||||
* Allows to select custom widget from the list of available widgets
|
||||
@@ -34,23 +33,25 @@ const testId = makeTestId('test-config-widget-');
|
||||
export class CustomSectionConfig extends Disposable {
|
||||
// Holds all available widget definitions.
|
||||
private _widgets: Observable<ICustomWidget[]>;
|
||||
// Holds selected option (either custom or a widgetId).
|
||||
private _selected: Computed<string|null>;
|
||||
// Holds selected option (either custom string or a widgetId).
|
||||
private _selectedId: Computed<string | null>;
|
||||
// Holds custom widget URL.
|
||||
private _url: Computed<string>;
|
||||
// Enable or disable widget repository.
|
||||
private _canSelect = true;
|
||||
// Selected access level.
|
||||
private _selectedAccess: Computed<AccessLevel>;
|
||||
// When widget is changed, it sets its desired access level. We will prompt
|
||||
// user to approve or reject it.
|
||||
private _desiredAccess: Observable<AccessLevel>;
|
||||
private _desiredAccess: Observable<AccessLevel|null>;
|
||||
// Current access level (stored inside a section).
|
||||
private _currentAccess: Computed<AccessLevel>;
|
||||
// Does widget has custom configuration.
|
||||
private _hasConfiguration: Computed<boolean>;
|
||||
|
||||
constructor(section: ViewSectionRec, api: UserAPI) {
|
||||
constructor(_section: ViewSectionRec, _gristDoc: GristDoc) {
|
||||
super();
|
||||
|
||||
const api = _gristDoc.app.topAppModel.api;
|
||||
|
||||
// Test if we can offer widget list.
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
||||
this._canSelect = gristConfig.enableWidgetRepository ?? true;
|
||||
@@ -61,107 +62,116 @@ export class CustomSectionConfig extends Disposable {
|
||||
if (this._canSelect) {
|
||||
// From the start we will provide single widget definition
|
||||
// that was chosen previously.
|
||||
if (section.customDef.widgetDef.peek()) {
|
||||
this._widgets.set([section.customDef.widgetDef.peek()!]);
|
||||
if (_section.customDef.widgetDef.peek()) {
|
||||
this._widgets.set([_section.customDef.widgetDef.peek()!]);
|
||||
}
|
||||
// Request for rest of the widgets.
|
||||
api.getWidgets().then(widgets => {
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
const existing = section.customDef.widgetDef.peek();
|
||||
// Make sure we have current widget in place.
|
||||
if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) {
|
||||
widgets.push(existing);
|
||||
}
|
||||
this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase())));
|
||||
}).catch(err => {
|
||||
reportError(err);
|
||||
});
|
||||
api
|
||||
.getWidgets()
|
||||
.then(widgets => {
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
const existing = _section.customDef.widgetDef.peek();
|
||||
// Make sure we have current widget in place.
|
||||
if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) {
|
||||
widgets.push(existing);
|
||||
}
|
||||
this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase())));
|
||||
})
|
||||
.catch(reportError);
|
||||
}
|
||||
|
||||
// Create temporary variable that will hold blank Custom Url state. When url is blank and widgetDef is not stored
|
||||
// we can either show "Select Custom Widget" or a Custom Url with a blank url.
|
||||
// To distinguish those states, we will mark Custom Url state at start (by checking that url is not blank and
|
||||
// widgetDef is not set). And then switch it during selectbox manipulation.
|
||||
const wantsToBeCustom = Observable.create(this,
|
||||
Boolean(section.customDef.url.peek() && !section.customDef.widgetDef.peek())
|
||||
const wantsToBeCustom = Observable.create(
|
||||
this,
|
||||
Boolean(_section.customDef.url.peek() && !_section.customDef.widgetDef.peek())
|
||||
);
|
||||
|
||||
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
|
||||
this._selected = Computed.create(this, use => {
|
||||
if (use(section.customDef.widgetDef)) {
|
||||
return section.customDef.widgetDef.peek()!.widgetId;
|
||||
this._selectedId = Computed.create(this, use => {
|
||||
if (use(_section.customDef.widgetDef)) {
|
||||
return _section.customDef.widgetDef.peek()!.widgetId;
|
||||
}
|
||||
if (use(section.customDef.url) || use(wantsToBeCustom)) {
|
||||
if (use(_section.customDef.url) || use(wantsToBeCustom)) {
|
||||
return CUSTOM_ID;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
this._selected.onWrite(async (value) => {
|
||||
this._selectedId.onWrite(async value => {
|
||||
if (value === CUSTOM_ID) {
|
||||
// Select Custom URL
|
||||
bundleChanges(() => {
|
||||
// Clear url.
|
||||
section.customDef.url(null);
|
||||
_section.customDef.url(null);
|
||||
// Clear widget definition.
|
||||
section.customDef.widgetDef(null);
|
||||
_section.customDef.widgetDef(null);
|
||||
// Set intermediate state
|
||||
wantsToBeCustom.set(true);
|
||||
// Reset access level to none.
|
||||
section.customDef.access(AccessLevel.none);
|
||||
_section.customDef.access(AccessLevel.none);
|
||||
// Clear all saved options.
|
||||
_section.customDef.widgetOptions(null);
|
||||
// Reset custom configuration flag.
|
||||
_section.hasCustomOptions(false);
|
||||
this._desiredAccess.set(AccessLevel.none);
|
||||
});
|
||||
await section.saveCustomDef();
|
||||
await _section.saveCustomDef();
|
||||
} else {
|
||||
// Select Widget
|
||||
const selectedWidget = this._widgets.get().find(w => w.widgetId === value);
|
||||
if (!selectedWidget) {
|
||||
// should not happen
|
||||
throw new Error("Error accessing widget from the list");
|
||||
throw new Error('Error accessing widget from the list');
|
||||
}
|
||||
// If user selected the same one, do nothing.
|
||||
if (section.customDef.widgetDef.peek()?.widgetId === value) {
|
||||
if (_section.customDef.widgetDef.peek()?.widgetId === value) {
|
||||
return;
|
||||
}
|
||||
bundleChanges(() => {
|
||||
// Clear access level
|
||||
section.customDef.access(AccessLevel.none);
|
||||
_section.customDef.access(AccessLevel.none);
|
||||
// When widget wants some access, set desired access level.
|
||||
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
|
||||
// Update widget definition.
|
||||
section.customDef.widgetDef(selectedWidget);
|
||||
_section.customDef.widgetDef(selectedWidget);
|
||||
// Update widget URL.
|
||||
section.customDef.url(selectedWidget.url);
|
||||
_section.customDef.url(selectedWidget.url);
|
||||
// Clear options.
|
||||
_section.customDef.widgetOptions(null);
|
||||
// Clear has custom configuration.
|
||||
_section.hasCustomOptions(false);
|
||||
// Clear intermediate state.
|
||||
wantsToBeCustom.set(false);
|
||||
});
|
||||
await section.saveCustomDef();
|
||||
await _section.saveCustomDef();
|
||||
}
|
||||
});
|
||||
|
||||
// Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
|
||||
// For custom widget, we will store url also in section definition.
|
||||
this._url = Computed.create(this, use => use(section.customDef.url) || "");
|
||||
this._url.onWrite((newUrl) => section.customDef.url.setAndSave(newUrl));
|
||||
this._url = Computed.create(this, use => use(_section.customDef.url) || '');
|
||||
this._url.onWrite(newUrl => _section.customDef.url.setAndSave(newUrl));
|
||||
|
||||
// Compute current access level.
|
||||
this._currentAccess = Computed.create(this,
|
||||
use => use(section.customDef.access) as AccessLevel || AccessLevel.none);
|
||||
|
||||
// From the start desired access level is the same as current one.
|
||||
this._desiredAccess = Observable.create(this, this._currentAccess.get());
|
||||
|
||||
// Selected access level will show desired one, but will updated both (desired and current).
|
||||
this._selectedAccess = Computed.create(this, use => use(this._desiredAccess));
|
||||
this._selectedAccess.onWrite(async newAccess => {
|
||||
this._desiredAccess.set(newAccess);
|
||||
await section.customDef.access.setAndSave(newAccess);
|
||||
this._currentAccess = Computed.create(
|
||||
this,
|
||||
use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none
|
||||
);
|
||||
this._currentAccess.onWrite(async newAccess => {
|
||||
await _section.customDef.access.setAndSave(newAccess);
|
||||
});
|
||||
// From the start desired access level is the same as current one.
|
||||
this._desiredAccess = fromKo(_section.desiredAccessLevel);
|
||||
|
||||
// Clear intermediate state when section changes.
|
||||
this.autoDispose(section.id.subscribe(() => wantsToBeCustom.set(false)));
|
||||
this.autoDispose(section.id.subscribe(() => this._reject()));
|
||||
this.autoDispose(_section.id.subscribe(() => wantsToBeCustom.set(false)));
|
||||
this.autoDispose(_section.id.subscribe(() => this._reject()));
|
||||
|
||||
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
@@ -169,16 +179,29 @@ export class CustomSectionConfig extends Disposable {
|
||||
const holder = new MultiHolder();
|
||||
|
||||
// Show prompt, when desired access level is different from actual one.
|
||||
const prompt = Computed.create(holder, use => use(this._currentAccess) !== use(this._desiredAccess));
|
||||
const prompt = Computed.create(holder, use =>
|
||||
use(this._desiredAccess)
|
||||
&& !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
|
||||
// If this is empty section or not.
|
||||
const isSelected = Computed.create(holder, use => Boolean(use(this._selected)));
|
||||
const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId)));
|
||||
// If user is using custom url.
|
||||
const isCustom = Computed.create(holder, use => use(this._selected) === CUSTOM_ID || !this._canSelect);
|
||||
// Options for the selectbox (all widgets definitions and Custom URL)
|
||||
const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect);
|
||||
// Options for the select-box (all widgets definitions and Custom URL)
|
||||
const options = Computed.create(holder, use => [
|
||||
{label: 'Custom URL', value: 'custom'},
|
||||
...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})),
|
||||
]);
|
||||
function buildPrompt(level: AccessLevel|null) {
|
||||
if (!level) {
|
||||
return null;
|
||||
}
|
||||
switch(level) {
|
||||
case AccessLevel.none: return cssConfirmLine("Widget does not require any permissions.");
|
||||
case AccessLevel.read_table: return cssConfirmLine("Widget needs to ", dom("b", "read"), " the current table.");
|
||||
case AccessLevel.full: return cssConfirmLine("Widget needs a ", dom("b", "full access"), " to this document.");
|
||||
default: throw new Error(`Unsupported ${level} access level`);
|
||||
}
|
||||
}
|
||||
// Options for access level.
|
||||
const levels: IOptionFull<string>[] = [
|
||||
{label: 'No document access', value: AccessLevel.none},
|
||||
@@ -188,14 +211,15 @@ export class CustomSectionConfig extends Disposable {
|
||||
return dom(
|
||||
'div',
|
||||
dom.autoDispose(holder),
|
||||
this._canSelect ?
|
||||
cssRow(
|
||||
select(this._selected, options, {
|
||||
defaultLabel: 'Select Custom Widget',
|
||||
menuCssClass: cssMenu.className
|
||||
}),
|
||||
testId('select')
|
||||
) : null,
|
||||
this._canSelect
|
||||
? cssRow(
|
||||
select(this._selectedId, options, {
|
||||
defaultLabel: 'Select Custom Widget',
|
||||
menuCssClass: cssMenu.className,
|
||||
}),
|
||||
testId('select')
|
||||
)
|
||||
: null,
|
||||
dom.maybe(isCustom, () => [
|
||||
cssRow(
|
||||
cssTextInput(
|
||||
@@ -206,6 +230,48 @@ export class CustomSectionConfig extends Disposable {
|
||||
)
|
||||
),
|
||||
]),
|
||||
dom.maybe(prompt, () =>
|
||||
kf.prompt(
|
||||
{tabindex: '-1'},
|
||||
cssColumns(
|
||||
cssWarningWrapper(icon('Lock')),
|
||||
dom(
|
||||
'div',
|
||||
cssConfirmRow(
|
||||
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
|
||||
),
|
||||
cssConfirmRow(
|
||||
primaryButton(
|
||||
'Accept',
|
||||
testId('access-accept'),
|
||||
dom.on('click', () => this._accept())
|
||||
),
|
||||
basicButton(
|
||||
'Reject',
|
||||
testId('access-reject'),
|
||||
dom.on('click', () => this._reject())
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
dom.maybe(
|
||||
use => use(isSelected) || !this._canSelect,
|
||||
() => [
|
||||
cssLabel('ACCESS LEVEL'),
|
||||
cssRow(select(this._currentAccess, levels), testId('access')),
|
||||
]
|
||||
),
|
||||
dom.maybe(this._hasConfiguration, () =>
|
||||
cssSection(
|
||||
textButton(
|
||||
'Open configuration',
|
||||
dom.on('click', () => this._openConfiguration()),
|
||||
testId('open-configuration')
|
||||
)
|
||||
)
|
||||
),
|
||||
cssSection(
|
||||
cssLink(
|
||||
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
||||
@@ -213,50 +279,29 @@ export class CustomSectionConfig extends Disposable {
|
||||
'Learn more about custom widgets'
|
||||
)
|
||||
),
|
||||
dom.maybe((use) => use(isSelected) || !this._canSelect, () => [
|
||||
cssLabel('ACCESS LEVEL'),
|
||||
cssRow(select(this._selectedAccess, levels), testId('access')),
|
||||
dom.maybe(prompt, () =>
|
||||
kf.prompt(
|
||||
{tabindex: '-1'},
|
||||
cssColumns(
|
||||
cssWarningWrapper(
|
||||
icon('Lock'),
|
||||
),
|
||||
dom('div',
|
||||
cssConfirmRow(
|
||||
"Approve requested access level?"
|
||||
),
|
||||
cssConfirmRow(
|
||||
primaryButton("Accept",
|
||||
testId('access-accept'),
|
||||
dom.on('click', () => this._accept())),
|
||||
basicButton("Reject",
|
||||
testId('access-reject'),
|
||||
dom.on('click', () => this._reject()))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private _openConfiguration(): void {
|
||||
allCommands.openWidgetConfiguration.run();
|
||||
}
|
||||
|
||||
private _accept() {
|
||||
this._selectedAccess.set(this._desiredAccess.get());
|
||||
if (this._desiredAccess.get()) {
|
||||
this._currentAccess.set(this._desiredAccess.get()!);
|
||||
}
|
||||
this._reject();
|
||||
}
|
||||
|
||||
private _reject() {
|
||||
this._desiredAccess.set(this._currentAccess.get());
|
||||
this._desiredAccess.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
const cssWarningWrapper = styled('div', `
|
||||
padding-left: 8px;
|
||||
padding-top: 6px;
|
||||
--icon-color: ${colors.lightGreen}
|
||||
--icon-color: ${colors.error}
|
||||
`);
|
||||
|
||||
const cssColumns = styled('div', `
|
||||
@@ -269,6 +314,10 @@ const cssConfirmRow = styled('div', `
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
const cssConfirmLine = styled('span', `
|
||||
white-space: pre-wrap;
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
margin: 16px 16px 12px 16px;
|
||||
`);
|
||||
|
||||
@@ -337,7 +337,7 @@ export class RightPanel extends Disposable {
|
||||
// In the default url mode, allow picking a url and granting/forbidding
|
||||
// access to data.
|
||||
dom.maybe(use => use(activeSection.customDef.mode) === 'url',
|
||||
() => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)),
|
||||
() => dom.create(CustomSectionConfig, activeSection, this._gristDoc)),
|
||||
];
|
||||
}),
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionR
|
||||
menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection'),
|
||||
|
||||
menuDivider(),
|
||||
dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () =>
|
||||
menuItemCmd(allCommands.openWidgetConfiguration, 'Open configuration',
|
||||
testId('section-open-configuration')),
|
||||
),
|
||||
menuItemCmd(allCommands.deleteSection, 'Delete widget',
|
||||
dom.cls('disabled', viewModel.viewSections().peekLength <= 1 || isReadonly),
|
||||
testId('section-delete')),
|
||||
|
||||
@@ -19,30 +19,39 @@ const testId = makeTestId('test-section-menu-');
|
||||
|
||||
const TOOLTIP_DELAY_OPEN = 750;
|
||||
|
||||
// Handler for [Save] button.
|
||||
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
|
||||
await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([
|
||||
viewSection.activeSortJson.save(), // Save sort
|
||||
viewSection.saveFilters(), // Save filter
|
||||
viewSection.activeFilterBar.save(), // Save bar
|
||||
viewSection.activeSortJson.save(), // Save sort
|
||||
viewSection.saveFilters(), // Save filter
|
||||
viewSection.activeFilterBar.save(), // Save bar
|
||||
viewSection.activeCustomOptions.save(), // Save widget options
|
||||
]));
|
||||
}
|
||||
|
||||
// Handler for [Revert] button.
|
||||
function doRevert(viewSection: ViewSectionRec) {
|
||||
viewSection.activeSortJson.revert(); // Revert sort
|
||||
viewSection.revertFilters(); // Revert filter
|
||||
viewSection.activeFilterBar.revert(); // Revert bar
|
||||
viewSection.activeSortJson.revert(); // Revert sort
|
||||
viewSection.revertFilters(); // Revert filter
|
||||
viewSection.activeFilterBar.revert(); // Revert bar
|
||||
viewSection.activeCustomOptions.revert(); // Revert widget options
|
||||
}
|
||||
|
||||
// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu.
|
||||
export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec,
|
||||
viewModel: ViewRec, isReadonly: Observable<boolean>) {
|
||||
|
||||
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
||||
|
||||
// If there is any filter (should [Filter Icon] be green).
|
||||
const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length));
|
||||
|
||||
// Should border be green, and should we show [Save] [Revert] (v) (x) buttons.
|
||||
const displaySaveObs: Computed<boolean> = Computed.create(owner, (use) => (
|
||||
use(viewSection.filterSpecChanged)
|
||||
|| !use(viewSection.activeSortJson.isSaved)
|
||||
|| !use(viewSection.activeFilterBar.isSaved)
|
||||
|| !use(viewSection.activeCustomOptions.isSaved)
|
||||
));
|
||||
|
||||
const save = () => { doSave(docModel, viewSection).catch(reportError); };
|
||||
@@ -55,21 +64,32 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
testId('wrapper'),
|
||||
cssMenu(
|
||||
testId('sortAndFilter'),
|
||||
// [Filter icon] grey or green
|
||||
cssFilterIconWrapper(
|
||||
testId('filter-icon'),
|
||||
// Make green when there are some filters. If there are only sort options, leave grey.
|
||||
cssFilterIconWrapper.cls('-any', anyFilter),
|
||||
cssFilterIcon('Filter')
|
||||
),
|
||||
menu(ctl => [
|
||||
// Sorted by section.
|
||||
dom.domComputed(use => {
|
||||
use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky.
|
||||
return makeSortPanel(viewSection, use(viewSection.activeSortSpec),
|
||||
(row: number) => docModel.columns.getRowModel(row));
|
||||
}),
|
||||
// Filtered by section.
|
||||
dom.domComputed(viewSection.activeFilters, filters =>
|
||||
makeFilterPanel(viewSection, filters, popupControls, () => ctl.close())),
|
||||
// [+] Add filter
|
||||
makeAddFilterButton(viewSection, popupControls),
|
||||
// [+] Toggle filter bar
|
||||
makeFilterBarToggle(viewSection.activeFilterBar),
|
||||
// Widget options
|
||||
dom.maybe(use => use(viewSection.customDef.mode) === 'url', () =>
|
||||
makeCustomOptions(viewSection)
|
||||
),
|
||||
// [Save] [Revert] buttons
|
||||
dom.domComputed(displaySaveObs, displaySave => [
|
||||
displaySave ? cssMenuInfoHeader(
|
||||
cssSaveButton('Save', testId('btn-save'),
|
||||
@@ -81,7 +101,10 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
]),
|
||||
]),
|
||||
),
|
||||
// Two icons (v) (x) left to the toggle, when there are unsaved filters or sort options.
|
||||
// Those buttons are equivalent of the [Save] [Revert] buttons in the menu.
|
||||
dom.maybe(displaySaveObs, () => cssSaveIconsWrapper(
|
||||
// (v)
|
||||
cssSmallIconWrapper(
|
||||
cssIcon('Tick'), cssSmallIconWrapper.cls('-green'),
|
||||
dom.on('click', save),
|
||||
@@ -89,6 +112,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
testId('small-btn-save'),
|
||||
dom.hide(isReadonly),
|
||||
),
|
||||
// (x)
|
||||
cssSmallIconWrapper(
|
||||
cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'),
|
||||
dom.on('click', revert),
|
||||
@@ -106,6 +130,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
];
|
||||
}
|
||||
|
||||
// Sorted by section (and all columns underneath or (Default) label).
|
||||
function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColumn: (row: number) => ColumnRec) {
|
||||
const changedColumns = difference(sortSpec, Sort.parseSortColRefs(section.sortColRefs.peek()));
|
||||
const sortColumns = sortSpec.map(colSpec => {
|
||||
@@ -140,6 +165,7 @@ function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColu
|
||||
];
|
||||
}
|
||||
|
||||
// [+] Add Filter.
|
||||
export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) {
|
||||
return dom.domComputed((use) => {
|
||||
const filters = use(viewSectionRec.filters);
|
||||
@@ -160,6 +186,7 @@ export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControl
|
||||
});
|
||||
}
|
||||
|
||||
// [v] or [x] Toggle Filter Bar.
|
||||
export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
@@ -178,7 +205,7 @@ export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label).
|
||||
function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
||||
popupControls: WeakMap<ColumnRec, PopupControl>,
|
||||
onCloseContent: () => void) {
|
||||
@@ -213,6 +240,38 @@ function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Custom Options
|
||||
// (empty)|(customized)|(modified) [Remove Icon]
|
||||
function makeCustomOptions(section: ViewSectionRec) {
|
||||
const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-gray" : "-green");
|
||||
const text = Computed.create(null, use => {
|
||||
if (use(section.activeCustomOptions)) {
|
||||
return use(section.activeCustomOptions.isSaved) ? "(customized)" : "(modified)";
|
||||
} else {
|
||||
return "(empty)";
|
||||
}
|
||||
});
|
||||
return [
|
||||
cssMenuInfoHeader('Custom options', testId('heading-widget-options')),
|
||||
cssMenuText(
|
||||
dom.autoDispose(text),
|
||||
dom.autoDispose(color),
|
||||
dom.text(text),
|
||||
cssMenuText.cls(color),
|
||||
cssSpacer(),
|
||||
dom.maybe(use => use(section.activeCustomOptions), () =>
|
||||
cssMenuIconWrapper(
|
||||
cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () =>
|
||||
section.activeCustomOptions(null)
|
||||
))
|
||||
),
|
||||
),
|
||||
testId("custom-options")
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
const clsOldUI = styled('div', ``);
|
||||
|
||||
const cssFixHeight = styled('div', `
|
||||
@@ -328,11 +387,16 @@ const cssMenuText = styled('div', `
|
||||
padding: 0px 24px 8px 24px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
&-green {
|
||||
color: ${colors.lightGreen};
|
||||
}
|
||||
&-gray {
|
||||
color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssGrayedMenuText = styled(cssMenuText, `
|
||||
color: ${colors.slate};
|
||||
padding-left: 24px;
|
||||
`);
|
||||
|
||||
const cssMenuTextLabel = styled('span', `
|
||||
@@ -363,9 +427,12 @@ const cssSmallIconWrapper = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
const cssSaveIconsWrapper = styled('div', `
|
||||
padding: 0 1px 0 1px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssSpacer = styled('div', `
|
||||
margin: 0 auto;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user