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,230 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Lula Authors
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Modal action for Svelte components
|
|
6
|
+
* Creates a modal dialog that appears when the trigger element is clicked
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Types for the modal action
|
|
10
|
+
type ModalOptions = {
|
|
11
|
+
openOnInit?: boolean; // Whether to open the modal immediately when component mounts
|
|
12
|
+
closeOnEscape?: boolean; // Whether to close on Escape key
|
|
13
|
+
closeOnOutsideClick?: boolean; // Whether to close when clicking outside
|
|
14
|
+
onOpen?: () => void; // Callback for when modal opens
|
|
15
|
+
onClose?: () => void; // Callback for when modal closes
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a click-to-open modal from an element with a .modal-content child
|
|
20
|
+
* @param node The HTML element to attach the modal trigger to
|
|
21
|
+
* @param options Configuration options
|
|
22
|
+
* @returns Svelte action object
|
|
23
|
+
*/
|
|
24
|
+
// Store a map of modal nodes to their closeModal functions for external access
|
|
25
|
+
const modalRegistry = new Map<string, () => void>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Closes a modal by its ID
|
|
29
|
+
* @param modalId The ID of the modal to close
|
|
30
|
+
*/
|
|
31
|
+
export function closeModalById(modalId: string): void {
|
|
32
|
+
const closeFunction = modalRegistry.get(modalId);
|
|
33
|
+
if (closeFunction) {
|
|
34
|
+
closeFunction();
|
|
35
|
+
} else {
|
|
36
|
+
console.warn(`No modal with ID ${modalId} found in registry`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function modal(node: HTMLElement, options: ModalOptions = {}) {
|
|
41
|
+
const defaults: ModalOptions = {
|
|
42
|
+
openOnInit: false,
|
|
43
|
+
closeOnEscape: true,
|
|
44
|
+
closeOnOutsideClick: true
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Merge defaults with provided options
|
|
48
|
+
const settings = { ...defaults, ...options };
|
|
49
|
+
|
|
50
|
+
// Find modal content element
|
|
51
|
+
const modalContent = node.querySelector('.modal-content') as HTMLElement;
|
|
52
|
+
// Use the parent element of the node as the trigger element
|
|
53
|
+
const triggerElement = node.parentElement as HTMLElement;
|
|
54
|
+
|
|
55
|
+
if (!modalContent) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!triggerElement) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Create backdrop/overlay element
|
|
64
|
+
const backdrop = document.createElement('div');
|
|
65
|
+
backdrop.className = 'modal-backdrop';
|
|
66
|
+
backdrop.style.display = 'none';
|
|
67
|
+
document.body.appendChild(backdrop);
|
|
68
|
+
|
|
69
|
+
// Set initial ARIA attributes
|
|
70
|
+
modalContent.setAttribute('role', 'dialog');
|
|
71
|
+
modalContent.setAttribute('aria-modal', 'true');
|
|
72
|
+
modalContent.setAttribute('aria-hidden', 'true');
|
|
73
|
+
|
|
74
|
+
// State to track if modal is open
|
|
75
|
+
let isOpen = false;
|
|
76
|
+
|
|
77
|
+
// Function to open the modal
|
|
78
|
+
const openModal = () => {
|
|
79
|
+
if (isOpen) return;
|
|
80
|
+
|
|
81
|
+
isOpen = true;
|
|
82
|
+
// Move modal content to body to avoid stacking context issues
|
|
83
|
+
document.body.appendChild(modalContent);
|
|
84
|
+
modalContent.setAttribute('aria-hidden', 'false');
|
|
85
|
+
backdrop.style.display = 'block';
|
|
86
|
+
|
|
87
|
+
// Prevent scrolling on the body
|
|
88
|
+
document.body.style.overflow = 'hidden';
|
|
89
|
+
|
|
90
|
+
// Call onOpen callback if provided
|
|
91
|
+
if (settings.onOpen) settings.onOpen();
|
|
92
|
+
|
|
93
|
+
// Focus first focusable element in modal
|
|
94
|
+
const focusableElements = modalContent.querySelectorAll(
|
|
95
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
96
|
+
);
|
|
97
|
+
if (focusableElements.length > 0) {
|
|
98
|
+
(focusableElements[0] as HTMLElement).focus();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Function to close the modal
|
|
103
|
+
const closeModal = () => {
|
|
104
|
+
if (!isOpen) return;
|
|
105
|
+
|
|
106
|
+
isOpen = false;
|
|
107
|
+
modalContent.setAttribute('aria-hidden', 'true');
|
|
108
|
+
backdrop.style.display = 'none';
|
|
109
|
+
|
|
110
|
+
// Move modal back to original position
|
|
111
|
+
if (document.body.contains(modalContent)) {
|
|
112
|
+
node.appendChild(modalContent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Restore scrolling on body
|
|
116
|
+
document.body.style.overflow = '';
|
|
117
|
+
|
|
118
|
+
// Call onClose callback if provided
|
|
119
|
+
if (settings.onClose) settings.onClose();
|
|
120
|
+
|
|
121
|
+
// Return focus to trigger element
|
|
122
|
+
if (triggerElement) {
|
|
123
|
+
triggerElement.focus();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Register the modal in our registry if it has an ID
|
|
128
|
+
const modalId = node.getAttribute('id');
|
|
129
|
+
if (modalId) {
|
|
130
|
+
modalRegistry.set(modalId, closeModal);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Handle click on trigger to open modal
|
|
134
|
+
const handleTriggerClick = (e: Event) => {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
openModal();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Handle click on backdrop to close modal if closeOnOutsideClick is true
|
|
141
|
+
const handleBackdropClick = (e: MouseEvent) => {
|
|
142
|
+
if (!settings.closeOnOutsideClick) return;
|
|
143
|
+
|
|
144
|
+
// Only close if the click was directly on the backdrop, not on modal content
|
|
145
|
+
if (e.target === backdrop) {
|
|
146
|
+
closeModal();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Handle escape key to close modal if closeOnEscape is true
|
|
151
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
152
|
+
if (!settings.closeOnEscape) return;
|
|
153
|
+
|
|
154
|
+
if (e.key === 'Escape' && isOpen) {
|
|
155
|
+
closeModal();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Add close button to modal
|
|
160
|
+
const closeButton = document.createElement('button');
|
|
161
|
+
closeButton.className = 'modal-close';
|
|
162
|
+
closeButton.innerHTML = '×';
|
|
163
|
+
closeButton.setAttribute('aria-label', 'Close modal');
|
|
164
|
+
modalContent.appendChild(closeButton);
|
|
165
|
+
|
|
166
|
+
// Handle click on close button
|
|
167
|
+
const handleCloseClick = (e: Event) => {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
closeModal();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Set up event listeners
|
|
174
|
+
triggerElement.addEventListener('click', handleTriggerClick);
|
|
175
|
+
|
|
176
|
+
closeButton.addEventListener('click', handleCloseClick);
|
|
177
|
+
backdrop.addEventListener('click', handleBackdropClick);
|
|
178
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
179
|
+
|
|
180
|
+
// Open modal on init if specified
|
|
181
|
+
if (settings.openOnInit) {
|
|
182
|
+
setTimeout(openModal, 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
update(newOptions: ModalOptions) {
|
|
187
|
+
// Update settings
|
|
188
|
+
Object.assign(settings, newOptions);
|
|
189
|
+
},
|
|
190
|
+
destroy() {
|
|
191
|
+
// Clean up event listeners
|
|
192
|
+
triggerElement.removeEventListener('click', handleTriggerClick);
|
|
193
|
+
|
|
194
|
+
closeButton.removeEventListener('click', handleCloseClick);
|
|
195
|
+
backdrop.removeEventListener('click', handleBackdropClick);
|
|
196
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
197
|
+
|
|
198
|
+
// Remove backdrop from DOM
|
|
199
|
+
if (document.body.contains(backdrop)) {
|
|
200
|
+
document.body.removeChild(backdrop);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Return modal to original position if needed
|
|
204
|
+
if (document.body.contains(modalContent)) {
|
|
205
|
+
node.appendChild(modalContent);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Remove close button from modal
|
|
209
|
+
if (modalContent.contains(closeButton)) {
|
|
210
|
+
modalContent.removeChild(closeButton);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reset attributes
|
|
214
|
+
modalContent.removeAttribute('role');
|
|
215
|
+
modalContent.removeAttribute('aria-modal');
|
|
216
|
+
modalContent.removeAttribute('aria-hidden');
|
|
217
|
+
|
|
218
|
+
// If modal is open when destroyed, restore body scrolling
|
|
219
|
+
if (isOpen) {
|
|
220
|
+
document.body.style.overflow = '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Remove from registry if it was registered
|
|
224
|
+
const modalId = node.getAttribute('id');
|
|
225
|
+
if (modalId && modalRegistry.has(modalId)) {
|
|
226
|
+
modalRegistry.delete(modalId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2023-Present The Lula Authors
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tooltip positioning action for Svelte components
|
|
6
|
+
* Adds dynamic positioning based on viewport and element bounds
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applies tooltip positioning to an element with a .tooltip child
|
|
11
|
+
* @param node The HTML element to attach the tooltip to
|
|
12
|
+
* @returns Svelte action object
|
|
13
|
+
*/
|
|
14
|
+
export function tooltip(node: HTMLElement) {
|
|
15
|
+
const tooltipEl = node.querySelector('.tooltip') as HTMLElement;
|
|
16
|
+
|
|
17
|
+
// Add data attribute to allow CSS targeting
|
|
18
|
+
node.setAttribute('data-tooltip-trigger', 'true');
|
|
19
|
+
|
|
20
|
+
function updatePosition() {
|
|
21
|
+
if (!tooltipEl) return;
|
|
22
|
+
|
|
23
|
+
// Get element positions
|
|
24
|
+
const rect = node.getBoundingClientRect();
|
|
25
|
+
|
|
26
|
+
// Set position variables as CSS custom properties
|
|
27
|
+
Object.entries({
|
|
28
|
+
'--group-top': `${rect.top}px`,
|
|
29
|
+
'--group-right': `${rect.right}px`,
|
|
30
|
+
'--group-left': `${rect.left}px`,
|
|
31
|
+
'--group-width': `${rect.width}px`,
|
|
32
|
+
'--group-bottom': `${rect.bottom}px`,
|
|
33
|
+
'--group-height': `${rect.height}px`
|
|
34
|
+
}).forEach(([prop, val]) => tooltipEl.style.setProperty(prop, val));
|
|
35
|
+
|
|
36
|
+
// Check if tooltip already has a position class
|
|
37
|
+
const hasPositionClass = /tooltip-(left|right|top|bottom)/.test(tooltipEl.className);
|
|
38
|
+
|
|
39
|
+
// Only calculate and apply position if no position class is already specified
|
|
40
|
+
if (!hasPositionClass) {
|
|
41
|
+
// Determine tooltip positioning based on available space
|
|
42
|
+
const spaceOnRight = window.innerWidth - rect.right;
|
|
43
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
44
|
+
const tooltipWidth = 288; // Approximate tooltip min-width
|
|
45
|
+
const tooltipHeight = 100; // Approximate tooltip height
|
|
46
|
+
|
|
47
|
+
// Default horizontal position (right or left)
|
|
48
|
+
const horizontalPosition = spaceOnRight < tooltipWidth + 20 ? 'left' : 'right';
|
|
49
|
+
|
|
50
|
+
// Default vertical position (bottom or top)
|
|
51
|
+
const verticalPosition = spaceBelow < tooltipHeight + 20 ? 'top' : 'bottom';
|
|
52
|
+
|
|
53
|
+
// Determine if we should prioritize vertical positioning
|
|
54
|
+
const useVerticalPosition =
|
|
55
|
+
(horizontalPosition === 'left' && rect.left < tooltipWidth) ||
|
|
56
|
+
(spaceBelow < tooltipHeight && rect.top > tooltipHeight) ||
|
|
57
|
+
(rect.top < tooltipHeight && spaceBelow > tooltipHeight);
|
|
58
|
+
|
|
59
|
+
// Add appropriate positioning class
|
|
60
|
+
const position = useVerticalPosition
|
|
61
|
+
? `tooltip-${verticalPosition}`
|
|
62
|
+
: `tooltip-${horizontalPosition}`;
|
|
63
|
+
|
|
64
|
+
tooltipEl.className =
|
|
65
|
+
tooltipEl.className.replace(/tooltip-(left|right|top|bottom)/g, '') + ' ' + position;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Create a real function reference for event listener removal
|
|
70
|
+
const handleMouseEnter = () => window.requestAnimationFrame(updatePosition);
|
|
71
|
+
|
|
72
|
+
// Only update position on hover
|
|
73
|
+
node.addEventListener('mouseenter', handleMouseEnter);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
update: updatePosition,
|
|
77
|
+
destroy() {
|
|
78
|
+
node.removeEventListener('mouseenter', handleMouseEnter);
|
|
79
|
+
node.removeAttribute('data-tooltip-trigger');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { appState } from '$lib/websocket';
|
|
6
|
+
import { ArrowsHorizontal } from 'carbon-icons-svelte';
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
{#if $appState.isConnected}
|
|
10
|
+
<a
|
|
11
|
+
href="/setup"
|
|
12
|
+
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
|
13
|
+
title="Switch control set"
|
|
14
|
+
>
|
|
15
|
+
<ArrowsHorizontal size={16} />
|
|
16
|
+
Switch
|
|
17
|
+
</a>
|
|
18
|
+
{:else}
|
|
19
|
+
<div class="text-sm text-gray-500 dark:text-gray-400">Loading...</div>
|
|
20
|
+
{/if}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { createEventDispatcher } from 'svelte';
|
|
6
|
+
import type { ControlSet } from '$lib/types';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
currentSet: ControlSet;
|
|
10
|
+
availableSets: ControlSet[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { currentSet, availableSets }: Props = $props();
|
|
14
|
+
|
|
15
|
+
const dispatch = createEventDispatcher<{ change: string }>();
|
|
16
|
+
|
|
17
|
+
function handleChange(event: Event) {
|
|
18
|
+
const target = event.target as HTMLSelectElement;
|
|
19
|
+
dispatch('change', target.value);
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<div class="control-set-selector">
|
|
24
|
+
<label for="control-set" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
25
|
+
Control Set
|
|
26
|
+
</label>
|
|
27
|
+
<select
|
|
28
|
+
id="control-set"
|
|
29
|
+
value={currentSet.id}
|
|
30
|
+
onchange={handleChange}
|
|
31
|
+
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white"
|
|
32
|
+
>
|
|
33
|
+
{#each availableSets as set}
|
|
34
|
+
<option value={set.id}>{set.name} {set.version}</option>
|
|
35
|
+
{/each}
|
|
36
|
+
</select>
|
|
37
|
+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
38
|
+
{currentSet.description}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.control-set-selector {
|
|
44
|
+
margin-bottom: 1rem;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Control } from '$lib/types';
|
|
6
|
+
import { appState, wsClient } from '$lib/websocket';
|
|
7
|
+
import {
|
|
8
|
+
CheckmarkFilled,
|
|
9
|
+
Connect,
|
|
10
|
+
Edit,
|
|
11
|
+
Information,
|
|
12
|
+
InProgress,
|
|
13
|
+
Time,
|
|
14
|
+
WarningFilled
|
|
15
|
+
} from 'carbon-icons-svelte';
|
|
16
|
+
import { TabNavigation } from '../ui';
|
|
17
|
+
import {
|
|
18
|
+
CustomFieldsTab,
|
|
19
|
+
ImplementationTab,
|
|
20
|
+
MappingsTab,
|
|
21
|
+
OverviewTab,
|
|
22
|
+
TimelineTab
|
|
23
|
+
} from './tabs';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
control: Control;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let { control }: Props = $props();
|
|
30
|
+
|
|
31
|
+
// Component state
|
|
32
|
+
let editedControl = $state({ ...control });
|
|
33
|
+
let originalControl = $state({ ...control });
|
|
34
|
+
let activeTab = $state<'details' | 'narrative' | 'custom' | 'mappings' | 'history'>('details');
|
|
35
|
+
let saveDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
let isSaving = $state(false);
|
|
37
|
+
let showSavedMessage = $state(false);
|
|
38
|
+
let savedMessageTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
|
|
40
|
+
// Derived values
|
|
41
|
+
const fieldSchema = $derived($appState.fieldSchema.fields);
|
|
42
|
+
const hasChanges = $derived(JSON.stringify(editedControl) !== JSON.stringify(originalControl));
|
|
43
|
+
const associatedMappings = $derived(
|
|
44
|
+
$appState.mappings.filter((m) => m.control_id === control.id)
|
|
45
|
+
);
|
|
46
|
+
const saveStatus = $derived(
|
|
47
|
+
isSaving ? 'saving' : (hasChanges ? 'unsaved' : (showSavedMessage ? 'just-saved' : 'clean'))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Check if tabs have any fields
|
|
51
|
+
const hasCustomFields = $derived(() => {
|
|
52
|
+
if (!fieldSchema) return false;
|
|
53
|
+
return Object.values(fieldSchema).some((field: any) => field.tab === 'custom');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const hasImplementationFields = $derived(() => {
|
|
57
|
+
if (!fieldSchema) return false;
|
|
58
|
+
return Object.values(fieldSchema).some((field: any) => field.tab === 'implementation');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Watch for control changes - only reset when ID changes
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (control.id !== editedControl?.id) {
|
|
64
|
+
if (saveDebounceTimeout) {
|
|
65
|
+
clearTimeout(saveDebounceTimeout);
|
|
66
|
+
}
|
|
67
|
+
editedControl = { ...control };
|
|
68
|
+
originalControl = { ...control };
|
|
69
|
+
activeTab = 'details';
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Manual trigger for auto-save when field changes
|
|
74
|
+
function triggerAutoSave() {
|
|
75
|
+
if (!hasChanges) return;
|
|
76
|
+
|
|
77
|
+
// Clear any existing save timeout
|
|
78
|
+
if (saveDebounceTimeout) {
|
|
79
|
+
clearTimeout(saveDebounceTimeout);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Debounce the save by 500ms to avoid too many saves while typing
|
|
83
|
+
saveDebounceTimeout = setTimeout(() => {
|
|
84
|
+
performSave();
|
|
85
|
+
}, 500);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Perform the actual save
|
|
89
|
+
async function performSave() {
|
|
90
|
+
if (!hasChanges || isSaving) return;
|
|
91
|
+
|
|
92
|
+
isSaving = true;
|
|
93
|
+
try {
|
|
94
|
+
// Only send the fields that have actually changed
|
|
95
|
+
const changes: Record<string, any> = { id: editedControl.id };
|
|
96
|
+
|
|
97
|
+
// Compare each field and only include changed ones
|
|
98
|
+
for (const [key, value] of Object.entries(editedControl)) {
|
|
99
|
+
// Skip runtime fields
|
|
100
|
+
if (key === 'timeline' || key === 'unifiedHistory' || key === '_metadata') {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Only include if value has changed
|
|
105
|
+
if (JSON.stringify(value) !== JSON.stringify(originalControl[key])) {
|
|
106
|
+
changes[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Only send if there are actual changes beyond the ID
|
|
111
|
+
if (Object.keys(changes).length > 1) {
|
|
112
|
+
await wsClient.updateControl(changes as Control);
|
|
113
|
+
originalControl = { ...editedControl };
|
|
114
|
+
showTemporarySavedMessage();
|
|
115
|
+
console.log('Saved changes:', Object.keys(changes).filter(k => k !== 'id').join(', '));
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Save failed:', error);
|
|
119
|
+
} finally {
|
|
120
|
+
isSaving = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Show the saved message temporarily
|
|
125
|
+
function showTemporarySavedMessage() {
|
|
126
|
+
// Clear any existing timeout
|
|
127
|
+
if (savedMessageTimeout) {
|
|
128
|
+
clearTimeout(savedMessageTimeout);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Show the message
|
|
132
|
+
showSavedMessage = true;
|
|
133
|
+
|
|
134
|
+
// Hide it after 3 seconds
|
|
135
|
+
savedMessageTimeout = setTimeout(() => {
|
|
136
|
+
showSavedMessage = false;
|
|
137
|
+
savedMessageTimeout = null;
|
|
138
|
+
}, 3000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle field changes from custom tab
|
|
142
|
+
function handleFieldChange(fieldName: string, value: any) {
|
|
143
|
+
triggerAutoSave();
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<!-- Header outside of any card -->
|
|
148
|
+
<header class="flex-shrink-0">
|
|
149
|
+
<div class="py-5">
|
|
150
|
+
<div class="flex items-center justify-between">
|
|
151
|
+
<div class="flex items-center space-x-4">
|
|
152
|
+
<div>
|
|
153
|
+
<h1 class="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
|
154
|
+
{control.id}
|
|
155
|
+
</h1>
|
|
156
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 font-medium mt-1">
|
|
157
|
+
{control.title}
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="flex items-center">
|
|
162
|
+
<!-- Save status icon indicator -->
|
|
163
|
+
{#if saveStatus === 'saving'}
|
|
164
|
+
<div
|
|
165
|
+
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"
|
|
166
|
+
title="Saving..."
|
|
167
|
+
>
|
|
168
|
+
<InProgress class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
|
169
|
+
</div>
|
|
170
|
+
{:else if saveStatus === 'unsaved'}
|
|
171
|
+
<div
|
|
172
|
+
class="w-8 h-8 flex items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30"
|
|
173
|
+
title="Unsaved changes"
|
|
174
|
+
>
|
|
175
|
+
<WarningFilled class="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
|
176
|
+
</div>
|
|
177
|
+
{:else if saveStatus === 'just-saved'}
|
|
178
|
+
<div
|
|
179
|
+
class="w-8 h-8 flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30 animate-fade-in"
|
|
180
|
+
title="Saved"
|
|
181
|
+
>
|
|
182
|
+
<CheckmarkFilled class="w-5 h-5 text-green-600 dark:text-green-400" />
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
<!-- Tab Navigation outside of any card -->
|
|
191
|
+
<div class="mb-2">
|
|
192
|
+
<TabNavigation
|
|
193
|
+
active={activeTab}
|
|
194
|
+
tabs={[
|
|
195
|
+
{ id: 'details', label: 'Overview', icon: Information },
|
|
196
|
+
...(hasImplementationFields() ? [{ id: 'narrative', label: 'Implementation', icon: Edit }] : []),
|
|
197
|
+
...(hasCustomFields() ? [{ id: 'custom', label: 'Custom', icon: Edit }] : []),
|
|
198
|
+
{ id: 'mappings', label: 'Mappings', icon: Connect, count: associatedMappings.length },
|
|
199
|
+
{
|
|
200
|
+
id: 'history',
|
|
201
|
+
label: 'Timeline',
|
|
202
|
+
icon: Time,
|
|
203
|
+
count: control.timeline?.totalCommits
|
|
204
|
+
}
|
|
205
|
+
]}
|
|
206
|
+
onSelect={(tabId) => (activeTab = tabId as typeof activeTab)}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<!-- Tab content without card wrapper -->
|
|
211
|
+
<main class="flex-1 overflow-auto pt-4">
|
|
212
|
+
<div class="">
|
|
213
|
+
{#if activeTab === 'details'}
|
|
214
|
+
<OverviewTab control={editedControl} {fieldSchema} />
|
|
215
|
+
{:else if activeTab === 'narrative'}
|
|
216
|
+
<ImplementationTab control={editedControl} {fieldSchema} />
|
|
217
|
+
{:else if activeTab === 'custom'}
|
|
218
|
+
<CustomFieldsTab
|
|
219
|
+
control={editedControl}
|
|
220
|
+
{fieldSchema}
|
|
221
|
+
onFieldChange={handleFieldChange}
|
|
222
|
+
/>
|
|
223
|
+
{:else if activeTab === 'mappings'}
|
|
224
|
+
<MappingsTab
|
|
225
|
+
{control}
|
|
226
|
+
mappings={associatedMappings}
|
|
227
|
+
/>
|
|
228
|
+
{:else if activeTab === 'history'}
|
|
229
|
+
<TimelineTab
|
|
230
|
+
{control}
|
|
231
|
+
timeline={control.timeline}
|
|
232
|
+
/>
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
</main>
|