2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* Usage in Grist: Multi-selector component serves as the base class for Sorting, Filtering,
|
|
|
|
* Group-By, Linking, and possibly other widgets in Grist.
|
|
|
|
*
|
|
|
|
* The multi-selector component allows the user to create an ordered list of items selected from a
|
|
|
|
* unique list. Visually it shows a list of Items, and a link to add a new item. Each item shows a
|
|
|
|
* trash-can icon to remove it. Optionally, items may be reordered relative to each other using a
|
|
|
|
* dragger. Each Item contains a dropdown which shows a fixed set of options (e.g. column names).
|
|
|
|
* Options already selected (present as other Items) are omitted from the list.
|
|
|
|
*
|
|
|
|
* Using the link to add a new item creates an "Empty Item" row, which then gets added to
|
|
|
|
* the list and becomes a real item when the user chooses a value. Note that the Empty Item
|
|
|
|
* uses the empty string '' as its value, so it is not a valid value in the list of items.
|
|
|
|
*
|
|
|
|
* The MultiSelect class may be extended to be used with enhanced items, to show additional UI (to
|
|
|
|
* control additional properties of items, e.g. ascending/descending for sorting), and to provide
|
|
|
|
* custom implementation for changes (e.g. adding an item may involve a request to the server).
|
|
|
|
*
|
|
|
|
* TODO: Implement optional reordering of items
|
|
|
|
* TODO: Optionally omit selected items from list
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { computed, MutableObsArray, ObsArray, observable, Observable } from 'grainjs';
|
|
|
|
import { Disposable, dom, makeTestId, select, styled } from 'grainjs';
|
|
|
|
import { button1 } from './buttons';
|
|
|
|
|
|
|
|
export interface BaseItem {
|
|
|
|
value: any;
|
|
|
|
label: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
const testId = makeTestId('test-ms-');
|
|
|
|
|
|
|
|
export abstract class MultiItemSelector<Item extends BaseItem> extends Disposable {
|
|
|
|
|
|
|
|
constructor(private _incItems: MutableObsArray<Item>, private _allItems: ObsArray<Item>,
|
|
|
|
private _options: {
|
|
|
|
addItemLabel: string,
|
|
|
|
addItemText: string
|
|
|
|
}) {
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom() {
|
|
|
|
return cssMultiSelectorWrapper(
|
|
|
|
cssItemList(testId('list'),
|
|
|
|
dom.forEach(this._incItems, item => this.buildItemDom(item)),
|
2021-05-23 17:43:11 +00:00
|
|
|
this._buildAddItemDom(this._options.addItemLabel, this._options.addItemText)
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Must be overridden to return list of available items. Items already present in the items
|
|
|
|
// array (according to value) may be safely included, and will not be shown in the select-box.
|
|
|
|
|
|
|
|
// The default implementations update items array, but may be overridden.
|
|
|
|
protected async add(item: Item): Promise<void> {
|
|
|
|
this._incItems.push(item);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Called with an item from `_allItems`
|
|
|
|
protected async remove(item: Item): Promise<void> {
|
2021-05-23 17:43:11 +00:00
|
|
|
const idx = this._findIncIndex(item);
|
2020-10-02 15:10:00 +00:00
|
|
|
if (idx === -1) { return; }
|
|
|
|
this._incItems.splice(idx, 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Called with an item in the items array
|
|
|
|
protected async reorder(item: Item, nextItem: Item): Promise<void> { return; }
|
|
|
|
|
|
|
|
// Replaces an existing item (if found) with a new one
|
|
|
|
protected async changeItem(item: Item, newItem: Item): Promise<void> {
|
2021-05-23 17:43:11 +00:00
|
|
|
const idx = this._findIncIndex(item);
|
2020-10-02 15:10:00 +00:00
|
|
|
if (idx === -1) { return; }
|
|
|
|
this._incItems.splice(idx, 1, newItem);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exposed for use by custom buildItemDom().
|
|
|
|
protected buildDragHandle(item: Item): Element { return new Element(); }
|
|
|
|
|
|
|
|
protected buildSelectBox(selectedValue: string,
|
|
|
|
selectCb: (newItem: Item) => void,
|
|
|
|
selectOptions?: {}): Element {
|
|
|
|
const obs = computed(use => selectedValue).onWrite(async value => {
|
2021-05-23 17:43:11 +00:00
|
|
|
const newItem = this._findItemByValue(value);
|
2020-10-02 15:10:00 +00:00
|
|
|
if (newItem) {
|
|
|
|
selectCb(newItem);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
const result = select(
|
|
|
|
obs,
|
|
|
|
this._allItems,
|
|
|
|
selectOptions
|
|
|
|
);
|
|
|
|
dom.autoDisposeElem(result, obs);
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected buildRemoveButton(removeCb: () => void): Element {
|
|
|
|
return cssItemRemove(testId('remove-btn'),
|
|
|
|
dom.on('click', removeCb),
|
|
|
|
'✖'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// May be overridden for custom-looking items.
|
|
|
|
protected buildItemDom(item: Item): Element {
|
|
|
|
return dom('li', testId('item'),
|
|
|
|
// this.buildDragHandle(item), TODO: once dragging is implemented
|
|
|
|
this.buildSelectBox(item.value, async newItem => this.changeItem(item, newItem)),
|
|
|
|
this.buildRemoveButton(() => this.remove(item))
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns the index (order) of the item if it's been included, or -1 otherwise.
|
2021-05-23 17:43:11 +00:00
|
|
|
private _findIncIndex(item: Item): number {
|
2020-10-02 15:10:00 +00:00
|
|
|
return this._incItems.get().findIndex(_item => _item === item);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns the item object given it's value, or undefined if not found.
|
2021-05-23 17:43:11 +00:00
|
|
|
private _findItemByValue(value: string): Item | undefined {
|
2020-10-02 15:10:00 +00:00
|
|
|
return this._allItems.get().find(_item => _item.value === value);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Builds the about-to-be-added item
|
2021-05-23 17:43:11 +00:00
|
|
|
private _buildAddItemDom(defLabel: string, defText: string): Element {
|
2020-10-02 15:10:00 +00:00
|
|
|
const addNewItem: Observable<boolean> = observable(false);
|
|
|
|
return dom('li', testId('add-item'),
|
|
|
|
dom.domComputed(addNewItem, isAdding => isAdding
|
|
|
|
? dom.frag(
|
|
|
|
this.buildSelectBox('', async newItem => {
|
|
|
|
await this.add(newItem);
|
|
|
|
addNewItem.set(false);
|
|
|
|
}, { defLabel }),
|
|
|
|
this.buildRemoveButton(() => addNewItem.set(false)))
|
|
|
|
: button1(defText, testId('add-btn'),
|
|
|
|
dom.on('click', () => addNewItem.set(true)))
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const cssMultiSelectorWrapper = styled('div', `
|
|
|
|
border: 1px solid blue;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssItemList = styled('ul', `
|
|
|
|
list-style-type: none;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssItemRemove = styled('span', `
|
|
|
|
padding: 0 .5rem;
|
|
|
|
vertical-align: middle;
|
|
|
|
cursor: pointer;
|
|
|
|
`);
|