2022-01-12 13:30:51 +00:00
|
|
|
import {allCommands} from 'app/client/components/commands';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
2023-10-06 09:11:37 +00:00
|
|
|
import {makeTestId} from 'app/client/lib/domUtils';
|
2024-08-13 23:21:48 +00:00
|
|
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
2021-11-26 10:43:55 +00:00
|
|
|
import * as kf from 'app/client/lib/koForm';
|
2023-08-29 14:50:42 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2024-08-13 23:21:48 +00:00
|
|
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
2022-02-08 15:23:14 +00:00
|
|
|
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
|
|
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
2024-08-13 23:21:48 +00:00
|
|
|
import {
|
|
|
|
cssDeveloperLink,
|
|
|
|
cssWidgetMetadata,
|
|
|
|
cssWidgetMetadataName,
|
|
|
|
cssWidgetMetadataRow,
|
|
|
|
cssWidgetMetadataValue,
|
|
|
|
CUSTOM_URL_WIDGET_ID,
|
|
|
|
getWidgetName,
|
|
|
|
showCustomWidgetGallery,
|
|
|
|
} from 'app/client/ui/CustomWidgetGallery';
|
2022-08-26 12:25:34 +00:00
|
|
|
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
2023-10-06 09:11:37 +00:00
|
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
2022-02-08 15:23:14 +00:00
|
|
|
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
|
2022-01-12 13:30:51 +00:00
|
|
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
2023-04-11 05:00:28 +00:00
|
|
|
import {theme, vars} from 'app/client/ui2018/cssVars';
|
2022-02-08 15:23:14 +00:00
|
|
|
import {cssDragger} from 'app/client/ui2018/draggableList';
|
2022-08-08 13:32:50 +00:00
|
|
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
2022-01-12 13:30:51 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2023-10-06 09:11:37 +00:00
|
|
|
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
2023-10-27 19:34:42 +00:00
|
|
|
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
2023-10-06 09:11:37 +00:00
|
|
|
import {not, unwrap} from 'app/common/gutil';
|
2023-08-29 14:50:42 +00:00
|
|
|
import {
|
|
|
|
bundleChanges,
|
|
|
|
Computed,
|
|
|
|
Disposable,
|
|
|
|
dom,
|
2024-08-13 23:21:48 +00:00
|
|
|
DomContents,
|
2023-08-29 14:50:42 +00:00
|
|
|
fromKo,
|
|
|
|
MultiHolder,
|
|
|
|
Observable,
|
|
|
|
styled,
|
|
|
|
UseCBOwner
|
|
|
|
} from 'grainjs';
|
2022-10-28 16:11:08 +00:00
|
|
|
|
|
|
|
const t = makeT('CustomSectionConfig');
|
2021-11-26 10:43:55 +00:00
|
|
|
|
|
|
|
const testId = makeTestId('test-config-widget-');
|
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
class ColumnPicker extends Disposable {
|
|
|
|
constructor(
|
|
|
|
private _value: Observable<number|number[]|null>,
|
|
|
|
private _column: ColumnToMapImpl,
|
|
|
|
private _section: ViewSectionRec){
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
public buildDom() {
|
|
|
|
// Rewrite value to ignore old configuration when allowMultiple is switched.
|
|
|
|
const properValue = Computed.create(this, use => {
|
|
|
|
const value = use(this._value);
|
|
|
|
return Array.isArray(value) ? null : value;
|
|
|
|
});
|
2023-10-06 09:11:37 +00:00
|
|
|
properValue.onWrite(value => this._value.set(value || null));
|
|
|
|
|
|
|
|
const canBeMapped = Computed.create(this, use => {
|
2022-02-08 15:23:14 +00:00
|
|
|
return use(this._section.columns)
|
2023-10-06 09:11:37 +00:00
|
|
|
.filter(col => this._column.canByMapped(use(col.pureType)));
|
|
|
|
});
|
|
|
|
|
|
|
|
// This is a HACK, to refresh options only when the menu is opened (or closed)
|
|
|
|
// and not to track down all the dependencies. Otherwise the select menu won't
|
|
|
|
// be hidden when option is selected - there is a bug that prevents it from closing
|
|
|
|
// when list of options is changed.
|
|
|
|
const refreshTrigger = Observable.create(this, false);
|
|
|
|
|
|
|
|
const options = Computed.create(this, use => {
|
|
|
|
void use(refreshTrigger);
|
|
|
|
|
|
|
|
const columnsAsOptions: IOption<number|null>[] = use(canBeMapped)
|
|
|
|
.map((col) => ({
|
|
|
|
value: col.getRowId(),
|
|
|
|
label: col.label.peek(),
|
|
|
|
icon: 'FieldColumn',
|
|
|
|
}));
|
|
|
|
|
|
|
|
// For optional mappings, add 'Blank' option but only if the value is set.
|
|
|
|
// This option will allow to clear the selection.
|
|
|
|
if (this._column.optional && properValue.get()) {
|
|
|
|
columnsAsOptions.push({
|
|
|
|
value: 0,
|
|
|
|
// Another hack. Select doesn't allow to have different label for blank option and the default text.
|
|
|
|
// So we will render this label ourselves later using `renderOptionArgs`.
|
|
|
|
label: '',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return columnsAsOptions;
|
|
|
|
});
|
|
|
|
|
|
|
|
const isDisabled = Computed.create(this, use => {
|
|
|
|
return use(canBeMapped).length === 0;
|
2022-02-08 15:23:14 +00:00
|
|
|
});
|
2023-10-06 09:11:37 +00:00
|
|
|
|
|
|
|
const defaultLabel = this._column.typeDesc != "any"
|
|
|
|
? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc})
|
|
|
|
: t("Pick a column");
|
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
return [
|
|
|
|
cssLabel(
|
|
|
|
this._column.title,
|
2022-12-06 13:57:29 +00:00
|
|
|
this._column.optional ? cssSubLabel(t(" (optional)")) : null,
|
2022-02-08 15:23:14 +00:00
|
|
|
testId('label-for-' + this._column.name),
|
|
|
|
),
|
2022-08-26 12:25:34 +00:00
|
|
|
this._column.description ? cssHelp(
|
|
|
|
this._column.description,
|
|
|
|
testId('help-for-' + this._column.name),
|
|
|
|
) : null,
|
2023-10-06 09:11:37 +00:00
|
|
|
dom.maybe(not(isDisabled), () => [
|
|
|
|
cssRow(
|
|
|
|
dom.update(
|
|
|
|
select(
|
|
|
|
properValue,
|
|
|
|
options,
|
|
|
|
{
|
|
|
|
defaultLabel,
|
|
|
|
renderOptionArgs : (opt) => {
|
|
|
|
// If there is a label, render it.
|
|
|
|
// Otherwise show the 'Clear selection' label as a greyed out text.
|
|
|
|
// This is the continuation of the hack from above - were we added an option
|
|
|
|
// without a label.
|
|
|
|
return (opt.label) ? null : [
|
|
|
|
cssBlank(t("Clear selection")),
|
|
|
|
testId('clear-selection'),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
),
|
|
|
|
dom.on('click', () => {
|
|
|
|
// When the menu is opened or closed, refresh the options.
|
|
|
|
refreshTrigger.set(!refreshTrigger.get());
|
|
|
|
})
|
|
|
|
),
|
|
|
|
testId('mapping-for-' + this._column.name),
|
|
|
|
testId('enabled'),
|
|
|
|
),
|
|
|
|
]),
|
|
|
|
dom.maybe(isDisabled, () => [
|
|
|
|
cssRow(
|
|
|
|
cssDisabledSelect(
|
|
|
|
Observable.create(this, null),
|
|
|
|
[], {
|
|
|
|
disabled: true,
|
|
|
|
defaultLabel: t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc})
|
|
|
|
}
|
|
|
|
),
|
|
|
|
hoverTooltip(t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc})),
|
|
|
|
testId('mapping-for-' + this._column.name),
|
|
|
|
testId('disabled'),
|
|
|
|
),
|
|
|
|
]),
|
2022-02-08 15:23:14 +00:00
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class ColumnListPicker extends Disposable {
|
|
|
|
constructor(
|
|
|
|
private _value: Observable<number|number[]|null>,
|
|
|
|
private _column: ColumnToMapImpl,
|
|
|
|
private _section: ViewSectionRec) {
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
public buildDom() {
|
|
|
|
return dom.domComputed((use) => {
|
|
|
|
return [
|
|
|
|
cssLabel(this._column.title,
|
|
|
|
cssLabel.cls("-required", !this._column.optional),
|
|
|
|
testId('label-for-' + this._column.name),
|
|
|
|
),
|
|
|
|
this._buildDraggableList(use),
|
|
|
|
this._buildAddColumn()
|
|
|
|
];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
private _buildAddColumn() {
|
2023-10-06 09:11:37 +00:00
|
|
|
|
|
|
|
const owner = MultiHolder.create(null);
|
|
|
|
|
|
|
|
const notMapped = Computed.create(owner, use => {
|
|
|
|
const value = use(this._value) || [];
|
|
|
|
const mapped = !Array.isArray(value) ? [] : value;
|
|
|
|
return this._section.columns().filter(col => !mapped.includes(use(col.id)));
|
|
|
|
});
|
|
|
|
|
|
|
|
const typedColumns = Computed.create(owner, use => {
|
|
|
|
return use(notMapped).filter(this._typeFilter(use));
|
|
|
|
});
|
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
return [
|
|
|
|
cssRow(
|
2023-10-06 09:11:37 +00:00
|
|
|
dom.autoDispose(owner),
|
2022-02-08 15:23:14 +00:00
|
|
|
cssAddMapping(
|
2022-12-06 13:57:29 +00:00
|
|
|
cssAddIcon('Plus'), t("Add") + ' ' + this._column.title,
|
2023-10-06 09:11:37 +00:00
|
|
|
dom.cls('disabled', use => use(notMapped).length === 0),
|
|
|
|
testId('disabled', use => use(notMapped).length === 0),
|
2022-02-08 15:23:14 +00:00
|
|
|
menu(() => {
|
2023-10-06 09:11:37 +00:00
|
|
|
const wrongTypeCount = notMapped.get().length - typedColumns.get().length;
|
2022-02-08 15:23:14 +00:00
|
|
|
return [
|
2023-10-06 09:11:37 +00:00
|
|
|
...typedColumns.get()
|
2022-02-08 15:23:14 +00:00
|
|
|
.map((col) => menuItem(
|
|
|
|
() => this._addColumn(col),
|
|
|
|
col.label.peek(),
|
|
|
|
)),
|
|
|
|
wrongTypeCount > 0 ? menuText(
|
2022-12-27 18:35:03 +00:00
|
|
|
t("{{wrongTypeCount}} non-{{columnType}} columns are not shown", {
|
|
|
|
wrongTypeCount,
|
|
|
|
columnType: this._column.type.toLowerCase(),
|
|
|
|
count: wrongTypeCount
|
|
|
|
}),
|
2022-02-08 15:23:14 +00:00
|
|
|
testId('map-message-' + this._column.name)
|
|
|
|
) : null
|
|
|
|
];
|
|
|
|
}),
|
|
|
|
testId('add-column-for-' + this._column.name),
|
|
|
|
)
|
|
|
|
),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper method for filtering columns that can be picked by the widget.
|
2023-10-06 09:11:37 +00:00
|
|
|
private _typeFilter = (use = unwrap) => (col: ColumnRec|null) =>
|
|
|
|
!col ? false : this._column.canByMapped(use(col.pureType));
|
2022-02-08 15:23:14 +00:00
|
|
|
|
|
|
|
private _buildDraggableList(use: UseCBOwner) {
|
|
|
|
return dom.update(kf.draggableList(
|
|
|
|
this._readItems(use),
|
|
|
|
this._renderItem.bind(this, use),
|
|
|
|
{
|
|
|
|
itemClass: cssDragRow.className,
|
|
|
|
reorder: this._reorder.bind(this),
|
|
|
|
receive: this._addColumn.bind(this),
|
|
|
|
drag_indicator: cssDragger,
|
|
|
|
}
|
|
|
|
), testId('map-list-for-' + this._column.name));
|
|
|
|
}
|
2023-10-06 09:11:37 +00:00
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
private _readItems(use: UseCBOwner): ColumnRec[] {
|
|
|
|
let selectedRefs = (use(this._value) || []) as number[];
|
|
|
|
// Ignore if configuration was changed from what it was saved.
|
|
|
|
if (!Array.isArray(selectedRefs)) {
|
|
|
|
selectedRefs = [];
|
|
|
|
}
|
|
|
|
// Filter columns by type - when column type has changed since mapping.
|
|
|
|
const columns = use(this._section.columns).filter(this._typeFilter(use));
|
|
|
|
const columnMap = new Map(columns.map(c => [c.id.peek(), c]));
|
|
|
|
// Remove any columns that are no longer there.
|
2023-08-29 14:50:42 +00:00
|
|
|
return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));
|
2022-02-08 15:23:14 +00:00
|
|
|
}
|
2023-10-06 09:11:37 +00:00
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
private _renderItem(use: UseCBOwner, field: ColumnRec): any {
|
|
|
|
return cssFieldEntry(
|
|
|
|
cssFieldLabel(
|
|
|
|
dom.text(field.label),
|
|
|
|
testId('ref-select-label'),
|
|
|
|
),
|
|
|
|
cssRemoveIcon(
|
|
|
|
'Remove',
|
|
|
|
dom.on('click', () => this._remove(field)),
|
|
|
|
testId('ref-select-remove'),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper method that for accessing mapped columns. Can be used to set and retrieve the value.
|
|
|
|
private _list(value: number[]): void
|
|
|
|
private _list(): number[]
|
|
|
|
private _list(value?: number[]) {
|
|
|
|
if (value) {
|
|
|
|
this._value.set(value);
|
|
|
|
} else {
|
|
|
|
let current = (this._value.get() || []) as number[];
|
2023-10-06 09:11:37 +00:00
|
|
|
// Ignore if the saved value is not a number list.
|
2022-02-08 15:23:14 +00:00
|
|
|
if (!Array.isArray(current)) {
|
|
|
|
current = [];
|
|
|
|
}
|
|
|
|
return current;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _reorder(column: ColumnRec, nextColumn: ColumnRec|null): any {
|
|
|
|
const id = column.id.peek();
|
|
|
|
const nextId = nextColumn?.id.peek();
|
|
|
|
const currentList = this._list();
|
|
|
|
const indexOfId = currentList.indexOf(id);
|
|
|
|
// Remove element from the list.
|
|
|
|
currentList.splice(indexOfId, 1);
|
|
|
|
const indexOfNext = nextId ? currentList.indexOf(nextId) : currentList.length;
|
|
|
|
// Insert before next element or at the end.
|
|
|
|
currentList.splice(indexOfNext, 0, id);
|
|
|
|
this._list(currentList);
|
|
|
|
}
|
|
|
|
private _remove(column: ColumnRec): any {
|
|
|
|
const current = this._list();
|
|
|
|
this._value.set(current.filter(c => c != column.id.peek()));
|
|
|
|
}
|
|
|
|
private _addColumn(col: ColumnRec): any {
|
2023-10-06 09:11:37 +00:00
|
|
|
// Helper to find column model.
|
|
|
|
const model = (id: number) => this._section.columns().find(c => c.id.peek() === id) || null;
|
|
|
|
// Get the list of currently mapped columns.
|
|
|
|
let current = this._list();
|
|
|
|
// Add new column.
|
2022-02-08 15:23:14 +00:00
|
|
|
current.push(col.id.peek());
|
2023-10-06 09:11:37 +00:00
|
|
|
// Remove those that don't exists anymore.
|
|
|
|
current = current.filter(c => model(c));
|
|
|
|
// And those with wrong type.
|
|
|
|
current = current.filter(c => this._typeFilter()(model(c)));
|
2022-02-08 15:23:14 +00:00
|
|
|
this._value.set(current);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-29 14:50:42 +00:00
|
|
|
class CustomSectionConfigurationConfig extends Disposable{
|
2024-08-13 23:21:48 +00:00
|
|
|
private readonly _hasConfiguration = Computed.create(this, use =>
|
|
|
|
Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
|
|
|
|
|
2023-09-19 02:48:56 +00:00
|
|
|
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
2023-08-29 14:50:42 +00:00
|
|
|
super();
|
|
|
|
}
|
2024-08-13 23:21:48 +00:00
|
|
|
|
2023-08-29 14:50:42 +00:00
|
|
|
public buildDom() {
|
2024-08-13 23:21:48 +00:00
|
|
|
return dom.maybe(this._hasConfiguration, () => [
|
|
|
|
cssSeparator(),
|
|
|
|
dom.maybe(this._section.hasCustomOptions, () =>
|
2023-08-29 14:50:42 +00:00
|
|
|
cssSection(
|
|
|
|
textButton(
|
|
|
|
t("Open configuration"),
|
|
|
|
dom.on('click', () => this._openConfiguration()),
|
|
|
|
testId('open-configuration')
|
|
|
|
)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
|
|
|
|
const createObs = (column: ColumnToMapImpl) => {
|
|
|
|
const obs = Computed.create(owner, use => {
|
|
|
|
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
|
|
|
|
return savedDefinition[column.name];
|
|
|
|
});
|
|
|
|
obs.onWrite(async (value) => {
|
|
|
|
const savedDefinition = this._section.customDef.columnsMapping.peek() || {};
|
|
|
|
savedDefinition[column.name] = value;
|
|
|
|
await this._section.customDef.columnsMapping.setAndSave(savedDefinition);
|
|
|
|
});
|
|
|
|
return obs;
|
|
|
|
};
|
|
|
|
// Create observables for all columns to pick.
|
|
|
|
const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({
|
|
|
|
value: createObs(column),
|
|
|
|
column
|
|
|
|
}));
|
2023-09-19 02:48:56 +00:00
|
|
|
return dom('div',
|
|
|
|
this._attachColumnMappingTip(this._section.customDef.url()),
|
2023-08-29 14:50:42 +00:00
|
|
|
...mappings.map(m => m.column.allowMultiple
|
|
|
|
? dom.create(ColumnListPicker, m.value, m.column, this._section)
|
2023-09-19 02:48:56 +00:00
|
|
|
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
|
|
|
);
|
2023-08-29 14:50:42 +00:00
|
|
|
})
|
2024-08-13 23:21:48 +00:00
|
|
|
]);
|
2023-08-29 14:50:42 +00:00
|
|
|
}
|
|
|
|
private _openConfiguration(): void {
|
|
|
|
allCommands.openWidgetConfiguration.run();
|
|
|
|
}
|
|
|
|
|
2023-09-19 02:48:56 +00:00
|
|
|
private _attachColumnMappingTip(widgetUrl: string | null) {
|
|
|
|
switch (widgetUrl) {
|
|
|
|
// TODO: come up with a way to attach tips without hardcoding widget URLs.
|
|
|
|
case 'https://gristlabs.github.io/grist-widget/calendar/index.html': {
|
2024-02-14 21:18:09 +00:00
|
|
|
return this._gristDoc.behavioralPromptsManager.attachPopup('calendarConfig', {
|
2023-09-19 02:48:56 +00:00
|
|
|
popupOptions: {placement: 'left-start'},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
default: {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-08-29 14:50:42 +00:00
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
/**
|
|
|
|
* Custom widget configuration.
|
|
|
|
*
|
|
|
|
* Allows picking a custom widget from a gallery of available widgets
|
|
|
|
* (fetched from the `/widgets` endpoint), which includes the Custom URL
|
|
|
|
* widget.
|
|
|
|
*
|
|
|
|
* When a custom widget has a desired `accessLevel` set to a value other
|
|
|
|
* than `"None"`, a prompt will be shown to grant the requested access level
|
|
|
|
* to the widget.
|
|
|
|
*
|
|
|
|
* When `gristConfig.enableWidgetRepository` is set to false, only the
|
|
|
|
* Custom URL widget will be available to select in the gallery.
|
|
|
|
*/
|
2021-11-26 10:43:55 +00:00
|
|
|
export class CustomSectionConfig extends Disposable {
|
2024-08-13 23:21:48 +00:00
|
|
|
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
|
|
|
|
this._section, this._gristDoc);
|
|
|
|
|
|
|
|
private readonly _widgetId = Computed.create(this, use => {
|
|
|
|
// Stored in one of two places, depending on age of document.
|
|
|
|
const widgetId = use(this._section.customDef.widgetId) ||
|
|
|
|
use(this._section.customDef.widgetDef)?.widgetId;
|
|
|
|
if (widgetId) {
|
|
|
|
const pluginId = use(this._section.customDef.pluginId);
|
|
|
|
return (pluginId || '') + ':' + widgetId;
|
|
|
|
} else {
|
|
|
|
return CUSTOM_URL_WIDGET_ID;
|
|
|
|
}
|
|
|
|
});
|
2023-08-29 14:50:42 +00:00
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
|
|
|
|
return widgetId === CUSTOM_URL_WIDGET_ID;
|
|
|
|
});
|
2023-08-29 14:50:42 +00:00
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private readonly _currentAccess = Computed.create(this, use =>
|
|
|
|
(use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
|
|
|
|
.onWrite(async newAccess => {
|
|
|
|
await this._section.customDef.access.setAndSave(newAccess);
|
2021-11-26 10:43:55 +00:00
|
|
|
});
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
|
|
|
|
|
|
|
|
private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
|
|
|
|
.onWrite(async newUrl => {
|
2023-09-19 18:44:22 +00:00
|
|
|
bundleChanges(() => {
|
2024-08-13 23:21:48 +00:00
|
|
|
this._section.customDef.renderAfterReady(false);
|
2023-10-27 19:34:42 +00:00
|
|
|
if (newUrl) {
|
2024-08-13 23:21:48 +00:00
|
|
|
this._section.customDef.widgetId(null);
|
|
|
|
this._section.customDef.pluginId('');
|
|
|
|
this._section.customDef.widgetDef(null);
|
2023-10-27 19:34:42 +00:00
|
|
|
}
|
2024-08-13 23:21:48 +00:00
|
|
|
this._section.customDef.url(newUrl);
|
2023-09-19 18:44:22 +00:00
|
|
|
});
|
2024-08-13 23:21:48 +00:00
|
|
|
await this._section.saveCustomDef();
|
2023-09-19 18:44:22 +00:00
|
|
|
});
|
2021-11-26 10:43:55 +00:00
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private readonly _requiresAccess = Computed.create(this, use => {
|
|
|
|
const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
|
|
|
|
return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
|
|
|
|
});
|
|
|
|
|
|
|
|
private readonly _widgetDetailsExpanded: Observable<boolean>;
|
|
|
|
|
|
|
|
private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
|
|
|
|
|
|
|
|
private readonly _selectedWidget = Computed.create(this, use => {
|
|
|
|
const id = use(this._widgetId);
|
|
|
|
if (id === CUSTOM_URL_WIDGET_ID) { return null; }
|
|
|
|
|
|
|
|
const widgets = use(this._widgets);
|
|
|
|
if (!widgets) { return null; }
|
|
|
|
|
|
|
|
const [pluginId, widgetId] = id.split(':');
|
|
|
|
return matchWidget(widgets, {pluginId, widgetId}) ?? null;
|
|
|
|
});
|
|
|
|
|
|
|
|
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
|
|
|
|
this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
|
|
|
|
`u:${userId};customWidgetDetailsExpanded`,
|
|
|
|
true
|
|
|
|
));
|
|
|
|
|
|
|
|
this._getWidgets()
|
|
|
|
.then(widgets => {
|
|
|
|
if (this.isDisposed()) { return; }
|
|
|
|
|
|
|
|
this._widgets.set(widgets);
|
|
|
|
})
|
|
|
|
.catch(reportError);
|
2021-11-26 10:43:55 +00:00
|
|
|
|
|
|
|
// Clear intermediate state when section changes.
|
2024-08-13 23:21:48 +00:00
|
|
|
this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
|
2021-11-26 10:43:55 +00:00
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
public buildDom(): DomContents {
|
|
|
|
return dom('div',
|
|
|
|
this._buildWidgetSelector(),
|
|
|
|
this._buildAccessLevelConfig(),
|
|
|
|
this._customSectionConfigurationConfig.buildDom(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected shouldRenderWidgetSelector(): boolean {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected async _getWidgets() {
|
|
|
|
return await this._gristDoc.app.topAppModel.getWidgets();
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildWidgetSelector() {
|
|
|
|
if (!this.shouldRenderWidgetSelector()) { return null; }
|
|
|
|
|
|
|
|
return [
|
|
|
|
cssRow(
|
|
|
|
cssWidgetSelector(
|
|
|
|
this._buildShowWidgetDetailsButton(),
|
|
|
|
this._buildWidgetName(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
this._maybeBuildWidgetDetails(),
|
2021-11-26 10:43:55 +00:00
|
|
|
];
|
2024-08-13 23:21:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _buildShowWidgetDetailsButton() {
|
|
|
|
return cssShowWidgetDetails(
|
|
|
|
cssShowWidgetDetailsIcon(
|
|
|
|
'Dropdown',
|
|
|
|
cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
|
|
|
|
testId('toggle-custom-widget-details'),
|
|
|
|
testId(use => !use(this._widgetDetailsExpanded)
|
|
|
|
? 'show-custom-widget-details'
|
|
|
|
: 'hide-custom-widget-details'
|
|
|
|
),
|
|
|
|
),
|
|
|
|
cssWidgetLabel(t('Widget')),
|
|
|
|
dom.on('click', () => {
|
|
|
|
this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildWidgetName() {
|
|
|
|
return cssWidgetName(
|
|
|
|
dom.text(use => {
|
|
|
|
if (use(this._isCustomUrlWidget)) {
|
|
|
|
return t('Custom URL');
|
|
|
|
} else {
|
|
|
|
const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);
|
|
|
|
return widget ? getWidgetName(widget) : use(this._widgetId);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
dom.on('click', () => showCustomWidgetGallery(this._gristDoc, {
|
|
|
|
sectionRef: this._section.id(),
|
|
|
|
})),
|
|
|
|
testId('open-custom-widget-gallery'),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _maybeBuildWidgetDetails() {
|
|
|
|
return dom.maybe(this._widgetDetailsExpanded, () =>
|
|
|
|
dom.domComputed(this._selectedWidget, (widget) =>
|
2021-11-26 10:43:55 +00:00
|
|
|
cssRow(
|
2024-08-13 23:21:48 +00:00
|
|
|
this._buildWidgetDetails(widget),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildWidgetDetails(widget: ICustomWidget | null) {
|
|
|
|
return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
|
|
|
|
if (isCustomUrlWidget) {
|
|
|
|
return cssCustomUrlDetails(
|
2021-11-26 10:43:55 +00:00
|
|
|
cssTextInput(
|
|
|
|
this._url,
|
|
|
|
async value => this._url.set(value),
|
2024-08-13 23:21:48 +00:00
|
|
|
dom.show(this._isCustomUrlWidget),
|
|
|
|
{placeholder: t('Enter Custom URL')},
|
2023-04-14 10:09:50 +00:00
|
|
|
),
|
2024-08-13 23:21:48 +00:00
|
|
|
);
|
|
|
|
} else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
|
|
|
|
return cssDetailsMessage(t('Missing description and author information.'));
|
|
|
|
} else {
|
|
|
|
return cssWidgetDetails(
|
|
|
|
!widget?.description ? null : cssWidgetDescription(
|
|
|
|
widget.description,
|
|
|
|
testId('custom-widget-description'),
|
|
|
|
),
|
|
|
|
cssWidgetMetadata(
|
|
|
|
!widget?.authors?.[0] ? null : cssWidgetMetadataRow(
|
|
|
|
cssWidgetMetadataName(t('Developer:')),
|
|
|
|
cssWidgetMetadataValue(
|
|
|
|
widget.authors[0].url
|
|
|
|
? cssDeveloperLink(
|
|
|
|
widget.authors[0].name,
|
|
|
|
{href: widget.authors[0].url, target: '_blank'},
|
|
|
|
testId('custom-widget-developer'),
|
|
|
|
)
|
|
|
|
: dom('span',
|
|
|
|
widget.authors[0].name,
|
|
|
|
testId('custom-widget-developer'),
|
|
|
|
),
|
|
|
|
testId('custom-widget-developer'),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
!widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(
|
|
|
|
cssWidgetMetadataName(t('Last updated:')),
|
|
|
|
cssWidgetMetadataValue(
|
|
|
|
new Date(widget.lastUpdatedAt).toLocaleDateString('default', {
|
|
|
|
month: 'long',
|
|
|
|
day: 'numeric',
|
|
|
|
year: 'numeric',
|
|
|
|
}),
|
|
|
|
testId('custom-widget-last-updated'),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildAccessLevelConfig() {
|
|
|
|
return [
|
|
|
|
cssSeparator({style: 'margin-top: 0px'}),
|
|
|
|
cssLabel(t('ACCESS LEVEL')),
|
|
|
|
cssRow(select(this._currentAccess, getAccessLevels()), testId('access')),
|
|
|
|
dom.maybeOwned(this._requiresAccess, (owner) => kf.prompt(
|
|
|
|
(elem: HTMLDivElement) => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
|
|
|
cssColumns(
|
|
|
|
cssWarningWrapper(icon('Lock')),
|
|
|
|
dom('div',
|
|
|
|
cssConfirmRow(
|
|
|
|
dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
|
|
|
|
),
|
|
|
|
cssConfirmRow(
|
|
|
|
primaryButton(
|
|
|
|
t('Accept'),
|
|
|
|
testId('access-accept'),
|
|
|
|
dom.on('click', () => this._grantDesiredAccess())
|
2021-11-26 10:43:55 +00:00
|
|
|
),
|
2024-08-13 23:21:48 +00:00
|
|
|
basicButton(
|
|
|
|
t('Reject'),
|
|
|
|
testId('access-reject'),
|
|
|
|
dom.on('click', () => this._dismissAccessPrompt())
|
2021-11-26 10:43:55 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
)
|
2024-08-13 23:21:48 +00:00
|
|
|
),
|
|
|
|
dom.onKeyDown({
|
|
|
|
Enter: () => this._grantDesiredAccess(),
|
|
|
|
Escape:() => this._dismissAccessPrompt(),
|
|
|
|
}),
|
|
|
|
)),
|
|
|
|
];
|
2021-11-26 10:43:55 +00:00
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private _buildAccessLevelPrompt(level: AccessLevel | null) {
|
|
|
|
if (!level) { return null; }
|
2023-08-29 14:50:42 +00:00
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
switch (level) {
|
|
|
|
case AccessLevel.none: {
|
|
|
|
return cssConfirmLine(t("Widget does not require any permissions."));
|
|
|
|
}
|
|
|
|
case AccessLevel.read_table: {
|
|
|
|
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
|
|
|
|
}
|
|
|
|
case AccessLevel.full: {
|
|
|
|
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
|
|
|
|
fullAccess: dom("b", "full access")
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
2022-01-12 13:30:51 +00:00
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private _grantDesiredAccess() {
|
2022-01-12 13:30:51 +00:00
|
|
|
if (this._desiredAccess.get()) {
|
|
|
|
this._currentAccess.set(this._desiredAccess.get()!);
|
|
|
|
}
|
2024-08-13 23:21:48 +00:00
|
|
|
this._dismissAccessPrompt();
|
2021-11-26 10:43:55 +00:00
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
private _dismissAccessPrompt() {
|
2022-01-12 13:30:51 +00:00
|
|
|
this._desiredAccess.set(null);
|
2021-11-26 10:43:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-08-13 23:21:48 +00:00
|
|
|
function getAccessLevels(): IOptionFull<string>[] {
|
|
|
|
return [
|
|
|
|
{label: t("No document access"), value: AccessLevel.none},
|
|
|
|
{label: t("Read selected table"), value: AccessLevel.read_table},
|
|
|
|
{label: t("Full document access"), value: AccessLevel.full},
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2021-11-26 10:43:55 +00:00
|
|
|
const cssWarningWrapper = styled('div', `
|
|
|
|
padding-left: 8px;
|
|
|
|
padding-top: 6px;
|
2023-04-11 05:00:28 +00:00
|
|
|
--icon-color: ${theme.iconError}
|
2021-11-26 10:43:55 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
const cssColumns = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssConfirmRow = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
padding: 8px;
|
|
|
|
gap: 8px;
|
|
|
|
`);
|
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
const cssConfirmLine = styled('span', `
|
|
|
|
white-space: pre-wrap;
|
|
|
|
`);
|
|
|
|
|
2021-11-26 10:43:55 +00:00
|
|
|
const cssSection = styled('div', `
|
|
|
|
margin: 16px 16px 12px 16px;
|
|
|
|
`);
|
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
const cssAddIcon = styled(icon, `
|
|
|
|
margin-right: 4px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssRemoveIcon = styled(icon, `
|
|
|
|
display: none;
|
|
|
|
cursor: pointer;
|
|
|
|
flex: none;
|
|
|
|
margin-left: 8px;
|
|
|
|
.${cssFieldEntry.className}:hover & {
|
|
|
|
display: block;
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
// Additional text in label (greyed out)
|
|
|
|
const cssSubLabel = styled('span', `
|
|
|
|
text-transform: none;
|
|
|
|
font-size: ${vars.xsmallFontSize};
|
2023-04-11 05:00:28 +00:00
|
|
|
color: ${theme.lightText};
|
2022-08-08 13:32:50 +00:00
|
|
|
`);
|
|
|
|
|
2022-02-08 15:23:14 +00:00
|
|
|
const cssAddMapping = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
cursor: pointer;
|
2023-04-11 05:00:28 +00:00
|
|
|
color: ${theme.controlFg};
|
|
|
|
--icon-color: ${theme.controlFg};
|
2022-02-08 15:23:14 +00:00
|
|
|
|
|
|
|
&:not(:first-child) {
|
|
|
|
margin-top: 8px;
|
|
|
|
}
|
|
|
|
&:hover, &:focus, &:active {
|
2023-04-11 05:00:28 +00:00
|
|
|
color: ${theme.controlHoverFg};
|
|
|
|
--icon-color: ${theme.controlHoverFg};
|
2022-02-08 15:23:14 +00:00
|
|
|
}
|
2023-10-06 09:11:37 +00:00
|
|
|
&.disabled {
|
|
|
|
color: ${theme.lightText};
|
|
|
|
--icon-color: ${theme.lightText};
|
|
|
|
pointer-events: none;
|
|
|
|
}
|
2022-02-08 15:23:14 +00:00
|
|
|
`);
|
2022-08-08 13:32:50 +00:00
|
|
|
|
|
|
|
const cssTextInput = styled(textInput, `
|
2023-04-11 05:00:28 +00:00
|
|
|
color: ${theme.inputFg};
|
|
|
|
background-color: ${theme.inputBg};
|
|
|
|
|
|
|
|
&::placeholder {
|
|
|
|
color: ${theme.inputPlaceholderFg};
|
|
|
|
}
|
2022-08-08 13:32:50 +00:00
|
|
|
`);
|
2023-10-06 09:11:37 +00:00
|
|
|
|
|
|
|
const cssDisabledSelect = styled(select, `
|
|
|
|
opacity: unset !important;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssBlank = styled(cssOptionLabel, `
|
|
|
|
--grist-option-label-color: ${theme.lightText};
|
|
|
|
`);
|
2024-08-13 23:21:48 +00:00
|
|
|
|
|
|
|
const cssWidgetSelector = styled('div', `
|
|
|
|
width: 100%;
|
|
|
|
display: flex;
|
|
|
|
justify-content: space-between;
|
|
|
|
column-gap: 16px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssShowWidgetDetails = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
column-gap: 4px;
|
|
|
|
cursor: pointer;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssShowWidgetDetailsIcon = styled(icon, `
|
|
|
|
--icon-color: ${theme.lightText};
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
&-collapsed {
|
|
|
|
transform: rotate(-90deg);
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssWidgetLabel = styled('div', `
|
|
|
|
text-transform: uppercase;
|
|
|
|
font-size: ${vars.xsmallFontSize};
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssWidgetName = styled('div', `
|
|
|
|
color: ${theme.rightPanelCustomWidgetButtonFg};
|
|
|
|
background-color: ${theme.rightPanelCustomWidgetButtonBg};
|
|
|
|
height: 24px;
|
|
|
|
padding: 4px 8px;
|
|
|
|
border-radius: 4px;
|
|
|
|
cursor: pointer;
|
|
|
|
overflow: hidden;
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
white-space: nowrap;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssWidgetDetails = styled('div', `
|
|
|
|
margin-top: 8px;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
margin-bottom: 8px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssCustomUrlDetails = styled(cssWidgetDetails, `
|
|
|
|
flex: 1 0 auto;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssDetailsMessage = styled('div', `
|
|
|
|
color: ${theme.lightText};
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssWidgetDescription = styled('div', `
|
|
|
|
margin-bottom: 16px;
|
|
|
|
`);
|