@av-controls/reconcile-ui 0.5.2
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/package.json +26 -0
- package/src/ReconcileMatcher.vue +276 -0
- package/src/index.ts +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@av-controls/reconcile-ui",
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "Shared Vue UI for reconciling saved control state against a current spec",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@av-controls/protocol": "^0.5.2"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"vue": "^3.5.21"
|
|
20
|
+
},
|
|
21
|
+
"author": "felix niemeyer",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { Reconcile } from '@av-controls/protocol'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
diff: Reconcile.ReconcileDiff
|
|
7
|
+
modelValue: Reconcile.MatchMap
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
confirm: [map: Reconcile.MatchMap]
|
|
12
|
+
cancel: []
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const working = ref<Reconcile.MatchMap>({ ...props.modelValue })
|
|
16
|
+
const selectedOrphan = ref<string | null>(null)
|
|
17
|
+
|
|
18
|
+
const specByPath = computed(() => new Map(props.diff.onlyInSpec.map(leaf => [leaf.path, leaf])))
|
|
19
|
+
const orphanByPath = computed(() => new Map(props.diff.onlyInFile.map(leaf => [leaf.path, leaf])))
|
|
20
|
+
|
|
21
|
+
function leafName(path: string) {
|
|
22
|
+
const segments = path.split('/')
|
|
23
|
+
return segments[segments.length - 1] ?? path
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Row = {
|
|
27
|
+
key: string
|
|
28
|
+
spec?: Reconcile.SpecLeaf
|
|
29
|
+
orphan?: Reconcile.FileLeaf
|
|
30
|
+
orphanPath?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rows = computed<Row[]>(() => {
|
|
34
|
+
const usedSpec = new Set(Object.values(working.value))
|
|
35
|
+
const mappedOrphans = new Set(Object.keys(working.value))
|
|
36
|
+
|
|
37
|
+
const paired: Row[] = Object.entries(working.value).map(([orphanPath, specPath]) => ({
|
|
38
|
+
key: `pair:${orphanPath}`,
|
|
39
|
+
orphan: orphanByPath.value.get(orphanPath),
|
|
40
|
+
orphanPath,
|
|
41
|
+
spec: specByPath.value.get(specPath),
|
|
42
|
+
}))
|
|
43
|
+
const specOnly: Row[] = props.diff.onlyInSpec
|
|
44
|
+
.filter(spec => !usedSpec.has(spec.path))
|
|
45
|
+
.map(spec => ({ key: `spec:${spec.path}`, spec }))
|
|
46
|
+
const orphanOnly: Row[] = props.diff.onlyInFile
|
|
47
|
+
.filter(orphan => !mappedOrphans.has(orphan.path))
|
|
48
|
+
.map(orphan => ({ key: `orphan:${orphan.path}`, orphan, orphanPath: orphan.path }))
|
|
49
|
+
|
|
50
|
+
return [...paired, ...specOnly, ...orphanOnly]
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const droppedCount = computed(
|
|
54
|
+
() => props.diff.onlyInFile.filter(o => !(o.path in working.value)).length,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
function isSelected(path?: string) {
|
|
58
|
+
return !!path && selectedOrphan.value === path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compatible(spec: Reconcile.SpecLeaf) {
|
|
62
|
+
if (!selectedOrphan.value) return false
|
|
63
|
+
const orphan = orphanByPath.value.get(selectedOrphan.value)
|
|
64
|
+
if (orphan?.type && spec.type && orphan.type !== spec.type) return false
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onOrphanClick(path?: string) {
|
|
69
|
+
if (!path) return
|
|
70
|
+
selectedOrphan.value = selectedOrphan.value === path ? null : path
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onSpecClick(spec: Reconcile.SpecLeaf) {
|
|
74
|
+
if (!selectedOrphan.value || !compatible(spec)) return
|
|
75
|
+
// a spec target is used by at most one orphan
|
|
76
|
+
for (const [orphanPath, specPath] of Object.entries(working.value)) {
|
|
77
|
+
if (specPath === spec.path) delete working.value[orphanPath]
|
|
78
|
+
}
|
|
79
|
+
working.value[selectedOrphan.value] = spec.path
|
|
80
|
+
selectedOrphan.value = null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearMapping(orphanPath?: string) {
|
|
84
|
+
if (orphanPath) delete working.value[orphanPath]
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<div class="reconcile">
|
|
90
|
+
<div class="reconcile-head">
|
|
91
|
+
<h3>Match saved values to current controls</h3>
|
|
92
|
+
<p>
|
|
93
|
+
{{ diff.matched.length }} matched automatically.
|
|
94
|
+
Connect orphaned saved values (right) to current controls (left).
|
|
95
|
+
Unconnected values ({{ droppedCount }}) will be dropped.
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="reconcile-cols-head">
|
|
100
|
+
<span class="col-spec">Current controls (no saved value)</span>
|
|
101
|
+
<span></span>
|
|
102
|
+
<span class="col-orphan">Saved values (no matching control)</span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="reconcile-rows">
|
|
106
|
+
<div
|
|
107
|
+
v-for="row in rows"
|
|
108
|
+
:key="row.key"
|
|
109
|
+
class="reconcile-row"
|
|
110
|
+
:class="{ paired: row.spec && row.orphan }"
|
|
111
|
+
>
|
|
112
|
+
<button
|
|
113
|
+
v-if="row.spec"
|
|
114
|
+
class="cell cell-spec"
|
|
115
|
+
:class="{ targetable: selectedOrphan && compatible(row.spec), disabled: selectedOrphan && !compatible(row.spec) }"
|
|
116
|
+
@click="onSpecClick(row.spec)"
|
|
117
|
+
>
|
|
118
|
+
<span class="name">{{ leafName(row.spec.path) }}</span>
|
|
119
|
+
<span class="path">{{ row.spec.path }}</span>
|
|
120
|
+
<span v-if="row.spec.type" class="type">{{ row.spec.type }}</span>
|
|
121
|
+
</button>
|
|
122
|
+
<span v-else class="cell cell-empty"></span>
|
|
123
|
+
|
|
124
|
+
<span class="connector">
|
|
125
|
+
<button
|
|
126
|
+
v-if="row.spec && row.orphan"
|
|
127
|
+
class="break"
|
|
128
|
+
title="Break this match"
|
|
129
|
+
@click="clearMapping(row.orphanPath)"
|
|
130
|
+
>↔</button>
|
|
131
|
+
</span>
|
|
132
|
+
|
|
133
|
+
<button
|
|
134
|
+
v-if="row.orphan"
|
|
135
|
+
class="cell cell-orphan"
|
|
136
|
+
:class="{ selected: isSelected(row.orphanPath) }"
|
|
137
|
+
@click="onOrphanClick(row.orphanPath)"
|
|
138
|
+
>
|
|
139
|
+
<span class="name">{{ leafName(row.orphan.path) }}</span>
|
|
140
|
+
<span class="path">{{ row.orphan.path }}</span>
|
|
141
|
+
<span v-if="row.orphan.type" class="type">{{ row.orphan.type }}</span>
|
|
142
|
+
</button>
|
|
143
|
+
<span v-else class="cell cell-empty"></span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="reconcile-actions">
|
|
148
|
+
<button class="btn cancel" @click="emit('cancel')">Cancel</button>
|
|
149
|
+
<button class="btn apply" @click="emit('confirm', { ...working })">Apply & import</button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</template>
|
|
153
|
+
|
|
154
|
+
<style scoped>
|
|
155
|
+
.reconcile {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
gap: 12px;
|
|
159
|
+
padding: 16px;
|
|
160
|
+
max-height: 80vh;
|
|
161
|
+
color: #d8dde2;
|
|
162
|
+
|
|
163
|
+
& h3 {
|
|
164
|
+
margin: 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
& p {
|
|
168
|
+
margin: 4px 0 0;
|
|
169
|
+
opacity: 0.75;
|
|
170
|
+
font-size: 0.85em;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
& .reconcile-cols-head,
|
|
174
|
+
& .reconcile-row {
|
|
175
|
+
display: grid;
|
|
176
|
+
grid-template-columns: 1fr 40px 1fr;
|
|
177
|
+
align-items: stretch;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
& .reconcile-cols-head {
|
|
182
|
+
font-size: 0.8em;
|
|
183
|
+
opacity: 0.7;
|
|
184
|
+
|
|
185
|
+
& .col-spec { color: #9cd6a3; }
|
|
186
|
+
& .col-orphan { color: #d6b78a; text-align: right; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
& .reconcile-rows {
|
|
190
|
+
overflow-y: auto;
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
gap: 6px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
& .cell {
|
|
197
|
+
display: flex;
|
|
198
|
+
flex-direction: column;
|
|
199
|
+
text-align: left;
|
|
200
|
+
gap: 2px;
|
|
201
|
+
padding: 6px 8px;
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
border: 1px solid transparent;
|
|
204
|
+
background: rgba(255, 255, 255, 0.04);
|
|
205
|
+
color: inherit;
|
|
206
|
+
cursor: default;
|
|
207
|
+
|
|
208
|
+
& .name { font-weight: 600; }
|
|
209
|
+
& .path { font-size: 0.75em; opacity: 0.6; }
|
|
210
|
+
& .type { font-size: 0.7em; opacity: 0.5; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
& .cell-empty {
|
|
214
|
+
background: transparent;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
& .cell-spec {
|
|
218
|
+
border-color: rgba(156, 214, 163, 0.3);
|
|
219
|
+
|
|
220
|
+
&.targetable {
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
border-color: rgba(156, 214, 163, 0.8);
|
|
223
|
+
background: rgba(156, 214, 163, 0.12);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
&.disabled {
|
|
227
|
+
opacity: 0.35;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
& .cell-orphan {
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
border-color: rgba(214, 183, 138, 0.3);
|
|
234
|
+
|
|
235
|
+
&.selected {
|
|
236
|
+
border-color: rgba(214, 183, 138, 0.95);
|
|
237
|
+
background: rgba(214, 183, 138, 0.16);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
& .connector {
|
|
242
|
+
display: flex;
|
|
243
|
+
align-items: center;
|
|
244
|
+
justify-content: center;
|
|
245
|
+
|
|
246
|
+
& .link { opacity: 0.6; }
|
|
247
|
+
|
|
248
|
+
& .break {
|
|
249
|
+
border: none;
|
|
250
|
+
background: transparent;
|
|
251
|
+
color: #d68a8a;
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
& .reconcile-actions {
|
|
257
|
+
display: flex;
|
|
258
|
+
justify-content: flex-end;
|
|
259
|
+
gap: 8px;
|
|
260
|
+
|
|
261
|
+
& .btn {
|
|
262
|
+
padding: 6px 14px;
|
|
263
|
+
border-radius: 4px;
|
|
264
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
265
|
+
background: rgba(255, 255, 255, 0.06);
|
|
266
|
+
color: inherit;
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
& .apply {
|
|
271
|
+
border-color: rgba(156, 214, 163, 0.6);
|
|
272
|
+
background: rgba(156, 214, 163, 0.18);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ReconcileMatcher } from './ReconcileMatcher.vue'
|