@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,155 @@
1
+ <template>
2
+ <div ref="rootRef" class="approvable-popover">
3
+ <button class="approvable-btn" :title="messages.comments" type="button" @click="onToggle">
4
+ <svg
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ width="16"
7
+ height="16"
8
+ viewBox="0 0 24 24"
9
+ fill="none"
10
+ stroke="currentColor"
11
+ stroke-width="1.8"
12
+ stroke-linecap="round"
13
+ stroke-linejoin="round"
14
+ aria-hidden="true"
15
+ >
16
+ <path d="M8 9h8" />
17
+ <path d="M8 13h6" />
18
+ <path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
19
+ </svg>
20
+ </button>
21
+
22
+ <div v-if="isOpen" class="approvable-popover-panel">
23
+ <div v-if="isLoading" class="approvable-loading">
24
+ <span class="approvable-spinner" />
25
+ </div>
26
+
27
+ <div class="approvable-popover-title">{{ messages.comments }}</div>
28
+
29
+ <div v-if="commentedBy" class="approvable-popover-meta">
30
+ <div>{{ messages.commentAuthor }}:</div>
31
+ <div class="approvable-popover-author">{{ commentedBy }}</div>
32
+ </div>
33
+
34
+ <template v-if="!disabled">
35
+ <textarea
36
+ ref="textareaRef"
37
+ v-model="comment"
38
+ class="approvable-textarea"
39
+ rows="1"
40
+ @input="resizeTextarea"
41
+ />
42
+
43
+ <div class="approvable-popover-actions">
44
+ <button class="approvable-btn approvable-btn--primary" type="button" @click="onComment">
45
+ {{ messages.save }}
46
+ </button>
47
+ </div>
48
+ </template>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup>
54
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
55
+
56
+ import { ApprovableStepRepository, get, isSuccessResponse, notifyError, notifySuccess } from '@stsdti/approvable-core'
57
+ import { useMessages } from '../../messages.js'
58
+
59
+ const props = defineProps({
60
+ approvableStep: {
61
+ required: true,
62
+ },
63
+ disabled: {
64
+ required: true,
65
+ type: Boolean,
66
+ },
67
+ })
68
+
69
+ const emits = defineEmits(['isLoading', 'refresh'])
70
+
71
+ const rootRef = useTemplateRef('rootRef')
72
+ const textareaRef = useTemplateRef('textareaRef')
73
+
74
+ const isOpen = ref(false)
75
+ const isLoading = ref(false)
76
+ const comment = ref(null)
77
+
78
+ const commentedBy = computed(() => {
79
+ return get(props.approvableStep, 'commented_by')
80
+ })
81
+
82
+ function onToggle () {
83
+ isOpen.value ? hide() : show()
84
+ }
85
+
86
+ function show () {
87
+ comment.value = get(props.approvableStep, 'comment')
88
+ isOpen.value = true
89
+ nextTick(resizeTextarea)
90
+ }
91
+
92
+ function hide () {
93
+ isOpen.value = false
94
+ }
95
+
96
+ function resizeTextarea () {
97
+ const textarea = textareaRef.value
98
+ if (!textarea) {
99
+ return
100
+ }
101
+ textarea.style.height = 'auto'
102
+ textarea.style.height = `${textarea.scrollHeight}px`
103
+ }
104
+
105
+ function onDocumentClick (event) {
106
+ if (isOpen.value && rootRef.value && !rootRef.value.contains(event.target)) {
107
+ hide()
108
+ }
109
+ }
110
+
111
+ function onDocumentKeydown (event) {
112
+ if (event.key === 'Escape') {
113
+ hide()
114
+ }
115
+ }
116
+
117
+ onMounted(() => {
118
+ document.addEventListener('click', onDocumentClick)
119
+ document.addEventListener('keydown', onDocumentKeydown)
120
+ })
121
+
122
+ onBeforeUnmount(() => {
123
+ document.removeEventListener('click', onDocumentClick)
124
+ document.removeEventListener('keydown', onDocumentKeydown)
125
+ })
126
+
127
+ const messages = useMessages()
128
+
129
+ async function onComment () {
130
+ if (!get(props.approvableStep, 'comment') && !comment.value) {
131
+ notifyError(messages.commentRequired)
132
+ return
133
+ }
134
+
135
+ isLoading.value = true
136
+ emits('isLoading', true)
137
+
138
+ const data = {
139
+ comment: comment.value,
140
+ }
141
+
142
+ const response = await new ApprovableStepRepository().comment(props.approvableStep.id, data)
143
+
144
+ if (isSuccessResponse(response)) {
145
+ notifySuccess(messages.commentSaved)
146
+ hide()
147
+ emits('refresh')
148
+ } else {
149
+ notifyError(messages.commentFailed)
150
+ }
151
+
152
+ isLoading.value = false
153
+ emits('isLoading', false)
154
+ }
155
+ </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <span :class="badgeClass">{{ badgeName }}</span>
3
+ </template>
4
+
5
+ <script setup>
6
+ import { computed } from 'vue'
7
+ import { ApprovableStatus, get } from '@stsdti/approvable-core'
8
+
9
+ const props = defineProps({
10
+ value: {
11
+ required: true
12
+ },
13
+ label: {
14
+ default: null
15
+ }
16
+ })
17
+ const badgeClass = computed(() => {
18
+ const approvableStatusCode = get(props.value, 'code', ' - ')
19
+ return {
20
+ 'approvable-badge': true,
21
+ 'approvable-badge--draft': [ApprovableStatus.CODES.CODE_DRAFT].includes(approvableStatusCode),
22
+ 'approvable-badge--waiting': [ApprovableStatus.CODES.CODE_WAITING].includes(approvableStatusCode),
23
+ 'approvable-badge--success': [ApprovableStatus.CODES.CODE_APPROVED, ApprovableStatus.CODES.CODE_VIEWED, ApprovableStatus.CODES.CODE_NOTICED].includes(approvableStatusCode),
24
+ 'approvable-badge--danger': [ApprovableStatus.CODES.CODE_REJECTED].includes(approvableStatusCode),
25
+ 'approvable-badge--muted': [ApprovableStatus.CODES.CODE_EXPIRED, ApprovableStatus.CODES.CODE_SOLVED].includes(approvableStatusCode),
26
+ 'approvable-badge--empty': approvableStatusCode === ' - '
27
+ }
28
+ })
29
+
30
+ const badgeName = computed(() => {
31
+ return props.label ? props.label : get(props.value, 'name', ' - ')
32
+ })
33
+ </script>
@@ -0,0 +1,23 @@
1
+ import { markRaw } from 'vue'
2
+ import {
3
+ registerActionRenderer,
4
+ unregisterActionRenderer,
5
+ actionRenderer,
6
+ actionSlotName
7
+ } from '@stsdti/approvable-core'
8
+
9
+ /**
10
+ * Vue binding over the core action registry.
11
+ *
12
+ * The only thing Vue-specific about registering a renderer is that a component
13
+ * must be marked raw before it goes into reactive state, so that is all this
14
+ * layer adds. The registry itself is framework-free, which is what allows a
15
+ * React binding to reuse it without inheriting Vue's reactivity rules.
16
+ */
17
+ export function registerApprovableActionRenderer (name, component) {
18
+ return registerActionRenderer(name, markRaw(component))
19
+ }
20
+
21
+ export { unregisterActionRenderer as unregisterApprovableActionRenderer }
22
+ export { actionRenderer as approvableActionRenderer }
23
+ export { actionSlotName as approvableActionSlotName }
@@ -0,0 +1,64 @@
1
+ <template>
2
+ <label class="approvable-select">
3
+ <span v-if="label" class="approvable-select-label">{{ label }}</span>
4
+ <select
5
+ :value="selectedKey"
6
+ class="approvable-select-input"
7
+ v-bind="$attrs"
8
+ @change="onChange"
9
+ >
10
+ <option value="">-</option>
11
+ <option v-for="option in options" :key="optionKey(option)" :value="optionKey(option)">
12
+ {{ optionName(option) }}
13
+ </option>
14
+ </select>
15
+ </label>
16
+ </template>
17
+
18
+ <script setup>
19
+ import { computed, onMounted, ref } from 'vue'
20
+ import { ApprovableStatusRepository, get } from '@stsdti/approvable-core'
21
+
22
+ defineOptions({
23
+ inheritAttrs: false
24
+ })
25
+
26
+ const emit = defineEmits(['mounted'])
27
+ const modelValue = defineModel()
28
+
29
+ const props = defineProps({
30
+ getList: {},
31
+ label: {
32
+ default: 'Status'
33
+ }
34
+ })
35
+
36
+ const options = ref([])
37
+
38
+ const getListDefault = async (search) => {
39
+ let filters = [{ field: 'search', value: search }]
40
+ return await new ApprovableStatusRepository().fetchAll({ filters })
41
+ }
42
+
43
+ const optionKey = (option) => String(get(option, 'id') ?? get(option, 'code', ''))
44
+ const optionName = (option) => get(option, 'name') ?? get(option, 'text', '')
45
+
46
+ const selectedKey = computed(() => {
47
+ return modelValue.value ? optionKey(modelValue.value) : ''
48
+ })
49
+
50
+ const onChange = (event) => {
51
+ const key = event.target.value
52
+ modelValue.value = options.value.find((option) => optionKey(option) === key) ?? null
53
+ }
54
+
55
+ const refresh = async (search = null) => {
56
+ const list = await (props.getList ?? getListDefault)(search)
57
+ options.value = Array.isArray(list) ? list : []
58
+ }
59
+
60
+ onMounted(async () => {
61
+ await refresh()
62
+ emit('mounted', { refresh, options })
63
+ })
64
+ </script>
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <div class="approvable-select-option">
3
+ <span>{{ displayName }}</span>
4
+ </div>
5
+ </template>
6
+
7
+
8
+ <script setup>
9
+ import { computed } from 'vue'
10
+ import { get } from '@stsdti/approvable-core'
11
+
12
+ const props = defineProps({
13
+ value: {}
14
+ })
15
+ const displayName = computed(() => get(props.value, 'name'))
16
+ </script>
package/src/config.js ADDED
@@ -0,0 +1,26 @@
1
+ import { inject } from 'vue'
2
+
3
+ export const APPROVABLE_LINK_COMPONENT = Symbol('approvable.linkComponent')
4
+
5
+ /**
6
+ * Which component renders a button that navigates.
7
+ *
8
+ * Defaults to a plain anchor so the package works with no router installed.
9
+ * Hardcoding <router-link> instead would silently fail to resolve — and log a
10
+ * Vue warning — in any application without vue-router.
11
+ *
12
+ * Applications using a router opt in once, at install:
13
+ *
14
+ * app.use(Approvable, { linkComponent: RouterLink })
15
+ */
16
+ export function useLinkComponent () {
17
+ return inject(APPROVABLE_LINK_COMPONENT, 'a')
18
+ }
19
+
20
+ /**
21
+ * Anchors take `href`; RouterLink and NuxtLink take `to`. Binding the right one
22
+ * keeps the call sites free of that distinction.
23
+ */
24
+ export function linkProps (component, target) {
25
+ return component === 'a' ? { href: target } : { to: target }
26
+ }
package/src/icons.js ADDED
@@ -0,0 +1,36 @@
1
+ import { inject } from 'vue'
2
+ import { isClassIcon, isRegisteredIcon, isUrlIcon } from '@stsdti/approvable-core'
3
+
4
+ export const APPROVABLE_ICON_REGISTRY = Symbol('approvable.iconRegistry')
5
+
6
+ /**
7
+ * How a button's `icon` is rendered.
8
+ *
9
+ * An icon is a *name*, resolved against a registry the application supplies —
10
+ * never raw markup rendered with v-html. A button's icon comes from the
11
+ * workflow definition — authored in the editor, stored, and then displayed in
12
+ * the browser of every person who can see the approval — so v-html here would
13
+ * let whoever can edit a workflow run script in every approver's session.
14
+ * `<svg onload="...">` is enough; it would be a stored XSS with an unusually
15
+ * good distribution list.
16
+ *
17
+ * app.use(Approvable, {
18
+ * icons: { approve: CheckIcon, reject: XIcon }
19
+ * })
20
+ *
21
+ * Anything not in the registry falls back to the two safe forms: an `http(s)`
22
+ * URL rendered as <img>, or a CSS class rendered on an <i>.
23
+ * A `#icon` slot is available for full control.
24
+ *
25
+ * Applications that genuinely need bespoke SVG register a component for it,
26
+ * which puts the markup in application code — where it is reviewed — rather
27
+ * than in a database row.
28
+ */
29
+ export function useIconRegistry () {
30
+ return inject(APPROVABLE_ICON_REGISTRY, {})
31
+ }
32
+
33
+ // The three predicates come from core rather than being defined here: the
34
+ // React binding applies the same rules, and a rule about what is safe to render
35
+ // is only worth trusting while there is one copy of it.
36
+ export { isRegisteredIcon, isUrlIcon, isClassIcon }
package/src/index.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @stsdti/approvable-vue
3
+ *
4
+ * Vue 3 components for laravel-approvable. All behaviour lives in
5
+ * @stsdti/approvable-core; this package renders it.
6
+ *
7
+ * Components ship unstyled and emit `.approvable-*` class names. Import the
8
+ * theme to give them a look — one stylesheet, light and dark:
9
+ *
10
+ * import '@stsdti/approvable-vue/themes/approvable.css'
11
+ */
12
+
13
+ import Approvable from './components/approvable/Approvable.vue'
14
+ import ApprovableStep from './components/approvable/ApprovableStep.vue'
15
+ import ApprovableButtons from './components/approvable/ApprovableButtons.vue'
16
+ import ApprovableStepComment from './components/approvable/ApprovableStepComment.vue'
17
+ import ApprovableAuditTimeline from './components/approvable/ApprovableAuditTimeline.vue'
18
+ import ApproveStatusInfo from './components/approvable/ApproveStatusInfo.vue'
19
+ import ApprovableStatusSelect from './components/select/approvable-status-select/ApprovableStatusSelect.vue'
20
+ import ApprovableStatusSelectOption from './components/select/approvable-status-select/ApprovableStatusSelectOption.vue'
21
+
22
+ import { APPROVABLE_LINK_COMPONENT } from './config.js'
23
+ import { APPROVABLE_ICON_REGISTRY } from './icons.js'
24
+ import { APPROVABLE_MESSAGES } from './messages.js'
25
+ import {
26
+ registerApprovableActionRenderer,
27
+ unregisterApprovableActionRenderer,
28
+ approvableActionRenderer,
29
+ approvableActionSlotName
30
+ } from './components/approvable/action-renderers.js'
31
+
32
+ export {
33
+ Approvable,
34
+ ApprovableStep,
35
+ ApprovableButtons,
36
+ ApprovableStepComment,
37
+ ApprovableAuditTimeline,
38
+ ApproveStatusInfo,
39
+ ApprovableStatusSelect,
40
+ ApprovableStatusSelectOption
41
+ }
42
+
43
+ export {
44
+ registerApprovableActionRenderer,
45
+ unregisterApprovableActionRenderer,
46
+ approvableActionRenderer,
47
+ approvableActionSlotName
48
+ }
49
+
50
+ export { APPROVABLE_LINK_COMPONENT, useLinkComponent, linkProps } from './config.js'
51
+ export {
52
+ APPROVABLE_ICON_REGISTRY,
53
+ useIconRegistry,
54
+ isRegisteredIcon,
55
+ isUrlIcon,
56
+ isClassIcon
57
+ } from './icons.js'
58
+ export { APPROVABLE_MESSAGES, useMessages } from './messages.js'
59
+
60
+ const components = {
61
+ Approvable,
62
+ ApprovableStep,
63
+ ApprovableButtons,
64
+ ApprovableStepComment,
65
+ ApprovableAuditTimeline,
66
+ ApproveStatusInfo,
67
+ ApprovableStatusSelect,
68
+ ApprovableStatusSelectOption
69
+ }
70
+
71
+ /**
72
+ * Vue plugin, for applications that prefer global registration.
73
+ *
74
+ * app.use(ApprovableVue, {
75
+ * linkComponent: RouterLink,
76
+ * icons: { approve: CheckIcon },
77
+ * messages: { commentSaved: 'Comentariul a fost adăugat.' }
78
+ * })
79
+ *
80
+ * `linkComponent` is what a navigating button renders as. It defaults to a
81
+ * plain anchor so the package works without a router installed.
82
+ *
83
+ * `icons` maps a button's `icon` name to a component. Icons are names, never
84
+ * raw SVG rendered with v-html — that would let anyone who can edit a workflow
85
+ * run script in every approver's browser.
86
+ *
87
+ * `messages` overrides the user-facing strings, which are English by default.
88
+ */
89
+ export default {
90
+ install (app, { linkComponent = null, icons = null, messages = null, register = true } = {}) {
91
+ if (linkComponent) {
92
+ app.provide(APPROVABLE_LINK_COMPONENT, linkComponent)
93
+ }
94
+
95
+ if (icons) {
96
+ app.provide(APPROVABLE_ICON_REGISTRY, icons)
97
+ }
98
+
99
+ if (messages) {
100
+ app.provide(APPROVABLE_MESSAGES, messages)
101
+ }
102
+
103
+ if (register) {
104
+ for (const [name, component] of Object.entries(components)) {
105
+ app.component(name, component)
106
+ }
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,19 @@
1
+ import { inject } from 'vue'
2
+ import { DEFAULT_MESSAGES, mergeMessages } from '@stsdti/approvable-core'
3
+
4
+ export const APPROVABLE_MESSAGES = Symbol('approvable.messages')
5
+
6
+ /**
7
+ * The user-facing strings, in English.
8
+ *
9
+ * Defined in core so that every binding ships the same wording and the same
10
+ * override surface — a string added for the React components is available
11
+ * here too. Any of it can be replaced:
12
+ *
13
+ * app.use(Approvable, { messages: { comments: 'Comentarii' } })
14
+ */
15
+ export function useMessages () {
16
+ return mergeMessages(inject(APPROVABLE_MESSAGES, null))
17
+ }
18
+
19
+ export { DEFAULT_MESSAGES, mergeMessages }