@servicetitan/titan-chatbot-api 4.3.3 → 4.4.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/api-client/models/__mocks__/models.mock.d.ts +1 -0
  3. package/dist/api-client/models/__mocks__/models.mock.d.ts.map +1 -1
  4. package/dist/api-client/models/__mocks__/models.mock.js +31 -7
  5. package/dist/api-client/models/__mocks__/models.mock.js.map +1 -1
  6. package/dist/api-client/utils/__tests__/model-utils.test.d.ts +2 -0
  7. package/dist/api-client/utils/__tests__/model-utils.test.d.ts.map +1 -0
  8. package/dist/api-client/utils/__tests__/model-utils.test.js +327 -0
  9. package/dist/api-client/utils/__tests__/model-utils.test.js.map +1 -0
  10. package/dist/api-client/utils/model-utils.d.ts +1 -1
  11. package/dist/api-client/utils/model-utils.d.ts.map +1 -1
  12. package/dist/api-client/utils/model-utils.js +33 -29
  13. package/dist/api-client/utils/model-utils.js.map +1 -1
  14. package/dist/stores/__tests__/chatbot-ui.store.test.js +1 -1
  15. package/dist/stores/__tests__/filter.store.test.js +49 -40
  16. package/dist/stores/__tests__/filter.store.test.js.map +1 -1
  17. package/dist/stores/chatbot-ui.store.d.ts +3 -3
  18. package/dist/stores/chatbot-ui.store.d.ts.map +1 -1
  19. package/dist/stores/chatbot-ui.store.js.map +1 -1
  20. package/dist/stores/filter.store.d.ts +50 -17
  21. package/dist/stores/filter.store.d.ts.map +1 -1
  22. package/dist/stores/filter.store.js +255 -179
  23. package/dist/stores/filter.store.js.map +1 -1
  24. package/dist/stores/index.d.ts +1 -0
  25. package/dist/stores/index.d.ts.map +1 -1
  26. package/dist/stores/index.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/api-client/models/__mocks__/models.mock.ts +33 -6
  29. package/src/api-client/utils/__tests__/model-utils.test.ts +388 -0
  30. package/src/api-client/utils/model-utils.ts +36 -32
  31. package/src/stores/__tests__/chatbot-ui.store.test.ts +1 -1
  32. package/src/stores/__tests__/filter.store.test.ts +63 -45
  33. package/src/stores/chatbot-ui.store.ts +3 -3
  34. package/src/stores/filter.store.ts +250 -187
  35. package/src/stores/index.ts +1 -0
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -3,17 +3,43 @@ import { action, makeObservable, observable } from 'mobx';
3
3
  import { nanoid } from 'nanoid';
4
4
  import { Models, ModelsUtils } from '../api-client';
5
5
 
6
- interface IOption extends Models.IOption {
7
- uid?: string;
8
- parentKeys?: string[];
6
+ export interface IUiFilterOption {
7
+ uid: string;
8
+ parentUids: string[];
9
+ key: string;
10
+ displayName?: string | undefined;
11
+ type: Models.OptionType;
12
+ subOptions?: IUiFilterOption[] | undefined;
9
13
  }
10
14
 
11
- export class FilterStore {
12
- @observable selectedOptions: Record<string, string[]> = {};
13
- @observable filters: IOption[] = [];
15
+ export interface IFilterStore {
16
+ selected: Map<string, string[]>;
17
+ filters: IUiFilterOption[];
18
+ initFilters(model: Models.IFrontendModel): void;
19
+ isOptionChecked(filterKey: string, optionKey: string): boolean;
20
+ isOptionDisabled(filterKey: string, optionKey: string): boolean;
21
+ getFilterOptions(filterKey: string): IUiFilterOption[];
22
+ selectOption(filterKey: string, optionKey: string): void;
23
+ selectAll(filterKey: string): void;
24
+ deselectOption(filterKey: string, optionKey: string): void;
25
+ deselectAll(filterKey: string): void;
26
+ getFilterLabel(key: string): string;
27
+ export(): Models.Selections | undefined;
28
+ }
14
29
 
15
- private model?: Models.IFrontendModel;
16
- private originalFilters: IOption[] = [];
30
+ export class FilterStore implements IFilterStore {
31
+ /**
32
+ * Selected items map: filter key -> selectable keys
33
+ */
34
+ @observable selected = new Map<string, string[]>();
35
+ /**
36
+ * Available filters with their selectable options (flattened and merged)
37
+ */
38
+ @observable filters: IUiFilterOption[] = [];
39
+ private localFilters: IUiFilterOption[] = [];
40
+ private originalFilters: Models.IOption[] = [];
41
+ private mapUidToOption = new Map<string, IUiFilterOption>();
42
+ private mapUidToParentUid = new Map<string, string | undefined>();
17
43
 
18
44
  constructor() {
19
45
  makeObservable(this);
@@ -21,56 +47,108 @@ export class FilterStore {
21
47
 
22
48
  @action
23
49
  initFilters(model: Models.IFrontendModel) {
24
- const setUids = (o?: IOption[]) => {
25
- o?.forEach(x => {
26
- x.uid = nanoid();
27
- setUids(x.subOptions as IOption[]);
28
- });
29
- };
30
-
31
- this.model = model;
32
50
  this.originalFilters = cloneDeep(
33
- model!.options.subOptions?.filter(x => x.type === Models.OptionType.Group) ?? []
51
+ model?.options.subOptions?.filter(x => x.type === Models.OptionType.Group) ?? []
34
52
  );
35
- setUids(this.originalFilters);
36
- this.filters = this.originalFilters;
53
+ this.initLocalFilters(this.originalFilters as IUiFilterOption[]);
37
54
  }
38
55
 
56
+ isOptionChecked = (filterKey: string, optionKey: string): boolean => {
57
+ const selectedOptions = this.selected.get(filterKey);
58
+ if (!selectedOptions) {
59
+ return false;
60
+ }
61
+ return selectedOptions.includes(optionKey);
62
+ };
63
+
64
+ isOptionDisabled = (filterKey: string, optionKey: string): boolean => {
65
+ const filter = this.getFilterByKey(filterKey);
66
+ const option = filter?.subOptions?.find(o => o.key === optionKey);
67
+ if (!option) {
68
+ return false;
69
+ }
70
+ // check any parent selectable is selected
71
+ for (const optionFilter of option.parentUids
72
+ .map(uid => this.mapUidToOption.get(uid))
73
+ .filter(Boolean)) {
74
+ if (!optionFilter?.parentUids?.length) {
75
+ // no parent -> root filter -> so not disabled
76
+ return false;
77
+ }
78
+ // Check all parent filters
79
+ for (const parentUid of optionFilter.parentUids) {
80
+ const parentOption = this.mapUidToOption.get(parentUid);
81
+ if (!parentOption) {
82
+ continue;
83
+ }
84
+ const parentFilter = this.getOptionFilter(parentOption);
85
+ if (!parentFilter) {
86
+ continue;
87
+ }
88
+ const isParentSelected = this.isOptionChecked(parentFilter.key, parentOption.key);
89
+ if (isParentSelected) {
90
+ return false;
91
+ }
92
+ }
93
+ }
94
+ return true;
95
+ };
96
+
97
+ getFilterOptions = (filterKey: string): IUiFilterOption[] => {
98
+ const filter = this.getFilterByKey(filterKey);
99
+ if (!filter?.subOptions) {
100
+ return [];
101
+ }
102
+ return filter.subOptions.filter(x => x.type === Models.OptionType.Selectable);
103
+ };
104
+
39
105
  @action
40
106
  selectOption = (filterKey: string, optionKey: string) => {
41
- const allFilterOptions = this.getExistingFilterOptions(filterKey);
42
- const option = allFilterOptions.find(o => o.key === optionKey);
43
- if (!option) {
44
- throw new Error(`Option "${optionKey}" does not exist in the filter "${filterKey}".`);
107
+ this.assertFilterKeyExists(filterKey);
108
+ this.assertOptionKeyExists(filterKey, optionKey);
109
+ if (!this.selected.has(filterKey)) {
110
+ this.selected.set(filterKey, []);
45
111
  }
46
- if (!this.selectedOptions[filterKey]) {
47
- this.selectedOptions[filterKey] = [];
112
+ const selectedOptions = this.selected.get(filterKey)!;
113
+ if (selectedOptions.includes(optionKey)) {
114
+ return;
48
115
  }
49
- this.selectedOptions[filterKey] = [...this.selectedOptions[filterKey], option.key];
50
- this.addSubSequentFilters(filterKey);
116
+ this.selected.set(filterKey, [...selectedOptions, optionKey]);
51
117
  };
52
118
 
53
119
  @action
54
120
  selectAll = (filterKey: string) => {
55
- this.selectedOptions[filterKey] = this.getExistingFilterOptions(filterKey).map(o => o.key);
56
- this.addSubSequentFilters(filterKey);
121
+ this.assertFilterKeyExists(filterKey);
122
+ const filter = this.getFilterByKey(filterKey);
123
+ if (!filter?.subOptions) {
124
+ return;
125
+ }
126
+ const selectableOptions = filter.subOptions.filter(
127
+ x => x.type === Models.OptionType.Selectable
128
+ );
129
+ this.selected.set(
130
+ filterKey,
131
+ selectableOptions.map(o => o.key)
132
+ );
57
133
  };
58
134
 
59
135
  @action
60
136
  deselectOption = (filterKey: string, optionKey: string) => {
61
- this.selectedOptions[filterKey] = this.selectedOptions[filterKey].filter(
62
- o => o !== optionKey
137
+ this.assertFilterKeyExists(filterKey);
138
+ this.assertOptionKeyExists(filterKey, optionKey);
139
+ const selectedOptions = this.selected.get(filterKey) ?? [];
140
+ this.selected.set(
141
+ filterKey,
142
+ selectedOptions.filter(o => o !== optionKey)
63
143
  );
64
- this.cleanUpFilterOptions(filterKey, optionKey);
65
- this.removeSubSequentFilters(filterKey);
66
- this.addSubSequentFilters(filterKey);
144
+ this.uncheckDisabledOptions();
67
145
  };
68
146
 
69
147
  @action
70
148
  deselectAll = (filterKey: string) => {
71
- for (const selectedKey of this.selectedOptions[filterKey]) {
72
- this.deselectOption(filterKey, selectedKey);
73
- }
149
+ this.assertFilterKeyExists(filterKey);
150
+ this.selected.set(filterKey, []);
151
+ this.uncheckDisabledOptions();
74
152
  };
75
153
 
76
154
  getFilterLabel = (key: string) => {
@@ -83,180 +161,165 @@ export class FilterStore {
83
161
  };
84
162
 
85
163
  export(): Models.Selections | undefined {
86
- return ModelsUtils.createSelectionsModel(this.originalFilters, this.selectedOptions);
164
+ return ModelsUtils.createSelectionsModel(this.originalFilters, this.selected);
87
165
  }
88
166
 
89
- @action
90
- private removeSubSequentFilters = (filterKey: string) => {
91
- const filter = this.getExistingFilterByKey(filterKey);
92
- const subFilters = this.getSubFilters(filter);
93
- this.filters = this.filters.filter(x => !subFilters.some(f => f.key === x.key));
94
- };
95
-
96
- @action
97
- private addSubSequentFilters = (filterKey: string) => {
98
- const filter = this.getExistingFilterByKey(filterKey);
99
- if (!filter.subOptions) {
100
- return;
167
+ private assertFilterKeyExists = (filterKey: string) => {
168
+ const filter = this.getFilterByKey(filterKey);
169
+ if (!filter) {
170
+ throw new Error(`Filter with key "${filterKey}" does not exist.`);
101
171
  }
172
+ };
102
173
 
103
- // Get selected items for the filter
104
- const filterSelectedKeys = this.selectedOptions[filterKey] ?? [];
105
- const filterSelectedOptions: IOption[] = filter.subOptions.filter(
106
- x => x.type === Models.OptionType.Selectable && filterSelectedKeys.includes(x.key!)
107
- );
108
-
109
- // Find all sub-filters for the selected options
110
- const allSubFilters: IOption[] =
111
- filterSelectedOptions.flatMap<IOption>(selectedOption => {
112
- // For each selected option, collect its sub-filters
113
- const subFilters: IOption[] = [];
114
- for (const subFilter of selectedOption.subOptions ?? []) {
115
- const originalFilter = cloneDeep(
116
- this.getOriginalFilter((subFilter as IOption).uid)
117
- );
118
- originalFilter.parentKeys = [selectedOption.uid!];
119
- subFilters.push(originalFilter);
120
- }
121
- return subFilters;
122
- }) ?? [];
123
-
124
- // Merge sub-filters with the same key and their options (with parent keys merging)
125
- const mergedSubFilters = allSubFilters.reduce<IOption[]>((acc, subFilter) => {
126
- if (!subFilter.subOptions) {
127
- return acc;
128
- }
129
- const subfilterOptions = (subFilter.subOptions ?? []) as IOption[];
130
- const subfilterParentKeys = subFilter.parentKeys;
131
-
132
- // Merge filters with the same key and their options if they already exist
133
- subfilterOptions.forEach(subfilterOption => {
134
- subfilterOption.parentKeys = this.mergeParentKeys(
135
- subfilterOption.parentKeys,
136
- subfilterParentKeys
137
- );
138
- });
139
-
140
- // Check if the filter already exists in the accumulator
141
- const existingFilter = acc.find(x => x.key === subFilter.key);
142
- if (!existingFilter) {
143
- return [...acc, subFilter];
144
- }
145
-
146
- // Merge parent keys for the filter
147
- existingFilter.parentKeys = this.mergeParentKeys(
148
- existingFilter.parentKeys,
149
- subfilterParentKeys
174
+ private assertOptionKeyExists = (filterKey: string, optionKey: string) => {
175
+ this.assertFilterKeyExists(filterKey);
176
+ const filter = this.getFilterByKey(filterKey)!;
177
+ const option = filter.subOptions?.find(o => o.key === optionKey);
178
+ if (!option) {
179
+ throw new Error(
180
+ `Option with key "${optionKey}" does not exist in filter "${filterKey}".`
150
181
  );
151
-
152
- // Merge options for subfilters by key
153
- existingFilter.subOptions = subfilterOptions.reduce(
154
- (acc2, subfilterOption) => {
155
- const existing = acc2.find(o => o.key === subfilterOption.key) as
156
- | IOption
157
- | undefined;
158
- if (!existing) {
159
- return [...acc2, subfilterOption];
160
- }
161
- existing.parentKeys = this.mergeParentKeys(
162
- existing.parentKeys,
163
- subfilterOption.parentKeys
164
- );
165
-
166
- return acc2;
167
- },
168
- [...(existingFilter.subOptions ?? [])]
169
- ) as Models.Option[];
170
- return acc;
171
- }, []);
172
-
173
- // Update the filters with the merged sub-filters
174
- for (const newFilter of mergedSubFilters) {
175
- const existingFilterIdx = this.filters.findIndex(f => f.key === newFilter.key);
176
- if (existingFilterIdx < 0) {
177
- this.filters.push(newFilter);
178
- } else {
179
- this.filters[existingFilterIdx] = newFilter;
180
- }
181
- this.addSubSequentFilters(newFilter.key);
182
182
  }
183
183
  };
184
184
 
185
- private mergeParentKeys = (keys1?: string[], keys2?: string[]): string[] => {
186
- if (!keys1 && !keys2) {
187
- return [];
188
- }
189
- if (!keys1) {
190
- return keys2 ?? [];
191
- }
192
- if (!keys2) {
193
- return keys1;
185
+ private getFilterByKey = (filterKey: string): IUiFilterOption | undefined => {
186
+ return this.filters.find(f => f.key === filterKey);
187
+ };
188
+
189
+ private getOptionFilter = (option: IUiFilterOption): IUiFilterOption | undefined => {
190
+ const parentUid = this.mapUidToParentUid.get(option.uid);
191
+ if (!parentUid) {
192
+ return undefined;
194
193
  }
195
- const mergedKeys = new Set<string>([...keys1, ...keys2]);
196
- return Array.from(mergedKeys);
194
+ return this.mapUidToOption.get(parentUid);
197
195
  };
198
196
 
199
- private cleanUpFilterOptions = (filterKey: string, parentKeyToRemove: string) => {
200
- const filter = this.getExistingFilterByKey(filterKey);
201
- const filterSubOptionToRemove = filter.subOptions?.find(x => x.key === parentKeyToRemove);
202
- const filterSubOptionToRemoveUid = (filterSubOptionToRemove as IOption)?.uid;
203
- const filterIdx = this.filters.findIndex(f => f.key === filterKey);
204
- for (let i = filterIdx + 1; i < this.filters.length; i++) {
205
- const subFilter = this.filters[i];
206
- if (!subFilter.parentKeys?.some(k => k === filterSubOptionToRemoveUid)) {
207
- // If the sub-filter does not depend on the removed option, skip it
197
+ @action
198
+ private uncheckDisabledOptions = () => {
199
+ for (const [filterKey, selectedOptions] of this.selected.entries()) {
200
+ const filter = this.getFilterByKey(filterKey);
201
+ if (!filter) {
208
202
  continue;
209
203
  }
210
- subFilter.subOptions?.forEach((o: IOption) => {
211
- o.parentKeys = (o.parentKeys ?? []).filter(p => p !== filterSubOptionToRemoveUid);
212
- // Uncheck sub-filter options if they no longer have parent keys
213
- if (!o.parentKeys?.length) {
214
- this.selectedOptions[subFilter.key] = this.selectedOptions[
215
- subFilter.key
216
- ]?.filter(x => x !== o.key);
204
+ const validSelectedOptions = selectedOptions.filter(optionKey => {
205
+ const option = filter.subOptions?.find(o => o.key === optionKey);
206
+ if (!option) {
207
+ return false;
217
208
  }
209
+ return !this.isOptionDisabled(filterKey, optionKey);
218
210
  });
219
- subFilter.subOptions = subFilter.subOptions?.filter((o: IOption) =>
220
- Boolean(o.parentKeys?.length)
221
- );
211
+ this.selected.set(filterKey, validSelectedOptions);
222
212
  }
223
213
  };
224
214
 
225
- private getSubFilters = (filter: IOption): IOption[] => {
226
- const result: IOption[] = [];
227
- filter.subOptions?.forEach(filterSelectable => {
228
- filterSelectable.subOptions?.forEach(subFilter => {
229
- result.push(subFilter, ...this.getSubFilters(subFilter));
215
+ /**
216
+ * Sets unique IDs (uids) for each option and builds a parent options map that tracks parent-child relationships.
217
+ * @param options The list of options to process (recursive).
218
+ */
219
+ private initLocalFilters = (options: IUiFilterOption[]) => {
220
+ // Set up local filters uids
221
+ this.localFilters = cloneDeep(options);
222
+ const setUids = (o?: IUiFilterOption[], parentUid?: string) => {
223
+ if (!o) {
224
+ return;
225
+ }
226
+ o.forEach(x => {
227
+ x.uid = nanoid();
228
+ x.parentUids = parentUid ? [parentUid] : [];
229
+ setUids(x.subOptions, x.uid);
230
230
  });
231
- });
232
- return result;
231
+ };
232
+ setUids(this.localFilters);
233
+ this.initLocalUiFilters(this.localFilters);
233
234
  };
234
235
 
235
- private getOriginalFilters = (): IOption[] => {
236
- return this.originalFilters.reduce<IOption[]>((acc, next) => {
237
- return [...acc, next, ...this.getSubFilters(next)];
238
- }, []);
239
- };
236
+ /**
237
+ * Go through local filters and set up UI objects that will be rendered: filters and filter selectables
238
+ */
239
+ @action
240
+ private initLocalUiFilters = (options: IUiFilterOption[]) => {
241
+ this.mapUidToOption = new Map<string, IUiFilterOption>();
242
+ this.mapUidToParentUid = new Map<string, string | undefined>();
243
+ const filtersKeyToFilter = new Map<string, IUiFilterOption>();
240
244
 
241
- private getOriginalFilter = (uid?: string): IOption => {
242
- const originalFilters = this.getOriginalFilters();
243
- const filter = originalFilters.find(f => f.uid === uid);
244
- if (!filter) {
245
- throw new Error(`Filter with key "${uid}" does not exist.`);
246
- }
247
- return filter;
248
- };
245
+ // Function to merge duplicate selectable options by key
246
+ const mergeDuplicates = (options: IUiFilterOption[]) => {
247
+ // Merge selectables with the same keys. Original uids will be stored in "parentUids"
248
+ const keyToOption = new Map<string, IUiFilterOption>();
249
+ for (const option of options) {
250
+ if (option.type !== Models.OptionType.Selectable) {
251
+ continue;
252
+ }
253
+ if (!keyToOption.has(option.key)) {
254
+ keyToOption.set(option.key, {
255
+ uid: option.uid,
256
+ parentUids: [],
257
+ key: option.key,
258
+ displayName: option.displayName,
259
+ type: option.type,
260
+ subOptions: [],
261
+ });
262
+ }
263
+ const existingOption = keyToOption.get(option.key)!;
264
+ existingOption.parentUids = [...existingOption.parentUids, ...option.parentUids];
265
+ }
266
+ return Array.from(keyToOption.values());
267
+ };
249
268
 
250
- private getExistingFilterOptions = (filterKey: string): IOption[] => {
251
- const filter = this.getExistingFilterByKey(filterKey);
252
- return filter.subOptions?.filter(x => x.type === Models.OptionType.Selectable) ?? [];
253
- };
269
+ // Recursive flattening function
270
+ const flatten = (curr: IUiFilterOption, parent?: IUiFilterOption) => {
271
+ // Skip empty objects and none types
272
+ if (!curr || curr.type === Models.OptionType.None) {
273
+ return;
274
+ }
275
+ // Map uid to parent uid
276
+ if (parent) {
277
+ this.mapUidToParentUid.set(curr.uid, parent.uid);
278
+ }
254
279
 
255
- private getExistingFilterByKey = (filterKey: string): IOption => {
256
- const filter = this.filters.find(f => f.key === filterKey);
257
- if (!filter) {
258
- throw new Error(`Filter with key "${filterKey}" does not exist.`);
280
+ // Process selectable
281
+ if (curr.type === Models.OptionType.Selectable) {
282
+ const newOption: IUiFilterOption = {
283
+ uid: curr.uid,
284
+ parentUids: parent ? [parent.uid] : [],
285
+ key: curr.key,
286
+ displayName: curr.displayName,
287
+ type: curr.type,
288
+ };
289
+ this.mapUidToOption.set(newOption.uid, newOption);
290
+ const optionFilter = filtersKeyToFilter.get(parent!.key)!;
291
+ optionFilter.subOptions = [...(optionFilter.subOptions ?? []), newOption];
292
+ // Go into subfilters
293
+ for (const subOption of curr.subOptions ?? []) {
294
+ flatten(subOption, newOption);
295
+ }
296
+ return;
297
+ }
298
+
299
+ // Process filter
300
+ if (!filtersKeyToFilter.has(curr.key)) {
301
+ const newFilter: IUiFilterOption = {
302
+ uid: curr.uid,
303
+ parentUids: parent ? [parent.uid] : [],
304
+ key: curr.key,
305
+ type: curr.type,
306
+ displayName: curr.displayName,
307
+ subOptions: [],
308
+ };
309
+ filtersKeyToFilter.set(curr.key, newFilter);
310
+ }
311
+ const filter = filtersKeyToFilter.get(curr.key)!;
312
+ this.mapUidToOption.set(curr.uid, curr);
313
+ for (const subOption of curr.subOptions ?? []) {
314
+ flatten(subOption, curr);
315
+ }
316
+ if (filter.subOptions?.length) {
317
+ filter.subOptions = mergeDuplicates(filter.subOptions);
318
+ }
319
+ };
320
+ for (const option of options) {
321
+ flatten(option);
259
322
  }
260
- return filter;
323
+ this.filters = Array.from(filtersKeyToFilter.values());
261
324
  };
262
325
  }
@@ -9,6 +9,7 @@ export {
9
9
  ChatbotUiStore,
10
10
  ChatbotUiEvent,
11
11
  } from './chatbot-ui.store';
12
+ export { type IUiFilterOption, type IFilterStore } from './filter.store';
12
13
  export { InitializeStore, InitializeStoreStatus } from './initialize.store';
13
14
  export { MessageFeedbackStore } from './message-feedback.store';
14
15
  export { MessageFeedbackGuardrailStore } from './message-feedback-guardrail.store';