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.
- package/dist/_app/immutable/assets/0.CLKu6Q8_.css +1 -0
- package/dist/_app/immutable/chunks/0xMSlW4Y.js +65 -0
- package/dist/_app/immutable/chunks/{BtOhWAVU.js → 152nb-LI.js} +2 -2
- package/dist/_app/immutable/chunks/{C5zWTfmV.js → 1spjHGNy.js} +1 -1
- package/dist/_app/immutable/chunks/{DnJ0bPgj.js → BtuEtkd3.js} +1 -1
- package/dist/_app/immutable/chunks/C113Bo4B.js +2 -0
- package/dist/_app/immutable/chunks/{CoF2vljD.js → CNOPXlDW.js} +1 -1
- package/dist/_app/immutable/chunks/{DqsOU3kV.js → DY3-lqhI.js} +1 -1
- package/dist/_app/immutable/chunks/{Ds14DLx0.js → DrOkR2YZ.js} +3 -3
- package/dist/_app/immutable/chunks/rsJnd9Tf.js +1 -0
- package/dist/_app/immutable/entry/{app.XLGRlmCF.js → app.CqP__jbe.js} +2 -2
- package/dist/_app/immutable/entry/start.C1hW6Z_g.js +1 -0
- package/dist/_app/immutable/nodes/{0.B6W16O68.js → 0.BWuQSqPo.js} +1 -1
- package/dist/_app/immutable/nodes/{1.wIilWkgu.js → 1.COMBeJ1R.js} +1 -1
- package/dist/_app/immutable/nodes/{2.CG53uQH9.js → 2.6bQMjmMR.js} +1 -1
- package/dist/_app/immutable/nodes/{3.D4uH9LCp.js → 3.BBPnkpxM.js} +1 -1
- package/dist/_app/immutable/nodes/{4.sYW2-VhJ.js → 4.CGIAObAE.js} +1 -1
- package/dist/_app/version.json +1 -1
- package/dist/cli/commands/ui.js +6 -6
- package/dist/cli/server/index.js +6 -6
- package/dist/cli/server/server.js +6 -6
- package/dist/cli/server/serverState.js +2 -2
- package/dist/cli/server/spreadsheetRoutes.js +4 -4
- package/dist/cli/server/websocketServer.js +6 -6
- package/dist/index.html +10 -10
- package/dist/index.js +6 -6
- package/package.json +21 -21
- package/src/lib/actions/clickOutside.ts +24 -0
- package/src/lib/components/controls/ControlsList.svelte +101 -75
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +1 -1
- package/src/lib/components/controls/tabs/TimelineTab.svelte +1 -1
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +1 -1
- package/src/lib/components/forms/DynamicControlForm.svelte +2 -2
- package/src/lib/components/setup/ExistingControlSets.svelte +0 -1
- package/src/lib/components/setup/SpreadsheetImport.svelte +10 -10
- package/src/lib/components/ui/CustomDropdown.svelte +96 -0
- package/src/lib/components/ui/FilterBuilder.svelte +415 -0
- package/src/stores/compliance.ts +149 -39
- package/dist/_app/immutable/assets/0.Dv98laBw.css +0 -1
- package/dist/_app/immutable/chunks/BW28MavF.js +0 -1
- package/dist/_app/immutable/chunks/Cby0Z7eP.js +0 -2
- package/dist/_app/immutable/chunks/DQAmyY_z.js +0 -66
- 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>
|
package/src/stores/compliance.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
36
|
-
([$controls, $
|
|
61
|
+
[controls, searchTerm, activeFilters],
|
|
62
|
+
([$controls, $searchTerm, $activeFilters]) => {
|
|
37
63
|
let results = $controls;
|
|
38
64
|
|
|
39
|
-
|
|
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
|
-
|
|
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
|
};
|