-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathFormSelect.ts
305 lines (264 loc) · 10.3 KB
/
FormSelect.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
import * as d3v3 from 'd3v3';
import { IPluginDesc } from 'visyn_core/plugin';
import { UserSession } from 'visyn_core/security';
import { AFormElement } from './AFormElement';
import { FormElementType, IForm, IFormElement, IFormElementDesc } from '../interfaces';
export interface IFormSelectOption {
name: string;
value: string;
data: any;
}
export interface IFormSelectOptionGroup {
name: string;
children: IFormSelectOption[];
}
export declare type ISelectOptions = (string | IFormSelectOption)[] | Promise<(string | IFormSelectOption)[]>;
export declare type IHierarchicalSelectOptions =
| (string | IFormSelectOption | IFormSelectOptionGroup)[]
| Promise<(string | IFormSelectOption | IFormSelectOptionGroup)[]>;
export interface IFormSelectOptions {
/**
* Data for the options elements of the select
*/
optionsData?: IHierarchicalSelectOptions | ((dependents: any[]) => IHierarchicalSelectOptions);
/**
* Index of the selected option; this option overrides the selected index from the `useSession` property
*/
selectedIndex?: number;
}
/**
* Add specific options for select form elements
*/
export interface IFormSelectDesc extends IFormElementDesc {
type: FormElementType.SELECT;
/**
* Additional options
*/
options?: IFormSelectOptions & IFormElementDesc['options'];
}
export interface IFormSelectElement extends IFormElement {
getSelectedIndex(): number;
updateOptionElements(data: (string | IFormSelectOption | IFormSelectOptionGroup)[]): void;
}
/**
* ResolveNow executes the result without an intermediate tick, and because FormSelect#resolveData is sometimes used
* as dependency for FormMap for example, the result needs to be there immediately as otherwise the dependents
* receive null as value. This is some form of race-condition, as the dependents are executed before the value is resolved and set.
* See https://github.com/datavisyn/tdp_core/issues/675 for details.
*/
class ResolveNow<T> implements PromiseLike<T> {
constructor(private readonly v: T) {}
// When using Typescript v2.7+ the typing can be further specified as `then<TResult1 = T, TResult2 = never>(...`
then<TResult1, TResult2>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
): PromiseLike<TResult1 | TResult2> {
return ResolveNow.resolveImmediately(onfulfilled(this.v));
}
/**
* similar to Promise.resolve but executes the result immediately without an intermediate tick
* @param {PromiseLike<T> | T} result
* @returns {PromiseLike<T>}
*/
static resolveImmediately<T>(result: T | PromiseLike<T>): PromiseLike<T> {
if (result instanceof Promise || (result && typeof (<any>result).then === 'function')) {
return <PromiseLike<T>>result;
}
return <PromiseLike<T>>new ResolveNow(result);
}
}
/**
* Select form element instance
* Propagates the changes from the DOM select element using the internal `change` event
*/
export class FormSelect extends AFormElement<IFormSelectDesc> implements IFormSelectElement {
private $select: d3v3.Selection<any>;
/**
* Constructor
* @param form The form this element is a part of
* @param elementDesc The form element description
* @param pluginDesc The phovea extension point description
*/
constructor(
form: IForm,
elementDesc: IFormSelectDesc,
readonly pluginDesc: IPluginDesc,
) {
super(form, elementDesc, pluginDesc);
}
protected updateStoredValue() {
if (!this.elementDesc.useSession) {
return;
}
UserSession.getInstance().store(`${this.id}_selectedIndex`, this.getSelectedIndex());
}
protected getStoredValue<T>(defaultValue: T): T {
if (!this.elementDesc.useSession) {
return defaultValue;
}
return UserSession.getInstance().retrieve(`${this.id}_selectedIndex`, defaultValue);
}
/**
* Build the label and select element
* @param $formNode The parent node this element will be attached to
*/
build($formNode: d3v3.Selection<any>) {
this.addChangeListener();
const testId = (this.elementDesc.label || this.elementDesc.id)
.replace(/<\/?[^>]+(>|$)/g, '')
.trim()
.replace(/\s+/g, '-')
.toLowerCase();
this.$rootNode = $formNode
.append('div')
.classed(this.elementDesc.options.inlineForm ? 'col-sm-auto' : 'col-sm-12 mt-1 mb-1', true)
.attr('data-testid', testId);
const rowNode = this.$rootNode.append('div').classed('row', true);
this.setVisible(this.elementDesc.visible);
this.appendLabel(rowNode);
const $colDiv = rowNode.append('div').classed('col', true);
this.$inputNode = $colDiv.append('select');
this.elementDesc.attributes.clazz = this.elementDesc.attributes.clazz.replace('form-control', 'form-select'); // filter out the form-control class, because the border it creates doesn't contain the whole element due to absolute positioning and it isn't necessary
this.$inputNode.attr('data-testid', 'form-select');
this.setAttributes(this.$inputNode, this.elementDesc.attributes);
}
/**
* Bind the change listener and propagate the selection by firing a change event
*/
init() {
super.init();
const { options } = this.elementDesc;
// propagate change action with the data of the selected option
this.$inputNode.on('change.propagate', () => {
this.fire(FormSelect.EVENT_CHANGE, this.value, this.$inputNode);
});
const data = FormSelect.resolveData(options.optionsData);
const values = this.handleDependent((val) => {
data(val).then((items) => {
this.updateOptionElements(items);
this.$inputNode.property('selectedIndex', options.selectedIndex || 0);
this.fire(FormSelect.EVENT_CHANGE, this.value, this.$inputNode);
});
});
const defaultSelectedIndex = this.getStoredValue(0);
data(values).then((items) => {
this.updateOptionElements(items);
const index = options.selectedIndex !== undefined ? options.selectedIndex : Math.min(items.length - 1, defaultSelectedIndex);
this.previousValue = items[index];
this.$inputNode.property('selectedIndex', index);
if (options.selectedIndex === undefined && index > 0) {
this.fire(FormSelect.EVENT_INITIAL_VALUE, this.value, items[0]);
}
});
}
/**
* Returns the selectedIndex. If the option `useSession` is enabled,
* the index from the session will be used as fallback
*/
getSelectedIndex(): number {
const defaultSelectedIndex = this.getStoredValue(0);
const currentSelectedIndex = <number>this.$inputNode.property('selectedIndex');
return currentSelectedIndex === -1 ? defaultSelectedIndex : currentSelectedIndex;
}
/**
* Update the options of a select form element using the given data array
* @param data
*/
updateOptionElements(data: (string | IFormSelectOption | IFormSelectOptionGroup)[]) {
const options = data.map(FormSelect.toOption);
const isGroup = (d: IFormSelectOption | IFormSelectOptionGroup): d is IFormSelectOptionGroup => {
return Array.isArray((<any>d).children);
};
const anyGroups = data.some(isGroup);
this.$inputNode.selectAll('option, optgroup').remove();
if (!anyGroups) {
const $options = this.$inputNode.selectAll('option').data(<IFormSelectOption[]>options);
$options.enter().append('option');
$options.attr('value', (d) => d.value).html((d) => d.name);
$options.exit().remove();
return;
}
const node = <HTMLSelectElement>this.$inputNode.node();
const $options = this.$inputNode.selectAll(() => <HTMLElement[]>Array.from(node.children)).data(options);
$options.enter().append((d) => node.ownerDocument.createElement(isGroup ? 'optgroup' : 'option'));
const $sub = $options
.filter(isGroup)
.attr('label', (d) => d.name)
.selectAll('option')
.data((d) => (<IFormSelectOptionGroup>d).children);
$sub.enter().append('option');
$sub.attr('value', (d) => d.value).html((d) => d.name);
$sub.exit().remove();
$options
.filter((d) => !isGroup)
.attr('value', (d) => (<IFormSelectOption>d).value)
.html((d) => d.name);
$options.exit().remove();
}
/**
* Returns the selected value or if nothing found `null`
* @returns {string|{name: string, value: string, data: any}|null}
*/
get value() {
const option = d3v3.select((<HTMLSelectElement>this.$inputNode.node()).selectedOptions[0]);
return option.size() > 0 ? option.datum() : null;
}
/**
* Select the option by value. If no value found, then the first option is selected.
* @param v If string then compares to the option value property. Otherwise compares the object reference.
*/
set value(v: any) {
// if value is undefined or null, set to first index
if (!v) {
this.$inputNode.property('selectedIndex', 0);
this.previousValue = null;
return;
}
this.$inputNode
.selectAll('option')
.data()
.forEach((d, i) => {
if ((v.value && d.value === v.value) || d.value === v || d === v) {
this.$inputNode.property('selectedIndex', i);
this.updateStoredValue();
this.previousValue = d; // force value update
}
});
}
hasValue() {
return this.value !== null;
}
focus() {
(<HTMLSelectElement>this.$inputNode.node()).focus();
}
static toOption(d: string | IFormSelectOption | IFormSelectOptionGroup): IFormSelectOption | IFormSelectOptionGroup {
if (typeof d === 'string') {
return { name: d, value: d, data: d };
}
return d;
}
static resolveData(
data?: IHierarchicalSelectOptions | ((dependents: any[]) => IHierarchicalSelectOptions),
): (dependents: any[]) => PromiseLike<(IFormSelectOption | IFormSelectOptionGroup)[]> {
if (data === undefined) {
return () => ResolveNow.resolveImmediately([]);
}
if (Array.isArray(data)) {
return () => ResolveNow.resolveImmediately(data.map(this.toOption));
}
if (data instanceof Promise) {
return () => data.then((r) => r.map(this.toOption));
}
// assume it is a function
return (dependents: any[]) => {
const r = data(dependents);
if (r instanceof Promise) {
return r.then((a) => a.map(this.toOption));
}
if (Array.isArray(r)) {
return ResolveNow.resolveImmediately(r.map(this.toOption));
}
return ResolveNow.resolveImmediately(r);
};
}
}