@stsdti/approvable-vue 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/approvable-vue.mjs +902 -0
- package/dist/approvable-vue.mjs.map +1 -0
- package/package.json +58 -0
- package/src/components/approvable/Approvable.vue +197 -0
- package/src/components/approvable/ApprovableAuditTimeline.vue +253 -0
- package/src/components/approvable/ApprovableButtons.vue +80 -0
- package/src/components/approvable/ApprovableStep.vue +235 -0
- package/src/components/approvable/ApprovableStepComment.vue +155 -0
- package/src/components/approvable/ApproveStatusInfo.vue +33 -0
- package/src/components/approvable/action-renderers.js +23 -0
- package/src/components/select/approvable-status-select/ApprovableStatusSelect.vue +64 -0
- package/src/components/select/approvable-status-select/ApprovableStatusSelectOption.vue +16 -0
- package/src/config.js +26 -0
- package/src/icons.js +36 -0
- package/src/index.js +109 -0
- package/src/messages.js +19 -0
- package/src/themes/approvable.css +1171 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { ApprovableAuditRepository, formatMessage } from '@stsdti/approvable-core'
|
|
4
|
+
import { useMessages } from '../../messages.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
approvableId: { type: [Number, String], required: true },
|
|
8
|
+
limit: { type: Number, default: 100 },
|
|
9
|
+
showHeader: { type: Boolean, default: true }
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const emits = defineEmits(['loaded'])
|
|
13
|
+
|
|
14
|
+
const messages = useMessages()
|
|
15
|
+
const repository = new ApprovableAuditRepository()
|
|
16
|
+
const audits = ref([])
|
|
17
|
+
const loading = ref(false)
|
|
18
|
+
const error = ref('')
|
|
19
|
+
|
|
20
|
+
// Only the fields a reader actually cares about. Everything else (ids,
|
|
21
|
+
// timestamps we already show, internal bookkeeping) is intentionally hidden.
|
|
22
|
+
const FIELD_LABELS = {
|
|
23
|
+
approvable_status_name: 'Status',
|
|
24
|
+
comment: 'Comment',
|
|
25
|
+
responsible: 'Assigned to',
|
|
26
|
+
is_active: 'Active step'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One glyph per kind of event, keyed by the `kind` we resolve below. Drawn on
|
|
30
|
+
// the same 16-unit grid and at the same weight as the discs on the approval
|
|
31
|
+
// trail, so a history row and a step row read as the same object.
|
|
32
|
+
const ICONS = {
|
|
33
|
+
approve: { paths: ['M4 8.4l2.8 2.8 5.4-6'], width: 2.8 },
|
|
34
|
+
reject: { paths: ['M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6'], width: 2.4 },
|
|
35
|
+
comment: { paths: ['M8 4.6v3.6l2.6 1.6'], width: 2.2 },
|
|
36
|
+
status: { paths: ['M4 8h8M9.2 5.4L11.8 8l-2.6 2.6'], width: 2.2 },
|
|
37
|
+
undo: { paths: ['M4.4 6.6h3.4M4.4 6.6v-3M4.6 9.4a3.6 3.6 0 1 0 1-3.2'], width: 2 },
|
|
38
|
+
signal: { paths: ['M8 3.4v3.2M5.2 5.2l2.2 2.2M8 11.6a1.4 1.4 0 1 0 0-2.8 1.4 1.4 0 0 0 0 2.8'], width: 1.8 },
|
|
39
|
+
created: { paths: ['M4.4 8h7.2'], width: 2.2 },
|
|
40
|
+
update: { paths: ['M4.4 8h7.2'], width: 2.2 }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bookkeeping, not news: these rows are dimmed the way the design dims them. */
|
|
44
|
+
const QUIET_KINDS = new Set(['created', 'update'])
|
|
45
|
+
|
|
46
|
+
const groups = computed(() => {
|
|
47
|
+
// The workflow-level rows are noise for a reader, so keep only step changes.
|
|
48
|
+
const stepAudits = audits.value.filter(audit => audit.approvable_step_id)
|
|
49
|
+
const grouped = new Map()
|
|
50
|
+
for (const audit of stepAudits) {
|
|
51
|
+
const key = audit.request_id || `audit-${audit.id}`
|
|
52
|
+
if (!grouped.has(key)) grouped.set(key, [])
|
|
53
|
+
grouped.get(key).push(audit)
|
|
54
|
+
}
|
|
55
|
+
return [...grouped.values()].map(entries => {
|
|
56
|
+
const audit = entries[0]
|
|
57
|
+
// Resolved here rather than in the template, where it was called twice per
|
|
58
|
+
// row on every render — once for the marker class and once for its icon.
|
|
59
|
+
const kind = auditKind(audit)
|
|
60
|
+
// A row only needs a second line when there is something to put on it.
|
|
61
|
+
const details = entries.flatMap(changeRows)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: audit.request_id || `audit-${audit.id}`,
|
|
65
|
+
entries,
|
|
66
|
+
audit,
|
|
67
|
+
kind,
|
|
68
|
+
// A modifier class rather than utilities: the accent for each kind of
|
|
69
|
+
// event is a theme decision, so it belongs in the stylesheets alongside
|
|
70
|
+
// every other colour, not baked into the component.
|
|
71
|
+
kindClass: `approvable-timeline-marker--${kind}`,
|
|
72
|
+
icon: ICONS[kind],
|
|
73
|
+
isQuiet: QUIET_KINDS.has(kind),
|
|
74
|
+
isSingleLine: details.length === 0
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
// Newest first: the last thing that happened is the thing a reader opened
|
|
78
|
+
// the history for.
|
|
79
|
+
.sort((a, b) => new Date(b.audit.created_at) - new Date(a.audit.created_at))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
function auditKind (audit) {
|
|
83
|
+
const button = audit.metadata?.button
|
|
84
|
+
if (button?.is_undo) return 'undo'
|
|
85
|
+
if (button?.result === 'rejected') return 'reject'
|
|
86
|
+
if (button?.result === 'approved') return 'approve'
|
|
87
|
+
if (audit.metadata?.operation === 'comment') return 'comment'
|
|
88
|
+
if (audit.metadata?.operation === 'signal') return 'signal'
|
|
89
|
+
if (audit.event === 'created') return 'created'
|
|
90
|
+
if (statusChanged(audit)) return 'status'
|
|
91
|
+
return 'update'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusChanged (audit) {
|
|
95
|
+
const before = audit.old_values?.approvable_status_name
|
|
96
|
+
const after = audit.new_values?.approvable_status_name
|
|
97
|
+
return after !== undefined && before !== after
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function operationTitle (audit) {
|
|
101
|
+
const button = audit.metadata?.button
|
|
102
|
+
if (button) {
|
|
103
|
+
const label = button.text || button.label || button.id || 'action'
|
|
104
|
+
if (button.is_undo) return 'Reverted the decision'
|
|
105
|
+
if (button.is_revision) return `Changed decision to ${label}`
|
|
106
|
+
return label
|
|
107
|
+
}
|
|
108
|
+
if (audit.metadata?.operation === 'comment') return 'Comment added'
|
|
109
|
+
if (audit.metadata?.operation === 'signal') return `Signal received: ${audit.metadata?.signal?.name || ''}`.trim()
|
|
110
|
+
if (audit.event === 'created') return 'Step started'
|
|
111
|
+
const after = audit.new_values?.approvable_status_name
|
|
112
|
+
if (statusChanged(audit) && after) return `Status set to ${after}`
|
|
113
|
+
return 'Step updated'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function actorLabel (actor = {}) {
|
|
117
|
+
if (actor.type === 'system') return 'System'
|
|
118
|
+
if (actor.name) return actor.name
|
|
119
|
+
if (actor.email) return actor.email
|
|
120
|
+
if (actor.id) return actor.type === 'fake' ? `Test user ${actor.id}` : `User ${actor.id}`
|
|
121
|
+
if (actor.type === 'fake') return 'Test identity'
|
|
122
|
+
return 'Automated'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Changed fields worth surfacing, with the status change collapsed into a
|
|
126
|
+
// single "before → after" row (never the raw id).
|
|
127
|
+
function changeRows (entry) {
|
|
128
|
+
const oldValues = entry.old_values || {}
|
|
129
|
+
const newValues = entry.new_values || {}
|
|
130
|
+
return Object.keys(FIELD_LABELS)
|
|
131
|
+
.filter(key => key in oldValues || key in newValues)
|
|
132
|
+
.map(key => ({ key, label: FIELD_LABELS[key], oldValue: oldValues[key], newValue: newValues[key] }))
|
|
133
|
+
.filter(row => row.oldValue !== row.newValue)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A change only reads as "before → after" when there was a before. */
|
|
137
|
+
function hasPreviousValue (change) {
|
|
138
|
+
return change.oldValue !== undefined && change.oldValue !== null && change.oldValue !== ''
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function displayValue (value) {
|
|
142
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
143
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
144
|
+
if (Array.isArray(value)) return value.length ? value.join(', ') : '—'
|
|
145
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
146
|
+
return text.length > 180 ? `${text.slice(0, 177)}…` : text
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatDate (value) {
|
|
150
|
+
if (!value) return ''
|
|
151
|
+
const date = new Date(value)
|
|
152
|
+
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, {
|
|
153
|
+
dateStyle: 'medium',
|
|
154
|
+
timeStyle: 'short'
|
|
155
|
+
}).format(date)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function refresh () {
|
|
159
|
+
if (!props.approvableId) {
|
|
160
|
+
audits.value = []
|
|
161
|
+
loading.value = false
|
|
162
|
+
error.value = 'The approval history is unavailable because the approvable has no id.'
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
loading.value = true
|
|
167
|
+
error.value = ''
|
|
168
|
+
try {
|
|
169
|
+
const response = await repository.fetchTimeline(props.approvableId, props.limit)
|
|
170
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
171
|
+
throw new Error(response?.data?.message || 'The audit timeline could not be loaded.')
|
|
172
|
+
}
|
|
173
|
+
const data = Array.isArray(response.data) ? response.data : response.data?.data
|
|
174
|
+
audits.value = Array.isArray(data) ? data : []
|
|
175
|
+
} catch (exception) {
|
|
176
|
+
error.value = exception?.message || 'The audit timeline could not be loaded.'
|
|
177
|
+
} finally {
|
|
178
|
+
loading.value = false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const eventSummary = computed(() => formatMessage(messages.eventCount, { count: groups.value.length }))
|
|
183
|
+
|
|
184
|
+
// The dialog titles itself with the count, and only this component knows it.
|
|
185
|
+
watch(groups, (value) => emits('loaded', value.length))
|
|
186
|
+
|
|
187
|
+
watch(() => props.approvableId, refresh, { immediate: true })
|
|
188
|
+
defineExpose({ refresh })
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<section :class="['approvable-timeline', showHeader && 'approvable-timeline--panel']">
|
|
193
|
+
<div v-if="showHeader" class="approvable-timeline-header">
|
|
194
|
+
<p class="approvable-timeline-eyebrow">{{ messages.history }}</p>
|
|
195
|
+
<h3 class="approvable-timeline-title">{{ eventSummary }}</h3>
|
|
196
|
+
<button type="button" class="approvable-timeline-refresh" :disabled="loading" @click="refresh">
|
|
197
|
+
{{ loading ? 'Loading…' : 'Refresh' }}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div v-if="loading" class="approvable-timeline-state" role="status">
|
|
202
|
+
<span class="approvable-spinner" aria-hidden="true" />
|
|
203
|
+
Loading approval history…
|
|
204
|
+
</div>
|
|
205
|
+
<p v-else-if="error" class="approvable-timeline-error">{{ error }}</p>
|
|
206
|
+
<p v-else-if="!groups.length" class="approvable-timeline-state">No approval activity yet.</p>
|
|
207
|
+
|
|
208
|
+
<template v-else>
|
|
209
|
+
<ol class="approvable-timeline-list">
|
|
210
|
+
<li
|
|
211
|
+
v-for="group in groups"
|
|
212
|
+
:key="group.id"
|
|
213
|
+
:class="['approvable-timeline-item', {
|
|
214
|
+
'approvable-timeline-item--single-line': group.isSingleLine,
|
|
215
|
+
'approvable-timeline-item--quiet': group.isQuiet
|
|
216
|
+
}]"
|
|
217
|
+
>
|
|
218
|
+
<span :class="['approvable-timeline-marker', group.kindClass]">
|
|
219
|
+
<svg fill="none" stroke="currentColor" :stroke-width="group.icon.width" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 16 16" aria-hidden="true">
|
|
220
|
+
<path v-for="(d, i) in group.icon.paths" :key="i" :d="d" />
|
|
221
|
+
</svg>
|
|
222
|
+
</span>
|
|
223
|
+
|
|
224
|
+
<div class="approvable-timeline-content">
|
|
225
|
+
<div class="approvable-timeline-line">
|
|
226
|
+
<p class="approvable-timeline-actor">{{ actorLabel(group.audit.actor) }}</p>
|
|
227
|
+
<p class="approvable-timeline-operation">{{ operationTitle(group.audit) }}</p>
|
|
228
|
+
<time class="approvable-timeline-time">{{ formatDate(group.audit.created_at) }}</time>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<template v-for="entry in group.entries" :key="entry.id">
|
|
232
|
+
<template v-for="change in changeRows(entry)" :key="`${entry.id}-${change.key}`">
|
|
233
|
+
<p v-if="change.key === 'comment'" class="approvable-timeline-comment">
|
|
234
|
+
{{ displayValue(change.newValue) }}
|
|
235
|
+
</p>
|
|
236
|
+
<p v-else class="approvable-timeline-change">
|
|
237
|
+
<span class="approvable-timeline-change-label">{{ change.label }}</span>
|
|
238
|
+
<template v-if="hasPreviousValue(change)">
|
|
239
|
+
<span class="approvable-timeline-value approvable-timeline-value--old">{{ displayValue(change.oldValue) }}</span>
|
|
240
|
+
<span class="approvable-timeline-arrow">→</span>
|
|
241
|
+
</template>
|
|
242
|
+
<span class="approvable-timeline-value approvable-timeline-value--new">{{ displayValue(change.newValue) }}</span>
|
|
243
|
+
</p>
|
|
244
|
+
</template>
|
|
245
|
+
</template>
|
|
246
|
+
</div>
|
|
247
|
+
</li>
|
|
248
|
+
</ol>
|
|
249
|
+
|
|
250
|
+
<p class="approvable-timeline-footer">{{ messages.allEventsShown }}</p>
|
|
251
|
+
</template>
|
|
252
|
+
</section>
|
|
253
|
+
</template>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="hasApprovableButtons" class="approvable-actions">
|
|
3
|
+
<hr class="approvable-divider" />
|
|
4
|
+
<div class="approvable-actions-list">
|
|
5
|
+
<button
|
|
6
|
+
v-for="approvableButton in approvableButtons"
|
|
7
|
+
:key="approvableButton.code"
|
|
8
|
+
:title="approvableButton.tooltip"
|
|
9
|
+
class="approvable-btn"
|
|
10
|
+
type="button"
|
|
11
|
+
@click="onApproveStatusChange(approvableButton)"
|
|
12
|
+
>
|
|
13
|
+
<slot name="icon" :button="approvableButton">
|
|
14
|
+
<component
|
|
15
|
+
:is="icons[approvableButton.icon]"
|
|
16
|
+
v-if="isRegisteredIcon(icons, approvableButton.icon)"
|
|
17
|
+
class="approvable-btn-icon"
|
|
18
|
+
/>
|
|
19
|
+
<img
|
|
20
|
+
v-else-if="isUrlIcon(approvableButton.icon)"
|
|
21
|
+
:src="approvableButton.icon"
|
|
22
|
+
alt=""
|
|
23
|
+
class="approvable-btn-icon"
|
|
24
|
+
/>
|
|
25
|
+
<i v-else-if="isClassIcon(approvableButton.icon)" :class="approvableButton.icon"></i>
|
|
26
|
+
</slot>
|
|
27
|
+
{{ approvableButton.text }}
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup>
|
|
34
|
+
import { ref, computed } from 'vue'
|
|
35
|
+
import { ApprovableStepRepository, get, isSuccessResponse, notifySuccess } from '@stsdti/approvable-core'
|
|
36
|
+
import { isClassIcon, isRegisteredIcon, isUrlIcon, useIconRegistry } from '../../icons.js'
|
|
37
|
+
import { useMessages } from '../../messages.js'
|
|
38
|
+
|
|
39
|
+
const props = defineProps({
|
|
40
|
+
value: {
|
|
41
|
+
required: true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
const emits = defineEmits(['isLoading', 'refresh'])
|
|
45
|
+
|
|
46
|
+
const isLoading = ref(false)
|
|
47
|
+
|
|
48
|
+
const approvableButtons = computed(() => {
|
|
49
|
+
return get(props.value, 'buttons', [])
|
|
50
|
+
})
|
|
51
|
+
const hasApprovableButtons = computed(() => {
|
|
52
|
+
if (!approvableButtons.value) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
return approvableButtons.value.length > 0
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const icons = useIconRegistry()
|
|
59
|
+
const messages = useMessages()
|
|
60
|
+
|
|
61
|
+
const onApproveStatusChange = (button) => {
|
|
62
|
+
let approvableId = get(props.value, 'id')
|
|
63
|
+
|
|
64
|
+
if (!approvableId) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
onUpdateStatus(approvableId, button)
|
|
68
|
+
}
|
|
69
|
+
const onUpdateStatus = async (id, button) => {
|
|
70
|
+
emits('isLoading', true)
|
|
71
|
+
isLoading.value = true
|
|
72
|
+
const response = await new ApprovableStepRepository().updateApproveStatus(id, button)
|
|
73
|
+
|
|
74
|
+
if (isSuccessResponse(response)) {
|
|
75
|
+
notifySuccess(messages.actionSucceeded)
|
|
76
|
+
}
|
|
77
|
+
emits('isLoading', false)
|
|
78
|
+
emits('refresh')
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<li v-if="step.isVisible" :class="rowClass">
|
|
3
|
+
<span :class="discClass">
|
|
4
|
+
<svg
|
|
5
|
+
v-if="discGlyph"
|
|
6
|
+
aria-hidden="true"
|
|
7
|
+
fill="none"
|
|
8
|
+
stroke="currentColor"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
viewBox="0 0 16 16"
|
|
12
|
+
>
|
|
13
|
+
<path :d="discGlyph.d" :stroke-width="discGlyph.width" />
|
|
14
|
+
</svg>
|
|
15
|
+
</span>
|
|
16
|
+
|
|
17
|
+
<div class="approvable-step-body">
|
|
18
|
+
<div class="approvable-step-line">
|
|
19
|
+
<slot :step="value" name="approvableStepInfo">
|
|
20
|
+
<span class="approvable-step-name">{{ step.approverName }}</span>
|
|
21
|
+
</slot>
|
|
22
|
+
|
|
23
|
+
<slot :step="value" :role="step.approverRole" name="approvableStepRole">
|
|
24
|
+
<!-- The title is the whole role, so a clipped one is still readable
|
|
25
|
+
on hover; without a role the spacer keeps the row's geometry. -->
|
|
26
|
+
<span v-if="step.approverRole" :title="step.approverRole" class="approvable-step-role">
|
|
27
|
+
{{ step.approverRole }}
|
|
28
|
+
</span>
|
|
29
|
+
<span v-else class="approvable-step-spacer" />
|
|
30
|
+
</slot>
|
|
31
|
+
|
|
32
|
+
<!-- On the step being waited on the buttons carry the meaning, so the
|
|
33
|
+
status word is redundant; everywhere else it is the outcome. -->
|
|
34
|
+
<span v-if="showStatus" class="approvable-step-status">
|
|
35
|
+
<span v-if="step.statusName">{{ step.statusName }}</span>
|
|
36
|
+
<time v-if="step.performedAt" class="approvable-step-date">{{ step.performedAt }}</time>
|
|
37
|
+
</span>
|
|
38
|
+
|
|
39
|
+
<div v-if="hasActions" class="approvable-step-actions">
|
|
40
|
+
<div class="approvable-btn-group">
|
|
41
|
+
<!-- APPROVABLE BUTTONS (custom renderer per button, else basic) -->
|
|
42
|
+
<template v-for="(button, index) in step.buttons" :key="button.id ?? index">
|
|
43
|
+
<component
|
|
44
|
+
:is="buttonRenderer(button)"
|
|
45
|
+
v-if="buttonRenderer(button)"
|
|
46
|
+
:action-ui="button.action_ui"
|
|
47
|
+
:approvable="approvable"
|
|
48
|
+
:button="button"
|
|
49
|
+
:buttons="[button]"
|
|
50
|
+
:complete="onApproveStatusChange"
|
|
51
|
+
:resource="resource"
|
|
52
|
+
:step="value"
|
|
53
|
+
@is-loading="(isLoading) => emits('isLoading', isLoading)"
|
|
54
|
+
@refresh="emits('refresh')"
|
|
55
|
+
/>
|
|
56
|
+
<component
|
|
57
|
+
:is="linkComponent"
|
|
58
|
+
v-else-if="button.link"
|
|
59
|
+
v-bind="linkProps(linkComponent, button.link)"
|
|
60
|
+
>
|
|
61
|
+
<button
|
|
62
|
+
:title="button.tooltip"
|
|
63
|
+
:class="buttonClass(button)"
|
|
64
|
+
type="button"
|
|
65
|
+
@click="onApproveStatusChange(button)"
|
|
66
|
+
>
|
|
67
|
+
<i v-if="isClassIcon(button.icon)" :class="button.icon"></i>
|
|
68
|
+
</button>
|
|
69
|
+
</component>
|
|
70
|
+
<button
|
|
71
|
+
v-else
|
|
72
|
+
:title="button.tooltip"
|
|
73
|
+
:class="buttonClass(button)"
|
|
74
|
+
type="button"
|
|
75
|
+
@click="onApproveStatusChange(button)"
|
|
76
|
+
>
|
|
77
|
+
<i v-if="isClassIcon(button.icon)" :class="button.icon"></i>
|
|
78
|
+
{{ button.text }}
|
|
79
|
+
</button>
|
|
80
|
+
</template>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div v-if="value.can_comment" class="approvable-btn-group">
|
|
84
|
+
<approvable-step-comment
|
|
85
|
+
:approvable-step="value"
|
|
86
|
+
:disabled="!value.can_comment"
|
|
87
|
+
@refresh="emits('refresh')"
|
|
88
|
+
@is-loading="(isLoading) => emits('isLoading', isLoading)"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<p v-if="step.comment.text" class="approvable-step-comment">
|
|
95
|
+
<span :title="step.comment.title">{{ commentText }}</span>
|
|
96
|
+
<button
|
|
97
|
+
v-if="step.comment.isTrimmed"
|
|
98
|
+
type="button"
|
|
99
|
+
class="approvable-step-comment-more"
|
|
100
|
+
:aria-expanded="isCommentExpanded"
|
|
101
|
+
@click="isCommentExpanded = !isCommentExpanded"
|
|
102
|
+
>
|
|
103
|
+
{{ isCommentExpanded ? messages.showLess : messages.showMore }}
|
|
104
|
+
</button>
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
</li>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<script setup>
|
|
111
|
+
import { computed, ref } from 'vue'
|
|
112
|
+
import {
|
|
113
|
+
approvableStatusTone,
|
|
114
|
+
approvableStepGlyph,
|
|
115
|
+
createStepViewModel,
|
|
116
|
+
performStepAction
|
|
117
|
+
} from '@stsdti/approvable-core'
|
|
118
|
+
import ApprovableStepComment from './ApprovableStepComment.vue'
|
|
119
|
+
import { approvableActionRenderer } from './action-renderers.js'
|
|
120
|
+
import { linkProps, useLinkComponent } from '../../config.js'
|
|
121
|
+
import { isClassIcon } from '../../icons.js'
|
|
122
|
+
import { useMessages } from '../../messages.js'
|
|
123
|
+
|
|
124
|
+
const emits = defineEmits(['isLoading', 'refresh'])
|
|
125
|
+
|
|
126
|
+
const props = defineProps({
|
|
127
|
+
value: {
|
|
128
|
+
type: Object,
|
|
129
|
+
required: true
|
|
130
|
+
},
|
|
131
|
+
approvable: {
|
|
132
|
+
type: Object,
|
|
133
|
+
required: true
|
|
134
|
+
},
|
|
135
|
+
resource: {
|
|
136
|
+
type: Object,
|
|
137
|
+
required: true
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const linkComponent = useLinkComponent()
|
|
142
|
+
const messages = useMessages()
|
|
143
|
+
|
|
144
|
+
const isCommentExpanded = ref(false)
|
|
145
|
+
|
|
146
|
+
// Every derivation lives in core, so this component is left with rendering and
|
|
147
|
+
// event plumbing only — and a React binding computes exactly the same values.
|
|
148
|
+
const step = computed(() => createStepViewModel(props.value))
|
|
149
|
+
|
|
150
|
+
const tone = computed(() => approvableStatusTone(step.value.status))
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A row shows controls when it has any — a finished step usually has none, and
|
|
154
|
+
* shows when it was decided instead.
|
|
155
|
+
*/
|
|
156
|
+
const hasActions = computed(() => step.value.buttons.length > 0 || !!props.value.can_comment)
|
|
157
|
+
|
|
158
|
+
const showStatus = computed(() => (
|
|
159
|
+
(!step.value.isActive || !hasActions.value) && !!(step.value.statusName || step.value.performedAt)
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
const rowClass = computed(() => ({
|
|
163
|
+
'approvable-step': true,
|
|
164
|
+
'approvable-step--active': step.value.isActive,
|
|
165
|
+
// A step nobody has reached yet is dimmed; one with an outcome is not.
|
|
166
|
+
'approvable-step--pending': !step.value.isActive && !tone.value,
|
|
167
|
+
[`approvable-step--${tone.value}`]: !!tone.value,
|
|
168
|
+
// Nothing wraps below the name, so the row can hold to a single line.
|
|
169
|
+
'approvable-step--single-line': !hasActions.value && !step.value.comment.text
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
/** `ring` and `pending` are drawn by the disc's border alone — no path. */
|
|
173
|
+
const GLYPHS = {
|
|
174
|
+
check: { d: 'M4 8.4l2.8 2.8 5.4-6', width: 2.8 },
|
|
175
|
+
cross: { d: 'M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6', width: 2.4 },
|
|
176
|
+
dash: { d: 'M4.4 8h7.2', width: 2.2 }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The disc's own modifier: the glyph names the shape, the tone names the colour. */
|
|
180
|
+
const DISC_MODIFIERS = {
|
|
181
|
+
check: 'success',
|
|
182
|
+
cross: 'danger',
|
|
183
|
+
dash: 'muted',
|
|
184
|
+
ring: 'ring',
|
|
185
|
+
pending: 'pending'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const glyph = computed(() => approvableStepGlyph(tone.value, step.value.isActive))
|
|
189
|
+
|
|
190
|
+
const discGlyph = computed(() => GLYPHS[glyph.value] ?? null)
|
|
191
|
+
|
|
192
|
+
const discClass = computed(() => ({
|
|
193
|
+
'approvable-step-disc': true,
|
|
194
|
+
[`approvable-step-disc--${DISC_MODIFIERS[glyph.value]}`]: true
|
|
195
|
+
}))
|
|
196
|
+
|
|
197
|
+
const commentText = computed(() => (
|
|
198
|
+
isCommentExpanded.value ? step.value.comment.text : step.value.comment.preview
|
|
199
|
+
))
|
|
200
|
+
|
|
201
|
+
const buttonClass = (button) => ({
|
|
202
|
+
'approvable-btn': true,
|
|
203
|
+
// A button carrying only an icon collapses to a circle, so the row keeps its
|
|
204
|
+
// rhythm instead of stretching around an empty label.
|
|
205
|
+
'approvable-btn--icon': !button.text,
|
|
206
|
+
'approvable-btn--primary': button.result === 'approved'
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const buttonRenderer = (button) => {
|
|
210
|
+
if (!step.value.isActive) {
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return approvableActionRenderer(button?.action_ui)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const onApproveStatusChange = async (buttonOrId) => {
|
|
218
|
+
emits('isLoading', true)
|
|
219
|
+
|
|
220
|
+
const result = await performStepAction({
|
|
221
|
+
stepId: step.value.id,
|
|
222
|
+
button: buttonOrId,
|
|
223
|
+
buttons: step.value.buttons
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
emits('isLoading', false)
|
|
227
|
+
|
|
228
|
+
// Refresh after any attempted request, success or failure — a failed action
|
|
229
|
+
// still means the local state may be stale. Only a skipped one (no id, no
|
|
230
|
+
// button, or a presentational button) reloads nothing, since it sent nothing.
|
|
231
|
+
if (!result.skipped) {
|
|
232
|
+
emits('refresh')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</script>
|