(core) Mappings improvements

Summary:
- Adding new icon for calendar view (the old one by just bigger)
- When there are no columns to map the select box is grayed out
- Optional mappings can be cleared now

Test Plan: Added

Reviewers: JakubSerafin

Reviewed By: JakubSerafin

Subscribers: JakubSerafin

Differential Revision: https://phab.getgrist.com/D4066
This commit is contained in:
Jarosław Sadziński
2023-10-06 11:11:37 +02:00
parent 34f366585d
commit 572279916e
8 changed files with 265 additions and 47 deletions

View File

@@ -25,7 +25,9 @@ export class ColumnToMapImpl implements Required<ColumnToMap> {
this.description = typeof def === 'string' ? '' : (def.description ?? '');
this.optional = typeof def === 'string' ? false : (def.optional ?? false);
this.type = typeof def === 'string' ? 'Any' : (def.type ?? 'Any');
this.typeDesc = String(UserType.typeDefs[this.type]?.label ?? "any").toLowerCase();
this.type = this.type.split(',').map(t => t.trim()).filter(Boolean).join(',');
this.typeDesc = this.type.split(',')
.map(t => String(UserType.typeDefs[t]?.label ?? "any").toLowerCase()).join(', ');
this.allowMultiple = typeof def === 'string' ? false : (def.allowMultiple ?? false);
}

View File

@@ -1,30 +1,30 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeTestId} from 'app/client/lib/domUtils';
import * as kf from 'app/client/lib/koForm';
import {makeT} from 'app/client/lib/localization';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls';
import {unwrap} from 'app/common/gutil';
import {not, unwrap} from 'app/common/gutil';
import {
bundleChanges,
Computed,
Disposable,
dom,
fromKo,
makeTestId,
MultiHolder,
Observable,
styled,
@@ -62,12 +62,50 @@ class ColumnPicker extends Disposable {
const value = use(this._value);
return Array.isArray(value) ? null : value;
});
properValue.onWrite(value => this._value.set(value));
const options = Computed.create(this, use => {
properValue.onWrite(value => this._value.set(value || null));
const canBeMapped = Computed.create(this, use => {
return use(this._section.columns)
.filter(col => this._column.canByMapped(use(col.pureType)))
.map((col) => ({value: col.getRowId(), label: use(col.label), icon: 'FieldColumn' as IconName}));
.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;
});
const defaultLabel = this._column.typeDesc != "any"
? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc})
: t("Pick a column");
return [
cssLabel(
this._column.title,
@@ -78,18 +116,49 @@ class ColumnPicker extends Disposable {
this._column.description,
testId('help-for-' + this._column.name),
) : null,
cssRow(
select(
properValue,
options,
{
defaultLabel: this._column.typeDesc != "any"
? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc})
: t("Pick a column")
}
),
testId('mapping-for-' + this._column.name),
),
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'),
),
]),
];
}
}
@@ -114,16 +183,30 @@ class ColumnListPicker extends Disposable {
});
}
private _buildAddColumn() {
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));
});
return [
cssRow(
dom.autoDispose(owner),
cssAddMapping(
cssAddIcon('Plus'), t("Add") + ' ' + this._column.title,
dom.cls('disabled', use => use(notMapped).length === 0),
testId('disabled', use => use(notMapped).length === 0),
menu(() => {
const otherColumns = this._getNotMappedColumns();
const typedColumns = otherColumns.filter(this._typeFilter());
const wrongTypeCount = otherColumns.length - typedColumns.length;
const wrongTypeCount = notMapped.get().length - typedColumns.get().length;
return [
...typedColumns
...typedColumns.get()
.map((col) => menuItem(
() => this._addColumn(col),
col.label.peek(),
@@ -145,7 +228,8 @@ class ColumnListPicker extends Disposable {
}
// Helper method for filtering columns that can be picked by the widget.
private _typeFilter = (use = unwrap) => (col: ColumnRec) => this._column.canByMapped(use(col.pureType));
private _typeFilter = (use = unwrap) => (col: ColumnRec|null) =>
!col ? false : this._column.canByMapped(use(col.pureType));
private _buildDraggableList(use: UseCBOwner) {
return dom.update(kf.draggableList(
@@ -159,12 +243,7 @@ class ColumnListPicker extends Disposable {
}
), testId('map-list-for-' + this._column.name));
}
private _getNotMappedColumns(): ColumnRec[] {
// Get all columns.
const all = this._section.columns.peek();
const mapped = this._list();
return all.filter(col => !mapped.includes(col.id.peek()));
}
private _readItems(use: UseCBOwner): ColumnRec[] {
let selectedRefs = (use(this._value) || []) as number[];
// Ignore if configuration was changed from what it was saved.
@@ -177,6 +256,7 @@ class ColumnListPicker extends Disposable {
// Remove any columns that are no longer there.
return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));
}
private _renderItem(use: UseCBOwner, field: ColumnRec): any {
return cssFieldEntry(
cssFieldLabel(
@@ -199,7 +279,7 @@ class ColumnListPicker extends Disposable {
this._value.set(value);
} else {
let current = (this._value.get() || []) as number[];
// Ignore if the saved value is not a number.
// Ignore if the saved value is not a number list.
if (!Array.isArray(current)) {
current = [];
}
@@ -224,8 +304,16 @@ class ColumnListPicker extends Disposable {
this._value.set(current.filter(c => c != column.id.peek()));
}
private _addColumn(col: ColumnRec): any {
const current = this._list();
// 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.
current.push(col.id.peek());
// 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)));
this._value.set(current);
}
}
@@ -551,8 +639,6 @@ export class CustomSectionConfig extends Disposable {
this._widgets.set(wigets);
}
private _accept() {
if (this._desiredAccess.get()) {
this._currentAccess.set(this._desiredAccess.get()!);
@@ -629,6 +715,11 @@ const cssAddMapping = styled('div', `
color: ${theme.controlHoverFg};
--icon-color: ${theme.controlHoverFg};
}
&.disabled {
color: ${theme.lightText};
--icon-color: ${theme.lightText};
pointer-events: none;
}
`);
const cssTextInput = styled(textInput, `
@@ -647,3 +738,11 @@ const cssTextInput = styled(textInput, `
color: ${theme.inputPlaceholderFg};
}
`);
const cssDisabledSelect = styled(select, `
opacity: unset !important;
`);
const cssBlank = styled(cssOptionLabel, `
--grist-option-label-color: ${theme.lightText};
`);

View File

@@ -8,7 +8,7 @@ export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}],
['custom.calendar', {label: 'Calendar', icon: 'FieldDate'}]
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
]);
// Widget type info.

View File

@@ -4,6 +4,7 @@ export type IconName = "ChartArea" |
"ChartKaplan" |
"ChartLine" |
"ChartPie" |
"TypeCalendar" |
"TypeCard" |
"TypeCardList" |
"TypeCell" |
@@ -152,6 +153,7 @@ export const IconList: IconName[] = ["ChartArea",
"ChartKaplan",
"ChartLine",
"ChartPie",
"TypeCalendar",
"TypeCard",
"TypeCardList",
"TypeCell",

View File

@@ -534,22 +534,25 @@ export const cssOptionRowIcon = styled(icon, `
}
`);
const cssOptionLabel = styled('div', `
export const cssOptionLabel = styled('div', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
--grist-option-label-color: ${theme.menuItemFg};
--grist-option-label-color-sel: ${theme.menuItemSelectedFg};
--grist-option-label-color-disabled: ${theme.menuItemDisabledFg};
.${weasel.cssMenuItem.className} & {
color: ${theme.menuItemFg};
color: var(--grist-option-label-color);
}
.${weasel.cssMenuItem.className}-sel & {
color: ${theme.menuItemSelectedFg};
color: var(--grist-option-label-color-sel);
background-color: ${theme.menuItemSelectedBg};
}
.${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg};
color: var(--grist-option-label-color-disabled);
}
`);