@stsdti/approvable-vue 0.1.7 → 0.1.12

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.
@@ -1,81 +1,149 @@
1
1
  <template>
2
- <div v-if="approval.hasApprovable" class="approvable">
2
+ <div v-if="approval.hasApprovable" :class="rootClass">
3
3
  <!-- A refresh has content to veil; an initial load has none, and gets the
4
4
  skeleton below instead. -->
5
5
  <div v-if="isLoading && approval.steps.length" class="approvable-loading">
6
6
  <span class="approvable-spinner" />
7
7
  </div>
8
8
 
9
- <div class="approvable-header">
9
+ <!-- The collapsed form replaces the trail rather than summarising it on top
10
+ of one, so it is the whole panel while it is on. -->
11
+ <template v-if="isCompact">
10
12
  <h2 class="approvable-sr-only">{{ title }}</h2>
11
13
 
12
- <template v-if="isInitialLoad">
14
+ <div v-if="isInitialLoad" class="approvable-compact">
15
+ <span class="approvable-sr-only" aria-live="polite">{{ messages.loadingStatus }}</span>
13
16
  <span aria-hidden="true" class="approvable-skeleton-bar approvable-skeleton-bar--pill" />
14
- <span class="approvable-skeleton-spacer" />
15
17
  <span
16
18
  aria-hidden="true"
17
19
  class="approvable-skeleton-bar approvable-skeleton-bar--soft"
18
- style="width:104px;animation-delay:0.2s"
20
+ style="width:96px;animation-delay:0.15s"
19
21
  />
20
- </template>
22
+ <span aria-hidden="true" class="approvable-skeleton-spacer" />
23
+ <span aria-hidden="true" class="approvable-skeleton-disc" style="animation-delay:0.25s" />
24
+ </div>
21
25
 
22
- <template v-else>
23
- <approve-status-info
24
- v-if="showApprovableStatus"
25
- :label="approval.statusName"
26
- :value="approval.status"
27
- />
26
+ <approvable-compact
27
+ v-else
28
+ :show-approvable-status="showApprovableStatus"
29
+ :value="value"
30
+ @expand="setCompact(false)"
31
+ @isLoading="(value) => (isLoading = value)"
32
+ @refresh="onRefresh"
33
+ >
34
+ <template v-for="slot in Object.keys($slots)" v-slot:[slot]="scoped">
35
+ <slot v-bind="scoped ?? {}" :name="slot" />
36
+ </template>
37
+ </approvable-compact>
38
+ </template>
28
39
 
29
- <span class="approvable-updated-at">
30
- {{ approval.updatedAt }}
31
- </span>
32
- <button
33
- v-if="approval.id"
34
- type="button"
35
- class="approvable-history-btn"
36
- :title="messages.historyTitle"
37
- :aria-label="messages.historyTitle"
38
- @click="historyOpen = true"
39
- >
40
- {{ messages.history }}
41
- </button>
42
- </template>
43
- </div>
40
+ <template v-else>
41
+ <div class="approvable-header">
42
+ <h2 class="approvable-sr-only">{{ title }}</h2>
44
43
 
45
- <template v-if="isInitialLoad">
46
- <span class="approvable-sr-only" aria-live="polite">{{ messages.loadingStatus }}</span>
47
- <div v-for="row in SKELETON_ROWS" :key="row.delay" aria-hidden="true" class="approvable-skeleton-row">
48
- <span class="approvable-skeleton-disc" :style="{ animationDelay: row.delay }" />
49
- <span class="approvable-skeleton-bar" :style="{ width: row.name, animationDelay: row.delay }" />
50
- <span class="approvable-skeleton-spacer" />
51
- <span
52
- class="approvable-skeleton-bar approvable-skeleton-bar--soft"
53
- :style="{ width: row.meta, animationDelay: row.metaDelay }"
54
- />
55
- </div>
56
- <div class="approvable-footer" aria-hidden="true">
57
- <span class="approvable-skeleton-bar approvable-skeleton-bar--soft" style="display:block;width:132px" />
44
+ <template v-if="isInitialLoad">
45
+ <span aria-hidden="true" class="approvable-skeleton-bar approvable-skeleton-bar--pill" />
46
+ <span class="approvable-skeleton-spacer" />
47
+ <span
48
+ aria-hidden="true"
49
+ class="approvable-skeleton-bar approvable-skeleton-bar--soft"
50
+ style="width:104px;animation-delay:0.2s"
51
+ />
52
+ </template>
53
+
54
+ <template v-else>
55
+ <approve-status-info
56
+ v-if="showApprovableStatus"
57
+ :label="approval.statusName"
58
+ :value="approval.status"
59
+ />
60
+
61
+ <span class="approvable-updated-at">
62
+ {{ approval.updatedAt }}
63
+ </span>
64
+ <!-- Only worth offering when the window is actually hiding something —
65
+ a short trail shows in full either way. -->
66
+ <button
67
+ v-if="stepWindow.isCollapsible"
68
+ type="button"
69
+ class="approvable-steps-toggle-btn"
70
+ :aria-expanded="stepsExpanded"
71
+ @click="stepsExpanded = !stepsExpanded"
72
+ >
73
+ {{ stepsExpanded
74
+ ? messages.stepsShowLess
75
+ : formatMessage(messages.stepsShowMore, { count: stepWindow.totalCount }) }}
76
+ </button>
77
+
78
+ <button
79
+ v-if="isHistoryVisible && approval.id"
80
+ type="button"
81
+ class="approvable-history-btn"
82
+ :title="messages.historyTitle"
83
+ :aria-label="messages.historyTitle"
84
+ @click="historyOpen = true"
85
+ >
86
+ {{ messages.history }}
87
+ </button>
88
+
89
+ <!-- The way back to the collapsed form: the same chevron the compact
90
+ row carries, turned over. One control, two directions. -->
91
+ <button
92
+ v-if="isCollapsible"
93
+ type="button"
94
+ class="approvable-compact-toggle approvable-compact-toggle--expanded"
95
+ :aria-expanded="true"
96
+ :aria-label="messages.compactCollapse"
97
+ :title="messages.compactCollapse"
98
+ @click="setCompact(true)"
99
+ >
100
+ <svg
101
+ aria-hidden="true"
102
+ class="approvable-icon"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ stroke-linecap="round"
106
+ stroke-linejoin="round"
107
+ stroke-width="2"
108
+ viewBox="0 0 16 16"
109
+ >
110
+ <path d="M4.5 6.4 8 9.9l3.5-3.5" />
111
+ </svg>
112
+ </button>
113
+ </template>
58
114
  </div>
59
- </template>
60
115
 
61
- <template v-else>
62
- <ol v-if="approval.steps.length" :class="approvableStepsClass">
63
- <approvable-step
64
- v-for="approvableStep of approval.steps"
65
- :key="approvableStep.id"
66
- :approvable="approval.approvable"
67
- :resource="value"
68
- :value="approvableStep"
69
- @isLoading="(value) => (isLoading = value)"
70
- @refresh="onRefresh"
71
- >
72
- <template v-for="slot in Object.keys($slots)" v-slot:[slot]="scoped">
73
- <slot v-bind="scoped ?? {}" :name="slot" />
74
- </template>
75
- </approvable-step>
76
- </ol>
116
+ <template v-if="isInitialLoad">
117
+ <span class="approvable-sr-only" aria-live="polite">{{ messages.loadingStatus }}</span>
118
+ <div v-for="row in SKELETON_ROWS" :key="row.delay" aria-hidden="true" class="approvable-skeleton-row">
119
+ <span class="approvable-skeleton-disc" :style="{ animationDelay: row.delay }" />
120
+ <span class="approvable-skeleton-bar" :style="{ width: row.name, animationDelay: row.delay }" />
121
+ <span class="approvable-skeleton-spacer" />
122
+ <span
123
+ class="approvable-skeleton-bar approvable-skeleton-bar--soft"
124
+ :style="{ width: row.meta, animationDelay: row.metaDelay }"
125
+ />
126
+ </div>
127
+ </template>
128
+
129
+ <template v-else>
130
+ <ol v-if="stepWindow.steps.length" :class="approvableStepsClass">
131
+ <approvable-step
132
+ v-for="approvableStep of stepWindow.steps"
133
+ :key="approvableStep.id"
134
+ :approvable="approval.approvable"
135
+ :resource="value"
136
+ :value="approvableStep"
137
+ @isLoading="(value) => (isLoading = value)"
138
+ @refresh="onRefresh"
139
+ >
140
+ <template v-for="slot in Object.keys($slots)" v-slot:[slot]="scoped">
141
+ <slot v-bind="scoped ?? {}" :name="slot" />
142
+ </template>
143
+ </approvable-step>
77
144
 
78
- <p v-if="progressLabel" class="approvable-footer">{{ progressLabel }}</p>
145
+ </ol>
146
+ </template>
79
147
  </template>
80
148
 
81
149
  <Teleport to="body">
@@ -121,9 +189,10 @@
121
189
  </template>
122
190
 
123
191
  <script setup>
124
- import { ref, computed } from 'vue'
125
- import { createApprovableViewModel, formatMessage } from '@stsdti/approvable-core'
192
+ import { ref, computed, watch } from 'vue'
193
+ import { createApprovableViewModel, createStepWindow, formatMessage } from '@stsdti/approvable-core'
126
194
  import ApprovableAuditTimeline from './ApprovableAuditTimeline.vue'
195
+ import ApprovableCompact from './ApprovableCompact.vue'
127
196
  import ApprovableStep from './ApprovableStep.vue'
128
197
  import ApproveStatusInfo from './ApproveStatusInfo.vue'
129
198
  import { useMessages } from '../../messages.js'
@@ -131,16 +200,37 @@ import { useMessages } from '../../messages.js'
131
200
  const props = defineProps({
132
201
  value: {},
133
202
  showApprovableStatus: { type: Boolean, default: true },
203
+ /** Whether the History button — and so the audit dialog — is offered. */
204
+ isHistoryVisible: { type: Boolean, default: true },
134
205
  title: { default: 'Approval' },
135
- isDirectionRow: { default: false }
206
+ isDirectionRow: { default: false },
207
+ /**
208
+ * How many steps stay on screen above and below the active one, with the
209
+ * rest folded behind a toggle. `null` shows the whole trail.
210
+ */
211
+ visibleStepRange: { type: Number, default: null },
212
+ /**
213
+ * Start collapsed: the status, a dot per step, and the active step's controls
214
+ * on one line. Supports `v-model:compact`, and the panel's own chevron drives
215
+ * it either way.
216
+ */
217
+ compact: { type: Boolean, default: false },
218
+ /**
219
+ * Whether the chevron that switches between the two forms is offered at all.
220
+ * `null` means "whenever the panel was asked for compact" — a caller that
221
+ * wants the control but the full trail first passes `true` explicitly.
222
+ */
223
+ collapsible: { type: Boolean, default: null }
136
224
  })
137
225
 
138
- const emits = defineEmits(['refresh', 'isLoading'])
226
+ const emits = defineEmits(['refresh', 'isLoading', 'update:compact'])
139
227
 
140
228
  const isLoading = ref(false)
141
229
  const auditTimeline = ref(null)
142
230
  const historyOpen = ref(false)
143
231
  const historyCount = ref(null)
232
+ const stepsExpanded = ref(false)
233
+ const isCompact = ref(props.compact)
144
234
 
145
235
  const messages = useMessages()
146
236
 
@@ -153,8 +243,25 @@ const SKELETON_ROWS = [
153
243
  { name: '78px', meta: '48px', delay: '0.2s', metaDelay: '0.35s' }
154
244
  ]
155
245
 
246
+ // The prop leads when the host drives it, and the chevron writes back through
247
+ // `update:compact` — so `v-model:compact` and a plain default both behave the
248
+ // way a caller expects.
249
+ watch(() => props.compact, (value) => (isCompact.value = value))
250
+
251
+ const setCompact = (value) => {
252
+ isCompact.value = value
253
+ emits('update:compact', value)
254
+ }
255
+
256
+ const isCollapsible = computed(() => props.collapsible ?? props.compact)
257
+
156
258
  const approval = computed(() => createApprovableViewModel(props.value))
157
259
 
260
+ const stepWindow = computed(() => createStepWindow(approval.value.steps, {
261
+ range: props.visibleStepRange,
262
+ isExpanded: stepsExpanded.value
263
+ }))
264
+
158
265
  /** Names the dialog, and once the history has loaded, counts it. */
159
266
  const historyTitle = computed(() => (
160
267
  historyCount.value === null
@@ -165,31 +272,16 @@ const historyTitle = computed(() => (
165
272
  /** Nothing to veil yet, so the panel shows its own shape instead. */
166
273
  const isInitialLoad = computed(() => isLoading.value && !approval.value.steps.length)
167
274
 
275
+ const rootClass = computed(() => ({
276
+ approvable: true,
277
+ 'approvable--compact': isCompact.value
278
+ }))
279
+
168
280
  const approvableStepsClass = computed(() => ({
169
281
  'approvable-steps': true,
170
282
  'approvable-steps--row': props.isDirectionRow
171
283
  }))
172
284
 
173
- /** "Step 2 of 3 — awaiting Priya Raman", with the tail dropped when nothing is pending. */
174
- const progressLabel = computed(() => {
175
- const { activeStepNumber, visibleStepCount, activeApproverName } = approval.value
176
-
177
- if (!activeStepNumber || !visibleStepCount) {
178
- return null
179
- }
180
-
181
- const progress = formatMessage(messages.stepProgress, {
182
- current: activeStepNumber,
183
- total: visibleStepCount
184
- })
185
-
186
- if (!activeApproverName) {
187
- return progress
188
- }
189
-
190
- return `${progress} — ${formatMessage(messages.awaitingApprover, { name: activeApproverName })}`
191
- })
192
-
193
285
  const onRefresh = () => {
194
286
  auditTimeline.value?.refresh()
195
287
  emits('refresh')
@@ -0,0 +1,130 @@
1
+ <template>
2
+ <div class="approvable-compact">
3
+ <div class="approvable-compact-main">
4
+ <div class="approvable-compact-line">
5
+ <approve-status-info
6
+ v-if="showApprovableStatus"
7
+ :label="approval.statusName"
8
+ :value="approval.status"
9
+ />
10
+
11
+ <!-- One dot per visible step, in workflow order: the strip is the only
12
+ thing the collapsed form keeps of the trail, so it has to carry
13
+ both how many steps there are and where each one landed. -->
14
+ <span
15
+ v-if="compact.dots.length"
16
+ class="approvable-compact-dots"
17
+ role="img"
18
+ :aria-label="stepsLabel"
19
+ >
20
+ <span
21
+ v-for="dot in compact.dots"
22
+ :key="dot.id"
23
+ :class="dotClass(dot)"
24
+ :title="dotTitle(dot)"
25
+ />
26
+ </span>
27
+ </div>
28
+
29
+ <p v-if="focus" class="approvable-compact-who">
30
+ <slot :step="compact.focusStep" name="approvableStepInfo">
31
+ <span class="approvable-compact-name">{{ focus.approverName }}</span>
32
+ </slot>
33
+ <slot :role="focus.approverRole" :step="compact.focusStep" name="approvableStepRole">
34
+ <template v-if="focus.approverRole">
35
+ <span aria-hidden="true" class="approvable-compact-sep">·</span>{{ focus.approverRole }}
36
+ </template>
37
+ </slot>
38
+ </p>
39
+ </div>
40
+
41
+ <div class="approvable-compact-side">
42
+ <!-- Only the step being waited on is actionable; a finished approval
43
+ collapses to its outcome and nothing to press. -->
44
+ <approvable-step-actions
45
+ v-if="compact.hasActiveStep && compact.focusStep"
46
+ :approvable="approval.approvable"
47
+ :resource="value"
48
+ :value="compact.focusStep"
49
+ @is-loading="(isLoading) => emits('isLoading', isLoading)"
50
+ @refresh="emits('refresh')"
51
+ />
52
+
53
+ <button
54
+ type="button"
55
+ class="approvable-compact-toggle"
56
+ :aria-expanded="false"
57
+ :aria-label="messages.compactExpand"
58
+ :title="messages.compactExpand"
59
+ @click="emits('expand')"
60
+ >
61
+ <svg
62
+ aria-hidden="true"
63
+ class="approvable-icon"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ stroke-linecap="round"
67
+ stroke-linejoin="round"
68
+ stroke-width="2"
69
+ viewBox="0 0 16 16"
70
+ >
71
+ <path d="M4.5 6.4 8 9.9l3.5-3.5" />
72
+ </svg>
73
+ </button>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <script setup>
79
+ import { computed } from 'vue'
80
+ import {
81
+ createApprovableViewModel,
82
+ createCompactViewModel,
83
+ formatMessage
84
+ } from '@stsdti/approvable-core'
85
+ import ApprovableStepActions from './ApprovableStepActions.vue'
86
+ import ApproveStatusInfo from './ApproveStatusInfo.vue'
87
+ import { useMessages } from '../../messages.js'
88
+
89
+ /**
90
+ * The collapsed form: status pill, a dot per step, who it is waiting on, and
91
+ * that step's controls — one row, for a header or a table cell where the full
92
+ * trail does not fit. The caller swaps it for {@link Approvable}'s full list
93
+ * on `expand`.
94
+ */
95
+ const props = defineProps({
96
+ value: {
97
+ type: Object,
98
+ required: true
99
+ },
100
+ showApprovableStatus: { type: Boolean, default: true }
101
+ })
102
+
103
+ const emits = defineEmits(['expand', 'isLoading', 'refresh'])
104
+
105
+ const messages = useMessages()
106
+
107
+ const approval = computed(() => createApprovableViewModel(props.value))
108
+
109
+ const compact = computed(() => createCompactViewModel(approval.value.steps))
110
+
111
+ const focus = computed(() => compact.value.focus)
112
+
113
+ /** "Step 5 of 9" — the strip's accessible name, and its own tooltip. */
114
+ const stepsLabel = computed(() => formatMessage(messages.stepProgress, {
115
+ current: compact.value.focusStepNumber ?? 0,
116
+ total: compact.value.stepCount
117
+ }))
118
+
119
+ const dotClass = (dot) => ({
120
+ 'approvable-compact-dot': true,
121
+ [`approvable-compact-dot--${dot.tone}`]: true
122
+ })
123
+
124
+ /** Hovering a dot names the step it stands for, so the strip is readable. */
125
+ const dotTitle = (dot) => [
126
+ formatMessage(messages.stepProgress, { current: dot.number, total: compact.value.stepCount }),
127
+ dot.approverName,
128
+ dot.statusName
129
+ ].filter(Boolean).join(' — ')
130
+ </script>
@@ -36,59 +36,13 @@
36
36
  <time v-if="step.performedAt" class="approvable-step-date">{{ step.performedAt }}</time>
37
37
  </span>
38
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
- <approvable-icon :icon="button.icon" />
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
- <approvable-icon :icon="button.icon" />
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>
39
+ <approvable-step-actions
40
+ :approvable="approvable"
41
+ :resource="resource"
42
+ :value="value"
43
+ @is-loading="(isLoading) => emits('isLoading', isLoading)"
44
+ @refresh="emits('refresh')"
45
+ />
92
46
  </div>
93
47
 
94
48
  <p v-if="step.comment.text" class="approvable-step-comment">
@@ -112,13 +66,9 @@ import { computed, ref } from 'vue'
112
66
  import {
113
67
  approvableStatusTone,
114
68
  approvableStepGlyph,
115
- createStepViewModel,
116
- performStepAction
69
+ createStepViewModel
117
70
  } from '@stsdti/approvable-core'
118
- import ApprovableIcon from './ApprovableIcon.vue'
119
- import ApprovableStepComment from './ApprovableStepComment.vue'
120
- import { approvableActionRenderer } from './action-renderers.js'
121
- import { linkProps, useLinkComponent } from '../../config.js'
71
+ import ApprovableStepActions from './ApprovableStepActions.vue'
122
72
  import { useMessages } from '../../messages.js'
123
73
 
124
74
  const emits = defineEmits(['isLoading', 'refresh'])
@@ -138,7 +88,6 @@ const props = defineProps({
138
88
  }
139
89
  })
140
90
 
141
- const linkComponent = useLinkComponent()
142
91
  const messages = useMessages()
143
92
 
144
93
  const isCommentExpanded = ref(false)
@@ -197,39 +146,4 @@ const discClass = computed(() => ({
197
146
  const commentText = computed(() => (
198
147
  isCommentExpanded.value ? step.value.comment.text : step.value.comment.preview
199
148
  ))
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
149
  </script>