qdadm 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- package/src/utils/transformers.js +105 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, onMounted, watch, inject } from 'vue'
|
|
3
|
+
import AutoComplete from 'primevue/autocomplete'
|
|
4
|
+
import Select from 'primevue/select'
|
|
5
|
+
import Button from 'primevue/button'
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
modelValue: {
|
|
9
|
+
type: Array,
|
|
10
|
+
default: () => []
|
|
11
|
+
},
|
|
12
|
+
disabled: {
|
|
13
|
+
type: Boolean,
|
|
14
|
+
default: false
|
|
15
|
+
},
|
|
16
|
+
// Scope configuration
|
|
17
|
+
scopeEndpoint: {
|
|
18
|
+
type: String,
|
|
19
|
+
default: '/reference/scopes' // Endpoint to load scope definition from API
|
|
20
|
+
},
|
|
21
|
+
scopePrefix: {
|
|
22
|
+
type: String,
|
|
23
|
+
default: 'faketual' // Prefix for scope strings (e.g., "faketual.resource:action")
|
|
24
|
+
},
|
|
25
|
+
// Default resources/actions if API not available
|
|
26
|
+
defaultResources: {
|
|
27
|
+
type: Array,
|
|
28
|
+
default: () => ['api', 'users', 'roles', 'apikeys']
|
|
29
|
+
},
|
|
30
|
+
defaultActions: {
|
|
31
|
+
type: Array,
|
|
32
|
+
default: () => ['read', 'write', 'grant']
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Get API adapter (optional)
|
|
37
|
+
const api = inject('apiAdapter', null)
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits(['update:modelValue'])
|
|
40
|
+
|
|
41
|
+
// Scope structure from API
|
|
42
|
+
const scopeDefinition = ref({
|
|
43
|
+
resources: [],
|
|
44
|
+
actions: [],
|
|
45
|
+
})
|
|
46
|
+
const loading = ref(true)
|
|
47
|
+
|
|
48
|
+
// Local scopes state
|
|
49
|
+
const scopeRows = ref([])
|
|
50
|
+
|
|
51
|
+
// Resource suggestions for autocomplete
|
|
52
|
+
const resourceSuggestions = ref([])
|
|
53
|
+
|
|
54
|
+
// All resources with * prefix
|
|
55
|
+
const allResources = computed(() => ['*', ...scopeDefinition.value.resources])
|
|
56
|
+
|
|
57
|
+
// All actions with access prefix
|
|
58
|
+
const allActions = computed(() => ['access', ...scopeDefinition.value.actions])
|
|
59
|
+
|
|
60
|
+
import { computed } from 'vue'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Search resources for autocomplete
|
|
64
|
+
*/
|
|
65
|
+
function searchResources(event) {
|
|
66
|
+
const query = (event.query || '').toLowerCase()
|
|
67
|
+
if (!query) {
|
|
68
|
+
resourceSuggestions.value = [...allResources.value]
|
|
69
|
+
} else {
|
|
70
|
+
resourceSuggestions.value = allResources.value.filter(r =>
|
|
71
|
+
r.toLowerCase().includes(query)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a scope string into resource and action
|
|
78
|
+
*/
|
|
79
|
+
function parseScope(scope) {
|
|
80
|
+
if (!scope) return { resource: '', action: '' }
|
|
81
|
+
// prefix.resource:action
|
|
82
|
+
const regex = new RegExp(`^${props.scopePrefix}\\.([^:]+):(.+)$`)
|
|
83
|
+
const match = scope.match(regex)
|
|
84
|
+
if (match) {
|
|
85
|
+
return { resource: match[1], action: match[2] }
|
|
86
|
+
}
|
|
87
|
+
return { resource: '', action: '' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build scope string from resource and action
|
|
92
|
+
*/
|
|
93
|
+
function buildScope(resource, action) {
|
|
94
|
+
if (!resource || !action) return ''
|
|
95
|
+
return `${props.scopePrefix}.${resource}:${action}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Initialize from modelValue
|
|
99
|
+
function initFromValue() {
|
|
100
|
+
if (!props.modelValue || props.modelValue.length === 0) {
|
|
101
|
+
scopeRows.value = []
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
scopeRows.value = props.modelValue.map(scope => {
|
|
105
|
+
const { resource, action } = parseScope(scope)
|
|
106
|
+
return { resource, action }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Emit changes - only complete scopes
|
|
111
|
+
function emitChanges() {
|
|
112
|
+
const scopes = scopeRows.value
|
|
113
|
+
.map(row => buildScope(row.resource, row.action))
|
|
114
|
+
.filter(s => s) // Filter out empty
|
|
115
|
+
emit('update:modelValue', scopes)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add new scope row
|
|
119
|
+
function addRow() {
|
|
120
|
+
scopeRows.value.push({ resource: '', action: 'read' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Remove scope row
|
|
124
|
+
function removeRow(index) {
|
|
125
|
+
scopeRows.value.splice(index, 1)
|
|
126
|
+
emitChanges()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Update resource
|
|
130
|
+
function updateResource(index, value) {
|
|
131
|
+
scopeRows.value[index].resource = value
|
|
132
|
+
if (value && scopeRows.value[index].action) {
|
|
133
|
+
emitChanges()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Update action
|
|
138
|
+
function updateAction(index, value) {
|
|
139
|
+
scopeRows.value[index].action = value
|
|
140
|
+
if (scopeRows.value[index].resource && value) {
|
|
141
|
+
emitChanges()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check if row is complete
|
|
146
|
+
function isRowComplete(row) {
|
|
147
|
+
return row.resource && row.action
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Load scope definition from API
|
|
151
|
+
async function loadScopeDefinition() {
|
|
152
|
+
loading.value = true
|
|
153
|
+
try {
|
|
154
|
+
if (!api) {
|
|
155
|
+
// No API adapter, use defaults
|
|
156
|
+
scopeDefinition.value = {
|
|
157
|
+
resources: [...props.defaultResources],
|
|
158
|
+
actions: [...props.defaultActions],
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await api.request('GET', props.scopeEndpoint)
|
|
164
|
+
scopeDefinition.value = {
|
|
165
|
+
resources: data.resources || [...props.defaultResources],
|
|
166
|
+
actions: data.actions || [...props.defaultActions],
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('[ScopeEditor] Failed to load scope definition:', error)
|
|
170
|
+
scopeDefinition.value = {
|
|
171
|
+
resources: [...props.defaultResources],
|
|
172
|
+
actions: [...props.defaultActions],
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
loading.value = false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Watch for external changes
|
|
180
|
+
watch(() => props.modelValue, (newVal) => {
|
|
181
|
+
// Compare to avoid loops
|
|
182
|
+
const currentScopes = scopeRows.value
|
|
183
|
+
.map(row => buildScope(row.resource, row.action))
|
|
184
|
+
.filter(s => s)
|
|
185
|
+
.sort()
|
|
186
|
+
.join(',')
|
|
187
|
+
const newScopes = (newVal || []).sort().join(',')
|
|
188
|
+
|
|
189
|
+
if (currentScopes !== newScopes) {
|
|
190
|
+
initFromValue()
|
|
191
|
+
}
|
|
192
|
+
}, { deep: true })
|
|
193
|
+
|
|
194
|
+
onMounted(async () => {
|
|
195
|
+
await loadScopeDefinition()
|
|
196
|
+
initFromValue()
|
|
197
|
+
})
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<template>
|
|
201
|
+
<div class="scope-editor">
|
|
202
|
+
<div v-if="scopeRows.length === 0" class="scope-empty">
|
|
203
|
+
<span class="text-surface-400">No scopes defined</span>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div v-for="(row, index) in scopeRows" :key="index" class="scope-row">
|
|
207
|
+
<span class="scope-prefix">{{ scopePrefix }}.</span>
|
|
208
|
+
<AutoComplete
|
|
209
|
+
v-model="row.resource"
|
|
210
|
+
:suggestions="resourceSuggestions"
|
|
211
|
+
@complete="searchResources"
|
|
212
|
+
@change="updateResource(index, row.resource)"
|
|
213
|
+
@item-select="(e) => updateResource(index, e.value)"
|
|
214
|
+
:disabled="disabled || loading"
|
|
215
|
+
placeholder="resource"
|
|
216
|
+
class="scope-resource"
|
|
217
|
+
:dropdown="true"
|
|
218
|
+
:minLength="0"
|
|
219
|
+
/>
|
|
220
|
+
<span class="scope-separator">:</span>
|
|
221
|
+
<Select
|
|
222
|
+
v-model="row.action"
|
|
223
|
+
:options="allActions"
|
|
224
|
+
@change="updateAction(index, row.action)"
|
|
225
|
+
:disabled="disabled || loading"
|
|
226
|
+
placeholder="action"
|
|
227
|
+
class="scope-action"
|
|
228
|
+
/>
|
|
229
|
+
<span v-if="isRowComplete(row)" class="scope-valid">✓</span>
|
|
230
|
+
<span v-else class="scope-incomplete">...</span>
|
|
231
|
+
<Button
|
|
232
|
+
icon="pi pi-trash"
|
|
233
|
+
severity="danger"
|
|
234
|
+
text
|
|
235
|
+
rounded
|
|
236
|
+
size="small"
|
|
237
|
+
:disabled="disabled"
|
|
238
|
+
@click="removeRow(index)"
|
|
239
|
+
class="scope-remove"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div class="scope-add">
|
|
244
|
+
<Button
|
|
245
|
+
label="Add Scope"
|
|
246
|
+
icon="pi pi-plus"
|
|
247
|
+
severity="secondary"
|
|
248
|
+
text
|
|
249
|
+
size="small"
|
|
250
|
+
:disabled="disabled"
|
|
251
|
+
@click="addRow"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</template>
|
|
256
|
+
|
|
257
|
+
<style scoped>
|
|
258
|
+
.scope-editor {
|
|
259
|
+
border: 1px solid var(--p-surface-200);
|
|
260
|
+
border-radius: 0.5rem;
|
|
261
|
+
padding: 0.75rem;
|
|
262
|
+
background: var(--p-surface-50);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.scope-empty {
|
|
266
|
+
padding: 1rem;
|
|
267
|
+
text-align: center;
|
|
268
|
+
font-size: 0.875rem;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.scope-row {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 0.25rem;
|
|
275
|
+
margin-bottom: 0.5rem;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.scope-prefix {
|
|
279
|
+
font-family: monospace;
|
|
280
|
+
font-size: 0.875rem;
|
|
281
|
+
color: var(--p-surface-500);
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.scope-separator {
|
|
286
|
+
font-family: monospace;
|
|
287
|
+
font-size: 0.875rem;
|
|
288
|
+
color: var(--p-surface-500);
|
|
289
|
+
flex-shrink: 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.scope-resource {
|
|
293
|
+
flex: 1 1 auto !important;
|
|
294
|
+
min-width: 200px !important;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.scope-action {
|
|
298
|
+
width: 120px !important;
|
|
299
|
+
min-width: 120px !important;
|
|
300
|
+
flex: 0 0 120px !important;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.scope-valid {
|
|
304
|
+
color: var(--p-green-500);
|
|
305
|
+
font-weight: bold;
|
|
306
|
+
width: 20px;
|
|
307
|
+
text-align: center;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.scope-incomplete {
|
|
311
|
+
color: var(--p-surface-400);
|
|
312
|
+
width: 20px;
|
|
313
|
+
text-align: center;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.scope-remove {
|
|
317
|
+
flex-shrink: 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.scope-add {
|
|
321
|
+
margin-top: 0.5rem;
|
|
322
|
+
padding-top: 0.5rem;
|
|
323
|
+
border-top: 1px solid var(--p-surface-200);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
:deep(.scope-resource .p-autocomplete) {
|
|
327
|
+
width: 100% !important;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
:deep(.scope-resource .p-autocomplete-input) {
|
|
331
|
+
width: 100%;
|
|
332
|
+
font-family: monospace;
|
|
333
|
+
font-size: 0.875rem;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
:deep(.scope-action .p-select) {
|
|
337
|
+
width: 120px !important;
|
|
338
|
+
font-family: monospace;
|
|
339
|
+
font-size: 0.875rem;
|
|
340
|
+
}
|
|
341
|
+
</style>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* VanillaJsonEditor - JSON editor using vanilla-jsoneditor
|
|
4
|
+
*
|
|
5
|
+
* Provides tree/text/table modes like jsoneditoronline.org
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <VanillaJsonEditor v-model="jsonData" mode="tree" />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
12
|
+
import { JSONEditor } from 'vanilla-jsoneditor'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
modelValue: {
|
|
16
|
+
type: [Object, Array, String, null],
|
|
17
|
+
default: () => ({})
|
|
18
|
+
},
|
|
19
|
+
mode: {
|
|
20
|
+
type: String,
|
|
21
|
+
default: 'tree', // 'tree', 'text', 'table'
|
|
22
|
+
validator: (v) => ['tree', 'text', 'table'].includes(v)
|
|
23
|
+
},
|
|
24
|
+
height: {
|
|
25
|
+
type: String,
|
|
26
|
+
default: '400px'
|
|
27
|
+
},
|
|
28
|
+
readOnly: {
|
|
29
|
+
type: Boolean,
|
|
30
|
+
default: false
|
|
31
|
+
},
|
|
32
|
+
mainMenuBar: {
|
|
33
|
+
type: Boolean,
|
|
34
|
+
default: true
|
|
35
|
+
},
|
|
36
|
+
navigationBar: {
|
|
37
|
+
type: Boolean,
|
|
38
|
+
default: true
|
|
39
|
+
},
|
|
40
|
+
statusBar: {
|
|
41
|
+
type: Boolean,
|
|
42
|
+
default: true
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits(['update:modelValue', 'change', 'error'])
|
|
47
|
+
|
|
48
|
+
const containerRef = ref(null)
|
|
49
|
+
let editor = null
|
|
50
|
+
// Flag to prevent onChange from firing during programmatic updates (instance-specific)
|
|
51
|
+
const updatingFromProp = ref(false)
|
|
52
|
+
|
|
53
|
+
// Parse value to ensure it's an object/array
|
|
54
|
+
function parseValue(val) {
|
|
55
|
+
if (val === null || val === undefined) {
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
if (typeof val === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(val)
|
|
61
|
+
} catch {
|
|
62
|
+
return {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return val
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Initialize editor
|
|
69
|
+
onMounted(() => {
|
|
70
|
+
if (!containerRef.value) return
|
|
71
|
+
|
|
72
|
+
const content = {
|
|
73
|
+
json: parseValue(props.modelValue)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
editor = new JSONEditor({
|
|
77
|
+
target: containerRef.value,
|
|
78
|
+
props: {
|
|
79
|
+
content,
|
|
80
|
+
mode: props.mode,
|
|
81
|
+
readOnly: props.readOnly,
|
|
82
|
+
mainMenuBar: props.mainMenuBar,
|
|
83
|
+
navigationBar: props.navigationBar,
|
|
84
|
+
statusBar: props.statusBar,
|
|
85
|
+
onChange: (updatedContent, previousContent, { contentErrors, patchResult: _patchResult }) => {
|
|
86
|
+
// Skip if this change came from a programmatic prop update
|
|
87
|
+
if (updatingFromProp.value) return
|
|
88
|
+
|
|
89
|
+
if (contentErrors) {
|
|
90
|
+
emit('error', contentErrors)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract JSON from content
|
|
95
|
+
let newValue
|
|
96
|
+
if (updatedContent.json !== undefined) {
|
|
97
|
+
newValue = updatedContent.json
|
|
98
|
+
} else if (updatedContent.text !== undefined) {
|
|
99
|
+
try {
|
|
100
|
+
newValue = JSON.parse(updatedContent.text)
|
|
101
|
+
} catch {
|
|
102
|
+
// Invalid JSON in text mode, don't emit
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (newValue !== undefined) {
|
|
108
|
+
emit('update:modelValue', newValue)
|
|
109
|
+
emit('change', newValue)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Watch for external changes
|
|
117
|
+
watch(() => props.modelValue, (newVal) => {
|
|
118
|
+
if (!editor) return
|
|
119
|
+
|
|
120
|
+
const currentContent = editor.get()
|
|
121
|
+
const newParsed = parseValue(newVal)
|
|
122
|
+
|
|
123
|
+
// Only update if different (avoid loops)
|
|
124
|
+
const currentJson = currentContent.json !== undefined
|
|
125
|
+
? currentContent.json
|
|
126
|
+
: (currentContent.text ? JSON.parse(currentContent.text) : null)
|
|
127
|
+
|
|
128
|
+
if (JSON.stringify(currentJson) !== JSON.stringify(newParsed)) {
|
|
129
|
+
// Set flag to prevent onChange from emitting during this update
|
|
130
|
+
updatingFromProp.value = true
|
|
131
|
+
editor.set({ json: newParsed })
|
|
132
|
+
// Reset flag after the update is processed
|
|
133
|
+
nextTick(() => {
|
|
134
|
+
updatingFromProp.value = false
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}, { deep: true })
|
|
138
|
+
|
|
139
|
+
// Watch mode changes
|
|
140
|
+
watch(() => props.mode, (newMode) => {
|
|
141
|
+
if (editor) {
|
|
142
|
+
editor.updateProps({ mode: newMode })
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Watch readOnly changes
|
|
147
|
+
watch(() => props.readOnly, (newReadOnly) => {
|
|
148
|
+
if (editor) {
|
|
149
|
+
editor.updateProps({ readOnly: newReadOnly })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Cleanup
|
|
154
|
+
onUnmounted(() => {
|
|
155
|
+
if (editor) {
|
|
156
|
+
editor.destroy()
|
|
157
|
+
editor = null
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<template>
|
|
163
|
+
<div class="vanilla-json-editor" :style="{ height }">
|
|
164
|
+
<div ref="containerRef" class="editor-container"></div>
|
|
165
|
+
</div>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.vanilla-json-editor {
|
|
170
|
+
width: 100%;
|
|
171
|
+
border: 1px solid var(--p-surface-300);
|
|
172
|
+
border-radius: 0.375rem;
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.editor-container {
|
|
177
|
+
height: 100%;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Override some vanilla-jsoneditor styles for dark mode compatibility */
|
|
181
|
+
:deep(.jse-main) {
|
|
182
|
+
--jse-theme-color: var(--p-primary-color, #10b981);
|
|
183
|
+
--jse-theme-color-highlight: var(--p-primary-100, #d1fae5);
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* FormActions - Reusable form action buttons
|
|
4
|
+
*
|
|
5
|
+
* Props:
|
|
6
|
+
* - isEdit: Boolean - Edit mode (changes labels)
|
|
7
|
+
* - saving: Boolean - Loading state
|
|
8
|
+
* - dirty: Boolean - Form has unsaved changes
|
|
9
|
+
*
|
|
10
|
+
* Emits:
|
|
11
|
+
* - save: Save and stay on form
|
|
12
|
+
* - saveAndClose: Save and navigate back
|
|
13
|
+
* - cancel: Cancel/close without saving
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Button from 'primevue/button'
|
|
17
|
+
|
|
18
|
+
defineProps({
|
|
19
|
+
isEdit: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: false
|
|
22
|
+
},
|
|
23
|
+
saving: {
|
|
24
|
+
type: Boolean,
|
|
25
|
+
default: false
|
|
26
|
+
},
|
|
27
|
+
dirty: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: true // Default true for backwards compatibility
|
|
30
|
+
},
|
|
31
|
+
showSaveAndClose: {
|
|
32
|
+
type: Boolean,
|
|
33
|
+
default: true
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits(['save', 'saveAndClose', 'cancel'])
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="form-actions">
|
|
42
|
+
<div class="form-actions-left">
|
|
43
|
+
<Button
|
|
44
|
+
type="button"
|
|
45
|
+
:label="isEdit ? 'Update' : 'Create'"
|
|
46
|
+
:loading="saving"
|
|
47
|
+
:disabled="!dirty || saving"
|
|
48
|
+
icon="pi pi-check"
|
|
49
|
+
@click="emit('save')"
|
|
50
|
+
v-tooltip.top="'Save and continue editing'"
|
|
51
|
+
/>
|
|
52
|
+
<Button
|
|
53
|
+
v-if="showSaveAndClose"
|
|
54
|
+
type="button"
|
|
55
|
+
:label="isEdit ? 'Update & Close' : 'Create & Close'"
|
|
56
|
+
:loading="saving"
|
|
57
|
+
:disabled="!dirty || saving"
|
|
58
|
+
icon="pi pi-check-circle"
|
|
59
|
+
severity="success"
|
|
60
|
+
@click="emit('saveAndClose')"
|
|
61
|
+
v-tooltip.top="'Save and return to list'"
|
|
62
|
+
/>
|
|
63
|
+
<span v-if="dirty" class="dirty-indicator" v-tooltip.top="'Unsaved changes'">
|
|
64
|
+
<i class="pi pi-circle-fill"></i>
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<Button
|
|
68
|
+
type="button"
|
|
69
|
+
label="Cancel"
|
|
70
|
+
severity="secondary"
|
|
71
|
+
icon="pi pi-times"
|
|
72
|
+
@click="emit('cancel')"
|
|
73
|
+
:disabled="saving"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style scoped>
|
|
79
|
+
.form-actions {
|
|
80
|
+
display: flex;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
align-items: center;
|
|
83
|
+
margin-top: 1.5rem;
|
|
84
|
+
padding-top: 1.5rem;
|
|
85
|
+
border-top: 1px solid var(--p-surface-200);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.form-actions-left {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 0.5rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.dirty-indicator {
|
|
95
|
+
color: var(--p-orange-500);
|
|
96
|
+
font-size: 0.5rem;
|
|
97
|
+
animation: pulse 2s infinite;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@keyframes pulse {
|
|
101
|
+
0%, 100% { opacity: 1; }
|
|
102
|
+
50% { opacity: 0.4; }
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* FormField - Wrapper for form fields with automatic dirty state styling
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <FormField name="username" label="Username *">
|
|
7
|
+
* <InputText v-model="form.username" />
|
|
8
|
+
* </FormField>
|
|
9
|
+
*
|
|
10
|
+
* The parent form must provide isFieldDirty via useForm's provideFormContext()
|
|
11
|
+
*/
|
|
12
|
+
import { inject, computed } from 'vue'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
name: {
|
|
16
|
+
type: String,
|
|
17
|
+
required: true
|
|
18
|
+
},
|
|
19
|
+
label: {
|
|
20
|
+
type: String,
|
|
21
|
+
default: ''
|
|
22
|
+
},
|
|
23
|
+
hint: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: ''
|
|
26
|
+
},
|
|
27
|
+
fullWidth: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Inject isFieldDirty from parent form (provided by useForm)
|
|
34
|
+
const isFieldDirty = inject('isFieldDirty', () => false)
|
|
35
|
+
|
|
36
|
+
const isDirty = computed(() => isFieldDirty(props.name))
|
|
37
|
+
|
|
38
|
+
const fieldClasses = computed(() => [
|
|
39
|
+
'form-field',
|
|
40
|
+
{
|
|
41
|
+
'field-dirty': isDirty.value
|
|
42
|
+
}
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const fieldStyle = computed(() =>
|
|
46
|
+
props.fullWidth ? { gridColumn: '1 / -1' } : {}
|
|
47
|
+
)
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div :class="fieldClasses" :style="fieldStyle">
|
|
52
|
+
<label v-if="label" :for="name">{{ label }}</label>
|
|
53
|
+
<slot ></slot>
|
|
54
|
+
<small v-if="hint" class="field-hint">{{ hint }}</small>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.field-hint {
|
|
60
|
+
color: var(--p-surface-500);
|
|
61
|
+
margin-top: 0.25rem;
|
|
62
|
+
display: block;
|
|
63
|
+
}
|
|
64
|
+
</style>
|