lula2 0.5.1 → 0.6.1-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 (28) hide show
  1. package/dist/_app/immutable/chunks/{CWggGNM7.js → BMNSAUym.js} +1 -1
  2. package/dist/_app/immutable/chunks/{R1gz3SOr.js → BXUi170M.js} +1 -1
  3. package/dist/_app/immutable/chunks/BmwkkWK-.js +65 -0
  4. package/dist/_app/immutable/chunks/{CPEw6sZY.js → CRa0j_Fx.js} +1 -1
  5. package/dist/_app/immutable/chunks/{CXdZVXJf.js → Cq7PwVfU.js} +1 -1
  6. package/dist/_app/immutable/chunks/DTWPdvjs.js +2 -0
  7. package/dist/_app/immutable/chunks/{BNbzyHlf.js → DYjeAJVm.js} +2 -2
  8. package/dist/_app/immutable/chunks/{zYrdvxnm.js → WlyXjfrM.js} +1 -1
  9. package/dist/_app/immutable/chunks/{GToHgjp8.js → e7OeeeKP.js} +1 -1
  10. package/dist/_app/immutable/entry/{app.DMBX4AyV.js → app.BMX3XH9B.js} +2 -2
  11. package/dist/_app/immutable/entry/start.CikNPmTv.js +1 -0
  12. package/dist/_app/immutable/nodes/{0.ePBNOec_.js → 0.DFzEgAsn.js} +1 -1
  13. package/dist/_app/immutable/nodes/{1.BjVPQIpm.js → 1.f-frqYrl.js} +1 -1
  14. package/dist/_app/immutable/nodes/{2.DAjmdkWJ.js → 2.DjKBnAK5.js} +1 -1
  15. package/dist/_app/immutable/nodes/{3.C42QL2CH.js → 3.Cm8oQ-Ws.js} +1 -1
  16. package/dist/_app/immutable/nodes/{4.BKr3XX2Z.js → 4.DVhDnucO.js} +1 -1
  17. package/dist/_app/version.json +1 -1
  18. package/dist/cli/commands/crawl.js +53 -1
  19. package/dist/index.html +10 -10
  20. package/dist/index.js +52 -1
  21. package/package.json +20 -21
  22. package/src/lib/components/controls/ControlsList.svelte +82 -9
  23. package/src/lib/components/ui/FilterBuilder.svelte +167 -27
  24. package/src/lib/types.ts +1 -0
  25. package/src/stores/compliance.ts +13 -0
  26. package/dist/_app/immutable/chunks/Bvop-7hR.js +0 -2
  27. package/dist/_app/immutable/chunks/y6toZMLe.js +0 -65
  28. package/dist/_app/immutable/entry/start.CXGxkyju.js +0 -1
package/dist/index.html CHANGED
@@ -6,28 +6,28 @@
6
6
  <link rel="icon" href="/lula.png" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
 
9
- <link rel="modulepreload" href="/_app/immutable/entry/start.CXGxkyju.js">
10
- <link rel="modulepreload" href="/_app/immutable/chunks/BNbzyHlf.js">
11
- <link rel="modulepreload" href="/_app/immutable/chunks/Bvop-7hR.js">
12
- <link rel="modulepreload" href="/_app/immutable/entry/app.DMBX4AyV.js">
9
+ <link rel="modulepreload" href="/_app/immutable/entry/start.CikNPmTv.js">
10
+ <link rel="modulepreload" href="/_app/immutable/chunks/DYjeAJVm.js">
11
+ <link rel="modulepreload" href="/_app/immutable/chunks/DTWPdvjs.js">
12
+ <link rel="modulepreload" href="/_app/immutable/entry/app.BMX3XH9B.js">
13
13
  <link rel="modulepreload" href="/_app/immutable/chunks/DsnmJJEf.js">
14
- <link rel="modulepreload" href="/_app/immutable/chunks/R1gz3SOr.js">
15
- <link rel="modulepreload" href="/_app/immutable/chunks/zYrdvxnm.js">
16
- <link rel="modulepreload" href="/_app/immutable/chunks/CPEw6sZY.js">
14
+ <link rel="modulepreload" href="/_app/immutable/chunks/BXUi170M.js">
15
+ <link rel="modulepreload" href="/_app/immutable/chunks/WlyXjfrM.js">
16
+ <link rel="modulepreload" href="/_app/immutable/chunks/CRa0j_Fx.js">
17
17
  </head>
18
18
  <body data-sveltekit-preload-data="hover">
19
19
  <div style="display: contents">
20
20
  <script>
21
21
  {
22
- __sveltekit_zave4b = {
22
+ __sveltekit_9ui1cm = {
23
23
  base: ""
24
24
  };
25
25
 
26
26
  const element = document.currentScript.parentElement;
27
27
 
28
28
  Promise.all([
29
- import("/_app/immutable/entry/start.CXGxkyju.js"),
30
- import("/_app/immutable/entry/app.DMBX4AyV.js")
29
+ import("/_app/immutable/entry/start.CikNPmTv.js"),
30
+ import("/_app/immutable/entry/app.BMX3XH9B.js")
31
31
  ]).then(([kit, app]) => {
32
32
  kit.start(app, element);
33
33
  });
package/dist/index.js CHANGED
@@ -5628,6 +5628,18 @@ function getChangedBlocks(oldText, newText) {
5628
5628
  }
5629
5629
  return changed;
5630
5630
  }
5631
+ function getRemovedBlocks(oldText, newText) {
5632
+ const oldBlocks = extractMapBlocks(oldText);
5633
+ const newBlocks = extractMapBlocks(newText);
5634
+ const removed = [];
5635
+ for (const oldBlock of oldBlocks) {
5636
+ const newMatch = newBlocks.find((b) => b.uuid === oldBlock.uuid);
5637
+ if (!newMatch) {
5638
+ removed.push(oldBlock);
5639
+ }
5640
+ }
5641
+ return removed;
5642
+ }
5631
5643
  function containsLulaAnnotations(text) {
5632
5644
  const lines = text.split("\n");
5633
5645
  return lines.some((line) => line.includes("@lulaStart") || line.includes("@lulaEnd"));
@@ -5708,6 +5720,7 @@ Please review whether:
5708
5720
  fetchRawFileViaAPI({ octokit, owner, repo, path: file.filename, ref: prBranch })
5709
5721
  ]);
5710
5722
  const changedBlocks = getChangedBlocks(oldText, newText);
5723
+ const removedBlocks = getRemovedBlocks(oldText, newText);
5711
5724
  for (const block of changedBlocks) {
5712
5725
  console.log(`Commenting regarding \`${file.filename}\`.`);
5713
5726
  leavePost = true;
@@ -5723,6 +5736,43 @@ Please review whether:
5723
5736
  > **uuid**-\`${block.uuid}\`
5724
5737
  **sha256** \`${blockSha256}\`
5725
5738
 
5739
+ `;
5740
+ }
5741
+ if (removedBlocks.length > 0) {
5742
+ leavePost = true;
5743
+ console.log(`Found removed annotations in \`${file.filename}\`.`);
5744
+ commentBody += `
5745
+
5746
+ **Compliance Warning: Lula annotations were removed from \`${file.filename}\`**
5747
+
5748
+ `;
5749
+ commentBody += `The following compliance annotation blocks were present in the original file but are missing in the updated version:
5750
+
5751
+ `;
5752
+ commentBody += `| File | Original Lines | UUID |
5753
+ `;
5754
+ commentBody += `| ---- | -------------- | ---- |
5755
+ `;
5756
+ for (const block of removedBlocks) {
5757
+ const oldBlockText = oldText.split("\n").slice(block.startLine, block.endLine).join("\n");
5758
+ const blockSha256 = createHash2("sha256").update(oldBlockText).digest("hex");
5759
+ commentBody += `| \`${file.filename}\` | \`${block.startLine + 1}\u2013${block.endLine}\` | \`${block.uuid}\` |
5760
+ `;
5761
+ commentBody += `> **sha256** \`${blockSha256}\`
5762
+
5763
+ `;
5764
+ }
5765
+ commentBody += `Please review whether:
5766
+ `;
5767
+ commentBody += `- The removal of these compliance annotations is intentional
5768
+ `;
5769
+ commentBody += `- Alternative compliance measures have been implemented
5770
+ `;
5771
+ commentBody += `- The compliance coverage is still adequate
5772
+
5773
+ `;
5774
+ commentBody += `---
5775
+
5726
5776
  `;
5727
5777
  }
5728
5778
  } catch (err) {
@@ -5826,7 +5876,8 @@ async function dismissOldReviews({
5826
5876
  if (!reviews.length) break;
5827
5877
  for (const r of reviews) {
5828
5878
  const hasSignature = (r.body ?? "").includes(LULA_SIGNATURE);
5829
- if (hasSignature) {
5879
+ const isAlreadyDismissed = r.state === "DISMISSED";
5880
+ if (hasSignature && !isAlreadyDismissed) {
5830
5881
  await octokit.pulls.dismissReview({
5831
5882
  owner,
5832
5883
  repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lula2",
3
- "version": "0.5.1",
3
+ "version": "0.6.1-nightly.0",
4
4
  "description": "A tool for managing compliance as code in your GitHub repositories.",
5
5
  "bin": {
6
6
  "lula2": "./dist/lula2"
@@ -33,25 +33,6 @@
33
33
  "!dist/**/*.test.js*",
34
34
  "!dist/**/*.test.d.ts*"
35
35
  ],
36
- "scripts": {
37
- "dev": "vite dev --port 5173",
38
- "dev:api": "tsx --watch index.ts --debug ui --port 3000 --no-open-browser",
39
- "dev:full": "concurrently \"npm run dev:api\" \"npm run dev\"",
40
- "build": "npm run build:svelte && npm run build:cli && npm run postbuild:cli",
41
- "build:svelte": "vite build",
42
- "build:cli": "esbuild index.ts cli/**/*.ts --bundle --platform=node --target=node22 --format=esm --outdir=dist --external:express --external:commander --external:js-yaml --external:yaml --external:isomorphic-git --external:glob --external:open --external:ws --external:cors --external:multer --external:@octokit/rest --external:undici --external:exceljs --external:csv-parse",
43
- "postbuild:cli": "cp cli-wrapper.mjs dist/lula2 && chmod +x dist/lula2",
44
- "preview": "vite preview",
45
- "prepare": "svelte-kit sync || echo ''",
46
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit",
47
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
48
- "format": "prettier --write 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts'",
49
- "format:check": "prettier --check 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts'",
50
- "lint": "prettier --check 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts' && eslint src cli",
51
- "test": "npm run test:unit -- --run --coverage",
52
- "test:integration": "vitest --config integration/vitest.config.integration.ts",
53
- "test:unit": "vitest"
54
- },
55
36
  "dependencies": {
56
37
  "@octokit/rest": "^22.0.0",
57
38
  "@types/ws": "^8.18.1",
@@ -124,5 +105,23 @@
124
105
  "main",
125
106
  "next"
126
107
  ]
108
+ },
109
+ "scripts": {
110
+ "dev": "vite dev --port 5173",
111
+ "dev:api": "tsx --watch index.ts --debug ui --port 3000 --no-open-browser",
112
+ "dev:full": "concurrently \"npm run dev:api\" \"npm run dev\"",
113
+ "build": "npm run build:svelte && npm run build:cli && npm run postbuild:cli",
114
+ "build:svelte": "vite build",
115
+ "build:cli": "esbuild index.ts cli/**/*.ts --bundle --platform=node --target=node22 --format=esm --outdir=dist --external:express --external:commander --external:js-yaml --external:yaml --external:isomorphic-git --external:glob --external:open --external:ws --external:cors --external:multer --external:@octokit/rest --external:undici --external:exceljs --external:csv-parse",
116
+ "postbuild:cli": "cp cli-wrapper.mjs dist/lula2 && chmod +x dist/lula2",
117
+ "preview": "vite preview",
118
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit",
119
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
120
+ "format": "prettier --write 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts'",
121
+ "format:check": "prettier --check 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts'",
122
+ "lint": "prettier --check 'src/**/*.{ts,js,svelte}' 'cli/**/*.ts' 'index.ts' 'tests/**/*.ts' && eslint src cli",
123
+ "test": "npm run test:unit -- --run --coverage",
124
+ "test:integration": "vitest --config integration/vitest.config.integration.ts",
125
+ "test:unit": "vitest"
127
126
  }
128
- }
127
+ }
@@ -119,15 +119,66 @@
119
119
  results = results.filter((control) => {
120
120
  // Control must match all filters
121
121
  return $activeFilters.every(filter => {
122
+ // Handle mapping-related fields specially
123
+ if (filter.fieldName === 'has_mappings') {
124
+ const hasMappings = control.mappings && control.mappings.length > 0;
125
+ const filterValue = getFilterValue(filter.value);
126
+ switch (filter.operator) {
127
+ case 'equals':
128
+ return hasMappings === (filterValue === 'true' || filterValue === true);
129
+ case 'not_equals':
130
+ return hasMappings !== (filterValue === 'true' || filterValue === true);
131
+ case 'exists':
132
+ return hasMappings;
133
+ case 'not_exists':
134
+ return !hasMappings;
135
+ default:
136
+ return true;
137
+ }
138
+ }
139
+
140
+ if (filter.fieldName === 'mapping_status') {
141
+ if (!control.mappings || control.mappings.length === 0) {
142
+ return filter.operator === 'not_exists';
143
+ }
144
+
145
+ const filterValue = getFilterValue(filter.value);
146
+ const hasStatus = control.mappings.some(mapping => {
147
+ switch (filter.operator) {
148
+ case 'equals':
149
+ return mapping.status === filterValue;
150
+ case 'not_equals':
151
+ return mapping.status !== filterValue;
152
+ case 'includes':
153
+ return (typeof mapping.status === 'string' ? mapping.status.toLowerCase() : '').includes(String(filterValue).toLowerCase());
154
+ case 'not_includes':
155
+ return !(typeof mapping.status === 'string' ? mapping.status.toLowerCase() : '').includes(String(filterValue).toLowerCase());
156
+ default:
157
+ return true;
158
+ }
159
+ });
160
+
161
+ switch (filter.operator) {
162
+ case 'exists':
163
+ return control.mappings.some(m => m.status !== undefined && m.status !== null);
164
+ case 'not_exists':
165
+ return !control.mappings.some(m => m.status !== undefined && m.status !== null);
166
+ default:
167
+ return hasStatus;
168
+ }
169
+ }
170
+
171
+ // Handle regular control fields
122
172
  const dynamicControl = control as Record<string, unknown>;
123
173
  const fieldValue = dynamicControl[filter.fieldName];
174
+ const filterValue = getFilterValue(filter.value);
124
175
 
125
176
  switch (filter.operator) {
126
177
  case 'equals':
127
- return fieldValue === filter.value;
178
+ return fieldValue === filterValue;
128
179
 
129
180
  case 'not_equals':
130
- return fieldValue !== filter.value;
181
+ return fieldValue !== filterValue;
131
182
 
132
183
  case 'exists':
133
184
  return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
@@ -137,20 +188,20 @@
137
188
 
138
189
  case 'includes':
139
190
  if (typeof fieldValue === 'string') {
140
- return fieldValue.toLowerCase().includes(String(filter.value).toLowerCase());
191
+ return fieldValue.toLowerCase().includes(String(filterValue).toLowerCase());
141
192
  } else if (Array.isArray(fieldValue)) {
142
193
  return fieldValue.some(item =>
143
- String(item).toLowerCase().includes(String(filter.value).toLowerCase())
194
+ String(item).toLowerCase().includes(String(filterValue).toLowerCase())
144
195
  );
145
196
  }
146
197
  return false;
147
198
 
148
199
  case 'not_includes':
149
200
  if (typeof fieldValue === 'string') {
150
- return !fieldValue.toLowerCase().includes(String(filter.value).toLowerCase());
201
+ return !fieldValue.toLowerCase().includes(String(filterValue).toLowerCase());
151
202
  } else if (Array.isArray(fieldValue)) {
152
203
  return !fieldValue.some(item =>
153
- String(item).toLowerCase().includes(String(filter.value).toLowerCase())
204
+ String(item).toLowerCase().includes(String(filterValue).toLowerCase())
154
205
  );
155
206
  }
156
207
  return true;
@@ -198,6 +249,28 @@
198
249
  return 'No description available';
199
250
  }
200
251
 
252
+ function formatFilterValue(value: any): string {
253
+ if (value === null || value === undefined) {
254
+ return '';
255
+ }
256
+ if (typeof value === 'object') {
257
+ if (Array.isArray(value)) {
258
+ return value.join(', ');
259
+ }
260
+ if ('value' in value) {
261
+ return String((value as any).value);
262
+ }
263
+ return JSON.stringify(value);
264
+ }
265
+ return String(value);
266
+ }
267
+ function getFilterValue(filterValue: any): any {
268
+ if (typeof filterValue === 'object' && filterValue !== null && 'value' in filterValue) {
269
+ return filterValue.value;
270
+ }
271
+ return filterValue;
272
+ }
273
+
201
274
  // Get truncation length based on field type
202
275
  function getTruncationLength(field: FieldSchema | undefined): number {
203
276
  if (field?.ui_type === 'textarea' || field?.ui_type === 'long_text') {
@@ -258,11 +331,11 @@
258
331
  {#if filter.operator === 'exists' || filter.operator === 'not_exists'}
259
332
  <span>{getOperatorLabel(filter.operator).toLowerCase()}</span>
260
333
  {:else if filter.operator === 'equals'}
261
- <span>= {filter.value}</span>
334
+ <span>= {formatFilterValue(filter.value)}</span>
262
335
  {:else if filter.operator === 'not_equals'}
263
- <span>≠ {filter.value}</span>
336
+ <span>≠ {formatFilterValue(filter.value)}</span>
264
337
  {:else}
265
- <span>{getOperatorLabel(filter.operator).toLowerCase()} "{filter.value}"</span>
338
+ <span>{getOperatorLabel(filter.operator).toLowerCase()} "{formatFilterValue(filter.value)}"</span>
266
339
  {/if}
267
340
  <button
268
341
  onclick={() => complianceStore.removeFilter(index)}
@@ -9,7 +9,8 @@
9
9
  type FilterValue,
10
10
  activeFilters,
11
11
  FILTER_OPERATORS,
12
- getOperatorLabel
12
+ getOperatorLabel,
13
+ MAPPING_STATUS_OPTIONS
13
14
  } from '$stores/compliance';
14
15
  import { appState } from '$lib/websocket';
15
16
  import { Filter, Add, TrashCan, ChevronDown, ChevronUp } from 'carbon-icons-svelte';
@@ -37,13 +38,18 @@
37
38
 
38
39
  // Get field type for the selected field
39
40
  $: selectedFieldSchema = $fieldSchema[newFilterField] || null;
40
- $: selectedFieldType = selectedFieldSchema?.type || 'string';
41
- $: selectedFieldUiType = selectedFieldSchema?.ui_type || 'short_text';
41
+ $: selectedFieldType =
42
+ getMappingFieldType(newFilterField) || selectedFieldSchema?.type || 'string';
43
+ $: selectedFieldUiType =
44
+ getMappingFieldUiType(newFilterField) || selectedFieldSchema?.ui_type || 'short_text';
42
45
  $: isSelectField = selectedFieldUiType === 'select';
43
- $: fieldOptions = isSelectField ? selectedFieldSchema?.options || [] : [];
46
+ $: fieldOptions =
47
+ getMappingFieldOptions(newFilterField) ||
48
+ (isSelectField ? selectedFieldSchema?.options || [] : []);
44
49
 
45
- // Force equals operator for select fields
46
- $: if (isSelectField) {
50
+ // Force equals operator for select fields (but allow operators for mapping fields)
51
+ // Also force equals operator for has_mappings field, but allow operators for mapping_status
52
+ $: if (isSelectField && newFilterField !== 'mapping_status') {
47
53
  newFilterOperator = 'equals';
48
54
  }
49
55
 
@@ -51,6 +57,7 @@
51
57
  $: fieldsByTab = {
52
58
  overview: [] as string[],
53
59
  implementation: [] as string[],
60
+ mappings: [] as string[],
54
61
  custom: [] as string[]
55
62
  };
56
63
 
@@ -58,19 +65,25 @@
58
65
  // Reset arrays before populating
59
66
  fieldsByTab.overview = [];
60
67
  fieldsByTab.implementation = [];
68
+ fieldsByTab.mappings = [];
61
69
  fieldsByTab.custom = [];
62
70
 
63
71
  // Group fields by tab
64
72
  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);
73
+ // Handle mapping-related fields specially
74
+ if (field === 'has_mappings' || field === 'mapping_status') {
75
+ fieldsByTab.mappings.push(field);
71
76
  } else {
72
- // If no schema, default to custom
73
- fieldsByTab.custom.push(field);
77
+ const schema = $fieldSchema[field];
78
+ if (schema) {
79
+ const tab = schema.tab || getDefaultTabForCategory(schema.category);
80
+ if (tab === 'overview') fieldsByTab.overview.push(field);
81
+ else if (tab === 'implementation') fieldsByTab.implementation.push(field);
82
+ else fieldsByTab.custom.push(field);
83
+ } else {
84
+ // If no schema, default to custom
85
+ fieldsByTab.custom.push(field);
86
+ }
74
87
  }
75
88
  });
76
89
  }
@@ -81,12 +94,23 @@
81
94
  // Create a new array from the readonly constant to make it mutable for Svelte
82
95
  const operatorOptions = FILTER_OPERATORS.map((op) => ({ value: op.value, label: op.label }));
83
96
 
97
+ // Limited operators for mapping status field
98
+ const mappingStatusOperatorOptions = [
99
+ { value: 'equals' as const, label: 'Equals' },
100
+ { value: 'not_equals' as const, label: 'Not equals' }
101
+ ];
102
+
84
103
  // Add a new filter
85
104
  function addFilter() {
86
105
  if (!newFilterField) return;
87
106
 
88
107
  let value = newFilterValue;
89
108
 
109
+ // Extract value from dropdown objects if necessary
110
+ if (typeof value === 'object' && value !== null && 'value' in value) {
111
+ value = (value as any).value;
112
+ }
113
+
90
114
  // Convert value based on field type
91
115
  let processedValue: FilterValue = value;
92
116
  if (selectedFieldType === 'boolean' && typeof value === 'string') {
@@ -125,8 +149,54 @@
125
149
  }
126
150
  }
127
151
 
152
+ // Centralized mapping field metadata
153
+ const mappingFieldConfig: Record<
154
+ string,
155
+ {
156
+ type: string;
157
+ ui_type: string;
158
+ options?: Array<{ value: string; label: string }>;
159
+ }
160
+ > = {
161
+ has_mappings: {
162
+ type: 'boolean',
163
+ ui_type: 'select',
164
+ options: [
165
+ { value: 'true', label: 'Yes' },
166
+ { value: 'false', label: 'No' }
167
+ ]
168
+ },
169
+ mapping_status: {
170
+ type: 'string',
171
+ ui_type: 'select',
172
+ options: MAPPING_STATUS_OPTIONS
173
+ }
174
+ };
175
+
176
+ function getMappingFieldType(fieldName: string): string | null {
177
+ return mappingFieldConfig[fieldName]?.type ?? null;
178
+ }
179
+
180
+ function getMappingFieldUiType(fieldName: string): string | null {
181
+ return mappingFieldConfig[fieldName]?.ui_type ?? null;
182
+ }
183
+
184
+ function getMappingFieldOptions(
185
+ fieldName: string
186
+ ): Array<{ value: string; label: string }> | null {
187
+ return mappingFieldConfig[fieldName]?.options ?? null;
188
+ }
189
+
128
190
  // Get display name for a field
129
191
  function getFieldDisplayName(fieldName: string): string {
192
+ // Handle mapping fields specially
193
+ switch (fieldName) {
194
+ case 'has_mappings':
195
+ return 'Has Mappings';
196
+ case 'mapping_status':
197
+ return 'Mapping Status';
198
+ }
199
+
130
200
  const schema = $fieldSchema[fieldName];
131
201
 
132
202
  // Use schema names if available
@@ -145,9 +215,27 @@
145
215
  if (filter.operator === 'exists') return 'exists';
146
216
  if (filter.operator === 'not_exists') return 'does not exist';
147
217
 
218
+ // Convert value to string, handling objects and arrays properly
219
+ let displayValue = '';
220
+ if (filter.value === null || filter.value === undefined) {
221
+ displayValue = '';
222
+ } else if (typeof filter.value === 'object') {
223
+ // Handle objects by converting to JSON or getting a meaningful representation
224
+ if (Array.isArray(filter.value)) {
225
+ displayValue = filter.value.join(', ');
226
+ } else if ('value' in filter.value) {
227
+ // Handle dropdown option objects
228
+ displayValue = String((filter.value as any).value);
229
+ } else {
230
+ displayValue = JSON.stringify(filter.value);
231
+ }
232
+ } else {
233
+ displayValue = String(filter.value);
234
+ }
235
+
148
236
  // Use the shared getOperatorLabel function
149
237
  const operatorText = getOperatorLabel(filter.operator).toLowerCase();
150
- return `${operatorText} "${filter.value}"`;
238
+ return `${operatorText} "${displayValue}"`;
151
239
  }
152
240
 
153
241
  // Handle click outside to close the panel
@@ -306,6 +394,33 @@
306
394
  {/each}
307
395
  {/if}
308
396
 
397
+ <!-- Mapping fields -->
398
+ {#if fieldsByTab.mappings.length > 0}
399
+ <!-- Divider -->
400
+ <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
401
+ <div
402
+ class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80"
403
+ >
404
+ Mapping Fields
405
+ </div>
406
+ {#each fieldsByTab.mappings as field (field)}
407
+ <button
408
+ class={twMerge(
409
+ 'w-full text-left px-3 py-2 text-sm',
410
+ newFilterField === field
411
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
412
+ : 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
413
+ )}
414
+ onclick={() => {
415
+ newFilterField = field;
416
+ showFieldDropdown = false;
417
+ }}
418
+ >
419
+ {getFieldDisplayName(field)}
420
+ </button>
421
+ {/each}
422
+ {/if}
423
+
309
424
  <!-- Custom fields -->
310
425
  {#if fieldsByTab.custom.length > 0}
311
426
  <!-- Divider -->
@@ -343,8 +458,8 @@
343
458
  >Operator</label
344
459
  >
345
460
 
346
- {#if isSelectField}
347
- <!-- Disabled dropdown for select fields (always equals) -->
461
+ {#if (isSelectField && !['has_mappings', 'mapping_status'].includes(newFilterField)) || newFilterField === 'has_mappings'}
462
+ <!-- Disabled dropdown for select fields and has_mappings (always equals) -->
348
463
  <div
349
464
  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
465
  >
@@ -354,7 +469,9 @@
354
469
  <!-- Custom Operator Dropdown -->
355
470
  <CustomDropdown
356
471
  bind:value={newFilterOperator}
357
- options={operatorOptions}
472
+ options={newFilterField === 'mapping_status'
473
+ ? mappingStatusOperatorOptions
474
+ : operatorOptions}
358
475
  getDisplayValue={getOperatorLabel}
359
476
  labelId="filter-operator"
360
477
  />
@@ -367,7 +484,30 @@
367
484
  <label for="filter-value" class="block text-xs text-gray-600 dark:text-gray-400 mb-1"
368
485
  >Value</label
369
486
  >
370
- {#if isSelectField}
487
+ {#if newFilterField === 'mapping_status'}
488
+ <!-- Special dropdown for mapping status -->
489
+ <select
490
+ id="filter-value"
491
+ bind:value={newFilterValue}
492
+ 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"
493
+ >
494
+ <option value="">Select status...</option>
495
+ {#each MAPPING_STATUS_OPTIONS as status}
496
+ <option value={status.value}>{status.label}</option>
497
+ {/each}
498
+ </select>
499
+ {:else if newFilterField === 'has_mappings'}
500
+ <!-- Special dropdown for has_mappings -->
501
+ <select
502
+ id="filter-value"
503
+ bind:value={newFilterValue}
504
+ 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"
505
+ >
506
+ <option value="">Select...</option>
507
+ <option value="true">Yes</option>
508
+ <option value="false">No</option>
509
+ </select>
510
+ {:else if isSelectField}
371
511
  <!-- Custom dropdown for select fields -->
372
512
  <CustomDropdown
373
513
  bind:value={newFilterValue}
@@ -377,15 +517,15 @@
377
517
  />
378
518
  {:else if selectedFieldType === 'boolean'}
379
519
  <!-- Boolean field with CustomDropdown -->
380
- <CustomDropdown
520
+ <select
521
+ id="filter-value"
381
522
  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
- />
523
+ 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"
524
+ >
525
+ <option value="">Select...</option>
526
+ <option value="true">Yes</option>
527
+ <option value="false">No</option>
528
+ </select>
389
529
  {:else}
390
530
  <!-- Text input for other fields -->
391
531
  <input
package/src/lib/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2023-Present The Lula Authors
3
+
3
4
  export interface Control {
4
5
  id: string;
5
6
  title: string;
@@ -5,6 +5,15 @@ import type { Control, Mapping } from '$lib/types';
5
5
  import { appState } from '$lib/websocket';
6
6
  import { get, writable } from 'svelte/store';
7
7
 
8
+ /**
9
+ * Shared mapping status options used across the application
10
+ */
11
+ export const MAPPING_STATUS_OPTIONS = [
12
+ { value: 'planned', label: 'Planned' },
13
+ { value: 'implemented', label: 'Implemented' },
14
+ { value: 'verified', label: 'Verified' }
15
+ ];
16
+
8
17
  /**
9
18
  * Shared filter operator options used across the application
10
19
  */
@@ -133,6 +142,10 @@ export const complianceStore = {
133
142
  });
134
143
  }
135
144
 
145
+ // Add mapping-related fields for filtering
146
+ fieldSet.add('has_mappings');
147
+ fieldSet.add('mapping_status');
148
+
136
149
  return Array.from(fieldSet).sort();
137
150
  }
138
151
  };