@stsdti/approvable-vue 0.1.7 → 0.1.11

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,154 @@
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="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
+ <div class="approvable-footer" aria-hidden="true">
128
+ <span class="approvable-skeleton-bar approvable-skeleton-bar--soft" style="display:block;width:132px" />
129
+ </div>
130
+ </template>
131
+
132
+ <template v-else>
133
+ <ol v-if="stepWindow.steps.length" :class="approvableStepsClass">
134
+ <approvable-step
135
+ v-for="approvableStep of stepWindow.steps"
136
+ :key="approvableStep.id"
137
+ :approvable="approval.approvable"
138
+ :resource="value"
139
+ :value="approvableStep"
140
+ @isLoading="(value) => (isLoading = value)"
141
+ @refresh="onRefresh"
142
+ >
143
+ <template v-for="slot in Object.keys($slots)" v-slot:[slot]="scoped">
144
+ <slot v-bind="scoped ?? {}" :name="slot" />
145
+ </template>
146
+ </approvable-step>
147
+
148
+ </ol>
77
149
 
78
- <p v-if="progressLabel" class="approvable-footer">{{ progressLabel }}</p>
150
+ <p v-if="progressLabel" class="approvable-footer">{{ progressLabel }}</p>
151
+ </template>
79
152
  </template>
80
153
 
81
154
  <Teleport to="body">
@@ -121,9 +194,10 @@
121
194
  </template>
122
195
 
123
196
  <script setup>
124
- import { ref, computed } from 'vue'
125
- import { createApprovableViewModel, formatMessage } from '@stsdti/approvable-core'
197
+ import { ref, computed, watch } from 'vue'
198
+ import { createApprovableViewModel, createStepWindow, formatMessage } from '@stsdti/approvable-core'
126
199
  import ApprovableAuditTimeline from './ApprovableAuditTimeline.vue'
200
+ import ApprovableCompact from './ApprovableCompact.vue'
127
201
  import ApprovableStep from './ApprovableStep.vue'
128
202
  import ApproveStatusInfo from './ApproveStatusInfo.vue'
129
203
  import { useMessages } from '../../messages.js'
@@ -132,15 +206,34 @@ const props = defineProps({
132
206
  value: {},
133
207
  showApprovableStatus: { type: Boolean, default: true },
134
208
  title: { default: 'Approval' },
135
- isDirectionRow: { default: false }
209
+ isDirectionRow: { default: false },
210
+ /**
211
+ * How many steps stay on screen above and below the active one, with the
212
+ * rest folded behind a toggle. `null` shows the whole trail.
213
+ */
214
+ visibleStepRange: { type: Number, default: null },
215
+ /**
216
+ * Start collapsed: the status, a dot per step, and the active step's controls
217
+ * on one line. Supports `v-model:compact`, and the panel's own chevron drives
218
+ * it either way.
219
+ */
220
+ compact: { type: Boolean, default: false },
221
+ /**
222
+ * Whether the chevron that switches between the two forms is offered at all.
223
+ * `null` means "whenever the panel was asked for compact" — a caller that
224
+ * wants the control but the full trail first passes `true` explicitly.
225
+ */
226
+ collapsible: { type: Boolean, default: null }
136
227
  })
137
228
 
138
- const emits = defineEmits(['refresh', 'isLoading'])
229
+ const emits = defineEmits(['refresh', 'isLoading', 'update:compact'])
139
230
 
140
231
  const isLoading = ref(false)
141
232
  const auditTimeline = ref(null)
142
233
  const historyOpen = ref(false)
143
234
  const historyCount = ref(null)
235
+ const stepsExpanded = ref(false)
236
+ const isCompact = ref(props.compact)
144
237
 
145
238
  const messages = useMessages()
146
239
 
@@ -153,8 +246,25 @@ const SKELETON_ROWS = [
153
246
  { name: '78px', meta: '48px', delay: '0.2s', metaDelay: '0.35s' }
154
247
  ]
155
248
 
249
+ // The prop leads when the host drives it, and the chevron writes back through
250
+ // `update:compact` — so `v-model:compact` and a plain default both behave the
251
+ // way a caller expects.
252
+ watch(() => props.compact, (value) => (isCompact.value = value))
253
+
254
+ const setCompact = (value) => {
255
+ isCompact.value = value
256
+ emits('update:compact', value)
257
+ }
258
+
259
+ const isCollapsible = computed(() => props.collapsible ?? props.compact)
260
+
156
261
  const approval = computed(() => createApprovableViewModel(props.value))
157
262
 
263
+ const stepWindow = computed(() => createStepWindow(approval.value.steps, {
264
+ range: props.visibleStepRange,
265
+ isExpanded: stepsExpanded.value
266
+ }))
267
+
158
268
  /** Names the dialog, and once the history has loaded, counts it. */
159
269
  const historyTitle = computed(() => (
160
270
  historyCount.value === null
@@ -165,6 +275,11 @@ const historyTitle = computed(() => (
165
275
  /** Nothing to veil yet, so the panel shows its own shape instead. */
166
276
  const isInitialLoad = computed(() => isLoading.value && !approval.value.steps.length)
167
277
 
278
+ const rootClass = computed(() => ({
279
+ approvable: true,
280
+ 'approvable--compact': isCompact.value
281
+ }))
282
+
168
283
  const approvableStepsClass = computed(() => ({
169
284
  'approvable-steps': true,
170
285
  'approvable-steps--row': props.isDirectionRow
@@ -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>