@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.
@@ -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>