selectic 3.0.19 → 3.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/MainInput.tsx CHANGED
@@ -32,6 +32,9 @@ export default class MainInput extends Vue<Props> {
32
32
 
33
33
  private nbHiddenItems = 0;
34
34
 
35
+ /* reactivity non needed */
36
+ private domObserver: MutationObserver | null = null;
37
+
35
38
  /* }}} */
36
39
  /* {{{ computed */
37
40
 
@@ -79,19 +82,34 @@ export default class MainInput extends Vue<Props> {
79
82
  return !isMultiple || !hasOnlyOneValue;
80
83
  }
81
84
 
82
- get clearedLabel() {
85
+ get clearedLabel(): string {
83
86
  const isMultiple = this.store.state.multiple;
84
87
  const labelKey = isMultiple ? 'clearSelections' : 'clearSelection';
85
88
 
86
89
  return this.store.data.labels[labelKey];
87
90
  }
88
91
 
89
- get singleSelectedItem() {
92
+ get singleSelectedItem(): undefined | OptionItem {
90
93
  const state = this.store.state;
91
94
  const isMultiple = state.multiple;
92
- const selected = this.selectedOptions;
93
95
 
94
- return !isMultiple && !!selected && (selected as OptionItem).text;
96
+ if (isMultiple) {
97
+ return;
98
+ }
99
+
100
+ return this.selectedOptions as OptionItem;
101
+ }
102
+
103
+ get singleSelectedItemText(): string {
104
+ const item = this.singleSelectedItem;
105
+
106
+ return item?.text || '';
107
+ }
108
+
109
+ get singleSelectedItemTitle(): string {
110
+ const item = this.singleSelectedItem;
111
+
112
+ return item?.title || item?.text || '';
95
113
  }
96
114
 
97
115
  get singleStyle() {
@@ -221,6 +239,13 @@ export default class MainInput extends Vue<Props> {
221
239
  * currently shown */
222
240
  const el = this.$refs.selectedItems;
223
241
  const parentEl = el.parentElement as HTMLDivElement;
242
+
243
+ if (!document.contains(parentEl)) {
244
+ /* The element is currently not in DOM */
245
+ this.createObserver(parentEl);
246
+ return;
247
+ }
248
+
224
249
  const parentPadding = parseInt(getComputedStyle(parentEl).getPropertyValue('padding-right'), 10);
225
250
  const clearEl = parentEl.querySelector('.selectic-input__clear-icon') as HTMLSpanElement;
226
251
  const clearWidth = clearEl ? clearEl.offsetWidth : 0;
@@ -261,6 +286,36 @@ export default class MainInput extends Vue<Props> {
261
286
  this.nbHiddenItems = selectedOptions.length - idx;
262
287
  }
263
288
 
289
+ private closeObserver() {
290
+ const observer = this.domObserver;
291
+ if (observer) {
292
+ observer.disconnect();
293
+ }
294
+ this.domObserver = null;
295
+ }
296
+
297
+ private createObserver(el: HTMLElement) {
298
+ this.closeObserver();
299
+ const observer = new MutationObserver((mutationsList) => {
300
+ for (const mutation of mutationsList) {
301
+ if (mutation.type === 'childList') {
302
+ for (const elMutated of Array.from(mutation.addedNodes)) {
303
+ /* Check that element has been added to DOM */
304
+ if (elMutated.contains(el)) {
305
+ this.closeObserver();
306
+ this.computeSize();
307
+ return;
308
+ }
309
+ }
310
+ }
311
+ }
312
+ });
313
+ const config = { childList: true, subtree: true };
314
+
315
+ observer.observe(document, config);
316
+ this.domObserver = observer;
317
+ }
318
+
264
319
  /* }}} */
265
320
  /* {{{ watch */
266
321
 
@@ -276,6 +331,10 @@ export default class MainInput extends Vue<Props> {
276
331
  this.computeSize();
277
332
  }
278
333
 
334
+ public beforeUnmount() {
335
+ this.closeObserver();
336
+ }
337
+
279
338
  /* }}} */
280
339
 
281
340
  public render() {
@@ -298,9 +357,9 @@ export default class MainInput extends Vue<Props> {
298
357
  <span
299
358
  class="selectic-item_text"
300
359
  style={this.singleStyle}
301
- title={this.singleSelectedItem || ''}
360
+ title={this.singleSelectedItemTitle}
302
361
  >
303
- {this.singleSelectedItem}
362
+ {this.singleSelectedItemText}
304
363
  </span>
305
364
  )}
306
365
  {this.displayPlaceholder && (
@@ -330,7 +389,7 @@ export default class MainInput extends Vue<Props> {
330
389
  <div
331
390
  class="single-value"
332
391
  style={item.style}
333
- title={item.text}
392
+ title={item.title || item.text}
334
393
  on={{
335
394
  click: () => this.$emit('item:click', item.id),
336
395
  }}
package/src/Store.tsx CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { reactive, watch, unref, computed, ComputedRef } from 'vue';
8
- import { convertToRegExp, assignObject, deepClone } from './tools';
8
+ import { convertToRegExp, assignObject, deepClone, compareOptions } from './tools';
9
9
 
10
10
  /* {{{ Types definitions */
11
11
 
@@ -346,6 +346,7 @@ interface Messages {
346
346
  moreSelectedItem: string;
347
347
  moreSelectedItems: string;
348
348
  unknownPropertyValue: string;
349
+ wrongQueryResult: string;
349
350
  }
350
351
 
351
352
  export type PartialMessages = { [K in keyof Messages]?: Messages[K] };
@@ -376,6 +377,7 @@ let messages: Messages = {
376
377
  moreSelectedItem: '+1 other',
377
378
  moreSelectedItems: '+%d others',
378
379
  unknownPropertyValue: 'property "%s" has incorrect values.',
380
+ wrongQueryResult: 'Query did not return all results.',
379
381
  };
380
382
 
381
383
  let closePreviousSelectic: undefined | voidCaller;
@@ -1165,7 +1167,7 @@ export default class SelecticStore {
1165
1167
  return childOptions;
1166
1168
  }
1167
1169
 
1168
- private buildAllOptions(keepFetched = false) {
1170
+ private buildAllOptions(keepFetched = false, stopFetch = false) {
1169
1171
  const allOptions: OptionValue[] = [];
1170
1172
  let listOptions: OptionValue[] = [];
1171
1173
  let elementOptions: OptionValue[] = [];
@@ -1242,13 +1244,26 @@ export default class SelecticStore {
1242
1244
  }
1243
1245
  }
1244
1246
 
1245
- this.state.filteredOptions = [];
1246
- this.state.totalFilteredOptions = Infinity;
1247
+ if (!stopFetch) {
1248
+ this.state.filteredOptions = [];
1249
+ this.state.totalFilteredOptions = Infinity;
1247
1250
 
1248
- this.buildFilteredOptions().then(() => {
1249
- /* XXX: To recompute for strict mode and auto-select */
1250
- this.assertCorrectValue();
1251
- });
1251
+ this.buildFilteredOptions().then(() => {
1252
+ /* XXX: To recompute for strict mode and auto-select */
1253
+ this.assertCorrectValue();
1254
+ });
1255
+ } else {
1256
+ /* Do not fetch again just build filteredOptions */
1257
+ const search = this.state.searchText;
1258
+ if (!search) {
1259
+ this.state.filteredOptions = this.buildGroupItems(allOptions);
1260
+ this.state.totalFilteredOptions = this.state.filteredOptions.length;
1261
+ return;
1262
+ }
1263
+ const options = this.filterOptions(allOptions, search);
1264
+ this.state.filteredOptions = options;
1265
+ this.state.totalFilteredOptions = this.state.filteredOptions.length;
1266
+ }
1252
1267
  }
1253
1268
 
1254
1269
  private async buildFilteredOptions() {
@@ -1407,6 +1422,7 @@ export default class SelecticStore {
1407
1422
  const requestId = ++this.requestId;
1408
1423
  const {total: rTotal, result} = await fetchCallback(search, offset, limit);
1409
1424
  let total = rTotal;
1425
+ let errorMessage = '';
1410
1426
 
1411
1427
  /* Assert result is correctly formatted */
1412
1428
  if (!Array.isArray(result)) {
@@ -1426,8 +1442,22 @@ export default class SelecticStore {
1426
1442
  if (!search) {
1427
1443
  /* update cache */
1428
1444
  state.totalDynOptions = total;
1429
- state.dynOptions.splice(offset, limit, ...result);
1430
- setTimeout(() => this.buildAllOptions(true), 0);
1445
+ const old = state.dynOptions.splice(offset, limit, ...result);
1446
+ if (compareOptions(old, result)) {
1447
+ /* Added options are the same as previous ones.
1448
+ * Stop fetching to avoid infinite loop
1449
+ */
1450
+ if (!this.hasFetchedAllItems) {
1451
+ /* Display error if all items are not fetch
1452
+ * We can have the case where old value and result
1453
+ * are the same but total is correct when the
1454
+ * total is 0 */
1455
+ errorMessage = labels.wrongQueryResult;
1456
+ }
1457
+ setTimeout(() => this.buildAllOptions(true, true), 0);
1458
+ } else {
1459
+ setTimeout(() => this.buildAllOptions(true), 0);
1460
+ }
1431
1461
  }
1432
1462
 
1433
1463
  /* Check request is not obsolete */
@@ -1456,12 +1486,12 @@ export default class SelecticStore {
1456
1486
  this.addStaticFilteredOptions(true);
1457
1487
  }
1458
1488
 
1459
- state.status.errorMessage = '';
1489
+ state.status.errorMessage = errorMessage;
1460
1490
  } catch (e) {
1461
1491
  state.status.errorMessage = (e as Error).message;
1462
1492
  if (!search) {
1463
1493
  state.totalDynOptions = 0;
1464
- this.buildAllOptions(true);
1494
+ this.buildAllOptions(true, true);
1465
1495
  }
1466
1496
  }
1467
1497
 
package/src/tools.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { unref } from 'vue';
2
+ import { OptionValue } from './Store';
2
3
 
3
4
  /**
4
5
  * Clone the object and its inner properties.
@@ -86,3 +87,33 @@ export function assignObject<T>(obj: Partial<T>, ...sourceObjects: Array<Partial
86
87
  }
87
88
  return result as T;
88
89
  }
90
+
91
+ /** Compare 2 list of options.
92
+ * @returns true if there are no difference
93
+ */
94
+ export function compareOptions(oldOptions: OptionValue[], newOptions: OptionValue[]): boolean {
95
+ if (oldOptions.length !== newOptions.length) {
96
+ return false;
97
+ }
98
+
99
+ return oldOptions.every((oldOption, idx) => {
100
+ const newOption = newOptions[idx];
101
+ const keys = Object.keys(oldOption);
102
+ if (keys.length !== Object.keys(newOption).length) {
103
+ return false;
104
+ }
105
+ return keys.every((optionKey) => {
106
+ const key = optionKey as keyof OptionValue;
107
+ const oldValue = oldOption[key];
108
+ const newValue = newOption[key];
109
+
110
+ if (key === 'options') {
111
+ return compareOptions(oldValue, newValue);
112
+ }
113
+ if (key === 'data') {
114
+ return JSON.stringify(oldValue) === JSON.stringify(newValue);
115
+ }
116
+ return oldValue === newValue;
117
+ });
118
+ });
119
+ }
@@ -1387,7 +1387,7 @@ tape.test('Store creation', (subT) => {
1387
1387
  command1.fetch();
1388
1388
  await _.nextVueTick(store1, spy1.promise);
1389
1389
 
1390
- t.is(store1.state.filteredOptions.length, 3);
1390
+ t.is(store1.state.filteredOptions.length, 3, 'should contain the groups');
1391
1391
 
1392
1392
  const command2 = {};
1393
1393
  const spy2 = {};
@@ -748,6 +748,186 @@ tape.test('commit()', (st) => {
748
748
  t.true(toHaveBeenCalledWith(spy, ['', 100, 200]));
749
749
  t.deepEqual(store.state.allOptions.length, 300);
750
750
  t.deepEqual(store.state.offsetItem, 180);
751
+ t.is(store.state.status.errorMessage, '');
752
+
753
+ t.end();
754
+ });
755
+
756
+ sTest.test('should fetch only once', async (t) => {
757
+ const command = {};
758
+ let nbTry = 0;
759
+
760
+ const store = new Store({
761
+ fetchCallback: buildFetchCb({ total: 500, command }),
762
+ });
763
+ command.usage = 0;
764
+ store.commit('isOpen', true);
765
+
766
+ nbTry = 0;
767
+ while (nbTry !== command.usage && nbTry < 10) {
768
+ nbTry = command.usage;
769
+ command.fetch();
770
+ await _.nextVueTick(store, command.promise);
771
+ }
772
+
773
+ t.is(command.usage, 1, 'the fetch is done only once to get first page');
774
+ t.is(store.state.status.errorMessage, '');
775
+
776
+ command.usage = 0;
777
+ store.commit('offsetItem', 180);
778
+ nbTry = 0;
779
+ while (nbTry !== command.usage && nbTry < 10) {
780
+ nbTry = command.usage;
781
+ command.fetch();
782
+ await _.nextVueTick(store, command.promise);
783
+ }
784
+
785
+ t.is(command.usage, 1, 'fetch missing options only once');
786
+ t.is(store.state.status.errorMessage, '');
787
+
788
+ t.deepEqual(store.state.allOptions.length, 300);
789
+
790
+ t.end();
791
+ });
792
+
793
+ sTest.test('should fetch several time if needed', async (t) => {
794
+ const command = {};
795
+ let nbTry = 0;
796
+
797
+ const store = new Store({
798
+ fetchCallback: buildFetchCb({ total: 500, command }),
799
+ });
800
+ command.usage = 0;
801
+ store.commit('isOpen', true);
802
+
803
+ command.interceptResult = (result) => {
804
+ switch (command.usage) {
805
+ case 1:
806
+ /* returns only some of the first options */
807
+ result.result = result.result.slice(0, 15);
808
+ break;
809
+ case 2:
810
+ /* returns only some options */
811
+ result.result = result.result.slice(0, 25);
812
+ default:
813
+ /* do not change the result */
814
+ }
815
+ return result;
816
+ };
817
+
818
+ nbTry = 0;
819
+ while (nbTry !== command.usage && nbTry < 10) {
820
+ nbTry = command.usage;
821
+ command.fetch();
822
+ await _.nextVueTick(store, command.promise);
823
+ }
824
+
825
+ t.is(command.usage > 1, true, 'should fetch first options with several fetch');
826
+ t.is(command.usage < 10, true, 'should have stop fetching by itself');
827
+ t.is(store.state.allOptions.length >= 50, true, 'should have retrieve the first options');
828
+ t.is(store.state.status.errorMessage, '');
829
+
830
+ command.usage = 0;
831
+ store.commit('offsetItem', 180);
832
+ nbTry = 0;
833
+ while (nbTry !== command.usage && nbTry < 10) {
834
+ nbTry = command.usage;
835
+ command.fetch();
836
+ await _.nextVueTick(store, command.promise);
837
+ }
838
+
839
+ t.is(command.usage > 1, true, 'should fetch missing options with several fetch');
840
+ t.is(command.usage < 10, true, 'should have stop fetching by itself');
841
+
842
+ t.deepEqual(store.state.allOptions.length, 300);
843
+ t.is(store.state.status.errorMessage, '');
844
+
845
+ t.end();
846
+ });
847
+
848
+ sTest.test('should stop fetching with wrong result: similar results', async (t) => {
849
+ const command = {};
850
+ let nbTry = 0;
851
+
852
+ const store = new Store({
853
+ fetchCallback: buildFetchCb({ total: 500, command }),
854
+ });
855
+ command.usage = 0;
856
+ store.commit('isOpen', true);
857
+
858
+ command.interceptResult = (result) => {
859
+ /* Returns only the 2 first options */
860
+ result.result = result.result.slice(0, 2);
861
+
862
+ return result;
863
+ };
864
+
865
+ nbTry = 0;
866
+ while (nbTry !== command.usage && nbTry < 10) {
867
+ nbTry = command.usage;
868
+ command.fetch();
869
+ await _.nextVueTick(store, command.promise);
870
+ }
871
+
872
+ t.is(command.usage > 1, true, 'should have try to fetch several times');
873
+ t.is(command.usage < 10, true, 'should have stop fetching');
874
+ t.is(store.state.status.errorMessage, store.data.labels.wrongQueryResult);
875
+
876
+ command.usage = 0;
877
+ store.commit('offsetItem', 180);
878
+ nbTry = 0;
879
+ while (nbTry !== command.usage && nbTry < 10) {
880
+ nbTry = command.usage;
881
+ command.fetch();
882
+ await _.nextVueTick(store, command.promise);
883
+ }
884
+
885
+ t.is(command.usage < 10, true, 'should have stop fetching');
886
+ t.is(store.state.status.errorMessage, store.data.labels.wrongQueryResult);
887
+
888
+ t.end();
889
+ });
890
+
891
+ sTest.test('should stop fetching with wrong result: wrong total', async (t) => {
892
+ const command = {};
893
+ let nbTry = 0;
894
+
895
+ const store = new Store({
896
+ fetchCallback: buildFetchCb({ total: 20, command }),
897
+ });
898
+ command.usage = 0;
899
+ store.commit('isOpen', true);
900
+
901
+ command.interceptResult = (result) => {
902
+ result.total = 50;
903
+
904
+ return result;
905
+ };
906
+
907
+ nbTry = 0;
908
+ while (nbTry !== command.usage && nbTry < 10) {
909
+ nbTry = command.usage;
910
+ command.fetch();
911
+ await _.nextVueTick(store, command.promise);
912
+ }
913
+
914
+ t.is(command.usage > 1, true, 'should have try to fetch several times');
915
+ t.is(command.usage < 10, true, 'should have stop fetching');
916
+ t.is(store.state.status.errorMessage, store.data.labels.wrongQueryResult);
917
+
918
+ command.usage = 0;
919
+ store.commit('offsetItem', 30);
920
+ nbTry = 0;
921
+ while (nbTry !== command.usage && nbTry < 10) {
922
+ nbTry = command.usage;
923
+ command.fetch();
924
+ await _.nextVueTick(store, command.promise);
925
+ }
926
+
927
+ t.is(command.usage < 10, true, 'should have stop fetching');
928
+ t.is(store.state.status.errorMessage, store.data.labels.wrongQueryResult);
929
+
930
+ t.deepEqual(store.state.allOptions.length, 20);
751
931
 
752
932
  t.end();
753
933
  });
package/test/helper.js CHANGED
@@ -101,6 +101,7 @@ function buildFetchCb({
101
101
  command.reject = () => {};
102
102
  command.promise = () => {};
103
103
  command.fetch = () => {};
104
+ command.usage = 0;
104
105
  }
105
106
 
106
107
  return (search, offset, limit) => {
@@ -128,10 +129,16 @@ function buildFetchCb({
128
129
  });
129
130
 
130
131
  function resolveFetch() {
131
- resolve({
132
+ let result = {
132
133
  total,
133
134
  result: getOptions(nb, prefix, offset, groupName),
134
- });
135
+ };
136
+
137
+ if (command && command.interceptResult) {
138
+ result = command.interceptResult(result);
139
+ }
140
+
141
+ resolve(result);
135
142
  }
136
143
 
137
144
  if (command) {
@@ -145,6 +152,7 @@ function buildFetchCb({
145
152
 
146
153
  if (command) {
147
154
  command.promise = fetchPromise;
155
+ command.usage++;
148
156
  }
149
157
  spy.promise = fetchPromise;
150
158
 
@@ -11,13 +11,16 @@ export default class MainInput extends Vue<Props> {
11
11
  private store;
12
12
  private id;
13
13
  private nbHiddenItems;
14
+ private domObserver;
14
15
  get isDisabled(): boolean;
15
16
  get hasValue(): boolean;
16
17
  get displayPlaceholder(): boolean;
17
18
  get canBeCleared(): boolean;
18
19
  get showClearAll(): boolean;
19
20
  get clearedLabel(): string;
20
- get singleSelectedItem(): string | false;
21
+ get singleSelectedItem(): undefined | OptionItem;
22
+ get singleSelectedItemText(): string;
23
+ get singleSelectedItemTitle(): string;
21
24
  get singleStyle(): string | undefined;
22
25
  get selecticId(): string | undefined;
23
26
  get isSelectionReversed(): boolean;
@@ -31,7 +34,10 @@ export default class MainInput extends Vue<Props> {
31
34
  private selectItem;
32
35
  private clearSelection;
33
36
  private computeSize;
37
+ private closeObserver;
38
+ private createObserver;
34
39
  onInternalChange(): void;
35
40
  updated(): void;
41
+ beforeUnmount(): void;
36
42
  render(): h.JSX.Element;
37
43
  }
package/types/Store.d.ts CHANGED
@@ -139,6 +139,7 @@ interface Messages {
139
139
  moreSelectedItem: string;
140
140
  moreSelectedItems: string;
141
141
  unknownPropertyValue: string;
142
+ wrongQueryResult: string;
142
143
  }
143
144
  export declare type PartialMessages = {
144
145
  [K in keyof Messages]?: Messages[K];
package/types/tools.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { OptionValue } from './Store';
1
2
  /**
2
3
  * Clone the object and its inner properties.
3
4
  * @param obj The object to be clone.
@@ -20,3 +21,7 @@ export declare function deepClone<T = any>(origObject: T, ignoreAttributes?: str
20
21
  export declare function convertToRegExp(name: string, flag?: string): RegExp;
21
22
  /** Does the same as Object.assign but does not replace if value is undefined */
22
23
  export declare function assignObject<T>(obj: Partial<T>, ...sourceObjects: Array<Partial<T>>): T;
24
+ /** Compare 2 list of options.
25
+ * @returns true if there are no difference
26
+ */
27
+ export declare function compareOptions(oldOptions: OptionValue[], newOptions: OptionValue[]): boolean;