lula2 0.0.5 → 0.0.6
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/README.md +291 -8
- package/dist/_app/env.js +1 -0
- package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
- package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
- package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
- package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
- package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
- package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
- package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
- package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
- package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
- package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
- package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
- package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
- package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
- package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
- package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
- package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
- package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
- package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
- package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
- package/dist/_app/version.json +1 -0
- package/dist/cli/commands/crawl.js +128 -0
- package/dist/cli/commands/ui.js +2769 -0
- package/dist/cli/commands/version.js +30 -0
- package/dist/cli/server/index.js +2713 -0
- package/dist/cli/server/server.js +2702 -0
- package/dist/cli/server/serverState.js +1199 -0
- package/dist/cli/server/spreadsheetRoutes.js +788 -0
- package/dist/cli/server/types.js +0 -0
- package/dist/cli/server/websocketServer.js +2625 -0
- package/dist/cli/utils/debug.js +24 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +38 -0
- package/dist/index.js +2924 -37
- package/dist/lula.png +0 -0
- package/dist/lula2 +2 -0
- package/package.json +120 -72
- package/src/app.css +192 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +13 -0
- package/src/lib/actions/fadeWhenScrollable.ts +39 -0
- package/src/lib/actions/modal.ts +230 -0
- package/src/lib/actions/tooltip.ts +82 -0
- package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
- package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
- package/src/lib/components/control-sets/index.ts +5 -0
- package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
- package/src/lib/components/controls/ControlsList.svelte +608 -0
- package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
- package/src/lib/components/controls/MappingCard.svelte +105 -0
- package/src/lib/components/controls/MappingForm.svelte +188 -0
- package/src/lib/components/controls/index.ts +9 -0
- package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
- package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
- package/src/lib/components/controls/renderers/index.ts +5 -0
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
- package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
- package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
- package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
- package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
- package/src/lib/components/controls/tabs/index.ts +8 -0
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
- package/src/lib/components/controls/utils/textProcessor.ts +164 -0
- package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
- package/src/lib/components/forms/DynamicField.svelte +494 -0
- package/src/lib/components/forms/FormField.svelte +107 -0
- package/src/lib/components/forms/index.ts +6 -0
- package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
- package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
- package/src/lib/components/setup/index.ts +5 -0
- package/src/lib/components/ui/Dropdown.svelte +107 -0
- package/src/lib/components/ui/EmptyState.svelte +80 -0
- package/src/lib/components/ui/FeatureToggle.svelte +50 -0
- package/src/lib/components/ui/SearchBar.svelte +73 -0
- package/src/lib/components/ui/StatusBadge.svelte +79 -0
- package/src/lib/components/ui/TabNavigation.svelte +48 -0
- package/src/lib/components/ui/Tooltip.svelte +120 -0
- package/src/lib/components/ui/index.ts +10 -0
- package/src/lib/components/version-control/DiffViewer.svelte +292 -0
- package/src/lib/components/version-control/TimelineItem.svelte +107 -0
- package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
- package/src/lib/components/version-control/index.ts +6 -0
- package/src/lib/form-types.ts +57 -0
- package/src/lib/formatUtils.ts +17 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/types.ts +180 -0
- package/src/lib/websocket.ts +359 -0
- package/src/routes/+layout.svelte +236 -0
- package/src/routes/+page.svelte +38 -0
- package/src/routes/control/[id]/+page.svelte +112 -0
- package/src/routes/setup/+page.svelte +241 -0
- package/src/stores/compliance.ts +95 -0
- package/src/styles/highlightjs.css +20 -0
- package/src/styles/modal.css +58 -0
- package/src/styles/tables.css +111 -0
- package/src/styles/tooltip.css +65 -0
- package/dist/controls/index.d.ts +0 -18
- package/dist/controls/index.d.ts.map +0 -1
- package/dist/controls/index.js +0 -18
- package/dist/crawl.d.ts +0 -62
- package/dist/crawl.d.ts.map +0 -1
- package/dist/crawl.js +0 -172
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/src/controls/index.ts +0 -19
- package/src/crawl.ts +0 -227
- package/src/index.ts +0 -46
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { goto } from '$app/navigation';
|
|
6
|
+
import { page } from '$app/stores';
|
|
7
|
+
import { Dropdown, SearchBar, Tooltip } from '$components/ui';
|
|
8
|
+
import type { Control, FieldSchema } from '$lib/types';
|
|
9
|
+
import { appState } from '$lib/websocket';
|
|
10
|
+
import { complianceStore, searchTerm, selectedFamily } from '$stores/compliance';
|
|
11
|
+
import { Filter, Information } from 'carbon-icons-svelte';
|
|
12
|
+
import { derived } from 'svelte/store';
|
|
13
|
+
|
|
14
|
+
// Derive controls and families from appState
|
|
15
|
+
const controls = derived(appState, ($state) => $state.controls || []);
|
|
16
|
+
const families = derived(appState, ($state) => $state.families || []);
|
|
17
|
+
const loading = derived(appState, ($state) => !$state.isConnected);
|
|
18
|
+
|
|
19
|
+
// Derive controls with mappings
|
|
20
|
+
const controlsWithMappings = derived(appState, ($state) => {
|
|
21
|
+
return ($state.controls || []).map((control) => ({
|
|
22
|
+
...control,
|
|
23
|
+
mappings: ($state.mappings || []).filter((m) => m.control_id === control.id)
|
|
24
|
+
}));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Use compliance store for search and family filter - stores are imported directly
|
|
28
|
+
|
|
29
|
+
// Track selected control based on current route
|
|
30
|
+
let selectedControlId: string | null = null;
|
|
31
|
+
$: selectedControlId = $page.params.id || null;
|
|
32
|
+
|
|
33
|
+
// Dynamic field schema for table columns
|
|
34
|
+
let fieldSchema: Record<string, FieldSchema> = {};
|
|
35
|
+
let tableColumns: Array<{ fieldName: string; field: FieldSchema }> = [];
|
|
36
|
+
let schemaLoading = true;
|
|
37
|
+
|
|
38
|
+
// Watch for control set changes
|
|
39
|
+
$: if ($appState.name && $appState.name !== 'Unknown Control Set') {
|
|
40
|
+
const schema = $appState.fieldSchema || $appState.field_schema;
|
|
41
|
+
if (schema?.fields) {
|
|
42
|
+
fieldSchema = schema.fields;
|
|
43
|
+
// Get overview tab fields for table display
|
|
44
|
+
const overviewFields = Object.entries(fieldSchema)
|
|
45
|
+
.filter(([fieldName, field]) => {
|
|
46
|
+
// Use overview tab fields for the table
|
|
47
|
+
const fieldTab = field.tab || getDefaultTabForCategory(field.category);
|
|
48
|
+
// Include visible overview fields, but skip family as it's redundant
|
|
49
|
+
return fieldTab === 'overview' && field.visible && fieldName !== 'family';
|
|
50
|
+
})
|
|
51
|
+
.sort((a, b) => a[1].display_order - b[1].display_order);
|
|
52
|
+
|
|
53
|
+
// Get the control ID field from metadata or default to 'id'
|
|
54
|
+
const controlIdFieldName = $appState.control_id_field || 'id';
|
|
55
|
+
|
|
56
|
+
// Always include control ID column first
|
|
57
|
+
const idField = overviewFields.find(([name]) => name === controlIdFieldName || name === 'id');
|
|
58
|
+
const otherFields = overviewFields.filter(
|
|
59
|
+
([name]) => name !== controlIdFieldName && name !== 'id'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// If control ID field doesn't exist in schema, create a default one
|
|
63
|
+
const idColumn = idField
|
|
64
|
+
? { fieldName: idField[0], field: idField[1] }
|
|
65
|
+
: {
|
|
66
|
+
fieldName: 'id',
|
|
67
|
+
field: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
ui_type: 'short_text' as const,
|
|
70
|
+
is_array: false,
|
|
71
|
+
required: true,
|
|
72
|
+
visible: true,
|
|
73
|
+
editable: false,
|
|
74
|
+
display_order: 0,
|
|
75
|
+
category: 'core' as const,
|
|
76
|
+
display_name: 'Control ID',
|
|
77
|
+
original_name: 'Control ID'
|
|
78
|
+
} as FieldSchema
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Combine ID first, then other fields sorted by display_order
|
|
82
|
+
// Take up to 5 additional fields after the ID for a total of 6 columns
|
|
83
|
+
tableColumns = [
|
|
84
|
+
idColumn,
|
|
85
|
+
...otherFields.slice(0, 5).map(([fieldName, field]) => ({ fieldName, field }))
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
schemaLoading = false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Helper function to map category to tab (same logic as ControlDetailsPanel)
|
|
92
|
+
function getDefaultTabForCategory(category: string): 'overview' | 'implementation' | 'custom' {
|
|
93
|
+
switch (category) {
|
|
94
|
+
case 'core':
|
|
95
|
+
case 'metadata':
|
|
96
|
+
return 'overview';
|
|
97
|
+
case 'compliance':
|
|
98
|
+
case 'content':
|
|
99
|
+
return 'implementation';
|
|
100
|
+
default:
|
|
101
|
+
return 'custom';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Create filtered controls with mappings
|
|
106
|
+
const filteredControlsWithMappings = derived(
|
|
107
|
+
[controlsWithMappings, selectedFamily, searchTerm],
|
|
108
|
+
([$controlsWithMappings, $selectedFamily, $searchTerm]) => {
|
|
109
|
+
let results = $controlsWithMappings;
|
|
110
|
+
|
|
111
|
+
if ($selectedFamily) {
|
|
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
|
+
|
|
122
|
+
if ($searchTerm) {
|
|
123
|
+
const term = $searchTerm.toLowerCase();
|
|
124
|
+
results = results.filter((c) => JSON.stringify(c).toLowerCase().includes(term));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
function selectControl(control: Control) {
|
|
132
|
+
goto(`/control/${encodeURIComponent(control.id)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getStatusBadgeClass(status: string) {
|
|
136
|
+
switch (status) {
|
|
137
|
+
case 'Implemented':
|
|
138
|
+
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
|
|
139
|
+
case 'Planned':
|
|
140
|
+
return 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300';
|
|
141
|
+
case 'Not Implemented':
|
|
142
|
+
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
|
143
|
+
default:
|
|
144
|
+
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getComplianceBadgeClass(status: string) {
|
|
149
|
+
switch (status) {
|
|
150
|
+
case 'Compliant':
|
|
151
|
+
return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300';
|
|
152
|
+
case 'Non-Compliant':
|
|
153
|
+
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
|
154
|
+
case 'Not Assessed':
|
|
155
|
+
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
|
|
156
|
+
default:
|
|
157
|
+
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractDescriptionFromNested(data: any): string {
|
|
162
|
+
if (typeof data === 'string') {
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(data)) {
|
|
166
|
+
// Try to find the first string in the array or extract from nested objects
|
|
167
|
+
for (const item of data) {
|
|
168
|
+
if (typeof item === 'string') {
|
|
169
|
+
return item;
|
|
170
|
+
}
|
|
171
|
+
if (typeof item === 'object' && item !== null) {
|
|
172
|
+
// Extract the first text from nested objects
|
|
173
|
+
for (const [key, value] of Object.entries(item)) {
|
|
174
|
+
if (Array.isArray(value)) {
|
|
175
|
+
const firstText = extractDescriptionFromNested(value);
|
|
176
|
+
if (firstText && typeof firstText === 'string') {
|
|
177
|
+
return firstText;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Return the key as it's usually meaningful text
|
|
181
|
+
return key.replace(/:$/, ''); // Remove trailing colon
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return 'No description available';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Helper to determine if a field should be truncated and show tooltip
|
|
190
|
+
function shouldTruncateField(field: FieldSchema | undefined, value: string): boolean {
|
|
191
|
+
if (!value) return false;
|
|
192
|
+
|
|
193
|
+
// Always show tooltip for textarea fields if content is long
|
|
194
|
+
if (field?.ui_type === 'textarea' || field?.ui_type === 'long_text') {
|
|
195
|
+
return value.length > 100; // Lower threshold for long text fields
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// For short_text fields, only show if really long
|
|
199
|
+
if (field?.ui_type === 'short_text') {
|
|
200
|
+
return value.length > 200;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Default for unknown field types
|
|
204
|
+
return value.length > 150;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Get truncation length based on field type
|
|
208
|
+
function getTruncationLength(field: FieldSchema | undefined): number {
|
|
209
|
+
if (field?.ui_type === 'textarea' || field?.ui_type === 'long_text') {
|
|
210
|
+
return 100;
|
|
211
|
+
}
|
|
212
|
+
if (field?.ui_type === 'short_text') {
|
|
213
|
+
return 200;
|
|
214
|
+
}
|
|
215
|
+
return 150;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check if field should show only icon (for textarea/long_text fields)
|
|
219
|
+
function isLongTextField(field: FieldSchema | undefined): boolean {
|
|
220
|
+
return field?.ui_type === 'textarea' || field?.ui_type === 'long_text';
|
|
221
|
+
}
|
|
222
|
+
</script>
|
|
223
|
+
|
|
224
|
+
<div class="h-full flex flex-col">
|
|
225
|
+
{#if $loading || schemaLoading}
|
|
226
|
+
<!-- Loading state -->
|
|
227
|
+
<div class="flex-1 flex items-center justify-center">
|
|
228
|
+
<div class="text-center">
|
|
229
|
+
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
230
|
+
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading controls...</p>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
{:else}
|
|
234
|
+
<!-- Compact Header with Controls and Search -->
|
|
235
|
+
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 space-y-4">
|
|
236
|
+
<!-- Title and Count -->
|
|
237
|
+
<div class="flex items-center justify-between">
|
|
238
|
+
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
239
|
+
{$appState.title || $appState.name || 'Controls'}
|
|
240
|
+
</h2>
|
|
241
|
+
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
242
|
+
{$filteredControlsWithMappings.length} of {$controls.length}
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- Search Bar, Family Filter, and Export -->
|
|
247
|
+
<div class="flex gap-3">
|
|
248
|
+
<div class="flex-1">
|
|
249
|
+
<SearchBar />
|
|
250
|
+
</div>
|
|
251
|
+
<div class="flex-shrink-0">
|
|
252
|
+
<Dropdown
|
|
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>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<!-- Controls Table -->
|
|
307
|
+
<div class="flex-1 flex flex-col overflow-hidden">
|
|
308
|
+
<!-- Fixed Table Header -->
|
|
309
|
+
<div
|
|
310
|
+
class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-600 flex-shrink-0"
|
|
311
|
+
>
|
|
312
|
+
{#if tableColumns.length > 0}
|
|
313
|
+
<!-- Dynamic columns based on field schema -->
|
|
314
|
+
<div
|
|
315
|
+
class="grid gap-4 px-6 py-3"
|
|
316
|
+
style="grid-template-columns: repeat({tableColumns.length + 1}, minmax(0, 1fr)); max-width: 100%;"
|
|
317
|
+
>
|
|
318
|
+
{#each tableColumns as { fieldName, field }}
|
|
319
|
+
<div
|
|
320
|
+
class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
321
|
+
>
|
|
322
|
+
{field.original_name ||
|
|
323
|
+
(fieldName === 'id' ? 'Control ID' : fieldName.replace(/-/g, ' '))}
|
|
324
|
+
</div>
|
|
325
|
+
{/each}
|
|
326
|
+
<div
|
|
327
|
+
class="text-center text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
328
|
+
>
|
|
329
|
+
Mappings
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
{:else}
|
|
333
|
+
<!-- Fallback to default columns -->
|
|
334
|
+
<div class="grid grid-cols-5 gap-4 px-6 py-3">
|
|
335
|
+
<div
|
|
336
|
+
class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
337
|
+
>
|
|
338
|
+
Control
|
|
339
|
+
</div>
|
|
340
|
+
<div
|
|
341
|
+
class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
342
|
+
>
|
|
343
|
+
Title
|
|
344
|
+
</div>
|
|
345
|
+
<div
|
|
346
|
+
class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
347
|
+
>
|
|
348
|
+
Statement
|
|
349
|
+
</div>
|
|
350
|
+
<div
|
|
351
|
+
class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
352
|
+
>
|
|
353
|
+
Family
|
|
354
|
+
</div>
|
|
355
|
+
<div
|
|
356
|
+
class="text-center text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
|
|
357
|
+
>
|
|
358
|
+
Mappings
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
{/if}
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<!-- Scrollable Table Body -->
|
|
365
|
+
<div class="flex-1 overflow-auto">
|
|
366
|
+
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
367
|
+
{#each $filteredControlsWithMappings as control}
|
|
368
|
+
{@const rawDescription = (() => {
|
|
369
|
+
// Cast control to any to allow dynamic field access
|
|
370
|
+
const anyControl = control as any;
|
|
371
|
+
// Try to find a description from any text field in the schema
|
|
372
|
+
if (fieldSchema) {
|
|
373
|
+
for (const [fieldName, field] of Object.entries(fieldSchema)) {
|
|
374
|
+
if ((field.ui_type === 'long_text' || field.ui_type === 'short_text') && anyControl[fieldName]) {
|
|
375
|
+
return anyControl[fieldName];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Fallback to any field that contains text
|
|
380
|
+
for (const [key, value] of Object.entries(control)) {
|
|
381
|
+
if (typeof value === 'string' && value.length > 20 && !key.startsWith('_')) {
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return 'No description available';
|
|
386
|
+
})()}
|
|
387
|
+
{@const description = extractDescriptionFromNested(rawDescription) || ''}
|
|
388
|
+
{@const cleanDescription = description
|
|
389
|
+
? description
|
|
390
|
+
.replace(/^(a\.|b\.|1\.|2\.|\s|The organization:)+/, '')
|
|
391
|
+
.replace(/\s+/g, ' ')
|
|
392
|
+
.trim()
|
|
393
|
+
.substring(0, 200) + (description.length > 200 ? '...' : '')
|
|
394
|
+
: 'No description available'}
|
|
395
|
+
{#if tableColumns.length > 0}
|
|
396
|
+
<!-- Dynamic columns based on field schema -->
|
|
397
|
+
<div
|
|
398
|
+
class="grid gap-4 px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-all duration-150 {selectedControlId ===
|
|
399
|
+
control.id
|
|
400
|
+
? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 shadow-sm'
|
|
401
|
+
: ''}"
|
|
402
|
+
style="grid-template-columns: repeat({tableColumns.length + 1}, minmax(0, 1fr)); max-width: 100%;"
|
|
403
|
+
onclick={() => selectControl(control)}
|
|
404
|
+
onkeydown={(e) =>
|
|
405
|
+
e.key === 'Enter' || e.key === ' ' ? selectControl(control) : null}
|
|
406
|
+
role="button"
|
|
407
|
+
tabindex="0"
|
|
408
|
+
aria-label="Select control {control.id}"
|
|
409
|
+
>
|
|
410
|
+
{#each tableColumns as { fieldName, field }}
|
|
411
|
+
{@const value = (control as any)[fieldName]}
|
|
412
|
+
<div class="flex flex-col justify-center">
|
|
413
|
+
{#if field.ui_type === 'select' && value}
|
|
414
|
+
<span
|
|
415
|
+
class="inline-flex px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 w-fit"
|
|
416
|
+
>
|
|
417
|
+
{value}
|
|
418
|
+
</span>
|
|
419
|
+
{:else if field.ui_type === 'boolean'}
|
|
420
|
+
<span class="text-sm text-gray-900 dark:text-white">
|
|
421
|
+
{value ? 'Yes' : 'No'}
|
|
422
|
+
</span>
|
|
423
|
+
{:else if typeof value === 'string' && value}
|
|
424
|
+
{@const isLongField = isLongTextField(field)}
|
|
425
|
+
{@const truncLength = getTruncationLength(field)}
|
|
426
|
+
{@const needsTruncation = value.length > truncLength}
|
|
427
|
+
{@const displayValue = needsTruncation ? value.substring(0, truncLength) + '...' : value}
|
|
428
|
+
{@const previewText = value.substring(0, 600) + (value.length > 600 ? '...' : '')}
|
|
429
|
+
{#if isLongField && value}
|
|
430
|
+
<!-- For textarea/long_text fields, show only icon with tooltip -->
|
|
431
|
+
<div class="flex items-center">
|
|
432
|
+
<Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
|
|
433
|
+
<Information size={20} class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 cursor-help" />
|
|
434
|
+
</Tooltip>
|
|
435
|
+
</div>
|
|
436
|
+
{:else if needsTruncation}
|
|
437
|
+
<!-- For other fields that are truncated, show text with icon -->
|
|
438
|
+
<div class="flex items-start gap-1">
|
|
439
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
|
|
440
|
+
{displayValue}
|
|
441
|
+
</div>
|
|
442
|
+
<Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
|
|
443
|
+
<Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
|
|
444
|
+
</Tooltip>
|
|
445
|
+
</div>
|
|
446
|
+
{:else}
|
|
447
|
+
<!-- For short text, show as is -->
|
|
448
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2">
|
|
449
|
+
{value}
|
|
450
|
+
</div>
|
|
451
|
+
{/if}
|
|
452
|
+
{:else if Array.isArray(value)}
|
|
453
|
+
{@const extractedText = extractDescriptionFromNested(value)}
|
|
454
|
+
{@const isLongField = isLongTextField(field)}
|
|
455
|
+
{@const truncLength = getTruncationLength(field)}
|
|
456
|
+
{@const needsTruncation = extractedText.length > truncLength}
|
|
457
|
+
{@const displayValue = needsTruncation ? extractedText.substring(0, truncLength) + '...' : extractedText}
|
|
458
|
+
{@const previewText = extractedText.substring(0, 600) + (extractedText.length > 600 ? '...' : '')}
|
|
459
|
+
{#if isLongField && extractedText}
|
|
460
|
+
<!-- For textarea/long_text fields, show only icon with tooltip -->
|
|
461
|
+
<div class="flex items-center">
|
|
462
|
+
<Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
|
|
463
|
+
<Information size={20} class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 cursor-help" />
|
|
464
|
+
</Tooltip>
|
|
465
|
+
</div>
|
|
466
|
+
{:else if needsTruncation}
|
|
467
|
+
<!-- For other fields that are truncated, show text with icon -->
|
|
468
|
+
<div class="flex items-start gap-1">
|
|
469
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
|
|
470
|
+
{displayValue}
|
|
471
|
+
</div>
|
|
472
|
+
<Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
|
|
473
|
+
<Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
|
|
474
|
+
</Tooltip>
|
|
475
|
+
</div>
|
|
476
|
+
{:else}
|
|
477
|
+
<!-- For short text, show as is -->
|
|
478
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2">
|
|
479
|
+
{extractedText}
|
|
480
|
+
</div>
|
|
481
|
+
{/if}
|
|
482
|
+
{:else if value}
|
|
483
|
+
<div class="text-sm text-gray-900 dark:text-white">
|
|
484
|
+
{JSON.stringify(value)}
|
|
485
|
+
</div>
|
|
486
|
+
{:else}
|
|
487
|
+
<span class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
|
488
|
+
{/if}
|
|
489
|
+
</div>
|
|
490
|
+
{/each}
|
|
491
|
+
<!-- Mappings Column -->
|
|
492
|
+
<div class="flex items-center justify-center">
|
|
493
|
+
<span
|
|
494
|
+
class="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300"
|
|
495
|
+
>
|
|
496
|
+
{control.mappings?.length || 0}
|
|
497
|
+
</span>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
{:else}
|
|
501
|
+
{@const statementIsLongText = cleanDescription.length > 120}
|
|
502
|
+
<!-- Fallback to default columns -->
|
|
503
|
+
<div
|
|
504
|
+
class="grid grid-cols-5 gap-4 px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-all duration-150 {selectedControlId ===
|
|
505
|
+
control.id
|
|
506
|
+
? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 shadow-sm'
|
|
507
|
+
: ''}"
|
|
508
|
+
onclick={() => selectControl(control)}
|
|
509
|
+
onkeydown={(e) =>
|
|
510
|
+
e.key === 'Enter' || e.key === ' ' ? selectControl(control) : null}
|
|
511
|
+
role="button"
|
|
512
|
+
tabindex="0"
|
|
513
|
+
aria-label="Select control {control.id}"
|
|
514
|
+
>
|
|
515
|
+
<!-- Control Column -->
|
|
516
|
+
<div class="flex flex-col justify-center">
|
|
517
|
+
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
518
|
+
{control.id}
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
<!-- Title Column -->
|
|
522
|
+
<div class="flex flex-col justify-center">
|
|
523
|
+
<div class="text-sm text-gray-900 dark:text-white font-medium">
|
|
524
|
+
{control.title || 'No Title'}
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
<!-- Statement Column -->
|
|
528
|
+
<div class="flex flex-col justify-center">
|
|
529
|
+
{#if statementIsLongText}
|
|
530
|
+
<div class="flex items-start gap-1">
|
|
531
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
|
|
532
|
+
{cleanDescription.substring(0, 120)}...
|
|
533
|
+
</div>
|
|
534
|
+
<Tooltip content={cleanDescription.substring(0, 400) + (cleanDescription.length > 400 ? '...' : '')} placement="bottom" maxWidth="400px" multiline={true}>
|
|
535
|
+
<Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
|
|
536
|
+
</Tooltip>
|
|
537
|
+
</div>
|
|
538
|
+
{:else}
|
|
539
|
+
<div class="text-sm text-gray-900 dark:text-white line-clamp-2">
|
|
540
|
+
{cleanDescription}
|
|
541
|
+
</div>
|
|
542
|
+
{/if}
|
|
543
|
+
</div>
|
|
544
|
+
<!-- Family Column -->
|
|
545
|
+
<div class="flex items-center justify-center">
|
|
546
|
+
<span
|
|
547
|
+
class="inline-flex px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
|
548
|
+
>
|
|
549
|
+
{(control.family || control.id?.split('-')[0] || '').toUpperCase()}
|
|
550
|
+
</span>
|
|
551
|
+
</div>
|
|
552
|
+
<!-- Mappings Column -->
|
|
553
|
+
<div class="flex items-center justify-center">
|
|
554
|
+
<span
|
|
555
|
+
class="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300"
|
|
556
|
+
>
|
|
557
|
+
{control.mappings?.length || 0}
|
|
558
|
+
</span>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
{/if}
|
|
562
|
+
{/each}
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
|
|
567
|
+
{#if $filteredControlsWithMappings.length === 0}
|
|
568
|
+
<div class="flex items-center justify-center py-16">
|
|
569
|
+
<div class="text-center">
|
|
570
|
+
<svg
|
|
571
|
+
class="mx-auto h-16 w-16 text-gray-300 dark:text-gray-600"
|
|
572
|
+
fill="none"
|
|
573
|
+
viewBox="0 0 24 24"
|
|
574
|
+
stroke="currentColor"
|
|
575
|
+
>
|
|
576
|
+
<path
|
|
577
|
+
stroke-linecap="round"
|
|
578
|
+
stroke-linejoin="round"
|
|
579
|
+
stroke-width="1.5"
|
|
580
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
581
|
+
/>
|
|
582
|
+
</svg>
|
|
583
|
+
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No controls found</h3>
|
|
584
|
+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
|
585
|
+
{#if $searchTerm}
|
|
586
|
+
No controls match your search criteria. Try adjusting your search terms or clearing
|
|
587
|
+
filters.
|
|
588
|
+
{:else if $selectedFamily}
|
|
589
|
+
No controls available in this family. Select a different family or check your data.
|
|
590
|
+
{:else if $controls.length === 0}
|
|
591
|
+
No controls have been imported yet.
|
|
592
|
+
{:else}
|
|
593
|
+
No controls available. Select a different family or check your data.
|
|
594
|
+
{/if}
|
|
595
|
+
</p>
|
|
596
|
+
{#if $controls.length === 0}
|
|
597
|
+
<a
|
|
598
|
+
href="/setup"
|
|
599
|
+
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
|
600
|
+
>
|
|
601
|
+
Import Controls Now
|
|
602
|
+
</a>
|
|
603
|
+
{/if}
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
{/if}
|
|
607
|
+
{/if}
|
|
608
|
+
</div>
|