lula2 0.7.5 → 0.8.4

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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/_app/immutable/assets/{0.DqWJrcPI.css → 0.DLu2XH4u.css} +1 -1
  3. package/dist/_app/immutable/chunks/BHkKokgA.js +1 -0
  4. package/dist/_app/immutable/chunks/{DsuS1uUo.js → BNu54jRO.js} +1 -1
  5. package/dist/_app/immutable/chunks/BzIr_GrC.js +79 -0
  6. package/dist/_app/immutable/chunks/CC6oS456.js +1 -0
  7. package/dist/_app/immutable/chunks/DHuA7MQr.js +1 -0
  8. package/dist/_app/immutable/chunks/{BIdjJ0zz.js → DJTXGU6C.js} +1 -1
  9. package/dist/_app/immutable/chunks/DSxRA67V.js +2 -0
  10. package/dist/_app/immutable/chunks/DpCtGpHu.js +1 -0
  11. package/dist/_app/immutable/chunks/DznG4VMX.js +2 -0
  12. package/dist/_app/immutable/chunks/Ew6_cz_0.js +1 -0
  13. package/dist/_app/immutable/chunks/kRA7ZCNG.js +1 -0
  14. package/dist/_app/immutable/entry/{app.CjycYot0.js → app.CpHUD0XU.js} +2 -2
  15. package/dist/_app/immutable/entry/start.BavDkynd.js +1 -0
  16. package/dist/_app/immutable/nodes/{0.CGKh5y4X.js → 0.D5TULpJI.js} +2 -2
  17. package/dist/_app/immutable/nodes/1.BBgWG9H0.js +1 -0
  18. package/dist/_app/immutable/nodes/{2.Hrl6uq-b.js → 2.c2WlghKX.js} +1 -1
  19. package/dist/_app/immutable/nodes/3.hNTFAKFs.js +1 -0
  20. package/dist/_app/immutable/nodes/{4.DAVWsDkK.js → 4.C7MOPYAO.js} +7 -7
  21. package/dist/_app/version.json +1 -1
  22. package/dist/cli/commands/ui.js +98 -8
  23. package/dist/cli/server/index.js +98 -8
  24. package/dist/cli/server/server.js +98 -8
  25. package/dist/cli/server/serverState.js +40 -6
  26. package/dist/cli/server/websocketServer.js +1146 -1056
  27. package/dist/index.html +11 -11
  28. package/dist/index.js +98 -8
  29. package/package.json +2 -2
  30. package/src/lib/components/controls/MappingCard.svelte +6 -1
  31. package/src/lib/components/controls/MappingForm.svelte +36 -3
  32. package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +57 -0
  33. package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +1 -0
  34. package/src/lib/components/controls/tabs/ImplementationTab.svelte +1 -0
  35. package/src/lib/components/controls/tabs/MappingsTab.svelte +39 -14
  36. package/src/lib/components/controls/tabs/OverviewTab.svelte +1 -0
  37. package/src/lib/components/forms/FormField.svelte +65 -3
  38. package/src/lib/types.ts +2 -1
  39. package/src/lib/websocket.ts +7 -0
  40. package/src/routes/control/[id]/+page.svelte +2 -2
  41. package/dist/_app/immutable/chunks/BI-GirXZ.js +0 -1
  42. package/dist/_app/immutable/chunks/BSyVkqhj.js +0 -2
  43. package/dist/_app/immutable/chunks/Cng7c2CG.js +0 -1
  44. package/dist/_app/immutable/chunks/CxBMFlfX.js +0 -1
  45. package/dist/_app/immutable/chunks/DArZRX9-.js +0 -65
  46. package/dist/_app/immutable/chunks/DH2IP9c7.js +0 -1
  47. package/dist/_app/immutable/chunks/DXSHWIjJ.js +0 -2
  48. package/dist/_app/immutable/chunks/urFjAlpd.js +0 -1
  49. package/dist/_app/immutable/entry/start.Bgy9x4Qb.js +0 -1
  50. package/dist/_app/immutable/nodes/1.D5L7DxSG.js +0 -1
  51. package/dist/_app/immutable/nodes/3.BoHxdRm3.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.Bgy9x4Qb.js">
10
- <link rel="modulepreload" href="/_app/immutable/chunks/DH2IP9c7.js">
11
- <link rel="modulepreload" href="/_app/immutable/chunks/CxBMFlfX.js">
12
- <link rel="modulepreload" href="/_app/immutable/entry/app.CjycYot0.js">
13
- <link rel="modulepreload" href="/_app/immutable/chunks/DXSHWIjJ.js">
14
- <link rel="modulepreload" href="/_app/immutable/chunks/Cng7c2CG.js">
15
- <link rel="modulepreload" href="/_app/immutable/chunks/urFjAlpd.js">
16
- <link rel="modulepreload" href="/_app/immutable/chunks/DsuS1uUo.js">
9
+ <link rel="modulepreload" href="/_app/immutable/entry/start.BavDkynd.js">
10
+ <link rel="modulepreload" href="/_app/immutable/chunks/BHkKokgA.js">
11
+ <link rel="modulepreload" href="/_app/immutable/chunks/DHuA7MQr.js">
12
+ <link rel="modulepreload" href="/_app/immutable/entry/app.CpHUD0XU.js">
13
+ <link rel="modulepreload" href="/_app/immutable/chunks/DSxRA67V.js">
14
+ <link rel="modulepreload" href="/_app/immutable/chunks/Ew6_cz_0.js">
15
+ <link rel="modulepreload" href="/_app/immutable/chunks/CC6oS456.js">
16
+ <link rel="modulepreload" href="/_app/immutable/chunks/BNu54jRO.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_sd3spo = {
22
+ __sveltekit_11w68nn = {
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.Bgy9x4Qb.js"),
30
- import("/_app/immutable/entry/app.CjycYot0.js")
29
+ import("/_app/immutable/entry/start.BavDkynd.js"),
30
+ import("/_app/immutable/entry/app.CpHUD0XU.js")
31
31
  ]).then(([kit, app]) => {
32
32
  kit.start(app, element);
33
33
  });
package/dist/index.js CHANGED
@@ -1669,6 +1669,7 @@ var fileStore_exports = {};
1669
1669
  __export(fileStore_exports, {
1670
1670
  FileStore: () => FileStore
1671
1671
  });
1672
+ import { createHash as createHash2 } from "crypto";
1672
1673
  import {
1673
1674
  existsSync as existsSync2,
1674
1675
  promises as fs,
@@ -1681,7 +1682,6 @@ import {
1681
1682
  } from "fs";
1682
1683
  import * as yaml2 from "js-yaml";
1683
1684
  import { join as join2 } from "path";
1684
- import { createHash as createHash2 } from "crypto";
1685
1685
  var FileStore;
1686
1686
  var init_fileStore = __esm({
1687
1687
  "cli/server/infrastructure/fileStore.ts"() {
@@ -1701,6 +1701,40 @@ var init_fileStore = __esm({
1701
1701
  this.refreshControlsCache();
1702
1702
  }
1703
1703
  }
1704
+ /**
1705
+ * Update a single mapping in place, preserving file order
1706
+ */
1707
+ async updateMapping(oldCompositeKey, updatedMapping) {
1708
+ const mappingFiles = this.getAllMappingFiles();
1709
+ for (const file of mappingFiles) {
1710
+ try {
1711
+ const content = readFileSync2(file, "utf8");
1712
+ let mappings = yaml2.load(content) || [];
1713
+ let changed = false;
1714
+ mappings = mappings.map((m) => {
1715
+ const hash = createHash2("sha256").update(JSON.stringify(m)).digest("hex");
1716
+ if (`${m.control_id}:${hash}` === oldCompositeKey) {
1717
+ const clean = { ...updatedMapping };
1718
+ delete clean.hash;
1719
+ changed = true;
1720
+ return clean;
1721
+ }
1722
+ return m;
1723
+ });
1724
+ if (changed) {
1725
+ const yamlContent = yaml2.dump(mappings, {
1726
+ indent: 2,
1727
+ lineWidth: -1,
1728
+ noRefs: true
1729
+ });
1730
+ writeFileSync(file, yamlContent, "utf8");
1731
+ return;
1732
+ }
1733
+ } catch (error) {
1734
+ console.error(`Error processing mapping file ${file}:`, error);
1735
+ }
1736
+ }
1737
+ }
1704
1738
  /**
1705
1739
  * Get simple filename from control ID
1706
1740
  */
@@ -1723,8 +1757,8 @@ var init_fileStore = __esm({
1723
1757
  if (!this.baseDir || this.baseDir === "." || this.baseDir === process.cwd()) {
1724
1758
  return;
1725
1759
  }
1726
- const lulaConfigPath2 = join2(this.baseDir, "lula.yaml");
1727
- if (!existsSync2(lulaConfigPath2)) {
1760
+ const lulaConfigPath = join2(this.baseDir, "lula.yaml");
1761
+ if (!existsSync2(lulaConfigPath)) {
1728
1762
  return;
1729
1763
  }
1730
1764
  if (!existsSync2(this.controlsDir)) {
@@ -1883,10 +1917,10 @@ var init_fileStore = __esm({
1883
1917
  return [];
1884
1918
  }
1885
1919
  let controlOrder = null;
1920
+ const lulaConfigPath = join2(this.baseDir, "lula.yaml");
1886
1921
  try {
1887
- const lulaConfigPath2 = join2(this.baseDir, "lula.yaml");
1888
- if (existsSync2(lulaConfigPath2)) {
1889
- const content = readFileSync2(lulaConfigPath2, "utf8");
1922
+ if (existsSync2(lulaConfigPath)) {
1923
+ const content = readFileSync2(lulaConfigPath, "utf8");
1890
1924
  const metadata = yaml2.load(content);
1891
1925
  controlOrder = metadata?.controlOrder || null;
1892
1926
  }
@@ -5143,12 +5177,12 @@ import { fileURLToPath } from "url";
5143
5177
  import { readFileSync as readFileSync4 } from "fs";
5144
5178
  init_debug();
5145
5179
  init_controlHelpers();
5146
- init_serverState();
5147
5180
  init_gitHistory();
5181
+ init_serverState();
5148
5182
  import * as yaml5 from "js-yaml";
5183
+ import crypto2 from "node:crypto";
5149
5184
  import { join as join5 } from "path";
5150
5185
  import { WebSocket, WebSocketServer } from "ws";
5151
- import crypto2 from "node:crypto";
5152
5186
  var WebSocketManager = class {
5153
5187
  wss = null;
5154
5188
  clients = /* @__PURE__ */ new Set();
@@ -5189,6 +5223,62 @@ var WebSocketManager = class {
5189
5223
  }
5190
5224
  break;
5191
5225
  }
5226
+ case "update-mapping": {
5227
+ const state = getServerState();
5228
+ if (payload && payload.old_composite_key && payload.mapping) {
5229
+ const oldCompositeKey = payload.old_composite_key;
5230
+ const existing = state.mappingsCache.get(oldCompositeKey);
5231
+ if (!existing) {
5232
+ console.error("Mapping not found for update:", oldCompositeKey);
5233
+ break;
5234
+ }
5235
+ const incoming = payload.mapping;
5236
+ const updated = {
5237
+ ...existing,
5238
+ ...incoming,
5239
+ control_id: incoming.control_id || existing.control_id,
5240
+ uuid: incoming.uuid || existing.uuid
5241
+ };
5242
+ if (!updated.hash || updated.hash === "") {
5243
+ updated.hash = crypto2.createHash("sha256").update(JSON.stringify({ ...updated, hash: void 0 })).digest("hex");
5244
+ }
5245
+ const oldHash = existing.hash;
5246
+ const oldControlId = existing.control_id;
5247
+ const oldFamily = oldControlId.split("-")[0];
5248
+ const newHash = updated.hash;
5249
+ const newControlId = updated.control_id;
5250
+ const newFamily = newControlId.split("-")[0];
5251
+ const newCompositeKey = `${newControlId}:${newHash}`;
5252
+ await state.fileStore.updateMapping(oldCompositeKey, updated);
5253
+ const entries = Array.from(state.mappingsCache.entries());
5254
+ const oldIndex = entries.findIndex(([key]) => key === oldCompositeKey);
5255
+ if (oldIndex === -1) {
5256
+ state.mappingsCache.delete(oldCompositeKey);
5257
+ state.mappingsCache.set(newCompositeKey, updated);
5258
+ } else {
5259
+ entries[oldIndex] = [newCompositeKey, updated];
5260
+ state.mappingsCache = new Map(entries);
5261
+ }
5262
+ state.mappingsByFamily.get(oldFamily)?.delete(oldHash);
5263
+ state.mappingsByControl.get(oldControlId)?.delete(oldHash);
5264
+ if (!state.mappingsByFamily.has(newFamily)) {
5265
+ state.mappingsByFamily.set(newFamily, /* @__PURE__ */ new Set());
5266
+ }
5267
+ state.mappingsByFamily.get(newFamily).add(newHash);
5268
+ if (!state.mappingsByControl.has(newControlId)) {
5269
+ state.mappingsByControl.set(newControlId, /* @__PURE__ */ new Set());
5270
+ }
5271
+ state.mappingsByControl.get(newControlId).add(newHash);
5272
+ ws.send(
5273
+ JSON.stringify({
5274
+ type: "mapping-updated",
5275
+ payload: { uuid: updated.uuid, success: true }
5276
+ })
5277
+ );
5278
+ this.broadcastState();
5279
+ }
5280
+ break;
5281
+ }
5192
5282
  case "refresh-controls": {
5193
5283
  const state = getServerState();
5194
5284
  state.controlsCache.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lula2",
3
- "version": "0.7.5",
3
+ "version": "0.8.4",
4
4
  "description": "A tool for managing compliance as code in your GitHub repositories.",
5
5
  "bin": {
6
6
  "lula2": "./dist/lula2"
@@ -88,7 +88,7 @@
88
88
  "@types/js-yaml": "^4.0.9",
89
89
  "@types/jsdom": "^27.0.0",
90
90
  "@types/multer": "^2.0.0",
91
- "@types/node": "^24.4.0",
91
+ "@types/node": "^25.0.0",
92
92
  "@typescript-eslint/eslint-plugin": "^8.42.0",
93
93
  "@typescript-eslint/parser": "^8.42.0",
94
94
  "@vitest/browser": "^4.0.1",
@@ -27,6 +27,7 @@
27
27
  onDelete?.(mapping.hash!);
28
28
  }
29
29
  }
30
+
30
31
  </script>
31
32
 
32
33
  <div
@@ -75,7 +76,11 @@
75
76
  <p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed mb-4">
76
77
  {mapping.justification}
77
78
  </p>
78
-
79
+ {#if mapping.cci}
80
+ <div class="mb-4">
81
+ <h4 class="text-xs font-semibold text-gray-600 dark:text-gray-400 tracking-wider mb-2">CCIs:<span class="text-xs font-mono text-gray-500 dark:text-gray-300 ml-2 break-all" title="CCIs">{mapping.cci}</span></h4>
82
+ </div>
83
+ {/if}
79
84
  {#if mapping.source_entries && mapping.source_entries.length > 0}
80
85
  <div class="mb-4">
81
86
  <h4 class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">Source References</h4>
@@ -11,6 +11,7 @@
11
11
  justification: string;
12
12
  status: 'planned' | 'implemented' | 'verified';
13
13
  source_entries: SourceEntry[];
14
+ cci: string;
14
15
  }
15
16
 
16
17
  interface Props {
@@ -19,6 +20,7 @@
19
20
  onCancel: () => void;
20
21
  loading?: boolean;
21
22
  submitLabel?: string;
23
+ cci?: string;
22
24
  }
23
25
 
24
26
  let {
@@ -26,14 +28,16 @@
26
28
  onSubmit,
27
29
  onCancel,
28
30
  loading = false,
29
- submitLabel = 'Create Mapping'
31
+ submitLabel = 'Create Mapping',
32
+ cci
30
33
  }: Props = $props();
31
34
 
32
35
  let formData = $state<MappingFormData>({
33
36
  uuid: initialData.uuid || '',
34
37
  justification: initialData.justification || '',
35
38
  status: initialData.status || 'planned',
36
- source_entries: initialData.source_entries || []
39
+ source_entries: initialData.source_entries || [],
40
+ cci: initialData.cci ?? ''
37
41
  });
38
42
 
39
43
  let newLocation = $state('');
@@ -42,6 +46,25 @@
42
46
  const statusOptions = ['planned', 'implemented', 'verified'];
43
47
 
44
48
  const isValid = $derived(formData.justification.trim().length > 0);
49
+
50
+ const cciOptions = $derived(
51
+ (cci ?? '')
52
+ .split(';')
53
+ .map((s) => s.trim())
54
+ .filter(Boolean)
55
+ );
56
+
57
+
58
+ let selectedCCIs = $derived(
59
+ (formData.cci ?? '')
60
+ .split(';')
61
+ .map((s) => s.trim())
62
+ .filter(Boolean)
63
+ );
64
+
65
+ $effect(() => {
66
+ formData.cci = selectedCCIs.join('; ');
67
+ });
45
68
 
46
69
  function handleSubmit() {
47
70
  if (!isValid || loading) return;
@@ -54,7 +77,8 @@
54
77
  uuid: initialData.uuid || '',
55
78
  justification: initialData.justification || '',
56
79
  status: initialData.status || 'planned',
57
- source_entries: initialData.source_entries || []
80
+ source_entries: initialData.source_entries || [],
81
+ cci: initialData.cci ?? ''
58
82
  };
59
83
  newLocation = '';
60
84
  newShasum = '';
@@ -101,6 +125,15 @@
101
125
  placeholder="Explain how this compliance artifact satisfies the control requirements..."
102
126
  required
103
127
  />
128
+ {#if cci}
129
+ <FormField
130
+ id="mapping-cci"
131
+ label="Mapping CCI(s)"
132
+ type="multiselect"
133
+ bind:value={selectedCCIs}
134
+ options={cciOptions}
135
+ />
136
+ {/if}
104
137
 
105
138
  <FormField
106
139
  id="mapping-status"
@@ -42,6 +42,63 @@
42
42
  <option value={option}>{option}</option>
43
43
  {/each}
44
44
  </select>
45
+ {:else if field.ui_type === 'multiselect' && field.options}
46
+ {@const currentValues = Array.isArray(value) ? (value as string[]) : []}
47
+
48
+ <div id={fieldId} class="flex flex-wrap gap-2">
49
+ {#each field.options as option (option)}
50
+ {@const selected = currentValues.includes(option)}
51
+
52
+ <label
53
+ class={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm cursor-pointer transition-colors
54
+ focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2
55
+ dark:focus-within:ring-offset-gray-900
56
+ ${
57
+ selected
58
+ ? 'bg-blue-50 border-blue-500 text-blue-700 dark:bg-blue-900/40 dark:border-blue-400 dark:text-blue-100'
59
+ : 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200'
60
+ }
61
+ ${!field.editable ? 'opacity-50 cursor-not-allowed' : ''}
62
+ `}
63
+ >
64
+ <input
65
+ type="checkbox"
66
+ value={option}
67
+ checked={selected}
68
+ disabled={!field.editable}
69
+ onchange={(e: Event) => {
70
+ const input = e.currentTarget as HTMLInputElement;
71
+ const current = Array.isArray(value) ? (value as string[]) : [];
72
+ let updated: string[];
73
+
74
+ if (input.checked) {
75
+ updated = current.includes(option) ? current : [...current, option];
76
+ } else {
77
+ updated = current.filter((v) => v !== option);
78
+ }
79
+
80
+ value = updated;
81
+ onChange();
82
+ }}
83
+ class="sr-only"
84
+ />
85
+
86
+ <span
87
+ class={`inline-flex items-center justify-center w-4 h-4 rounded border text-[10px] font-bold
88
+ ${
89
+ selected
90
+ ? 'border-blue-500 bg-blue-500 text-white'
91
+ : 'border-gray-400 bg-transparent text-transparent'
92
+ }
93
+ `}
94
+ >
95
+
96
+ </span>
97
+
98
+ <span>{option}</span>
99
+ </label>
100
+ {/each}
101
+ </div>
45
102
  {:else if field.ui_type === 'textarea' || field.ui_type === 'long_text'}
46
103
  <textarea
47
104
  id={fieldId}
@@ -49,6 +49,7 @@
49
49
  // Dropdowns and short fields can be side by side
50
50
  if (
51
51
  field.ui_type === 'select' ||
52
+ field.ui_type === 'multiselect' ||
52
53
  field.ui_type === 'boolean' ||
53
54
  field.ui_type === 'date' ||
54
55
  field.ui_type === 'number' ||
@@ -48,6 +48,7 @@
48
48
  // Dropdowns and short fields can be side by side
49
49
  if (
50
50
  field.ui_type === 'select' ||
51
+ field.ui_type === 'multiselect' ||
51
52
  field.ui_type === 'boolean' ||
52
53
  field.ui_type === 'date' ||
53
54
  field.ui_type === 'number' ||
@@ -24,7 +24,8 @@
24
24
  uuid: '',
25
25
  justification: '',
26
26
  status: 'planned' as 'planned' | 'implemented' | 'verified',
27
- source_entries: [] as { location: string; shasum?: string }[]
27
+ source_entries: [] as { location: string; shasum?: string }[],
28
+ cci: ''
28
29
  });
29
30
 
30
31
  // Event handlers
@@ -36,18 +37,22 @@
36
37
  status: data.status,
37
38
  source_entries: data.source_entries,
38
39
  uuid: data.uuid || '', // Use the UUID from form or empty for auto-generation,
39
- hash: ''
40
+ hash: '',
40
41
  };
42
+ const mappingForHash = data.cci !== ''
43
+ ? { ...mappingData, cci: data.cci }
44
+ : { ...mappingData };
45
+
46
+
41
47
  const hash = await fetch('/hash', {
42
48
  method: 'POST',
43
49
  headers: {
44
50
  'Content-Type': 'application/json'
45
51
  },
46
- body: JSON.stringify(mappingData)
52
+ body: JSON.stringify(mappingForHash)
47
53
  });
48
- mappingData.hash = (await hash.json()).hash;
49
-
50
- await wsClient.createMapping(mappingData);
54
+ mappingForHash.hash = (await hash.json()).hash;
55
+ await wsClient.createMapping(mappingForHash);
51
56
  resetMappingForm();
52
57
  } catch (error) {
53
58
  console.error('Failed to create mapping:', error);
@@ -63,7 +68,8 @@
63
68
  uuid: '',
64
69
  justification: '',
65
70
  status: 'planned',
66
- source_entries: []
71
+ source_entries: [],
72
+ cci: ''
67
73
  };
68
74
  showNewMappingForm = false;
69
75
  editingMapping = null;
@@ -76,25 +82,41 @@
76
82
  uuid: mapping.uuid,
77
83
  justification: mapping.justification,
78
84
  status: mapping.status,
79
- source_entries: mapping.source_entries || []
85
+ source_entries: mapping.source_entries || [],
86
+ cci: mapping.cci || ''
80
87
  };
81
88
  }
82
89
 
83
90
  async function handleUpdateMapping(data: typeof newMappingData) {
84
91
  if (!editingMapping) return;
85
-
92
+
86
93
  try {
87
94
  const updatedMapping = {
88
95
  ...editingMapping,
89
96
  uuid: data.uuid || editingMapping.uuid, // Use form UUID or fallback to original
90
97
  justification: data.justification,
91
98
  status: data.status,
92
- source_entries: data.source_entries,
99
+ source_entries: data.source_entries
93
100
  };
94
- // hashes change every time so we just delete an create
95
- await wsClient.deleteMapping(`${editingMapping.control_id}:${editingMapping.hash!}`);
96
- delete updatedMapping.hash;
97
- await wsClient.createMapping(updatedMapping);
101
+
102
+
103
+ if(data.cci !== undefined) {
104
+ updatedMapping.cci = data.cci;
105
+ }
106
+
107
+ // Compute new hash for the updated mapping on the backend
108
+ const hashResponse = await fetch('/hash', {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json'
112
+ },
113
+ body: JSON.stringify({ ...updatedMapping, hash: '' })
114
+ });
115
+ const hashData = await hashResponse.json();
116
+ updatedMapping.hash = hashData.hash;
117
+
118
+ const oldCompositeKey = `${editingMapping.control_id}:${editingMapping.hash!}`;
119
+ await wsClient.updateMapping(oldCompositeKey, updatedMapping);
98
120
 
99
121
  resetMappingForm();
100
122
  } catch (error) {
@@ -134,6 +156,7 @@
134
156
  onSubmit={handleUpdateMapping}
135
157
  onCancel={cancelNewMapping}
136
158
  submitLabel="Update Mapping"
159
+ cci={control.cci}
137
160
  />
138
161
  </div>
139
162
  {:else}
@@ -158,6 +181,7 @@
158
181
  onSubmit={handleCreateMapping}
159
182
  onCancel={cancelNewMapping}
160
183
  submitLabel="Create Mapping"
184
+ cci={control.cci}
161
185
  />
162
186
  </div>
163
187
  {:else if !editingMapping}
@@ -182,6 +206,7 @@
182
206
  onSubmit={handleCreateMapping}
183
207
  onCancel={cancelNewMapping}
184
208
  submitLabel="Create Mapping"
209
+ cci={control.cci}
185
210
  />
186
211
  </div>
187
212
  {:else}
@@ -53,6 +53,7 @@
53
53
  // Dropdowns and short fields can be side by side
54
54
  if (
55
55
  field.ui_type === 'select' ||
56
+ field.ui_type === 'multiselect' ||
56
57
  field.ui_type === 'boolean' ||
57
58
  field.ui_type === 'date' ||
58
59
  field.ui_type === 'number' ||
@@ -2,11 +2,10 @@
2
2
  <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
3
 
4
4
  <script lang="ts">
5
- interface Props {
5
+ interface BaseProps {
6
6
  id: string;
7
7
  label: string;
8
- type?: 'text' | 'textarea' | 'select';
9
- value: string;
8
+ type?: 'text' | 'textarea' | 'select' | 'multiselect';
10
9
  options?: string[];
11
10
  rows?: number;
12
11
  placeholder?: string;
@@ -15,6 +14,18 @@
15
14
  onChange?: () => void;
16
15
  }
17
16
 
17
+ interface SingleValueProps extends BaseProps {
18
+ type?: 'text' | 'textarea' | 'select';
19
+ value: string;
20
+ }
21
+
22
+ interface MultiSelectProps extends BaseProps {
23
+ type: 'multiselect';
24
+ value: string[];
25
+ }
26
+
27
+ type Props = SingleValueProps | MultiSelectProps;
28
+
18
29
  let {
19
30
  id,
20
31
  label,
@@ -55,6 +66,57 @@
55
66
  </div>
56
67
  {/if}
57
68
  </div>
69
+ {:else if type === 'multiselect' && options.length > 0}
70
+ <div class="flex flex-wrap gap-2">
71
+ {#each options as option (option)}
72
+ {@const selected = (value as string[]).includes(option)}
73
+ <label
74
+ class={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm cursor-pointer transition-colors
75
+ focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2
76
+ dark:focus-within:ring-offset-gray-900
77
+ ${
78
+ selected
79
+ ? 'bg-blue-50 border-blue-500 text-blue-700 dark:bg-blue-900/40 dark:border-blue-400 dark:text-blue-100'
80
+ : 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200'
81
+ }
82
+ ${error ? 'border-red-400' : ''}
83
+ `}
84
+ >
85
+ <input
86
+ type="checkbox"
87
+ value={option}
88
+ checked={selected}
89
+ onchange={(e: Event) => {
90
+ const input = e.currentTarget as HTMLInputElement;
91
+ const current = value as string[];
92
+
93
+ if (input.checked) {
94
+ value = current.includes(option) ? current : [...current, option];
95
+ } else {
96
+ value = current.filter((v) => v !== option);
97
+ }
98
+
99
+ onChange?.();
100
+ }}
101
+ class="sr-only"
102
+ />
103
+
104
+ <span
105
+ class={`inline-flex items-center justify-center w-4 h-4 rounded border text-[10px] font-bold
106
+ ${
107
+ selected
108
+ ? 'border-blue-500 bg-blue-500 text-white'
109
+ : 'border-gray-400 bg-transparent text-transparent'
110
+ }
111
+ `}
112
+ >
113
+
114
+ </span>
115
+
116
+ <span>{option}</span>
117
+ </label>
118
+ {/each}
119
+ </div>
58
120
  {:else if type === 'select' && options.length > 0}
59
121
  <div class="relative">
60
122
  <select
package/src/lib/types.ts CHANGED
@@ -72,7 +72,8 @@ export interface FieldSchema {
72
72
  | 'date'
73
73
  | 'number'
74
74
  | 'boolean'
75
- | 'long_text';
75
+ | 'long_text'
76
+ | 'multiselect';
76
77
  is_array: boolean;
77
78
  max_length?: number;
78
79
  usage_count?: number;
@@ -355,6 +355,13 @@ class WebSocketClient {
355
355
  return this.sendCommand('create-mapping', mapping);
356
356
  }
357
357
 
358
+ async updateMapping(oldCompositeKey: string, mapping: Mapping) {
359
+ return this.sendCommand('update-mapping', {
360
+ old_composite_key: oldCompositeKey,
361
+ mapping
362
+ });
363
+ }
364
+
358
365
  async deleteMapping(composite_key: string) {
359
366
  return this.sendCommand('delete-mapping', { composite_key });
360
367
  }
@@ -3,7 +3,7 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import { goto } from '$app/navigation';
6
- import { page } from '$app/stores';
6
+ import { page } from '$app/state';
7
7
  import { ControlDetailsPanel, ControlsList } from '$components/controls';
8
8
  import { wsClient } from '$lib/websocket';
9
9
  import { selectedControl } from '$stores/compliance';
@@ -16,7 +16,7 @@
16
16
 
17
17
  // React to URL parameter changes and fetch control details
18
18
  $effect(() => {
19
- const controlId = $page.params.id;
19
+ const controlId = page.params.id;
20
20
  if (!controlId) return;
21
21
 
22
22
  const decodedControlId = decodeURIComponent(controlId);
@@ -1 +0,0 @@
1
- import{m as g,t as d,u as c,i as m,v as i,w as b,g as p,x as v,y,z as h}from"./CxBMFlfX.js";function x(n=!1){const s=g,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let a=0,t={};const _=y(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==t[o]&&(t[o]=r[o],l=!0);return l&&a++,a});f=()=>p(_)}e.b.length&&d(()=>{u(s,f),i(e.b)}),c(()=>{const a=m(()=>e.m.map(b));return()=>{for(const t of a)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}h();export{x as i};