lula2 0.3.0 → 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/DrOkR2YZ.js +3 -0
- package/dist/_app/immutable/chunks/rsJnd9Tf.js +1 -0
- package/dist/_app/immutable/entry/{app.3K_9hfHX.js → app.CqP__jbe.js} +2 -2
- package/dist/_app/immutable/entry/start.C1hW6Z_g.js +1 -0
- package/dist/_app/immutable/nodes/{0.CHOlW0sh.js → 0.BWuQSqPo.js} +1 -1
- package/dist/_app/immutable/nodes/{1.wExp2Xq4.js → 1.COMBeJ1R.js} +1 -1
- package/dist/_app/immutable/nodes/{2.BrqnOQ4V.js → 2.6bQMjmMR.js} +1 -1
- package/dist/_app/immutable/nodes/{3.Bo5PnZob.js → 3.BBPnkpxM.js} +1 -1
- package/dist/_app/immutable/nodes/{4.C_hUQcnJ.js → 4.CGIAObAE.js} +1 -1
- package/dist/_app/version.json +1 -1
- package/dist/cli/commands/ui.js +21 -13
- package/dist/cli/server/index.js +21 -13
- package/dist/cli/server/server.js +21 -13
- package/dist/cli/server/serverState.js +10 -4
- package/dist/cli/server/spreadsheetRoutes.js +10 -8
- package/dist/cli/server/websocketServer.js +21 -13
- package/dist/index.html +10 -10
- package/dist/index.js +21 -13
- 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/B30ytfCz.js +0 -1
- package/dist/_app/immutable/chunks/Cby0Z7eP.js +0 -2
- package/dist/_app/immutable/chunks/CwZwnEjo.js +0 -66
- package/dist/_app/immutable/chunks/D7S8Dq6O.js +0 -3
- package/dist/_app/immutable/entry/start.cmFs1yVq.js +0 -1
package/dist/index.js
CHANGED
|
@@ -1952,7 +1952,10 @@ var init_fileStore = __esm({
|
|
|
1952
1952
|
const controlId = mapping.control_id;
|
|
1953
1953
|
const family = this.getControlFamily(controlId);
|
|
1954
1954
|
const familyDir = join2(this.mappingsDir, family);
|
|
1955
|
-
const mappingFile = join2(
|
|
1955
|
+
const mappingFile = join2(
|
|
1956
|
+
familyDir,
|
|
1957
|
+
`${controlId.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`
|
|
1958
|
+
);
|
|
1956
1959
|
if (!existsSync2(familyDir)) {
|
|
1957
1960
|
mkdirSync(familyDir, { recursive: true });
|
|
1958
1961
|
}
|
|
@@ -2050,7 +2053,10 @@ var init_fileStore = __esm({
|
|
|
2050
2053
|
for (const [controlId, controlMappings] of mappingsByControl) {
|
|
2051
2054
|
const family = this.getControlFamily(controlId);
|
|
2052
2055
|
const familyDir = join2(this.mappingsDir, family);
|
|
2053
|
-
const mappingFile = join2(
|
|
2056
|
+
const mappingFile = join2(
|
|
2057
|
+
familyDir,
|
|
2058
|
+
`${controlId.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`
|
|
2059
|
+
);
|
|
2054
2060
|
if (!existsSync2(familyDir)) {
|
|
2055
2061
|
mkdirSync(familyDir, { recursive: true });
|
|
2056
2062
|
}
|
|
@@ -2556,7 +2562,7 @@ var init_gitHistory = __esm({
|
|
|
2556
2562
|
changes = diffResult.changes;
|
|
2557
2563
|
diff = diffResult.diff;
|
|
2558
2564
|
yamlDiff = diffResult.yamlDiff;
|
|
2559
|
-
} catch
|
|
2565
|
+
} catch {
|
|
2560
2566
|
}
|
|
2561
2567
|
}
|
|
2562
2568
|
gitCommits.push({
|
|
@@ -2613,7 +2619,7 @@ var init_gitHistory = __esm({
|
|
|
2613
2619
|
diff,
|
|
2614
2620
|
yamlDiff
|
|
2615
2621
|
};
|
|
2616
|
-
} catch
|
|
2622
|
+
} catch {
|
|
2617
2623
|
return { changes: { insertions: 0, deletions: 0, files: 1 } };
|
|
2618
2624
|
}
|
|
2619
2625
|
}
|
|
@@ -3288,7 +3294,7 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3288
3294
|
if (req.body.fieldSchema) {
|
|
3289
3295
|
try {
|
|
3290
3296
|
frontendFieldSchema = JSON.parse(req.body.fieldSchema);
|
|
3291
|
-
} catch
|
|
3297
|
+
} catch {
|
|
3292
3298
|
}
|
|
3293
3299
|
}
|
|
3294
3300
|
const fields = {};
|
|
@@ -3348,7 +3354,9 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3348
3354
|
uiType = "long_text";
|
|
3349
3355
|
}
|
|
3350
3356
|
let category = frontendConfig?.category || "custom";
|
|
3351
|
-
if (
|
|
3357
|
+
if (justificationFields.includes(fieldName)) {
|
|
3358
|
+
category = "mappings";
|
|
3359
|
+
} else if (!frontendConfig) {
|
|
3352
3360
|
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
3353
3361
|
category = "compliance";
|
|
3354
3362
|
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
@@ -3376,8 +3384,8 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3376
3384
|
// Control ID is always first
|
|
3377
3385
|
category: isControlIdField ? "core" : category,
|
|
3378
3386
|
// Control ID is always core
|
|
3379
|
-
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
3380
|
-
//
|
|
3387
|
+
tab: isControlIdField ? "overview" : justificationFields.includes(fieldName) ? "mappings" : frontendConfig?.tab || void 0
|
|
3388
|
+
// Use frontend config or default
|
|
3381
3389
|
};
|
|
3382
3390
|
if (uiType === "select") {
|
|
3383
3391
|
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
@@ -3440,7 +3448,7 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3440
3448
|
if (fieldName === "family") return;
|
|
3441
3449
|
if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
3442
3450
|
justificationContents.push(control[fieldName]);
|
|
3443
|
-
|
|
3451
|
+
filteredControl[fieldName] = control[fieldName];
|
|
3444
3452
|
}
|
|
3445
3453
|
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
3446
3454
|
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
@@ -3546,7 +3554,7 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3546
3554
|
if (!worksheet) {
|
|
3547
3555
|
return res.status(400).json({ error: "No worksheet found in file" });
|
|
3548
3556
|
}
|
|
3549
|
-
worksheet.eachRow({ includeEmpty: false }, (row,
|
|
3557
|
+
worksheet.eachRow({ includeEmpty: false }, (row, _rowNumber) => {
|
|
3550
3558
|
const rowData = [];
|
|
3551
3559
|
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
3552
3560
|
rowData[colNumber - 1] = cell.value;
|
|
@@ -3556,7 +3564,7 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3556
3564
|
}
|
|
3557
3565
|
const headerCandidates = rows.slice(0, 5).map((row, index) => ({
|
|
3558
3566
|
row: index + 1,
|
|
3559
|
-
preview: row.slice(0, 4).filter((v) => v
|
|
3567
|
+
preview: row.slice(0, 4).filter((v) => v !== null).filter((v) => v !== void 0).join(", ") + (row.length > 4 ? ", ..." : "")
|
|
3560
3568
|
}));
|
|
3561
3569
|
res.json({
|
|
3562
3570
|
sheets,
|
|
@@ -3591,7 +3599,7 @@ var init_spreadsheetRoutes = __esm({
|
|
|
3591
3599
|
if (!worksheet) {
|
|
3592
3600
|
return res.status(400).json({ error: `Sheet "${sheetName}" not found` });
|
|
3593
3601
|
}
|
|
3594
|
-
worksheet.eachRow({ includeEmpty: false }, (row,
|
|
3602
|
+
worksheet.eachRow({ includeEmpty: false }, (row, _rowNumber) => {
|
|
3595
3603
|
const rowData = [];
|
|
3596
3604
|
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
3597
3605
|
rowData[colNumber - 1] = cell.value;
|
|
@@ -4713,7 +4721,7 @@ var WebSocketManager = class {
|
|
|
4713
4721
|
totalCommits: controlHistory.totalCommits,
|
|
4714
4722
|
commits: controlHistory.commits?.length || 0
|
|
4715
4723
|
});
|
|
4716
|
-
const mappingFilename = `${control.id}-mappings.yaml`;
|
|
4724
|
+
const mappingFilename = `${control.id.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
4717
4725
|
const mappingPath = join5(currentPath2, "mappings", family, mappingFilename);
|
|
4718
4726
|
let mappingHistory = { commits: [], totalCommits: 0 };
|
|
4719
4727
|
if (existsSync6(mappingPath)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lula2",
|
|
3
|
-
"version": "0.3.0",
|
|
3
|
+
"version": "0.3.2-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",
|
|
@@ -75,6 +56,7 @@
|
|
|
75
56
|
"@commitlint/cli": "^19.8.1",
|
|
76
57
|
"@commitlint/config-conventional": "^19.8.1",
|
|
77
58
|
"@eslint/compat": "^1.3.2",
|
|
59
|
+
"@eslint/eslintrc": "^3.3.1",
|
|
78
60
|
"@eslint/js": "^9.35.0",
|
|
79
61
|
"@playwright/test": "^1.55.0",
|
|
80
62
|
"@sveltejs/adapter-static": "^3.0.9",
|
|
@@ -123,5 +105,23 @@
|
|
|
123
105
|
"main",
|
|
124
106
|
"next"
|
|
125
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"
|
|
126
126
|
}
|
|
127
|
-
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Lula Authors
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Action that dispatches an event when a click occurs outside of the node it's applied to
|
|
6
|
+
*/
|
|
7
|
+
export function clickOutside(node: HTMLElement, callback: () => void) {
|
|
8
|
+
const handleClick = (event: MouseEvent) => {
|
|
9
|
+
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
10
|
+
callback();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
document.addEventListener('click', handleClick, true);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
destroy() {
|
|
18
|
+
document.removeEventListener('click', handleClick, true);
|
|
19
|
+
},
|
|
20
|
+
update(newCallback: () => void) {
|
|
21
|
+
callback = newCallback;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { goto } from '$app/navigation';
|
|
6
6
|
import { page } from '$app/stores';
|
|
7
|
-
import {
|
|
7
|
+
import { SearchBar, Tooltip } from '$components/ui';
|
|
8
|
+
import FilterBuilder from '$components/ui/FilterBuilder.svelte';
|
|
8
9
|
import type { Control, FieldSchema } from '$lib/types';
|
|
9
10
|
import { appState } from '$lib/websocket';
|
|
10
|
-
import { complianceStore, searchTerm,
|
|
11
|
-
import {
|
|
11
|
+
import { complianceStore, searchTerm, activeFilters, getOperatorLabel } from '$stores/compliance';
|
|
12
|
+
import { Information } from 'carbon-icons-svelte';
|
|
12
13
|
import { derived } from 'svelte/store';
|
|
13
14
|
|
|
14
|
-
// Derive controls
|
|
15
|
+
// Derive controls from appState
|
|
15
16
|
const controls = derived(appState, ($state) => $state.controls || []);
|
|
16
|
-
const families = derived(appState, ($state) => $state.families || []);
|
|
17
17
|
const loading = derived(appState, ($state) => !$state.isConnected);
|
|
18
18
|
|
|
19
19
|
// Derive controls with mappings
|
|
@@ -104,26 +104,64 @@
|
|
|
104
104
|
|
|
105
105
|
// Create filtered controls with mappings
|
|
106
106
|
const filteredControlsWithMappings = derived(
|
|
107
|
-
[controlsWithMappings,
|
|
108
|
-
([$controlsWithMappings, $
|
|
107
|
+
[controlsWithMappings, searchTerm, activeFilters],
|
|
108
|
+
([$controlsWithMappings, $searchTerm, $activeFilters]) => {
|
|
109
109
|
let results = $controlsWithMappings;
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
results = results.filter((c) => {
|
|
113
|
-
const family =
|
|
114
|
-
(c as any)?._metadata?.family ||
|
|
115
|
-
(c as any)?.family ||
|
|
116
|
-
(c as any)?.['control-acronym']?.split('-')[0] ||
|
|
117
|
-
'';
|
|
118
|
-
return family === $selectedFamily;
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
111
|
+
// Apply search term
|
|
122
112
|
if ($searchTerm) {
|
|
123
113
|
const term = $searchTerm.toLowerCase();
|
|
124
114
|
results = results.filter((c) => JSON.stringify(c).toLowerCase().includes(term));
|
|
125
115
|
}
|
|
126
116
|
|
|
117
|
+
// Apply advanced filters
|
|
118
|
+
if ($activeFilters.length > 0) {
|
|
119
|
+
results = results.filter((control) => {
|
|
120
|
+
// Control must match all filters
|
|
121
|
+
return $activeFilters.every(filter => {
|
|
122
|
+
const dynamicControl = control as Record<string, unknown>;
|
|
123
|
+
const fieldValue = dynamicControl[filter.fieldName];
|
|
124
|
+
|
|
125
|
+
switch (filter.operator) {
|
|
126
|
+
case 'equals':
|
|
127
|
+
return fieldValue === filter.value;
|
|
128
|
+
|
|
129
|
+
case 'not_equals':
|
|
130
|
+
return fieldValue !== filter.value;
|
|
131
|
+
|
|
132
|
+
case 'exists':
|
|
133
|
+
return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
|
|
134
|
+
|
|
135
|
+
case 'not_exists':
|
|
136
|
+
return fieldValue === undefined || fieldValue === null || fieldValue === '';
|
|
137
|
+
|
|
138
|
+
case 'includes':
|
|
139
|
+
if (typeof fieldValue === 'string') {
|
|
140
|
+
return fieldValue.toLowerCase().includes(String(filter.value).toLowerCase());
|
|
141
|
+
} else if (Array.isArray(fieldValue)) {
|
|
142
|
+
return fieldValue.some(item =>
|
|
143
|
+
String(item).toLowerCase().includes(String(filter.value).toLowerCase())
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
|
|
148
|
+
case 'not_includes':
|
|
149
|
+
if (typeof fieldValue === 'string') {
|
|
150
|
+
return !fieldValue.toLowerCase().includes(String(filter.value).toLowerCase());
|
|
151
|
+
} else if (Array.isArray(fieldValue)) {
|
|
152
|
+
return !fieldValue.some(item =>
|
|
153
|
+
String(item).toLowerCase().includes(String(filter.value).toLowerCase())
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
|
|
158
|
+
default:
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
127
165
|
return results;
|
|
128
166
|
}
|
|
129
167
|
);
|
|
@@ -243,64 +281,53 @@
|
|
|
243
281
|
</span>
|
|
244
282
|
</div>
|
|
245
283
|
|
|
246
|
-
<!-- Search Bar,
|
|
284
|
+
<!-- Search Bar, Filter, and Export -->
|
|
247
285
|
<div class="flex gap-3">
|
|
248
286
|
<div class="flex-1">
|
|
249
287
|
<SearchBar />
|
|
250
288
|
</div>
|
|
289
|
+
|
|
290
|
+
<!-- Filter Builder -->
|
|
251
291
|
<div class="flex-shrink-0">
|
|
252
|
-
<
|
|
253
|
-
buttonLabel={$selectedFamily || 'All Families'}
|
|
254
|
-
buttonIcon={Filter}
|
|
255
|
-
buttonClass="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
256
|
-
dropdownClass="w-64"
|
|
257
|
-
>
|
|
258
|
-
{#snippet children()}
|
|
259
|
-
<div class="space-y-1">
|
|
260
|
-
<button
|
|
261
|
-
onclick={() => {
|
|
262
|
-
complianceStore.setSelectedFamily(null);
|
|
263
|
-
}}
|
|
264
|
-
class="w-full text-left px-3 py-2 text-sm rounded-md transition-colors duration-200 flex items-center justify-between {$selectedFamily ===
|
|
265
|
-
null
|
|
266
|
-
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
|
267
|
-
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
|
268
|
-
>
|
|
269
|
-
<span>All Families</span>
|
|
270
|
-
<span class="text-xs bg-gray-200 dark:bg-gray-600 px-2 py-1 rounded-full">
|
|
271
|
-
{$controls.length}
|
|
272
|
-
</span>
|
|
273
|
-
</button>
|
|
274
|
-
|
|
275
|
-
{#each $families as family}
|
|
276
|
-
{@const familyCount = $controls.filter((c) => {
|
|
277
|
-
const controlFamily =
|
|
278
|
-
(c as any)?._metadata?.family ||
|
|
279
|
-
(c as any)?.family ||
|
|
280
|
-
(c as any)?.['control-acronym']?.split('-')[0] ||
|
|
281
|
-
'';
|
|
282
|
-
return controlFamily === family;
|
|
283
|
-
}).length}
|
|
284
|
-
<button
|
|
285
|
-
onclick={() => {
|
|
286
|
-
complianceStore.setSelectedFamily(family);
|
|
287
|
-
}}
|
|
288
|
-
class="w-full text-left px-3 py-2 text-sm rounded-md transition-colors duration-200 flex items-center justify-between {$selectedFamily ===
|
|
289
|
-
family
|
|
290
|
-
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
|
291
|
-
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
|
292
|
-
>
|
|
293
|
-
<span>{family}</span>
|
|
294
|
-
<span class="text-xs bg-gray-200 dark:bg-gray-600 px-2 py-1 rounded-full">
|
|
295
|
-
{familyCount}
|
|
296
|
-
</span>
|
|
297
|
-
</button>
|
|
298
|
-
{/each}
|
|
299
|
-
</div>
|
|
300
|
-
{/snippet}
|
|
301
|
-
</Dropdown>
|
|
292
|
+
<FilterBuilder />
|
|
302
293
|
</div>
|
|
303
294
|
</div>
|
|
295
|
+
|
|
296
|
+
<!-- Active Filters Summary -->
|
|
297
|
+
{#if $activeFilters.length > 0}
|
|
298
|
+
<div class="mt-2 flex flex-wrap gap-2">
|
|
299
|
+
{#each $activeFilters as filter, index}
|
|
300
|
+
<div class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
|
301
|
+
<span>{filter.fieldName}: </span>
|
|
302
|
+
{#if filter.operator === 'exists' || filter.operator === 'not_exists'}
|
|
303
|
+
<span>{getOperatorLabel(filter.operator).toLowerCase()}</span>
|
|
304
|
+
{:else if filter.operator === 'equals'}
|
|
305
|
+
<span>= {filter.value}</span>
|
|
306
|
+
{:else if filter.operator === 'not_equals'}
|
|
307
|
+
<span>≠ {filter.value}</span>
|
|
308
|
+
{:else}
|
|
309
|
+
<span>{getOperatorLabel(filter.operator).toLowerCase()} "{filter.value}"</span>
|
|
310
|
+
{/if}
|
|
311
|
+
<button
|
|
312
|
+
onclick={() => complianceStore.removeFilter(index)}
|
|
313
|
+
class="ml-1 text-blue-700 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-100"
|
|
314
|
+
aria-label="Remove filter"
|
|
315
|
+
>
|
|
316
|
+
×
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
{/each}
|
|
320
|
+
|
|
321
|
+
{#if $activeFilters.length > 1}
|
|
322
|
+
<button
|
|
323
|
+
onclick={() => complianceStore.clearFilters()}
|
|
324
|
+
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
325
|
+
>
|
|
326
|
+
Clear all
|
|
327
|
+
</button>
|
|
328
|
+
{/if}
|
|
329
|
+
</div>
|
|
330
|
+
{/if}
|
|
304
331
|
</div>
|
|
305
332
|
|
|
306
333
|
<!-- Controls Table -->
|
|
@@ -582,15 +609,14 @@
|
|
|
582
609
|
</svg>
|
|
583
610
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No controls found</h3>
|
|
584
611
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
|
585
|
-
{#if $
|
|
586
|
-
No controls match your
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
No controls available in this family. Select a different family or check your data.
|
|
612
|
+
{#if $activeFilters.length > 0}
|
|
613
|
+
No controls match your filter criteria. Try adjusting or removing some filters.
|
|
614
|
+
{:else if $searchTerm}
|
|
615
|
+
No controls match your search criteria. Try adjusting your search terms.
|
|
590
616
|
{:else if $controls.length === 0}
|
|
591
617
|
No controls have been imported yet.
|
|
592
618
|
{:else}
|
|
593
|
-
No controls available.
|
|
619
|
+
No controls available. Check your data.
|
|
594
620
|
{/if}
|
|
595
621
|
</p>
|
|
596
622
|
{#if $controls.length === 0}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<div class="overflow-x-auto">
|
|
24
24
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
25
25
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
26
|
-
{#each section.data.rows as row,
|
|
26
|
+
{#each section.data.rows as row, _i}
|
|
27
27
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
28
28
|
{#each row.columns as column, j}
|
|
29
29
|
<td class="px-3 py-2 text-sm {j === 0 ? 'font-medium text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'}">
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
{#if readonly}
|
|
129
129
|
<!-- View Mode: Clean minimal layout -->
|
|
130
130
|
<div class="space-y-6">
|
|
131
|
-
{#each Object.entries(fieldGroups) as [
|
|
131
|
+
{#each Object.entries(fieldGroups) as [_groupName, fields]}
|
|
132
132
|
{#each [fields] as fieldList}
|
|
133
133
|
{@const importantFields = fieldList.filter((f) =>
|
|
134
134
|
['id', 'title', 'priority', 'status'].includes(f.id)
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
</span>
|
|
227
227
|
</h3>
|
|
228
228
|
<div class="space-y-3">
|
|
229
|
-
{#each value as item,
|
|
229
|
+
{#each value as item, _index}
|
|
230
230
|
<div
|
|
231
231
|
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
232
232
|
>
|
|
@@ -784,8 +784,8 @@
|
|
|
784
784
|
aria-label="Overview tab drop zone"
|
|
785
785
|
>
|
|
786
786
|
{#each Array.from(fieldConfigs.entries())
|
|
787
|
-
.filter(([
|
|
788
|
-
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field,
|
|
787
|
+
.filter(([_field, config]) => config.tab === 'overview')
|
|
788
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, _config], _index (field)}
|
|
789
789
|
<div
|
|
790
790
|
draggable="true"
|
|
791
791
|
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
@@ -805,7 +805,7 @@
|
|
|
805
805
|
<span class="truncate">{field}</span>
|
|
806
806
|
</div>
|
|
807
807
|
{/each}
|
|
808
|
-
{#if Array.from(fieldConfigs.entries()).filter(([
|
|
808
|
+
{#if Array.from(fieldConfigs.entries()).filter(([_field, config]) => config.tab === 'overview').length === 0}
|
|
809
809
|
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
810
810
|
Drop fields here
|
|
811
811
|
</p>
|
|
@@ -835,8 +835,8 @@
|
|
|
835
835
|
aria-label="Implementation tab drop zone"
|
|
836
836
|
>
|
|
837
837
|
{#each Array.from(fieldConfigs.entries())
|
|
838
|
-
.filter(([
|
|
839
|
-
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field,
|
|
838
|
+
.filter(([_field, config]) => config.tab === 'implementation')
|
|
839
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, _config], index (field)}
|
|
840
840
|
<div
|
|
841
841
|
draggable="true"
|
|
842
842
|
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
@@ -856,7 +856,7 @@
|
|
|
856
856
|
<span class="truncate">{field}</span>
|
|
857
857
|
</div>
|
|
858
858
|
{/each}
|
|
859
|
-
{#if Array.from(fieldConfigs.entries()).filter(([
|
|
859
|
+
{#if Array.from(fieldConfigs.entries()).filter(([_field, config]) => config.tab === 'implementation').length === 0}
|
|
860
860
|
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
861
861
|
Drop fields here
|
|
862
862
|
</p>
|
|
@@ -889,7 +889,7 @@
|
|
|
889
889
|
<div class="space-y-2">
|
|
890
890
|
{#if justificationFields.length > 0}
|
|
891
891
|
<!-- Display justification fields -->
|
|
892
|
-
{#each justificationFields as field,
|
|
892
|
+
{#each justificationFields as field, _index}
|
|
893
893
|
<div
|
|
894
894
|
draggable="true"
|
|
895
895
|
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
@@ -966,8 +966,8 @@
|
|
|
966
966
|
aria-label="Custom fields drop zone"
|
|
967
967
|
>
|
|
968
968
|
{#each Array.from(fieldConfigs.entries())
|
|
969
|
-
.filter(([
|
|
970
|
-
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field,
|
|
969
|
+
.filter(([_field, config]) => config.tab === 'custom')
|
|
970
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, _config], _index (field)}
|
|
971
971
|
<div
|
|
972
972
|
draggable="true"
|
|
973
973
|
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
@@ -987,7 +987,7 @@
|
|
|
987
987
|
<span class="truncate">{field}</span>
|
|
988
988
|
</div>
|
|
989
989
|
{/each}
|
|
990
|
-
{#if Array.from(fieldConfigs.entries()).filter(([
|
|
990
|
+
{#if Array.from(fieldConfigs.entries()).filter(([_field, config]) => config.tab === 'custom').length === 0}
|
|
991
991
|
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
992
992
|
Drop fields here
|
|
993
993
|
</p>
|
|
@@ -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>
|