@arrai-innovations/reactive-helpers 10.4.2 → 11.0.0

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.
@@ -0,0 +1,358 @@
1
+ import { difference, loadingCombine, assignReactiveObject, keyDiff } from "../utils/index.js";
2
+ import { getObjectRelatedCalculatedByKey } from "../utils/relatedCalculatedHelpers.js";
3
+ import {
4
+ listInstanceStateKeys,
5
+ listSubscriptionStateKeys,
6
+ listRelatedStateKeys,
7
+ listCalculatedStateKeys,
8
+ listSortStateKeys,
9
+ listFilterStateKeys,
10
+ listSearchStateKeys,
11
+ } from "./listKeys.js";
12
+ import { useSearch } from "./search.js";
13
+ import get from "lodash-es/get.js";
14
+ import isEqual from "lodash-es/isEqual.js";
15
+ import { reactive, effectScope, toRef, computed, watch, unref, onScopeDispose } from "vue";
16
+ import { deepUnref } from "vue-deepunref";
17
+
18
+ const parentStateKeys = difference(
19
+ new Set([
20
+ ...listInstanceStateKeys,
21
+ ...listSubscriptionStateKeys,
22
+ ...listRelatedStateKeys,
23
+ ...listCalculatedStateKeys,
24
+ ...listSortStateKeys,
25
+ ...listFilterStateKeys,
26
+ ]),
27
+ new Set(listSearchStateKeys)
28
+ );
29
+
30
+ /* eslint-disable jsdoc/check-types */
31
+ // types valid for jsdoc-to-markdown, which uses the strict jsdoc.app. Object shorthand syntax doesn't work.
32
+ /**
33
+ * A Vue composition function that creates multiple list instances, and returns them as an object.
34
+ * @param {Object.<string, ListSearchInstanceOptions>} listInstanceArgs - each desired list instance options, keyed by an instance name.
35
+ * @returns {Object.<string, ListSearchInstance>} - each list instance, keyed by the instance name.
36
+ */
37
+
38
+ /* eslint-enable jsdoc/check-types */
39
+ export function useListSearches(args, instances) {
40
+ const searches = {};
41
+ for (const [key, value] of Object.entries(args)) {
42
+ searches[key] = useListSearch({ parentState: instances[key].state, ...value });
43
+ }
44
+ return searches;
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} ListSearchProps
49
+ * @property {array} textSearchRules - rules for what to search for. Keys are the keys to search for, values are functions that take the object and return the value to search for.
50
+ * @property {string} textSearchValue - the value to search for.
51
+ * @property {object} customDocumentOptions - FlexSearch.Document options
52
+ * @property {object} customSearchOptions - FlexSearch.Search options
53
+ * @property {object} [customSearchOptions.limit=1000] - FlexSearch.Search options
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} ListSearchInstance
58
+ * @property {object} state - the state
59
+ * @property {SearchInstance} textSearchIndex - the text search index
60
+ * @property {object} effectScope - a Vue effect scope
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} ListSearchInstanceOptions
65
+ * @property {object} parentState - the list being filtered
66
+ * @property {ListSearchProps} props - reactive properties
67
+ * @property {number} [throttle=500] - throttle wait time
68
+ * @property {boolean} [showAllWhenEmpty=true] - whether to show all items when the search is empty
69
+ */
70
+
71
+ /**
72
+ * Text filter for list items. This will not be performant for large lists, as each item will be watched.
73
+ * However, the results will reactively update.
74
+ * @param {ListSearchInstanceOptions} options - the arguments
75
+ * @returns {ListSearchInstance} - the instance
76
+ */
77
+ export function useListSearch({ parentState, props, throttle = 500, showAllWhenEmpty = true }) {
78
+ if (!parentState) {
79
+ throw new Error("parentState is required");
80
+ }
81
+ const state = reactive({
82
+ objects: {},
83
+ objectsInOrder: [],
84
+ order: [],
85
+ textSearchRules: toRef(props, "textSearchRules"),
86
+ textSearchValue: toRef(props, "textSearchValue"),
87
+ objectIndexes: {},
88
+ updateSearchIndexesRunning: undefined,
89
+ customDocumentOptions: toRef(props, "customDocumentOptions"),
90
+ customSearchOptions: toRef(props, "customSearchOptions"),
91
+ searched: undefined,
92
+ running: undefined,
93
+ newSearchComputeds: undefined,
94
+ });
95
+ if (!state.customDocumentOptions) {
96
+ state.customDocumentOptions = {};
97
+ }
98
+ if (!state.customSearchOptions) {
99
+ state.customSearchOptions = {};
100
+ }
101
+ const textSearchIndexProps = reactive({
102
+ customDocumentOptions: computed(() => {
103
+ const options = {
104
+ tokenize: "forward",
105
+ minlength: 2,
106
+ ...state.customDocumentOptions, // todo: not sure if this is ok inside a computed
107
+ };
108
+ if (!options.document) {
109
+ options.document = {
110
+ id: "id",
111
+ };
112
+ }
113
+ options.document.index = state.textSearchRules;
114
+ return options;
115
+ }),
116
+ customSearchOptions: computed(() => ({
117
+ ...state.customSearchOptions, // todo: not sure if this is ok inside a computed
118
+ limit: state.customSearchOptions.limit ?? 1000,
119
+ })),
120
+ });
121
+
122
+ const es = effectScope();
123
+
124
+ let textSearchIndex;
125
+
126
+ const objectEffectScopes = {};
127
+ const objectComputeds = {};
128
+
129
+ const previousTextSearchRules = [];
130
+ const previousObjectIndexes = {};
131
+
132
+ const doPassthrough = (cleanComputed = false) => {
133
+ // pass through the objects if there are no rules.
134
+ assignReactiveObject(state.objects, showAllWhenEmpty ? parentState.objects : {});
135
+ if (!cleanComputed) {
136
+ return;
137
+ }
138
+ // if there were indexes or computeds, there is no point in keeping them.
139
+ for (const objectKey of Object.keys(objectEffectScopes)) {
140
+ objectEffectScopes[objectKey].stop();
141
+ }
142
+ assignReactiveObject(objectEffectScopes, {});
143
+ assignReactiveObject(objectComputeds, {});
144
+ assignReactiveObject(state.objectIndexes, {});
145
+ };
146
+
147
+ const makeComputeds = () => {
148
+ if (!state.textSearchRules?.length) {
149
+ doPassthrough(true);
150
+ return;
151
+ }
152
+ const {
153
+ addedKeys: addedObjectIds,
154
+ removedKeys: removedObjectIds,
155
+ sameKeys: sameObjectIds,
156
+ } = keyDiff(Object.keys(parentState.objects), Object.keys(state.objects));
157
+ const { addedKeys: addedTextSearchRules, removedKeys: removedTextSearchRules } = keyDiff(
158
+ state.textSearchRules,
159
+ previousTextSearchRules
160
+ );
161
+ for (const removedObjectId of removedObjectIds) {
162
+ delete state.objectIndexes[removedObjectId];
163
+ // the effect scope will be stopped when the object is removed.
164
+ delete objectComputeds[removedObjectId];
165
+ if (objectEffectScopes[removedObjectId]) {
166
+ objectEffectScopes[removedObjectId].stop();
167
+ delete objectEffectScopes[removedObjectId];
168
+ }
169
+ }
170
+ for (const addedObjectId of addedObjectIds) {
171
+ state.objectIndexes[addedObjectId] = { id: addedObjectId };
172
+ objectComputeds[addedObjectId] = {};
173
+ objectEffectScopes[addedObjectId] = effectScope();
174
+ const objectEffectScope = objectEffectScopes[addedObjectId];
175
+ const objectRef = toRef(parentState.objects, addedObjectId);
176
+ const relatedRef = parentState.relatedObjects
177
+ ? toRef(parentState.relatedObjects, addedObjectId)
178
+ : undefined;
179
+ const calculatedRef = parentState.calculatedObjects
180
+ ? toRef(parentState.calculatedObjects, addedObjectId)
181
+ : undefined;
182
+ objectEffectScope.run(() => {
183
+ for (const rule of state.textSearchRules || []) {
184
+ const [obj, key] = getObjectRelatedCalculatedByKey(objectRef, relatedRef, calculatedRef, rule);
185
+ state.newSearchComputeds = true;
186
+ state.objectIndexes[addedObjectId][rule] = objectComputeds[addedObjectId][rule] = computed(() => {
187
+ return get(unref(obj), key);
188
+ });
189
+ }
190
+ });
191
+ }
192
+ for (const sameObjectId of sameObjectIds) {
193
+ const objectEffectScope = objectEffectScopes[sameObjectId];
194
+ const objectRef = toRef(parentState.objects, sameObjectId);
195
+ const relatedRef = parentState.relatedObjects ? toRef(parentState.relatedObjects, sameObjectId) : undefined;
196
+ const calculatedRef = parentState.calculatedObjects
197
+ ? toRef(parentState.calculatedObjects, sameObjectId)
198
+ : undefined;
199
+ for (const key of removedTextSearchRules) {
200
+ delete state.objectIndexes[sameObjectId][key];
201
+ // stop a computed earlier than the effect scope
202
+ objectComputeds[sameObjectId][key].effect.stop();
203
+ delete objectComputeds[sameObjectId][key];
204
+ }
205
+ objectEffectScope.run(() => {
206
+ for (const rule of addedTextSearchRules) {
207
+ const [obj, key] = getObjectRelatedCalculatedByKey(objectRef, relatedRef, calculatedRef, rule);
208
+ state.newSearchComputeds = true;
209
+ state.objectIndexes[sameObjectId][rule] = objectComputeds[sameObjectId][rule] = computed(() => {
210
+ return get(unref(obj), key);
211
+ });
212
+ }
213
+ });
214
+ }
215
+ previousTextSearchRules.length = 0;
216
+ if (state.textSearchRules?.length) {
217
+ previousTextSearchRules.push(...state.textSearchRules);
218
+ }
219
+ };
220
+
221
+ const updateSearchIndexes = async () => {
222
+ if (state.updateSearchIndexesRunning === undefined) {
223
+ state.updateSearchIndexesRunning = 0;
224
+ }
225
+ state.updateSearchIndexesRunning++;
226
+ try {
227
+ const { addedKeys, removedKeys, sameKeys } = keyDiff(
228
+ Object.keys(state.objectIndexes),
229
+ Object.keys(previousObjectIndexes)
230
+ );
231
+ const promises = [];
232
+ for (const removedKey of removedKeys) {
233
+ promises.push(textSearchIndex.removeIndex(removedKey));
234
+ delete previousObjectIndexes[removedKey];
235
+ }
236
+ for (const addedKey of addedKeys) {
237
+ promises.push(textSearchIndex.addIndex(state.objectIndexes[addedKey]));
238
+ previousObjectIndexes[addedKey] = deepUnref(state.objectIndexes[addedKey]);
239
+ }
240
+ for (const sameKey of sameKeys) {
241
+ if (!isEqual(previousObjectIndexes[sameKey], state.objectIndexes[sameKey])) {
242
+ promises.push(textSearchIndex.updateIndex(state.objectIndexes[sameKey]));
243
+ previousObjectIndexes[sameKey] = deepUnref(state.objectIndexes[sameKey]);
244
+ }
245
+ }
246
+ if (promises.length) {
247
+ await Promise.all(promises);
248
+ }
249
+ } finally {
250
+ if (state.newSearchComputeds) {
251
+ state.newSearchComputeds = false;
252
+ }
253
+ state.updateSearchIndexesRunning--;
254
+ }
255
+ };
256
+
257
+ const updateObjectsForResults = () => {
258
+ if (!state.textSearchRules?.length || !state.textSearchValue?.length) {
259
+ doPassthrough();
260
+ return;
261
+ }
262
+ assignReactiveObject(
263
+ state.objects,
264
+ Object.fromEntries(
265
+ Object.entries(textSearchIndex.state.results)
266
+ .filter(([, value]) => !!value)
267
+ .map(([id]) => [id, toRef(parentState.objects, id)])
268
+ )
269
+ );
270
+ };
271
+
272
+ const updateOrder = () => {
273
+ assignReactiveObject(
274
+ state.objectsInOrder,
275
+ parentState.order.filter((id) => !!state.objects[id]).map((id) => toRef(state.objects, id))
276
+ );
277
+ assignReactiveObject(
278
+ state.order,
279
+ parentState.order.filter((id) => !!state.objects[id])
280
+ );
281
+ };
282
+
283
+ let firstIndexWasCleared = false;
284
+
285
+ const indexWasCleared = async () => {
286
+ // skip the first time, preventing clearing the index after makeComputeds already ran.
287
+ if (firstIndexWasCleared) {
288
+ return;
289
+ }
290
+ firstIndexWasCleared = true;
291
+ assignReactiveObject(previousObjectIndexes, {});
292
+ await makeComputeds();
293
+ };
294
+
295
+ let watchesRunning;
296
+
297
+ es.run(() => {
298
+ for (const key of parentStateKeys) {
299
+ state[key] = toRef(parentState, key);
300
+ }
301
+
302
+ textSearchIndex = useSearch({
303
+ props: textSearchIndexProps,
304
+ throttle,
305
+ });
306
+ textSearchIndex.state.search = toRef(state, "textSearchValue");
307
+ textSearchIndex.events.addEventListener("newIndex", indexWasCleared);
308
+ state.searched = toRef(() => textSearchIndex.state.searched);
309
+ state.running = computed(() => {
310
+ return loadingCombine(parentState.running, state.newSearchComputeds, textSearchIndex.state.running);
311
+ });
312
+
313
+ watch([() => Object.keys(parentState.objects), toRef(state.textSearchRules)], makeComputeds, {
314
+ immediate: true,
315
+ });
316
+
317
+ watch(
318
+ toRef(state, "objectIndexes"),
319
+ () => {
320
+ updateSearchIndexes();
321
+ },
322
+ {
323
+ deep: true,
324
+ immediate: true,
325
+ }
326
+ );
327
+
328
+ watch(
329
+ [
330
+ toRef(state, "textSearchValue"),
331
+ () => Object.keys(textSearchIndex.state.results),
332
+ toRef(textSearchIndex.state, "running"),
333
+ ],
334
+ updateObjectsForResults,
335
+ {
336
+ immediate: true,
337
+ }
338
+ );
339
+
340
+ watch(toRef(parentState, "order"), updateOrder, {
341
+ immediate: true,
342
+ deep: true,
343
+ });
344
+
345
+ onScopeDispose(() => {
346
+ for (const objectKey of Object.keys(objectEffectScopes)) {
347
+ objectEffectScopes[objectKey].stop();
348
+ }
349
+ });
350
+ });
351
+
352
+ return {
353
+ state,
354
+ effectScope: es,
355
+ textSearchIndex,
356
+ watchesRunning,
357
+ };
358
+ }
package/use/listSort.js CHANGED
@@ -1,9 +1,13 @@
1
- import { assignReactiveObject, keyDiff, loadingCombine } from "../utils/index.js";
2
- import { listCalculatedStateKeys } from "./listCalculated.js";
3
- import { listFilterStateKeys } from "./listFilter.js";
4
- import { listInstanceStateKeys } from "./listInstance.js";
5
- import { listRelatedStateKeys } from "./listRelated.js";
6
- import { listSubscriptionStateKeys } from "./listSubscription.js";
1
+ import { assignReactiveObject, keyDiff, loadingCombine, difference } from "../utils/index.js";
2
+ import {
3
+ listSortStateKeys,
4
+ listFilterStateKeys,
5
+ listRelatedStateKeys,
6
+ listCalculatedStateKeys,
7
+ listSubscriptionStateKeys,
8
+ listInstanceStateKeys,
9
+ listSearchStateKeys,
10
+ } from "./listKeys.js";
7
11
  import { useWatchesRunning } from "./watchesRunning.js";
8
12
  import cloneDeep from "lodash-es/cloneDeep.js";
9
13
  import get from "lodash-es/get.js";
@@ -16,21 +20,6 @@ import throttle from "lodash-es/throttle.js";
16
20
  import zip from "lodash-es/zip.js";
17
21
  import { effectScope, reactive, toRef, unref, watch, computed } from "vue";
18
22
 
19
- export const listSortStateKeys = [
20
- "orderByRules",
21
- // "order",
22
- // "objectsInOrder",
23
- "sortCriteria",
24
- "sortCriteriaEffectScopes",
25
- "orderByDesc",
26
- "sortCriteriaWatchRunning",
27
- "sortWatchRunning",
28
- "outstandingEffects",
29
- // "running",
30
- ];
31
-
32
- export const listSortFunctions = [];
33
-
34
23
  const collator = new Intl.Collator(undefined, { numeric: true });
35
24
 
36
25
  const defaultSortThrottleWait = Symbol("defaultSortThrottleWait");
@@ -39,6 +28,18 @@ const defaultOptions = {
39
28
  sortThrottleWait: 100,
40
29
  };
41
30
 
31
+ const parentStateKeys = difference(
32
+ new Set([
33
+ ...listInstanceStateKeys,
34
+ ...listSubscriptionStateKeys,
35
+ ...listRelatedStateKeys,
36
+ ...listCalculatedStateKeys,
37
+ ...listFilterStateKeys,
38
+ ...listSearchStateKeys,
39
+ ]),
40
+ new Set(listSortStateKeys)
41
+ );
42
+
42
43
  export function setListSortDefaultOptions({ sortThrottleWait }) {
43
44
  defaultOptions.sortThrottleWait = sortThrottleWait;
44
45
  }
@@ -55,12 +56,14 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
55
56
  if (sortThrottleWait === defaultSortThrottleWait) {
56
57
  sortThrottleWait = defaultOptions.sortThrottleWait;
57
58
  }
59
+
60
+ const sortCriteriaEffectScopes = {};
61
+
58
62
  const state = reactive({
59
63
  orderByRules,
60
64
  order: [],
61
65
  objectsInOrder: [],
62
66
  sortCriteria: {},
63
- sortCriteriaEffectScopes: {},
64
67
  orderByDesc: [],
65
68
  sortCriteriaWatchRunning: false,
66
69
  sortWatchRunning: false,
@@ -69,16 +72,16 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
69
72
  const es = effectScope();
70
73
 
71
74
  function removeSortCriteria(removedKey) {
72
- const oldScope = state.sortCriteriaEffectScopes[removedKey];
75
+ const oldScope = sortCriteriaEffectScopes[removedKey];
73
76
  if (oldScope) {
74
77
  oldScope.stop();
75
- delete state.sortCriteriaEffectScopes[removedKey];
78
+ delete sortCriteriaEffectScopes[removedKey];
76
79
  }
77
80
  delete state.sortCriteria[removedKey];
78
81
  }
79
82
 
80
83
  function addSortCriteria(object, relatedObject, calculatedObject, key) {
81
- const oldScope = state.sortCriteriaEffectScopes[key];
84
+ const oldScope = sortCriteriaEffectScopes[key];
82
85
  if (oldScope) {
83
86
  oldScope.stop();
84
87
  }
@@ -123,7 +126,7 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
123
126
  }
124
127
  );
125
128
  });
126
- state.sortCriteriaEffectScopes[key] = newScope;
129
+ sortCriteriaEffectScopes[key] = newScope;
127
130
  }
128
131
 
129
132
  function sortCriteriaWatch() {
@@ -220,22 +223,7 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
220
223
  let watchesRunning = null;
221
224
 
222
225
  es.run(() => {
223
- for (const key of listInstanceStateKeys) {
224
- if (["order", "objectsInOrder"].includes(key)) {
225
- continue;
226
- }
227
- state[key] = toRef(parentState, key);
228
- }
229
- for (const key of listSubscriptionStateKeys) {
230
- state[key] = toRef(parentState, key);
231
- }
232
- for (const key of listRelatedStateKeys) {
233
- state[key] = toRef(parentState, key);
234
- }
235
- for (const key of listCalculatedStateKeys) {
236
- state[key] = toRef(parentState, key);
237
- }
238
- for (const key of listFilterStateKeys) {
226
+ for (const key of parentStateKeys) {
239
227
  state[key] = toRef(parentState, key);
240
228
  }
241
229
  // this watch must come first or be immediate.
@@ -1,6 +1,7 @@
1
1
  import { loadingCombine } from "../utils/loadingCombine.js";
2
2
  import { useCancellableIntent } from "./cancellableIntent.js";
3
- import { listInstanceStateKeys, useListInstance } from "./listInstance.js";
3
+ import { useListInstance } from "./listInstance.js";
4
+ import { listInstanceStateKeys } from "./listKeys.js";
4
5
  import inspect from "browser-util-inspect";
5
6
  import cloneDeep from "lodash-es/cloneDeep.js";
6
7
  import isEmpty from "lodash-es/isEmpty.js";
@@ -14,17 +15,6 @@ export class ListSubscriptionError extends Error {
14
15
  }
15
16
  }
16
17
 
17
- export const listSubscriptionStateKeys = [
18
- "subscriptionLoading",
19
- "subscriptionErrored",
20
- "subscriptionError",
21
- "intendToList",
22
- "intendToSubscribe",
23
- "subscribed",
24
- ];
25
-
26
- export const listSubscriptionFunctions = ["subscribe", "unsubscribe", "clearError"];
27
-
28
18
  /**
29
19
  * The configuration options used to create a list subscription.
30
20
  * @typedef {object} ListSubscriptionOptions
@@ -190,7 +180,7 @@ export function useListSubscription({ listInstance, props, functions, keepOldPag
190
180
  listInstance.deleteListObject(id);
191
181
  } catch (err) {
192
182
  if (err.name === "ListError" && err.code === "missing-object") {
193
- console.warn(`deleteFromSubscription: delete for id not in objects (${id}).`);
183
+ console.warn(`deleteFromSubscription: delete for id not in objects (${inspect(id)}).`);
194
184
  return;
195
185
  }
196
186
  throw err;