lula2 0.3.1 → 0.3.2-nightly.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.
Files changed (43) hide show
  1. package/dist/_app/immutable/assets/0.CLKu6Q8_.css +1 -0
  2. package/dist/_app/immutable/chunks/0xMSlW4Y.js +65 -0
  3. package/dist/_app/immutable/chunks/{BtOhWAVU.js → 152nb-LI.js} +2 -2
  4. package/dist/_app/immutable/chunks/{C5zWTfmV.js → 1spjHGNy.js} +1 -1
  5. package/dist/_app/immutable/chunks/{DnJ0bPgj.js → BtuEtkd3.js} +1 -1
  6. package/dist/_app/immutable/chunks/C113Bo4B.js +2 -0
  7. package/dist/_app/immutable/chunks/{CoF2vljD.js → CNOPXlDW.js} +1 -1
  8. package/dist/_app/immutable/chunks/{DqsOU3kV.js → DY3-lqhI.js} +1 -1
  9. package/dist/_app/immutable/chunks/{Ds14DLx0.js → DrOkR2YZ.js} +3 -3
  10. package/dist/_app/immutable/chunks/rsJnd9Tf.js +1 -0
  11. package/dist/_app/immutable/entry/{app.XLGRlmCF.js → app.CqP__jbe.js} +2 -2
  12. package/dist/_app/immutable/entry/start.C1hW6Z_g.js +1 -0
  13. package/dist/_app/immutable/nodes/{0.B6W16O68.js → 0.BWuQSqPo.js} +1 -1
  14. package/dist/_app/immutable/nodes/{1.wIilWkgu.js → 1.COMBeJ1R.js} +1 -1
  15. package/dist/_app/immutable/nodes/{2.CG53uQH9.js → 2.6bQMjmMR.js} +1 -1
  16. package/dist/_app/immutable/nodes/{3.D4uH9LCp.js → 3.BBPnkpxM.js} +1 -1
  17. package/dist/_app/immutable/nodes/{4.sYW2-VhJ.js → 4.CGIAObAE.js} +1 -1
  18. package/dist/_app/version.json +1 -1
  19. package/dist/cli/commands/ui.js +6 -6
  20. package/dist/cli/server/index.js +6 -6
  21. package/dist/cli/server/server.js +6 -6
  22. package/dist/cli/server/serverState.js +2 -2
  23. package/dist/cli/server/spreadsheetRoutes.js +4 -4
  24. package/dist/cli/server/websocketServer.js +6 -6
  25. package/dist/index.html +10 -10
  26. package/dist/index.js +6 -6
  27. package/package.json +21 -21
  28. package/src/lib/actions/clickOutside.ts +24 -0
  29. package/src/lib/components/controls/ControlsList.svelte +101 -75
  30. package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +1 -1
  31. package/src/lib/components/controls/tabs/TimelineTab.svelte +1 -1
  32. package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +1 -1
  33. package/src/lib/components/forms/DynamicControlForm.svelte +2 -2
  34. package/src/lib/components/setup/ExistingControlSets.svelte +0 -1
  35. package/src/lib/components/setup/SpreadsheetImport.svelte +10 -10
  36. package/src/lib/components/ui/CustomDropdown.svelte +96 -0
  37. package/src/lib/components/ui/FilterBuilder.svelte +415 -0
  38. package/src/stores/compliance.ts +149 -39
  39. package/dist/_app/immutable/assets/0.Dv98laBw.css +0 -1
  40. package/dist/_app/immutable/chunks/BW28MavF.js +0 -1
  41. package/dist/_app/immutable/chunks/Cby0Z7eP.js +0 -2
  42. package/dist/_app/immutable/chunks/DQAmyY_z.js +0 -66
  43. package/dist/_app/immutable/entry/start.68S9ad6U.js +0 -1
@@ -0,0 +1,415 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts">
5
+ import {
6
+ complianceStore,
7
+ type FilterOperator,
8
+ type FilterCondition,
9
+ type FilterValue,
10
+ activeFilters,
11
+ FILTER_OPERATORS,
12
+ getOperatorLabel
13
+ } from '$stores/compliance';
14
+ import { appState } from '$lib/websocket';
15
+ import { Filter, Add, TrashCan, ChevronDown, ChevronUp } from 'carbon-icons-svelte';
16
+ import { clickOutside } from '$lib/actions/clickOutside';
17
+ import { slide, fade } from 'svelte/transition';
18
+ import { derived } from 'svelte/store';
19
+ import { twMerge } from 'tailwind-merge';
20
+ import CustomDropdown from './CustomDropdown.svelte';
21
+
22
+ // Local state
23
+ let showFilterPanel = false;
24
+ let showFieldDropdown = false;
25
+ let newFilterField = '';
26
+ let newFilterOperator: FilterOperator = 'equals';
27
+ let newFilterValue = '';
28
+
29
+ // Get field schema and families from app state
30
+ const fieldSchema = derived(appState, ($state) => $state.fieldSchema?.fields || {});
31
+
32
+ // Get available fields from the store
33
+ $: availableFields = complianceStore.getAvailableFields();
34
+
35
+ // Get the active filters from the store
36
+ $: activeFiltersList = $activeFilters;
37
+
38
+ // Get field type for the selected field
39
+ $: selectedFieldSchema = $fieldSchema[newFilterField] || null;
40
+ $: selectedFieldType = selectedFieldSchema?.type || 'string';
41
+ $: selectedFieldUiType = selectedFieldSchema?.ui_type || 'short_text';
42
+ $: isSelectField = selectedFieldUiType === 'select';
43
+ $: fieldOptions = isSelectField ? selectedFieldSchema?.options || [] : [];
44
+
45
+ // Force equals operator for select fields
46
+ $: if (isSelectField) {
47
+ newFilterOperator = 'equals';
48
+ }
49
+
50
+ // Group fields by tab
51
+ $: fieldsByTab = {
52
+ overview: [] as string[],
53
+ implementation: [] as string[],
54
+ custom: [] as string[]
55
+ };
56
+
57
+ $: {
58
+ // Reset arrays before populating
59
+ fieldsByTab.overview = [];
60
+ fieldsByTab.implementation = [];
61
+ fieldsByTab.custom = [];
62
+
63
+ // Group fields by tab
64
+ availableFields.forEach((field) => {
65
+ const schema = $fieldSchema[field];
66
+ if (schema) {
67
+ const tab = schema.tab || getDefaultTabForCategory(schema.category);
68
+ if (tab === 'overview') fieldsByTab.overview.push(field);
69
+ else if (tab === 'implementation') fieldsByTab.implementation.push(field);
70
+ else fieldsByTab.custom.push(field);
71
+ } else {
72
+ // If no schema, default to custom
73
+ fieldsByTab.custom.push(field);
74
+ }
75
+ });
76
+ }
77
+
78
+ // Determine if value input should be shown based on operator
79
+ $: showValueInput = newFilterOperator !== 'exists' && newFilterOperator !== 'not_exists';
80
+
81
+ // Create a new array from the readonly constant to make it mutable for Svelte
82
+ const operatorOptions = FILTER_OPERATORS.map((op) => ({ value: op.value, label: op.label }));
83
+
84
+ // Add a new filter
85
+ function addFilter() {
86
+ if (!newFilterField) return;
87
+
88
+ let value = newFilterValue;
89
+
90
+ // Convert value based on field type
91
+ let processedValue: FilterValue = value;
92
+ if (selectedFieldType === 'boolean' && typeof value === 'string') {
93
+ processedValue = value.toLowerCase() === 'true';
94
+ } else if (selectedFieldType === 'number' && typeof value === 'string') {
95
+ const parsedValue = parseFloat(value);
96
+ processedValue = isNaN(parsedValue) ? value : parsedValue; // Fallback to string if parsing fails
97
+ }
98
+
99
+ // Add the filter
100
+ complianceStore.addFilter(newFilterField, newFilterOperator, processedValue);
101
+
102
+ // Reset the form
103
+ newFilterField = '';
104
+ newFilterOperator = 'equals';
105
+ newFilterValue = '';
106
+ showFilterPanel = false;
107
+ }
108
+
109
+ // Toggle filter panel
110
+ function toggleFilterPanel() {
111
+ showFilterPanel = !showFilterPanel;
112
+ }
113
+
114
+ // Helper function to map category to tab (same logic as ControlDetailsPanel)
115
+ function getDefaultTabForCategory(category: string): 'overview' | 'implementation' | 'custom' {
116
+ switch (category) {
117
+ case 'core':
118
+ case 'metadata':
119
+ return 'overview';
120
+ case 'compliance':
121
+ case 'content':
122
+ return 'implementation';
123
+ default:
124
+ return 'custom';
125
+ }
126
+ }
127
+
128
+ // Get display name for a field
129
+ function getFieldDisplayName(fieldName: string): string {
130
+ const schema = $fieldSchema[fieldName];
131
+
132
+ // Use schema names if available
133
+ if (schema?.original_name || schema?.display_name) {
134
+ return schema.original_name || schema.display_name;
135
+ }
136
+
137
+ // Otherwise use the field name with hyphens replaced by spaces and first letter capitalized
138
+ let displayName = fieldName.replace(/-/g, ' ');
139
+ return displayName.charAt(0).toUpperCase() + displayName.slice(1);
140
+ }
141
+
142
+ // Get display value for a filter
143
+ function getFilterDisplayValue(filter: FilterCondition): string {
144
+ // Special cases for exists/not_exists that don't need to show a value
145
+ if (filter.operator === 'exists') return 'exists';
146
+ if (filter.operator === 'not_exists') return 'does not exist';
147
+
148
+ // Use the shared getOperatorLabel function
149
+ const operatorText = getOperatorLabel(filter.operator).toLowerCase();
150
+ return `${operatorText} "${filter.value}"`;
151
+ }
152
+
153
+ // Handle click outside to close the panel
154
+ function handleClickOutside() {
155
+ showFilterPanel = false;
156
+ }
157
+ </script>
158
+
159
+ <div class="relative" use:clickOutside={handleClickOutside}>
160
+ <!-- Filter Button -->
161
+ <button
162
+ onclick={toggleFilterPanel}
163
+ class="flex items-center px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
164
+ aria-expanded={showFilterPanel}
165
+ aria-controls="filter-panel"
166
+ >
167
+ <Filter class="w-4 h-4 mr-2" />
168
+ <span>Filters</span>
169
+ {#if activeFiltersList.length > 0}
170
+ <span
171
+ class="ml-2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-0.5 rounded-full"
172
+ >
173
+ {activeFiltersList.length}
174
+ </span>
175
+ {/if}
176
+ <ChevronDown class="w-4 h-4 ml-2" />
177
+ </button>
178
+
179
+ <!-- Filter Panel -->
180
+ {#if showFilterPanel}
181
+ <div
182
+ id="filter-panel"
183
+ class="absolute z-10 mt-2 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
184
+ transition:slide={{ duration: 200 }}
185
+ >
186
+ <!-- Active Filters -->
187
+ <div class="p-4 border-b border-gray-200 dark:border-gray-700">
188
+ <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Active Filters</h3>
189
+
190
+ {#if $activeFilters.length === 0}
191
+ <p class="text-sm text-gray-500 dark:text-gray-400">No filters</p>
192
+ {:else}
193
+ <div class="space-y-2">
194
+ {#each $activeFilters as filter, index}
195
+ <div
196
+ class="flex items-center justify-between bg-gray-50 dark:bg-gray-700 p-2 rounded-md"
197
+ >
198
+ <div class="flex items-center">
199
+ <span class="text-sm text-gray-700 dark:text-gray-300">
200
+ <span class="font-medium">{getFieldDisplayName(filter.fieldName)}</span>
201
+ <span class="mx-1 text-gray-500 dark:text-gray-400"
202
+ >{getFilterDisplayValue(filter)}</span
203
+ >
204
+ </span>
205
+ </div>
206
+ <button
207
+ onclick={() => complianceStore.removeFilter(index)}
208
+ class="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400"
209
+ aria-label="Remove filter"
210
+ >
211
+ <TrashCan class="w-4 h-4" />
212
+ </button>
213
+ </div>
214
+ {/each}
215
+ </div>
216
+ {/if}
217
+ </div>
218
+
219
+ <!-- Add New Filter -->
220
+ <div class="p-4">
221
+ <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Add Filter</h3>
222
+
223
+ <div class="space-y-3">
224
+ <!-- Field Selection with Custom Dropdown -->
225
+ <div>
226
+ <label for="filter-field" class="block text-xs text-gray-600 dark:text-gray-400 mb-1"
227
+ >Field</label
228
+ >
229
+
230
+ <!-- Custom Field Dropdown -->
231
+ <div class="relative" use:clickOutside={() => (showFieldDropdown = false)}>
232
+ <!-- Dropdown Trigger -->
233
+ <button
234
+ onclick={() => (showFieldDropdown = !showFieldDropdown)}
235
+ class={twMerge(
236
+ 'w-full flex items-center justify-between px-3 py-2 text-sm rounded-md border transition-colors',
237
+ showFieldDropdown
238
+ ? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-200 dark:ring-blue-900'
239
+ : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500',
240
+ 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
241
+ )}
242
+ >
243
+ <span class="truncate">
244
+ {newFilterField ? getFieldDisplayName(newFilterField) : 'Select a field'}
245
+ </span>
246
+ {#if showFieldDropdown}
247
+ <ChevronUp class="h-4 w-4 text-gray-500 dark:text-gray-400" />
248
+ {:else}
249
+ <ChevronDown class="h-4 w-4 text-gray-500 dark:text-gray-400" />
250
+ {/if}
251
+ </button>
252
+
253
+ <!-- Dropdown Menu -->
254
+ {#if showFieldDropdown}
255
+ <div
256
+ class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-y-auto"
257
+ transition:fade={{ duration: 100 }}
258
+ >
259
+ <!-- Overview fields -->
260
+ <div
261
+ class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80"
262
+ >
263
+ Overview Fields
264
+ </div>
265
+ {#each fieldsByTab.overview as field}
266
+ <button
267
+ class={twMerge(
268
+ 'w-full text-left px-3 py-2 text-sm',
269
+ newFilterField === field
270
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
271
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
272
+ )}
273
+ onclick={() => {
274
+ newFilterField = field;
275
+ showFieldDropdown = false;
276
+ }}
277
+ >
278
+ {getFieldDisplayName(field)}
279
+ </button>
280
+ {/each}
281
+
282
+ <!-- Implementation fields -->
283
+ {#if fieldsByTab.implementation.length > 0}
284
+ <!-- Divider -->
285
+ <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
286
+ <div
287
+ class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80"
288
+ >
289
+ Implementation Fields
290
+ </div>
291
+ {#each fieldsByTab.implementation as field}
292
+ <button
293
+ class={twMerge(
294
+ 'w-full text-left px-3 py-2 text-sm',
295
+ newFilterField === field
296
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
297
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
298
+ )}
299
+ onclick={() => {
300
+ newFilterField = field;
301
+ showFieldDropdown = false;
302
+ }}
303
+ >
304
+ {getFieldDisplayName(field)}
305
+ </button>
306
+ {/each}
307
+ {/if}
308
+
309
+ <!-- Custom fields -->
310
+ {#if fieldsByTab.custom.length > 0}
311
+ <!-- Divider -->
312
+ <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
313
+ <div
314
+ class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80"
315
+ >
316
+ Custom Fields
317
+ </div>
318
+ {#each fieldsByTab.custom as field}
319
+ <button
320
+ class={twMerge(
321
+ 'w-full text-left px-3 py-2 text-sm',
322
+ newFilterField === field
323
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
324
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
325
+ )}
326
+ onclick={() => {
327
+ newFilterField = field;
328
+ showFieldDropdown = false;
329
+ }}
330
+ >
331
+ {getFieldDisplayName(field)}
332
+ </button>
333
+ {/each}
334
+ {/if}
335
+ </div>
336
+ {/if}
337
+ </div>
338
+ </div>
339
+
340
+ <!-- Operator Selection -->
341
+ <div>
342
+ <label for="filter-operator" class="block text-xs text-gray-600 dark:text-gray-400 mb-1"
343
+ >Operator</label
344
+ >
345
+
346
+ {#if isSelectField}
347
+ <!-- Disabled dropdown for select fields (always equals) -->
348
+ <div
349
+ class="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
350
+ >
351
+ Equals
352
+ </div>
353
+ {:else}
354
+ <!-- Custom Operator Dropdown -->
355
+ <CustomDropdown
356
+ bind:value={newFilterOperator}
357
+ options={operatorOptions}
358
+ getDisplayValue={getOperatorLabel}
359
+ labelId="filter-operator"
360
+ />
361
+ {/if}
362
+ </div>
363
+
364
+ <!-- Value Input (conditional) -->
365
+ {#if showValueInput}
366
+ <div>
367
+ <label for="filter-value" class="block text-xs text-gray-600 dark:text-gray-400 mb-1"
368
+ >Value</label
369
+ >
370
+ {#if isSelectField}
371
+ <!-- Custom dropdown for select fields -->
372
+ <CustomDropdown
373
+ bind:value={newFilterValue}
374
+ options={fieldOptions.map((option: string) => ({ value: option, label: option }))}
375
+ placeholder="Select a value"
376
+ labelId="filter-value"
377
+ />
378
+ {:else if selectedFieldType === 'boolean'}
379
+ <!-- Boolean field with CustomDropdown -->
380
+ <CustomDropdown
381
+ bind:value={newFilterValue}
382
+ options={[
383
+ { value: 'true', label: 'Yes' },
384
+ { value: 'false', label: 'No' }
385
+ ]}
386
+ placeholder="Select yes/no"
387
+ labelId="filter-value"
388
+ />
389
+ {:else}
390
+ <!-- Text input for other fields -->
391
+ <input
392
+ id="filter-value"
393
+ type={selectedFieldType === 'number' ? 'number' : 'text'}
394
+ bind:value={newFilterValue}
395
+ placeholder="Enter value"
396
+ class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
397
+ />
398
+ {/if}
399
+ </div>
400
+ {/if}
401
+
402
+ <!-- Add Button -->
403
+ <button
404
+ onclick={addFilter}
405
+ disabled={!newFilterField}
406
+ class="flex items-center px-3 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
407
+ >
408
+ <Add class="w-4 h-4 mr-2" />
409
+ <span>Add Filter</span>
410
+ </button>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ {/if}
415
+ </div>
@@ -2,7 +2,51 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Lula Authors
3
3
 
4
4
  import type { Control, ControlWithMappings, Mapping } from '$lib/types';
5
- import { derived, writable } from 'svelte/store';
5
+ import { appState } from '$lib/websocket';
6
+ import { derived, get, writable } from 'svelte/store';
7
+
8
+ /**
9
+ * Shared filter operator options used across the application
10
+ */
11
+ export const FILTER_OPERATORS = [
12
+ { value: 'equals' as const, label: 'Equals' },
13
+ { value: 'not_equals' as const, label: 'Not equals' },
14
+ { value: 'includes' as const, label: 'Contains' },
15
+ { value: 'not_includes' as const, label: 'Does not contain' },
16
+ { value: 'exists' as const, label: 'Exists' },
17
+ { value: 'not_exists' as const, label: 'Not exists' }
18
+ ] as const;
19
+
20
+ // Extract the filter operator type from the constant
21
+ export type FilterOperator = (typeof FILTER_OPERATORS)[number]['value'];
22
+
23
+ /**
24
+ * Filter operator option with display label
25
+ */
26
+ export interface FilterOperatorOption {
27
+ value: FilterOperator;
28
+ label: string;
29
+ }
30
+
31
+ /**
32
+ * Helper function to get the display label for a filter operator
33
+ */
34
+ export function getOperatorLabel(operator: FilterOperator): string {
35
+ const option = FILTER_OPERATORS.find((op) => op.value === operator);
36
+ return option?.label || operator;
37
+ }
38
+
39
+ /**
40
+ * Type for filter values - intentionally flexible to handle any value type
41
+ * that might be found in control objects loaded from YAML files.
42
+ */
43
+ export type FilterValue = unknown;
44
+
45
+ export interface FilterCondition {
46
+ fieldName: string;
47
+ operator: FilterOperator;
48
+ value?: FilterValue;
49
+ }
6
50
 
7
51
  // Base stores
8
52
  export const controls = writable<Control[]>([]);
@@ -10,49 +54,62 @@ export const mappings = writable<Mapping[]>([]);
10
54
  export const loading = writable(true);
11
55
  export const saveStatus = writable<'saved' | 'saving' | 'error'>('saved');
12
56
  export const searchTerm = writable('');
13
- export const selectedFamily = writable<string | null>(null);
14
57
  export const selectedControl = writable<Control | null>(null);
15
-
16
- // Derived stores
17
- export const families = derived(controls, ($controls) => {
18
- const familySet = new Set(
19
- $controls.map((c) => {
20
- // Enhanced controls have family in _metadata.family, fallback to extracting from control-acronym
21
- return (
22
- (c as any)?._metadata?.family ||
23
- (c as any)?.family ||
24
- (c as any)?.['control-acronym']?.split('-')[0] ||
25
- ''
26
- );
27
- })
28
- );
29
- return Array.from(familySet)
30
- .filter((f) => f)
31
- .sort();
32
- });
58
+ export const activeFilters = writable<FilterCondition[]>([]);
33
59
 
34
60
  export const filteredControls = derived(
35
- [controls, selectedFamily, searchTerm],
36
- ([$controls, $selectedFamily, $searchTerm]) => {
61
+ [controls, searchTerm, activeFilters],
62
+ ([$controls, $searchTerm, $activeFilters]) => {
37
63
  let results = $controls;
38
64
 
39
- if ($selectedFamily) {
40
- results = results.filter((c) => {
41
- // Enhanced controls have family in _metadata.family, fallback to extracting from control-acronym
42
- const family =
43
- (c as any)?._metadata?.family ||
44
- (c as any)?.family ||
45
- (c as any)?.['control-acronym']?.split('-')[0] ||
46
- '';
47
- return family === $selectedFamily;
48
- });
49
- }
50
-
65
+ // Apply search term
51
66
  if ($searchTerm) {
52
67
  const term = $searchTerm.toLowerCase();
53
68
  results = results.filter((c) => JSON.stringify(c).toLowerCase().includes(term));
54
69
  }
55
70
 
71
+ // Apply advanced filters
72
+ if ($activeFilters.length > 0) {
73
+ results = results.filter((control) => {
74
+ // Cast to ControlWithDynamicFields for dynamic field access
75
+ const dynamicControl = control as Record<string, unknown>;
76
+
77
+ // Control must match all filters
78
+ return $activeFilters.every((filter) => {
79
+ const fieldValue = dynamicControl[filter.fieldName];
80
+
81
+ // For exists/not_exists operators, we just need to check if the field has a value
82
+ if (filter.operator === 'exists') {
83
+ return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
84
+ } else if (filter.operator === 'not_exists') {
85
+ return fieldValue === undefined || fieldValue === null || fieldValue === '';
86
+ }
87
+
88
+ // For other operators, convert values to strings for comparison
89
+ const fieldValueStr = String(fieldValue).toLowerCase();
90
+ const filterValueStr =
91
+ filter.value !== undefined ? String(filter.value).toLowerCase() : '';
92
+
93
+ switch (filter.operator) {
94
+ case 'equals':
95
+ return fieldValueStr === filterValueStr;
96
+
97
+ case 'not_equals':
98
+ return fieldValueStr !== filterValueStr;
99
+
100
+ case 'includes':
101
+ return fieldValueStr.includes(filterValueStr);
102
+
103
+ case 'not_includes':
104
+ return !fieldValueStr.includes(filterValueStr);
105
+
106
+ default:
107
+ return true;
108
+ }
109
+ });
110
+ });
111
+ }
112
+
56
113
  return results;
57
114
  }
58
115
  );
@@ -74,13 +131,10 @@ export const complianceStore = {
74
131
  searchTerm.set(term);
75
132
  },
76
133
 
77
- setSelectedFamily(family: string | null) {
78
- selectedFamily.set(family);
79
- },
80
-
81
134
  setSelectedControl(control: Control | null) {
82
135
  // Strip mappings property if it exists to prevent it from being saved to control files
83
136
  if (control && 'mappings' in control) {
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
138
  const { mappings: _mappings, ...controlWithoutMappings } = control as any;
85
139
  selectedControl.set(controlWithoutMappings);
86
140
  } else {
@@ -90,6 +144,62 @@ export const complianceStore = {
90
144
 
91
145
  clearFilters() {
92
146
  searchTerm.set('');
93
- selectedFamily.set(null);
147
+ activeFilters.set([]);
148
+ },
149
+
150
+ // Advanced filter methods
151
+ addFilter(fieldName: string, operator: FilterOperator, value?: FilterValue) {
152
+ const filters = get(activeFilters);
153
+ const newFilter: FilterCondition = {
154
+ fieldName,
155
+ operator,
156
+ value
157
+ };
158
+ activeFilters.set([...filters, newFilter]);
159
+ return newFilter;
160
+ },
161
+
162
+ updateFilter(index: number, updates: Partial<FilterCondition>) {
163
+ const filters = get(activeFilters);
164
+ if (index >= 0 && index < filters.length) {
165
+ const updatedFilters = [...filters];
166
+ updatedFilters[index] = { ...updatedFilters[index], ...updates };
167
+ activeFilters.set(updatedFilters);
168
+ }
169
+ },
170
+
171
+ removeFilter(index: number) {
172
+ const filters = get(activeFilters);
173
+ if (index >= 0 && index < filters.length) {
174
+ const updatedFilters = filters.filter((_, i) => i !== index);
175
+ activeFilters.set(updatedFilters);
176
+ }
177
+ },
178
+
179
+ getAvailableFields() {
180
+ // Get all unique field names from all controls and field schema
181
+ const allControls = get(controls);
182
+ const fieldSet = new Set<string>();
183
+
184
+ // Extract fields from controls
185
+ allControls.forEach((control) => {
186
+ Object.keys(control).forEach((key) => {
187
+ // Skip internal fields that start with underscore
188
+ if (!key.startsWith('_')) {
189
+ fieldSet.add(key);
190
+ }
191
+ });
192
+ });
193
+
194
+ // Get fields from the app state field schema if available
195
+ const state = get(appState);
196
+ const schema = state?.fieldSchema || state?.field_schema;
197
+ if (schema?.fields) {
198
+ Object.keys(schema.fields).forEach((key) => {
199
+ fieldSet.add(key);
200
+ });
201
+ }
202
+
203
+ return Array.from(fieldSet).sort();
94
204
  }
95
205
  };