mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
1a6d427339
Summary: The sort and filter UI now has a more unified UI, with similar capabilities that are accessible from different parts of Grist. It's now also possible to pin individual filters to the filter bar, which replaces the old toggle for showing all filters in the filter bar. Test Plan: Various tests (browser, migration, project). Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3669
819 lines
28 KiB
TypeScript
819 lines
28 KiB
TypeScript
/**
|
|
* Builds the structure of the right-side panel containing configuration and assorted tools.
|
|
* It includes the regular tabs, to configure the Page (including several sub-tabs), and Field;
|
|
* and allows other tools, such as Activity Feed, to be rendered temporarily in its place.
|
|
*
|
|
* A single RightPanel object is created in AppUI for a document page, and attached to PagePanels.
|
|
* GristDoc registers callbacks with it to create various standard tabs. These are created as
|
|
* needed, and destroyed when hidden.
|
|
*
|
|
* In addition, tools such as "Activity Feed" may use openTool() to replace the panel header and
|
|
* content. The user may dismiss this panel.
|
|
*
|
|
* All methods above return an object which may be disposed to close and dispose that specific
|
|
* tab from the outside (e.g. when GristDoc is disposed).
|
|
*/
|
|
|
|
import * as commands from 'app/client/components/commands';
|
|
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
|
import {RefSelect} from 'app/client/components/RefSelect';
|
|
import ViewConfigTab from 'app/client/components/ViewConfigTab';
|
|
import {domAsync} from 'app/client/lib/domAsync';
|
|
import * as imports from 'app/client/lib/imports';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {createSessionObs} from 'app/client/lib/sessionObs';
|
|
import {reportError} from 'app/client/models/AppModel';
|
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
|
import {GridOptions} from 'app/client/ui/GridOptions';
|
|
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
|
import {linkId, selectBy} from 'app/client/ui/selectBy';
|
|
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
|
import {cssLabel} from 'app/client/ui/RightPanelStyles';
|
|
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
|
|
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes';
|
|
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
|
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {select} from 'app/client/ui2018/menus';
|
|
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
|
import {StringUnion} from 'app/common/StringUnion';
|
|
import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
|
|
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
|
|
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
|
import * as ko from 'knockout';
|
|
|
|
const t = makeT('RightPanel');
|
|
|
|
// Represents a top tab of the right side-pane.
|
|
const TopTab = StringUnion("pageWidget", "field");
|
|
|
|
// Represents a subtab of pageWidget in the right side-pane.
|
|
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
|
|
|
|
// Returns the icon and label of a type, default to those associate to 'record' type.
|
|
export function getFieldType(widgetType: IWidgetType|null) {
|
|
// A map of widget type to the icon and label to use for a field of that widget.
|
|
const fieldTypes = new Map<IWidgetType, {label: string, icon: IconName, pluralLabel: string}>([
|
|
['record', {label: t('Column', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Column', { count: 2 })}],
|
|
['detail', {label: t('Field', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Field', { count: 2 })}],
|
|
['single', {label: t('Field', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Field', { count: 2 })}],
|
|
['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}],
|
|
['custom', {label: t('Column', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Column', { count: 2 })}],
|
|
]);
|
|
|
|
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
|
|
}
|
|
|
|
export class RightPanel extends Disposable {
|
|
public readonly header: DomContents;
|
|
public readonly content: DomContents;
|
|
|
|
// If the panel is showing a tool, such as Action Log, instead of the usual section/field
|
|
// configuration, this will be set to the tool's header and content.
|
|
private _extraTool: Observable<IExtraTool|null>;
|
|
|
|
// Which of the two standard top tabs (page widget or field) is selected, or was last selected.
|
|
private _topTab = createSessionObs(this, "rightTopTab", "pageWidget", TopTab.guard);
|
|
|
|
// Which subtab is open for configuring page widget.
|
|
private _subTab = createSessionObs(this, "rightPageSubTab", "widget", PageSubTab.guard);
|
|
|
|
// Which type of page widget is active, e.g. "record" or "chart". This affects the names and
|
|
// icons in the top tab.
|
|
private _pageWidgetType = Computed.create<IWidgetType|null>(this, (use) => {
|
|
const section: ViewSectionRec = use(this._gristDoc.viewModel.activeSection);
|
|
return (use(section.parentKey) || null) as IWidgetType;
|
|
});
|
|
|
|
// Returns the active section if it's valid, null otherwise.
|
|
private _validSection = Computed.create(this, (use) => {
|
|
const sec = use(this._gristDoc.viewModel.activeSection);
|
|
return sec.getRowId() ? sec : null;
|
|
});
|
|
|
|
constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) {
|
|
super();
|
|
this._extraTool = _gristDoc.rightPanelTool;
|
|
this.autoDispose(subscribe(this._extraTool, (_use, tool) => tool && _isOpen.set(true)));
|
|
this.header = this._buildHeaderDom();
|
|
this.content = this._buildContentDom();
|
|
|
|
this.autoDispose(commands.createGroup({
|
|
fieldTabOpen: () => this._openFieldTab(),
|
|
viewTabOpen: () => this._openViewTab(),
|
|
sortFilterTabOpen: () => this._openSortFilter(),
|
|
dataSelectionTabOpen: () => this._openDataSelection()
|
|
}, this, true));
|
|
}
|
|
|
|
private _openFieldTab() {
|
|
this._open('field');
|
|
}
|
|
|
|
private _openViewTab() {
|
|
this._open('pageWidget', 'widget');
|
|
}
|
|
|
|
private _openSortFilter() {
|
|
this._open('pageWidget', 'sortAndFilter');
|
|
}
|
|
|
|
private _openDataSelection() {
|
|
this._open('pageWidget', 'data');
|
|
}
|
|
|
|
private _open(topTab: typeof TopTab.type, subTab?: typeof PageSubTab.type) {
|
|
bundleChanges(() => {
|
|
this._isOpen.set(true);
|
|
this._topTab.set(topTab);
|
|
if (subTab) {
|
|
this._subTab.set(subTab);
|
|
}
|
|
});
|
|
}
|
|
|
|
private _buildHeaderDom() {
|
|
return dom.domComputed((use) => {
|
|
if (!use(this._isOpen)) { return null; }
|
|
const tool = use(this._extraTool);
|
|
return tool ? this._buildToolHeader(tool) : this._buildStandardHeader();
|
|
});
|
|
}
|
|
|
|
private _buildToolHeader(tool: IExtraTool) {
|
|
return cssTopBarItem(cssTopBarIcon(tool.icon), tool.label,
|
|
cssHoverCircle(cssHoverIcon("CrossBig"),
|
|
dom.on('click', () => this._gristDoc.showTool('none')),
|
|
testId('right-tool-close'),
|
|
),
|
|
cssTopBarItem.cls('-selected', true)
|
|
);
|
|
}
|
|
|
|
private _buildStandardHeader() {
|
|
return dom.maybe(this._pageWidgetType, (type) => {
|
|
const widgetInfo = widgetTypes.get(type) || {label: 'Table', icon: 'TypeTable'};
|
|
const fieldInfo = getFieldType(type);
|
|
return [
|
|
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label,
|
|
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'),
|
|
dom.on('click', () => this._topTab.set("pageWidget")),
|
|
testId('right-tab-pagewidget')),
|
|
cssTopBarItem(cssTopBarIcon(fieldInfo.icon), fieldInfo.label,
|
|
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'field'),
|
|
dom.on('click', () => this._topTab.set("field")),
|
|
testId('right-tab-field')),
|
|
];
|
|
});
|
|
}
|
|
|
|
private _buildContentDom() {
|
|
return dom.domComputed((use) => {
|
|
if (!use(this._isOpen)) { return null; }
|
|
const tool = use(this._extraTool);
|
|
if (tool) { return tabContentToDom(tool.content); }
|
|
|
|
const topTab = use(this._topTab);
|
|
if (topTab === 'field') {
|
|
return dom.create(this._buildFieldContent.bind(this));
|
|
}
|
|
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
|
|
return dom.create(this._buildPageWidgetContent.bind(this));
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
private _buildFieldContent(owner: MultiHolder) {
|
|
const fieldBuilder = owner.autoDispose(ko.computed(() => {
|
|
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
|
return vsi && vsi.activeFieldBuilder();
|
|
}));
|
|
|
|
const selectedColumns = owner.autoDispose(ko.computed(() => {
|
|
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
|
if (vsi && vsi.selectedColumns) {
|
|
return vsi.selectedColumns();
|
|
}
|
|
const field = fieldBuilder()?.field;
|
|
return field ? [field] : [];
|
|
}));
|
|
|
|
const isMultiSelect = owner.autoDispose(ko.pureComputed(() => {
|
|
const list = selectedColumns();
|
|
return Boolean(list && list.length > 1);
|
|
}));
|
|
|
|
owner.autoDispose(selectedColumns.subscribe(cols => {
|
|
if (owner.isDisposed() || this._gristDoc.isDisposed() || this._gristDoc.viewModel.isDisposed()) { return; }
|
|
const section = this._gristDoc.viewModel.activeSection();
|
|
if (!section || section.isDisposed()) { return; }
|
|
section.selectedFields(cols || []);
|
|
}));
|
|
this._gristDoc.viewModel.activeSection()?.selectedFields(selectedColumns.peek() || []);
|
|
|
|
const docModel = this._gristDoc.docModel;
|
|
const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));
|
|
const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));
|
|
const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef())));
|
|
|
|
// Builder for the reference display column multiselect.
|
|
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
|
|
|
|
// build cursor position observable
|
|
const cursor = owner.autoDispose(ko.computed(() => {
|
|
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
|
return vsi?.cursor.currentPosition() ?? {};
|
|
}));
|
|
|
|
return domAsync(imports.loadViewPane().then(ViewPane => {
|
|
const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig;
|
|
return dom.maybe(isColumnValid, () =>
|
|
buildConfigContainer(
|
|
cssSection(
|
|
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
|
|
),
|
|
cssSeparator(),
|
|
cssSection(
|
|
dom.create(buildFormulaConfig,
|
|
origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
|
|
),
|
|
cssSeparator(),
|
|
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
|
cssLabel(t('ColumnType')),
|
|
cssSection(
|
|
builder.buildSelectTypeDom(),
|
|
),
|
|
cssSection(
|
|
builder.buildSelectWidgetDom(),
|
|
),
|
|
cssSection(
|
|
builder.buildConfigDom(),
|
|
),
|
|
builder.buildColorConfigDom(),
|
|
cssSection(
|
|
builder.buildSettingOptions(),
|
|
dom.maybe(isMultiSelect, () => disabledSection())
|
|
),
|
|
]),
|
|
cssSeparator(),
|
|
cssSection(
|
|
dom.maybe(refSelect.isForeignRefCol, () => [
|
|
cssLabel('Add referenced columns'),
|
|
cssRow(refSelect.buildDom()),
|
|
cssSeparator()
|
|
]),
|
|
cssLabel(t('Transform')),
|
|
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
|
|
dom.maybe(isMultiSelect, () => disabledSection()),
|
|
testId('panel-transform'),
|
|
),
|
|
this._disableIfReadonly(),
|
|
)
|
|
);
|
|
}));
|
|
}
|
|
|
|
// Helper to activate the side-pane formula editor over the given HTML element.
|
|
private _activateFormulaEditor(
|
|
// Element to attach to.
|
|
refElem: Element,
|
|
// Simulate user typing on the cell - open editor with an initial value.
|
|
editValue?: string,
|
|
// Custom save handler.
|
|
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
|
// Custom cancel handler.
|
|
onCancel?: () => void) {
|
|
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
|
if (!vsi) { return; }
|
|
const editRowModel = vsi.moveEditRowToCursor();
|
|
return vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem, editValue, onSave, onCancel);
|
|
}
|
|
|
|
private _buildPageWidgetContent(_owner: MultiHolder) {
|
|
return [
|
|
cssSubTabContainer(
|
|
cssSubTab(t('Widget'),
|
|
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
|
|
dom.on('click', () => this._subTab.set("widget")),
|
|
testId('config-widget')),
|
|
cssSubTab(t('SortAndFilter'),
|
|
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'sortAndFilter'),
|
|
dom.on('click', () => this._subTab.set("sortAndFilter")),
|
|
testId('config-sortAndFilter')),
|
|
cssSubTab(t('Data'),
|
|
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
|
|
dom.on('click', () => this._subTab.set("data")),
|
|
testId('config-data')),
|
|
),
|
|
dom.domComputed(this._subTab, (subTab) => (
|
|
dom.maybe(this._validSection, (activeSection) => (
|
|
buildConfigContainer(
|
|
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
|
|
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) :
|
|
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
|
|
null
|
|
)
|
|
))
|
|
))
|
|
];
|
|
}
|
|
|
|
private _createViewConfigTab(owner: MultiHolder): Observable<null|ViewConfigTab> {
|
|
const viewConfigTab = Observable.create<null|ViewConfigTab>(owner, null);
|
|
const gristDoc = this._gristDoc;
|
|
imports.loadViewPane()
|
|
.then(ViewPane => {
|
|
if (owner.isDisposed()) { return; }
|
|
viewConfigTab.set(owner.autoDispose(
|
|
ViewPane.ViewConfigTab.create({gristDoc, viewModel: gristDoc.viewModel})));
|
|
})
|
|
.catch(reportError);
|
|
return viewConfigTab;
|
|
}
|
|
|
|
private _buildPageWidgetConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
|
|
// TODO: This uses private methods from ViewConfigTab. These methods are likely to get
|
|
// refactored, but if not, should be made public.
|
|
const viewConfigTab = this._createViewConfigTab(owner);
|
|
const hasCustomMapping = Computed.create(owner, use => {
|
|
const isCustom = use(this._pageWidgetType) === 'custom';
|
|
const hasColumnMapping = use(activeSection.columnsToMap);
|
|
return Boolean(isCustom && hasColumnMapping);
|
|
});
|
|
return dom.maybe(viewConfigTab, (vct) => [
|
|
this._disableIfReadonly(),
|
|
cssLabel(dom.text(use => use(activeSection.isRaw) ? t('DataTableName') : t('WidgetTitle')),
|
|
dom.style('margin-bottom', '14px'),
|
|
),
|
|
cssRow(cssTextInput(
|
|
Computed.create(owner, (use) => use(activeSection.titleDef)),
|
|
val => activeSection.titleDef.saveOnly(val),
|
|
dom.boolAttr('disabled', use => {
|
|
const isRawTable = use(activeSection.isRaw);
|
|
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
|
|
return isRawTable && isSummaryTable;
|
|
}),
|
|
testId('right-widget-title')
|
|
)),
|
|
|
|
dom.maybe(
|
|
(use) => !use(activeSection.isRaw),
|
|
() => cssRow(
|
|
primaryButton(t('ChangeWidget'), this._createPageWidgetPicker()),
|
|
cssRow.cls('-top-space')
|
|
),
|
|
),
|
|
|
|
cssSeparator(),
|
|
|
|
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
|
|
cssLabel(t('Theme')),
|
|
dom('div',
|
|
vct._buildThemeDom(),
|
|
vct._buildLayoutDom())
|
|
]),
|
|
|
|
domComputed((use) => {
|
|
if (use(this._pageWidgetType) !== 'record') { return null; }
|
|
return dom.create(GridOptions, activeSection);
|
|
}),
|
|
|
|
domComputed((use) => {
|
|
if (use(this._pageWidgetType) !== 'record') { return null; }
|
|
return [
|
|
cssSeparator(),
|
|
cssLabel(t('RowStyleUpper')),
|
|
domAsync(imports.loadViewPane().then(ViewPane =>
|
|
dom.create(ViewPane.ConditionalStyle, t("RowStyle"), activeSection, this._gristDoc)
|
|
))
|
|
];
|
|
}),
|
|
|
|
dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [
|
|
cssLabel(t('ChartType')),
|
|
vct._buildChartConfigDom(),
|
|
]),
|
|
|
|
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
|
const parts = vct._buildCustomTypeItems() as any[];
|
|
return [
|
|
cssLabel(t('Custom')),
|
|
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
|
|
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
|
|
// the only one that will be shown without the feature flag.
|
|
dom.maybe((use) => use(this._gristDoc.app.features).customViewPlugin,
|
|
() => dom('div', parts[0].buildDom())),
|
|
dom.maybe(use => use(activeSection.customDef.mode) === 'plugin',
|
|
() => dom('div', parts[2].buildDom())),
|
|
// 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)),
|
|
];
|
|
}),
|
|
|
|
dom.maybe(
|
|
(use) => !(
|
|
use(hasCustomMapping) ||
|
|
use(this._pageWidgetType) === 'chart' ||
|
|
use(activeSection.isRaw)
|
|
),
|
|
() => [
|
|
cssSeparator(),
|
|
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
private _buildPageSortFilterConfig(owner: MultiHolder) {
|
|
const viewConfigTab = this._createViewConfigTab(owner);
|
|
return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom());
|
|
}
|
|
|
|
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
|
|
const viewConfigTab = this._createViewConfigTab(owner);
|
|
const viewModel = this._gristDoc.viewModel;
|
|
const table = activeSection.table;
|
|
const groupedBy = Computed.create(owner, (use) => use(use(table).groupByColumns));
|
|
const link = Computed.create(owner, (use) => {
|
|
return linkId({
|
|
srcSectionRef: use(activeSection.linkSrcSectionRef),
|
|
srcColRef: use(activeSection.linkSrcColRef),
|
|
targetColRef: use(activeSection.linkTargetColRef)
|
|
});
|
|
});
|
|
|
|
// This computed is not enough to make sure that the linkOptions are up to date. Indeed
|
|
// the selectBy function depends on a much greater number of observables. Creating that many
|
|
// dependencies does not seem a better approach. Instead, we refresh the list of
|
|
// linkOptions only when the user clicks on the dropdown. Such behavior is not supported by the
|
|
// weasel select function as of writing and would require a custom implementation, so we will simulate
|
|
// this behavior by using temporary observable that will be changed when the user clicks on the dropdown.
|
|
const refreshTrigger = Observable.create(owner, false);
|
|
const linkOptions = Computed.create(owner, (use) => {
|
|
void use(refreshTrigger);
|
|
return selectBy(
|
|
this._gristDoc.docModel,
|
|
viewModel.viewSections().all(),
|
|
activeSection,
|
|
);
|
|
});
|
|
|
|
link.onWrite((val) => this._gristDoc.saveLink(val));
|
|
return [
|
|
this._disableIfReadonly(),
|
|
cssLabel(t('DataTable')),
|
|
cssRow(
|
|
cssIcon('TypeTable'), cssDataLabel(t('SourceData')),
|
|
cssContent(dom.text((use) => use(use(table).primaryTableId)),
|
|
testId('pwc-table'))
|
|
),
|
|
dom(
|
|
'div',
|
|
cssRow(cssIcon('Pivot'), cssDataLabel(t('GroupedBy'))),
|
|
cssRow(domComputed(groupedBy, (cols) => cssList(cols.map((c) => (
|
|
cssListItem(dom.text(c.label),
|
|
testId('pwc-groupedBy-col'))
|
|
))))),
|
|
|
|
testId('pwc-groupedBy'),
|
|
// hide if not a summary table
|
|
dom.hide((use) => !use(use(table).summarySourceTable)),
|
|
),
|
|
|
|
dom.maybe((use) => !use(activeSection.isRaw), () =>
|
|
cssButtonRow(primaryButton(t('EditDataSelection'), this._createPageWidgetPicker(),
|
|
testId('pwc-editDataSelection')),
|
|
dom.maybe(
|
|
use => Boolean(use(use(activeSection.table).summarySourceTable)),
|
|
() => basicButton(
|
|
t('Detach'),
|
|
dom.on('click', () => this._gristDoc.docData.sendAction(
|
|
["DetachSummaryViewSection", activeSection.getRowId()])),
|
|
testId('detach-button'),
|
|
)),
|
|
cssRow.cls('-top-space'),
|
|
)),
|
|
|
|
// TODO: "Advanced settings" is for "on-demand" marking of tables. This should only be shown
|
|
// for raw data tables (once that's supported), should have updated UI, and should possibly
|
|
// be hidden for free plans.
|
|
dom.maybe(viewConfigTab, (vct) => cssRow(
|
|
dom('div', vct._buildAdvancedSettingsDom()),
|
|
)),
|
|
cssSeparator(),
|
|
|
|
dom.maybe((use) => !use(activeSection.isRaw), () => [
|
|
cssLabel(t('SelectBy')),
|
|
cssRow(
|
|
dom.update(
|
|
select(link, linkOptions, {defaultLabel: t('SelectWidget')}),
|
|
dom.on('click', () => {
|
|
refreshTrigger.set(!refreshTrigger.get());
|
|
})
|
|
),
|
|
testId('right-select-by')
|
|
),
|
|
]),
|
|
|
|
domComputed((use) => {
|
|
const activeSectionRef = activeSection.getRowId();
|
|
const allViewSections = use(use(viewModel.viewSections).getObservable());
|
|
const selectorFor = allViewSections.filter((sec) => use(sec.linkSrcSectionRef) === activeSectionRef);
|
|
// TODO: sections should be listed following the order of appearance in the view layout (ie:
|
|
// left/right - top/bottom);
|
|
return selectorFor.length ? [
|
|
cssLabel(t('SelectorFor'), testId('selector-for')),
|
|
cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec))))
|
|
] : null;
|
|
}),
|
|
];
|
|
}
|
|
|
|
private _createPageWidgetPicker(): DomElementMethod {
|
|
const gristDoc = this._gristDoc;
|
|
const section = gristDoc.viewModel.activeSection;
|
|
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
|
return (elem) => { attachPageWidgetPicker(elem, gristDoc.docModel, onSave, {
|
|
buttonLabel: t('Save'),
|
|
value: () => toPageWidget(section.peek()),
|
|
selectBy: (val) => gristDoc.selectBy(val),
|
|
}); };
|
|
}
|
|
|
|
// Returns dom for a section item.
|
|
private _buildSectionItem(sec: ViewSectionRec) {
|
|
return cssListItem(
|
|
dom.text(sec.titleDef),
|
|
testId('selector-for-entry')
|
|
);
|
|
}
|
|
|
|
// Returns a DomArg that disables the content of the panel by adding a transparent overlay on top
|
|
// of it.
|
|
private _disableIfReadonly() {
|
|
if (this._gristDoc.docPageModel) {
|
|
return dom.maybe(this._gristDoc.docPageModel.isReadonly, () => (
|
|
cssOverlay(
|
|
testId('disable-overlay'),
|
|
cssBottomText(t('NoEditAccess')),
|
|
)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
function disabledSection() {
|
|
return cssOverlay(
|
|
testId('panel-disabled-section'),
|
|
);
|
|
}
|
|
|
|
export function buildConfigContainer(...args: DomElementArg[]): HTMLElement {
|
|
return cssConfigContainer(
|
|
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
|
|
// we cannot set it on the cssConfigContainer directly because it conflicts with how overflow
|
|
// works. `padding-top: 1px;` prevents collapsing the top margins for the container and the
|
|
// first child.
|
|
dom('div', {style: 'position: relative; padding-top: 1px;'}, ...args),
|
|
);
|
|
}
|
|
|
|
// This logic is copied from SidePane.js for building DOM from TabContent.
|
|
// TODO It may not be needed after new-ui refactoring of the side-pane content.
|
|
function tabContentToDom(content: Observable<TabContent[]>|TabContent[]|IDomComponent) {
|
|
function buildItemDom(item: any) {
|
|
return dom('div.config_item',
|
|
dom.show(item.showObs || true),
|
|
item.buildDom()
|
|
);
|
|
}
|
|
|
|
if ("buildDom" in content) {
|
|
return content.buildDom();
|
|
}
|
|
|
|
return cssTabContents(
|
|
dom.forEach(content, itemOrHeader => {
|
|
if (itemOrHeader.header) {
|
|
return dom('div.config_group',
|
|
dom.show(itemOrHeader.showObs || true),
|
|
itemOrHeader.label ? dom('div.config_header', itemOrHeader.label) : null,
|
|
dom.forEach(itemOrHeader.items, item => buildItemDom(item)),
|
|
);
|
|
} else {
|
|
return buildItemDom(itemOrHeader);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
const cssOverlay = styled('div', `
|
|
background-color: ${theme.rightPanelDisabledOverlay};
|
|
opacity: 0.8;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 100;
|
|
`);
|
|
|
|
const cssBottomText = styled('span', `
|
|
color: ${theme.text};
|
|
position: absolute;
|
|
bottom: -40px;
|
|
padding: 4px 16px;
|
|
`);
|
|
|
|
const cssRow = styled('div', `
|
|
color: ${theme.text};
|
|
display: flex;
|
|
margin: 8px 16px;
|
|
align-items: center;
|
|
&-top-space {
|
|
margin-top: 24px;
|
|
}
|
|
&-disabled {
|
|
color: ${theme.disabledText};
|
|
}
|
|
`);
|
|
|
|
|
|
const cssButtonRow = styled(cssRow, `
|
|
margin-left: 0;
|
|
margin-right: 0;
|
|
& > button {
|
|
margin-left: 16px;
|
|
}
|
|
`);
|
|
|
|
const cssIcon = styled(icon, `
|
|
flex: 0 0 auto;
|
|
--icon-color: ${theme.lightText};
|
|
`);
|
|
|
|
const cssTopBarItem = styled('div', `
|
|
flex: 1 1 0px;
|
|
height: 100%;
|
|
background-color: ${theme.rightPanelTabBg};
|
|
font-weight: ${vars.headerControlTextWeight};
|
|
color: ${theme.rightPanelTabFg};
|
|
--icon-color: ${theme.rightPanelTabIcon};
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: default;
|
|
|
|
&-selected {
|
|
background-color: ${theme.rightPanelTabSelectedBg};
|
|
font-weight: initial;
|
|
color: ${theme.rightPanelTabSelectedFg};
|
|
--icon-color: ${theme.rightPanelTabSelectedFg};
|
|
}
|
|
&:not(&-selected):hover {
|
|
background-color: ${theme.rightPanelTabHoverBg};
|
|
--icon-color: ${theme.rightPanelTabIconHover};
|
|
}
|
|
`);
|
|
|
|
const cssTopBarIcon = styled(icon, `
|
|
flex: none;
|
|
margin: 16px;
|
|
height: 16px;
|
|
width: 16px;
|
|
background-color: var(--icon-color);
|
|
`);
|
|
|
|
const cssHoverCircle = styled('div', `
|
|
margin-left: auto;
|
|
margin-right: 8px;
|
|
width: 32px;
|
|
height: 32px;
|
|
background: none;
|
|
border-radius: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&:hover {
|
|
background-color: ${theme.rightPanelTabButtonHoverBg};
|
|
}
|
|
`);
|
|
|
|
const cssHoverIcon = styled(icon, `
|
|
height: 16px;
|
|
width: 16px;
|
|
background-color: var(--icon-color);
|
|
`);
|
|
|
|
const cssSubTabContainer = styled('div', `
|
|
height: 48px;
|
|
flex: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
`);
|
|
|
|
const cssSubTab = styled('div', `
|
|
color: ${theme.rightPanelSubtabFg};
|
|
flex: auto;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
text-align: center;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
|
cursor: default;
|
|
|
|
&-selected {
|
|
color: ${theme.rightPanelSubtabSelectedFg};
|
|
border-bottom: 1px solid ${theme.rightPanelSubtabSelectedUnderline};
|
|
}
|
|
&:not(&-selected):hover {
|
|
color: ${theme.rightPanelSubtabHoverFg};
|
|
}
|
|
&:hover {
|
|
border-bottom: 1px solid ${theme.rightPanelSubtabHoverUnderline};
|
|
}
|
|
.${cssSubTabContainer.className}:hover > &-selected:not(:hover) {
|
|
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
|
}
|
|
`);
|
|
|
|
const cssTabContents = styled('div', `
|
|
padding: 16px 8px;
|
|
overflow: auto;
|
|
`);
|
|
|
|
const cssSeparator = styled('div', `
|
|
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
|
margin-top: 16px;
|
|
`);
|
|
|
|
const cssConfigContainer = styled('div', `
|
|
overflow: auto;
|
|
--color-list-item: none;
|
|
--color-list-item-hover: none;
|
|
|
|
&:after {
|
|
content: "";
|
|
display: block;
|
|
height: 40px;
|
|
}
|
|
& .fieldbuilder_settings {
|
|
margin: 16px 0 0 0;
|
|
}
|
|
`);
|
|
|
|
const cssDataLabel = styled('div', `
|
|
flex: 0 0 81px;
|
|
color: ${theme.lightText};
|
|
font-size: ${vars.xsmallFontSize};
|
|
margin-left: 4px;
|
|
margin-top: 2px;
|
|
`);
|
|
|
|
const cssContent = styled('div', `
|
|
flex: 0 1 auto;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
min-width: 1em;
|
|
`);
|
|
|
|
const cssList = styled('div', `
|
|
list-style: none;
|
|
width: 100%;
|
|
`);
|
|
|
|
|
|
const cssListItem = styled('li', `
|
|
background-color: ${theme.hover};
|
|
border-radius: 2px;
|
|
margin-bottom: 4px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
`);
|
|
|
|
const cssTextInput = styled(textInput, `
|
|
flex: 1 0 auto;
|
|
color: ${theme.inputFg};
|
|
background-color: ${theme.inputBg};
|
|
|
|
&:disabled {
|
|
color: ${theme.inputDisabledFg};
|
|
background-color: ${theme.inputDisabledBg};
|
|
pointer-events: none;
|
|
}
|
|
`);
|
|
|
|
const cssSection = styled('div', `
|
|
position: relative;
|
|
`);
|