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,103 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { FieldSchema } from '$lib/types';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
fieldName: string;
|
|
9
|
+
field: FieldSchema;
|
|
10
|
+
value: any;
|
|
11
|
+
onChange: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { fieldName, field, value = $bindable(), onChange }: Props = $props();
|
|
15
|
+
|
|
16
|
+
const displayName = $derived(
|
|
17
|
+
field.original_name || fieldName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Generate unique ID for form control
|
|
21
|
+
const fieldId = $derived(`field-${fieldName}-${Math.random().toString(36).substr(2, 9)}`);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<div class="space-y-2">
|
|
25
|
+
<label for={fieldId} class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
26
|
+
{displayName}
|
|
27
|
+
{#if field.required}
|
|
28
|
+
<span class="text-red-500">*</span>
|
|
29
|
+
{/if}
|
|
30
|
+
</label>
|
|
31
|
+
|
|
32
|
+
{#if field.ui_type === 'select' && field.options}
|
|
33
|
+
<select
|
|
34
|
+
id={fieldId}
|
|
35
|
+
bind:value
|
|
36
|
+
disabled={!field.editable}
|
|
37
|
+
onchange={onChange}
|
|
38
|
+
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400"
|
|
39
|
+
>
|
|
40
|
+
<option value="">-- Select --</option>
|
|
41
|
+
{#each field.options as option}
|
|
42
|
+
<option value={option}>{option}</option>
|
|
43
|
+
{/each}
|
|
44
|
+
</select>
|
|
45
|
+
{:else if field.ui_type === 'textarea' || field.ui_type === 'long_text'}
|
|
46
|
+
<textarea
|
|
47
|
+
id={fieldId}
|
|
48
|
+
bind:value
|
|
49
|
+
disabled={!field.editable}
|
|
50
|
+
oninput={onChange}
|
|
51
|
+
rows="8"
|
|
52
|
+
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed resize-y min-h-[120px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400"
|
|
53
|
+
placeholder={field.examples?.[0] || ''}
|
|
54
|
+
></textarea>
|
|
55
|
+
{:else if field.ui_type === 'boolean'}
|
|
56
|
+
<div class="flex items-center">
|
|
57
|
+
<input
|
|
58
|
+
id={fieldId}
|
|
59
|
+
type="checkbox"
|
|
60
|
+
bind:checked={value}
|
|
61
|
+
disabled={!field.editable}
|
|
62
|
+
onchange={onChange}
|
|
63
|
+
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
64
|
+
/>
|
|
65
|
+
<label class="ml-2 text-sm text-gray-900 dark:text-gray-300" for={fieldId}>
|
|
66
|
+
{value ? 'Yes' : 'No'}
|
|
67
|
+
</label>
|
|
68
|
+
</div>
|
|
69
|
+
{:else if field.ui_type === 'date'}
|
|
70
|
+
<input
|
|
71
|
+
id={fieldId}
|
|
72
|
+
type="date"
|
|
73
|
+
bind:value
|
|
74
|
+
disabled={!field.editable}
|
|
75
|
+
onchange={onChange}
|
|
76
|
+
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400"
|
|
77
|
+
/>
|
|
78
|
+
{:else if field.ui_type === 'number'}
|
|
79
|
+
<input
|
|
80
|
+
id={fieldId}
|
|
81
|
+
type="number"
|
|
82
|
+
bind:value
|
|
83
|
+
disabled={!field.editable}
|
|
84
|
+
oninput={onChange}
|
|
85
|
+
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400"
|
|
86
|
+
/>
|
|
87
|
+
{:else}
|
|
88
|
+
<!-- Default to text input -->
|
|
89
|
+
<input
|
|
90
|
+
id={fieldId}
|
|
91
|
+
type="text"
|
|
92
|
+
bind:value
|
|
93
|
+
disabled={!field.editable}
|
|
94
|
+
oninput={onChange}
|
|
95
|
+
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400"
|
|
96
|
+
placeholder={field.examples?.[0] || ''}
|
|
97
|
+
/>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
{#if field.is_array}
|
|
101
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">This field supports multiple values</p>
|
|
102
|
+
{/if}
|
|
103
|
+
</div>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { FieldSchema } from '$lib/types';
|
|
6
|
+
import ProcessedTextRenderer from '../utils/ProcessedTextRenderer.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
fieldName: string;
|
|
10
|
+
field: FieldSchema | null;
|
|
11
|
+
value: any;
|
|
12
|
+
readonly?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { fieldName, field, value, readonly = true }: Props = $props();
|
|
16
|
+
|
|
17
|
+
const displayName = $derived(
|
|
18
|
+
field?.original_name || fieldName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Determine if this is a multiline text field that should be processed
|
|
22
|
+
const shouldProcessText = $derived(
|
|
23
|
+
field &&
|
|
24
|
+
(field.ui_type === 'textarea' || field.ui_type === 'long_text') &&
|
|
25
|
+
typeof value === 'string' &&
|
|
26
|
+
value.includes('\n')
|
|
27
|
+
);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="space-y-2">
|
|
31
|
+
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
32
|
+
{displayName}
|
|
33
|
+
</div>
|
|
34
|
+
<div class="text-gray-900 dark:text-white font-medium">
|
|
35
|
+
{#if value === null || value === undefined || value === ''}
|
|
36
|
+
<span class="text-gray-400 dark:text-gray-500 italic">Not specified</span>
|
|
37
|
+
{:else if typeof value === 'boolean'}
|
|
38
|
+
<span class="capitalize">{value ? 'Yes' : 'No'}</span>
|
|
39
|
+
{:else if Array.isArray(value)}
|
|
40
|
+
<span>{value.join(', ')}</span>
|
|
41
|
+
{:else if shouldProcessText}
|
|
42
|
+
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
|
43
|
+
<ProcessedTextRenderer text={value} />
|
|
44
|
+
</div>
|
|
45
|
+
{:else}
|
|
46
|
+
<span class="whitespace-pre-line">{value}</span>
|
|
47
|
+
{/if}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Control, FieldSchema } from '$lib/types';
|
|
6
|
+
import { EditableFieldRenderer } from '../renderers';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
control: Control;
|
|
10
|
+
fieldSchema: Record<string, FieldSchema>;
|
|
11
|
+
onFieldChange: (fieldName: string, value: any) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { control, fieldSchema, onFieldChange }: Props = $props();
|
|
15
|
+
|
|
16
|
+
// Get fields for custom tab
|
|
17
|
+
function getCustomFields(): Array<[string, FieldSchema]> {
|
|
18
|
+
return Object.entries(fieldSchema)
|
|
19
|
+
.filter(([_, field]) => {
|
|
20
|
+
const fieldTab = field.tab || getDefaultTabForCategory(field.category);
|
|
21
|
+
return fieldTab === 'custom' && field.visible;
|
|
22
|
+
})
|
|
23
|
+
.sort((a, b) => a[1].display_order - b[1].display_order);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDefaultTabForCategory(category: string): 'overview' | 'implementation' | 'custom' {
|
|
27
|
+
switch (category) {
|
|
28
|
+
case 'core':
|
|
29
|
+
case 'metadata':
|
|
30
|
+
return 'overview';
|
|
31
|
+
case 'compliance':
|
|
32
|
+
case 'content':
|
|
33
|
+
return 'implementation';
|
|
34
|
+
default:
|
|
35
|
+
return 'custom';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper to determine field layout class based on field type
|
|
40
|
+
function getFieldLayoutClass(field: FieldSchema): string {
|
|
41
|
+
// Textareas and long text fields get full width
|
|
42
|
+
if (field.ui_type === 'textarea' || field.ui_type === 'long_text') {
|
|
43
|
+
return 'col-span-full';
|
|
44
|
+
}
|
|
45
|
+
// Medium text fields also get full width
|
|
46
|
+
if (field.ui_type === 'medium_text' && field.max_length && field.max_length > 100) {
|
|
47
|
+
return 'col-span-full';
|
|
48
|
+
}
|
|
49
|
+
// Dropdowns and short fields can be side by side
|
|
50
|
+
if (
|
|
51
|
+
field.ui_type === 'select' ||
|
|
52
|
+
field.ui_type === 'boolean' ||
|
|
53
|
+
field.ui_type === 'date' ||
|
|
54
|
+
field.ui_type === 'number' ||
|
|
55
|
+
(field.ui_type === 'short_text' && field.max_length && field.max_length <= 50)
|
|
56
|
+
) {
|
|
57
|
+
return 'col-span-1';
|
|
58
|
+
}
|
|
59
|
+
// Default to full width for everything else
|
|
60
|
+
return 'col-span-full';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Helper to group fields by layout type
|
|
64
|
+
function groupFieldsForLayout(fields: Array<[string, FieldSchema]>) {
|
|
65
|
+
const groups: Array<Array<[string, FieldSchema]>> = [];
|
|
66
|
+
let currentGroup: Array<[string, FieldSchema]> = [];
|
|
67
|
+
|
|
68
|
+
for (const field of fields) {
|
|
69
|
+
const layoutClass = getFieldLayoutClass(field[1]);
|
|
70
|
+
|
|
71
|
+
if (layoutClass === 'col-span-full') {
|
|
72
|
+
// Full width fields go in their own group
|
|
73
|
+
if (currentGroup.length > 0) {
|
|
74
|
+
groups.push(currentGroup);
|
|
75
|
+
currentGroup = [];
|
|
76
|
+
}
|
|
77
|
+
groups.push([field]);
|
|
78
|
+
} else {
|
|
79
|
+
// Half width fields can be grouped
|
|
80
|
+
currentGroup.push(field);
|
|
81
|
+
if (currentGroup.length === 2) {
|
|
82
|
+
groups.push(currentGroup);
|
|
83
|
+
currentGroup = [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Add any remaining fields
|
|
89
|
+
if (currentGroup.length > 0) {
|
|
90
|
+
groups.push(currentGroup);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return groups;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const customFields = $derived(getCustomFields());
|
|
97
|
+
const fieldGroups = $derived(groupFieldsForLayout(customFields));
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<div class="space-y-8">
|
|
101
|
+
{#if customFields.length > 0}
|
|
102
|
+
<div class="space-y-4">
|
|
103
|
+
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6">
|
|
104
|
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
|
105
|
+
Additional fields specific to your organization
|
|
106
|
+
</div>
|
|
107
|
+
<div class="space-y-8">
|
|
108
|
+
{#each fieldGroups as fieldGroup}
|
|
109
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
110
|
+
{#each fieldGroup as [fieldName, field]}
|
|
111
|
+
<div class={getFieldLayoutClass(field)}>
|
|
112
|
+
<EditableFieldRenderer
|
|
113
|
+
{fieldName}
|
|
114
|
+
{field}
|
|
115
|
+
bind:value={control[fieldName]}
|
|
116
|
+
onChange={() => onFieldChange(fieldName, control[fieldName])}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
{/each}
|
|
120
|
+
</div>
|
|
121
|
+
{/each}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
{:else}
|
|
126
|
+
<div class="text-center py-12">
|
|
127
|
+
<p class="text-gray-500 dark:text-gray-400">No custom fields configured</p>
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
</div>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Control, FieldSchema } from '$lib/types';
|
|
6
|
+
import { FieldRenderer } from '../renderers';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
control: Control;
|
|
10
|
+
fieldSchema: Record<string, FieldSchema>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { control, fieldSchema }: Props = $props();
|
|
14
|
+
|
|
15
|
+
// Get fields for implementation tab
|
|
16
|
+
function getImplementationFields(): Array<[string, FieldSchema]> {
|
|
17
|
+
return Object.entries(fieldSchema)
|
|
18
|
+
.filter(([_, field]) => {
|
|
19
|
+
const fieldTab = field.tab || getDefaultTabForCategory(field.category);
|
|
20
|
+
return fieldTab === 'implementation' && field.visible;
|
|
21
|
+
})
|
|
22
|
+
.sort((a, b) => a[1].display_order - b[1].display_order);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getDefaultTabForCategory(category: string): 'overview' | 'implementation' | 'custom' {
|
|
26
|
+
switch (category) {
|
|
27
|
+
case 'core':
|
|
28
|
+
case 'metadata':
|
|
29
|
+
return 'overview';
|
|
30
|
+
case 'compliance':
|
|
31
|
+
case 'content':
|
|
32
|
+
return 'implementation';
|
|
33
|
+
default:
|
|
34
|
+
return 'custom';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to determine field layout class based on field type
|
|
39
|
+
function getFieldLayoutClass(field: FieldSchema): string {
|
|
40
|
+
// Textareas and long text fields get full width
|
|
41
|
+
if (field.ui_type === 'textarea' || field.ui_type === 'long_text') {
|
|
42
|
+
return 'col-span-full';
|
|
43
|
+
}
|
|
44
|
+
// Medium text fields also get full width
|
|
45
|
+
if (field.ui_type === 'medium_text' && field.max_length && field.max_length > 100) {
|
|
46
|
+
return 'col-span-full';
|
|
47
|
+
}
|
|
48
|
+
// Dropdowns and short fields can be side by side
|
|
49
|
+
if (
|
|
50
|
+
field.ui_type === 'select' ||
|
|
51
|
+
field.ui_type === 'boolean' ||
|
|
52
|
+
field.ui_type === 'date' ||
|
|
53
|
+
field.ui_type === 'number' ||
|
|
54
|
+
(field.ui_type === 'short_text' && field.max_length && field.max_length <= 50)
|
|
55
|
+
) {
|
|
56
|
+
return 'col-span-1';
|
|
57
|
+
}
|
|
58
|
+
// Default to full width for everything else
|
|
59
|
+
return 'col-span-full';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper to group fields by layout type
|
|
63
|
+
function groupFieldsForLayout(fields: Array<[string, FieldSchema]>) {
|
|
64
|
+
const groups: Array<Array<[string, FieldSchema]>> = [];
|
|
65
|
+
let currentGroup: Array<[string, FieldSchema]> = [];
|
|
66
|
+
|
|
67
|
+
for (const field of fields) {
|
|
68
|
+
const layoutClass = getFieldLayoutClass(field[1]);
|
|
69
|
+
|
|
70
|
+
if (layoutClass === 'col-span-full') {
|
|
71
|
+
// Full width fields go in their own group
|
|
72
|
+
if (currentGroup.length > 0) {
|
|
73
|
+
groups.push(currentGroup);
|
|
74
|
+
currentGroup = [];
|
|
75
|
+
}
|
|
76
|
+
groups.push([field]);
|
|
77
|
+
} else {
|
|
78
|
+
// Half width fields can be grouped
|
|
79
|
+
currentGroup.push(field);
|
|
80
|
+
if (currentGroup.length === 2) {
|
|
81
|
+
groups.push(currentGroup);
|
|
82
|
+
currentGroup = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add any remaining fields
|
|
88
|
+
if (currentGroup.length > 0) {
|
|
89
|
+
groups.push(currentGroup);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return groups;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const implementationFields = $derived(getImplementationFields());
|
|
96
|
+
const fieldGroups = $derived(groupFieldsForLayout(implementationFields));
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<!-- Readonly Implementation Tab with Clean Styling -->
|
|
100
|
+
<div class="space-y-8">
|
|
101
|
+
{#if implementationFields.length > 0}
|
|
102
|
+
<div class="space-y-4">
|
|
103
|
+
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6">
|
|
104
|
+
<div class="space-y-8">
|
|
105
|
+
{#each fieldGroups as fieldGroup}
|
|
106
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
107
|
+
{#each fieldGroup as [fieldName, field]}
|
|
108
|
+
<div class={getFieldLayoutClass(field)}>
|
|
109
|
+
<FieldRenderer
|
|
110
|
+
{fieldName}
|
|
111
|
+
{field}
|
|
112
|
+
value={control[fieldName]}
|
|
113
|
+
readonly={true}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
{/each}
|
|
117
|
+
</div>
|
|
118
|
+
{/each}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
{:else}
|
|
123
|
+
<div class="text-center py-12">
|
|
124
|
+
<p class="text-gray-500 dark:text-gray-400">No implementation fields available</p>
|
|
125
|
+
</div>
|
|
126
|
+
{/if}
|
|
127
|
+
</div>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Control, Mapping } from '$lib/types';
|
|
6
|
+
import { wsClient } from '$lib/websocket';
|
|
7
|
+
import { Add, Document } from 'carbon-icons-svelte';
|
|
8
|
+
import MappingCard from '../MappingCard.svelte';
|
|
9
|
+
import MappingForm from '../MappingForm.svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
control: Control;
|
|
13
|
+
mappings: Mapping[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { control, mappings }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// Component state
|
|
19
|
+
let showNewMappingForm = $state(false);
|
|
20
|
+
let editingMapping = $state<Mapping | null>(null);
|
|
21
|
+
|
|
22
|
+
// Form state
|
|
23
|
+
let newMappingData = $state({
|
|
24
|
+
justification: '',
|
|
25
|
+
status: 'planned' as 'planned' | 'implemented' | 'verified',
|
|
26
|
+
source_entries: [] as { location: string; shasum?: string }[]
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Event handlers
|
|
30
|
+
async function handleCreateMapping(data: typeof newMappingData) {
|
|
31
|
+
try {
|
|
32
|
+
const mappingData = {
|
|
33
|
+
control_id: control.id,
|
|
34
|
+
justification: data.justification,
|
|
35
|
+
status: data.status,
|
|
36
|
+
source_entries: data.source_entries,
|
|
37
|
+
uuid: '' // Will be generated by backend
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await wsClient.createMapping(mappingData);
|
|
41
|
+
resetMappingForm();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Failed to create mapping:', error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cancelNewMapping() {
|
|
48
|
+
resetMappingForm();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resetMappingForm() {
|
|
52
|
+
newMappingData = {
|
|
53
|
+
justification: '',
|
|
54
|
+
status: 'planned',
|
|
55
|
+
source_entries: []
|
|
56
|
+
};
|
|
57
|
+
showNewMappingForm = false;
|
|
58
|
+
editingMapping = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function startEditMapping(mapping: Mapping) {
|
|
62
|
+
editingMapping = { ...mapping };
|
|
63
|
+
showNewMappingForm = true;
|
|
64
|
+
newMappingData = {
|
|
65
|
+
justification: mapping.justification,
|
|
66
|
+
status: mapping.status,
|
|
67
|
+
source_entries: mapping.source_entries || []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handleUpdateMapping(data: typeof newMappingData) {
|
|
72
|
+
if (!editingMapping) return;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const updatedMapping = {
|
|
76
|
+
...editingMapping,
|
|
77
|
+
justification: data.justification,
|
|
78
|
+
status: data.status,
|
|
79
|
+
source_entries: data.source_entries
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
await wsClient.updateMapping(updatedMapping);
|
|
83
|
+
resetMappingForm();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Failed to update mapping:', error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleDeleteMapping(uuid: string) {
|
|
90
|
+
try {
|
|
91
|
+
await wsClient.deleteMapping(uuid);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Failed to delete mapping:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<div class="space-y-6">
|
|
99
|
+
<!-- Existing Mappings -->
|
|
100
|
+
{#if mappings.length > 0}
|
|
101
|
+
<div class="space-y-4">
|
|
102
|
+
{#each mappings as mapping}
|
|
103
|
+
{#if editingMapping && editingMapping.uuid === mapping.uuid}
|
|
104
|
+
<!-- Edit Form in place of the mapping being edited -->
|
|
105
|
+
<div
|
|
106
|
+
class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"
|
|
107
|
+
>
|
|
108
|
+
<MappingForm
|
|
109
|
+
initialData={newMappingData}
|
|
110
|
+
onSubmit={handleUpdateMapping}
|
|
111
|
+
onCancel={cancelNewMapping}
|
|
112
|
+
submitLabel="Update Mapping"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
{:else}
|
|
116
|
+
<MappingCard
|
|
117
|
+
{mapping}
|
|
118
|
+
showActions={true}
|
|
119
|
+
onEdit={startEditMapping}
|
|
120
|
+
onDelete={handleDeleteMapping}
|
|
121
|
+
/>
|
|
122
|
+
{/if}
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<!-- Add New Mapping Button or Form -->
|
|
127
|
+
{#if showNewMappingForm && !editingMapping}
|
|
128
|
+
<!-- New Mapping Form appears after the button -->
|
|
129
|
+
<div
|
|
130
|
+
class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"
|
|
131
|
+
>
|
|
132
|
+
<MappingForm
|
|
133
|
+
initialData={newMappingData}
|
|
134
|
+
onSubmit={handleCreateMapping}
|
|
135
|
+
onCancel={cancelNewMapping}
|
|
136
|
+
submitLabel="Create Mapping"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
{:else if !editingMapping}
|
|
140
|
+
<button
|
|
141
|
+
onclick={() => (showNewMappingForm = true)}
|
|
142
|
+
class="w-full p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl hover:border-blue-400 dark:hover:border-blue-500 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
|
143
|
+
>
|
|
144
|
+
<div class="flex items-center justify-center text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
|
145
|
+
<Add class="w-5 h-5 mr-2" />
|
|
146
|
+
<span class="text-sm font-medium">Add New Mapping</span>
|
|
147
|
+
</div>
|
|
148
|
+
</button>
|
|
149
|
+
{/if}
|
|
150
|
+
{:else}
|
|
151
|
+
{#if showNewMappingForm}
|
|
152
|
+
<!-- New Mapping Form for empty state -->
|
|
153
|
+
<div
|
|
154
|
+
class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"
|
|
155
|
+
>
|
|
156
|
+
<MappingForm
|
|
157
|
+
initialData={newMappingData}
|
|
158
|
+
onSubmit={handleCreateMapping}
|
|
159
|
+
onCancel={cancelNewMapping}
|
|
160
|
+
submitLabel="Create Mapping"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
{:else}
|
|
164
|
+
<!-- Empty State with integrated Add Button -->
|
|
165
|
+
<div class="text-center py-12">
|
|
166
|
+
<div class="mx-auto w-16 h-16 mb-4 text-gray-400 dark:text-gray-500">
|
|
167
|
+
<Document class="w-full h-full" />
|
|
168
|
+
</div>
|
|
169
|
+
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No mappings yet</h3>
|
|
170
|
+
<p class="text-gray-500 dark:text-gray-400 mb-6">Create your first mapping for this control.</p>
|
|
171
|
+
|
|
172
|
+
<button
|
|
173
|
+
onclick={() => (showNewMappingForm = true)}
|
|
174
|
+
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
175
|
+
>
|
|
176
|
+
<Add class="w-4 h-4 mr-2" />
|
|
177
|
+
Add New Mapping
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
{/if}
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|