lula2 0.3.1 → 0.3.2-nightly.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 (44) hide show
  1. package/dist/_app/immutable/assets/0.CLKu6Q8_.css +1 -0
  2. package/dist/_app/immutable/chunks/{BtOhWAVU.js → 152nb-LI.js} +2 -2
  3. package/dist/_app/immutable/chunks/{C5zWTfmV.js → 1spjHGNy.js} +1 -1
  4. package/dist/_app/immutable/chunks/{DnJ0bPgj.js → BtuEtkd3.js} +1 -1
  5. package/dist/_app/immutable/chunks/C113Bo4B.js +2 -0
  6. package/dist/_app/immutable/chunks/{CoF2vljD.js → CNOPXlDW.js} +1 -1
  7. package/dist/_app/immutable/chunks/DFKxAz5y.js +3 -0
  8. package/dist/_app/immutable/chunks/DJ-Jk3EP.js +65 -0
  9. package/dist/_app/immutable/chunks/DRm-CuN2.js +1 -0
  10. package/dist/_app/immutable/chunks/{DqsOU3kV.js → DY3-lqhI.js} +1 -1
  11. package/dist/_app/immutable/entry/{app.XLGRlmCF.js → app.BZBPzL5_.js} +2 -2
  12. package/dist/_app/immutable/entry/start.DhLq_cQt.js +1 -0
  13. package/dist/_app/immutable/nodes/{0.B6W16O68.js → 0.D4xnWOOY.js} +1 -1
  14. package/dist/_app/immutable/nodes/{1.wIilWkgu.js → 1.CfErG2gH.js} +1 -1
  15. package/dist/_app/immutable/nodes/{2.CG53uQH9.js → 2.CykQIDMZ.js} +1 -1
  16. package/dist/_app/immutable/nodes/{3.D4uH9LCp.js → 3.MN3LEF69.js} +1 -1
  17. package/dist/_app/immutable/nodes/{4.sYW2-VhJ.js → 4.DIv4kITF.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/chunks/Ds14DLx0.js +0 -3
  44. package/dist/_app/immutable/entry/start.68S9ad6U.js +0 -1
@@ -0,0 +1,96 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts" generics="T">
5
+ import { clickOutside } from '$lib/actions/clickOutside';
6
+ import { fade } from 'svelte/transition';
7
+ import { twMerge } from 'tailwind-merge';
8
+ import { ChevronDown, ChevronUp } from 'carbon-icons-svelte';
9
+
10
+ // Props
11
+ export let value: T = undefined as unknown as T;
12
+ export let options: Array<{ value: T; label: string }> = [];
13
+ export let placeholder: string = 'Select an option';
14
+ export let label: string | undefined = undefined;
15
+ export let labelId: string | undefined = undefined;
16
+ export let getDisplayValue: (value: T) => string = (val) => {
17
+ const option = options.find((opt) => opt.value === val);
18
+ return option ? option.label : String(val);
19
+ };
20
+
21
+ // State
22
+ let isOpen = false;
23
+
24
+ // Handle selection
25
+ function selectOption(optionValue: T) {
26
+ value = optionValue;
27
+ isOpen = false;
28
+ }
29
+
30
+ // Handle toggle
31
+ function toggle() {
32
+ isOpen = !isOpen;
33
+ }
34
+
35
+ // Handle close
36
+ function close() {
37
+ isOpen = false;
38
+ }
39
+ </script>
40
+
41
+ <div>
42
+ {#if label}
43
+ <label for={labelId} class="block text-xs text-gray-600 dark:text-gray-400 mb-1">{label}</label>
44
+ {/if}
45
+ <div class="relative" use:clickOutside={close}>
46
+ <!-- Dropdown Trigger -->
47
+ <button
48
+ type="button"
49
+ id={labelId}
50
+ on:click={toggle}
51
+ class={twMerge(
52
+ 'w-full flex items-center justify-between px-3 py-2 text-sm rounded-md border transition-colors',
53
+ isOpen
54
+ ? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-200 dark:ring-blue-900'
55
+ : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500',
56
+ 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white'
57
+ )}
58
+ >
59
+ <span class="truncate">
60
+ {value ? getDisplayValue(value) : placeholder}
61
+ </span>
62
+ {#if isOpen}
63
+ <ChevronUp class="h-4 w-4 text-gray-500 dark:text-gray-400" />
64
+ {:else}
65
+ <ChevronDown class="h-4 w-4 text-gray-500 dark:text-gray-400" />
66
+ {/if}
67
+ </button>
68
+
69
+ <!-- Dropdown Menu -->
70
+ {#if isOpen}
71
+ <div
72
+ 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"
73
+ transition:fade={{ duration: 100 }}
74
+ >
75
+ <slot name="header" />
76
+
77
+ {#each options as option}
78
+ <button
79
+ type="button"
80
+ class={twMerge(
81
+ 'w-full text-left px-3 py-2 text-sm',
82
+ value === option.value
83
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
84
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
85
+ )}
86
+ on:click={() => selectOption(option.value)}
87
+ >
88
+ {option.label}
89
+ </button>
90
+ {/each}
91
+
92
+ <slot name="footer" />
93
+ </div>
94
+ {/if}
95
+ </div>
96
+ </div>
@@ -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>