@cat-factory/app 0.251.0 → 0.253.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/app/components/board/nodes/TaskCard.vue +1 -0
- package/app/components/layout/AccountDeploymentSettings.vue +85 -14
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/IntegrationsHub.vue +10 -0
- package/app/components/media/ImageCompare.vue +10 -0
- package/app/components/notifications/NotificationSettingsPanel.vue +252 -0
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +48 -1
- package/app/composables/api/notifications.ts +13 -1
- package/app/composables/usePipelineErrorToast.ts +1 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/notifications.settings.spec.ts +80 -0
- package/app/stores/notifications.ts +80 -2
- package/app/stores/ui/modals.ts +14 -0
- package/app/types/execution.ts +4 -0
- package/app/types/notifications.ts +18 -0
- package/i18n/locales/de.json +80 -4
- package/i18n/locales/en.json +80 -4
- package/i18n/locales/es.json +80 -4
- package/i18n/locales/fr.json +80 -4
- package/i18n/locales/he.json +80 -4
- package/i18n/locales/it.json +80 -4
- package/i18n/locales/ja.json +80 -4
- package/i18n/locales/pl.json +80 -4
- package/i18n/locales/tr.json +80 -4
- package/i18n/locales/uk.json +80 -4
- package/package.json +2 -2
|
@@ -60,16 +60,67 @@ const contentBackendLabels = computed<Record<ContentStorageBackend, string>>(()
|
|
|
60
60
|
s3: t('layout.accountDeployment.contentStorage.backends.s3'),
|
|
61
61
|
r2: t('layout.accountDeployment.contentStorage.backends.r2'),
|
|
62
62
|
db: t('layout.accountDeployment.contentStorage.backends.db'),
|
|
63
|
+
custom: t('layout.accountDeployment.contentStorage.backends.custom'),
|
|
63
64
|
}))
|
|
64
65
|
const storageCapability = computed(() => store.view?.contentStorageCapability ?? null)
|
|
65
66
|
const storageSummary = computed(() => summary.value?.contentStorage ?? null)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// A deployment-registered store is selected as `custom` PLUS an id, so one select carries both:
|
|
68
|
+
// each registered store is its own option, tagged so the save path can tell it from a built-in
|
|
69
|
+
// backend. A two-control version (backend, then store) would leave "custom" selectable with no
|
|
70
|
+
// store chosen, which is the one content-storage config that means nothing.
|
|
71
|
+
const CUSTOM_OPTION_PREFIX = 'custom:'
|
|
72
|
+
const customStores = computed(() => storageCapability.value?.customStores ?? [])
|
|
73
|
+
/**
|
|
74
|
+
* The store an account is configured with that this deployment does NOT register: its build no
|
|
75
|
+
* longer carries it, or it never did. Kept as its own selectable option rather than dropped, so
|
|
76
|
+
* the control shows what is actually stored; hiding it would render an empty select over a
|
|
77
|
+
* working-looking account whose artifacts are going nowhere.
|
|
78
|
+
*/
|
|
79
|
+
const unregisteredStoreId = computed(() => {
|
|
80
|
+
const configured = store.view?.config?.contentStorage
|
|
81
|
+
if (configured?.backend !== 'custom') return null
|
|
82
|
+
const id = configured.custom?.storeId
|
|
83
|
+
if (!id || customStores.value.some((s) => s.id === id)) return null
|
|
84
|
+
return id
|
|
85
|
+
})
|
|
86
|
+
const backendItems = computed(() => [
|
|
87
|
+
...(storageCapability.value?.supportedBackends ?? []).flatMap((b) =>
|
|
88
|
+
b === 'custom'
|
|
89
|
+
? customStores.value.map((s) => ({
|
|
90
|
+
label: s.name,
|
|
91
|
+
value: `${CUSTOM_OPTION_PREFIX}${s.id}`,
|
|
92
|
+
}))
|
|
93
|
+
: [{ label: contentBackendLabels.value[b], value: b as string }],
|
|
94
|
+
),
|
|
95
|
+
...(unregisteredStoreId.value
|
|
96
|
+
? [
|
|
97
|
+
{
|
|
98
|
+
label: t('layout.accountDeployment.contentStorage.unregisteredStore', {
|
|
99
|
+
store: unregisteredStoreId.value,
|
|
100
|
+
}),
|
|
101
|
+
value: `${CUSTOM_OPTION_PREFIX}${unregisteredStoreId.value}`,
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
: []),
|
|
105
|
+
])
|
|
106
|
+
/** The select's value: a backend id, or `custom:<storeId>` for a registered store. */
|
|
107
|
+
const csBackend = ref<string>('off')
|
|
108
|
+
/** The registered store the select currently names, for the note under it. */
|
|
109
|
+
const selectedCustomStore = computed(() =>
|
|
110
|
+
customStores.value.find((s) => `${CUSTOM_OPTION_PREFIX}${s.id}` === csBackend.value),
|
|
71
111
|
)
|
|
72
|
-
|
|
112
|
+
/**
|
|
113
|
+
* What the status badge says. `custom` is the one backend whose own label names nothing an
|
|
114
|
+
* operator can act on, so it resolves to the STORE: its registered name, or the bare id when this
|
|
115
|
+
* build does not register it (which the warning below then explains).
|
|
116
|
+
*/
|
|
117
|
+
const configuredStorageLabel = computed(() => {
|
|
118
|
+
const configured = storageSummary.value
|
|
119
|
+
if (!configured?.backend) return null
|
|
120
|
+
if (configured.backend !== 'custom') return contentBackendLabels.value[configured.backend]
|
|
121
|
+
const registered = customStores.value.find((s) => s.id === configured.customStoreId)
|
|
122
|
+
return registered?.name ?? configured.customStoreId ?? contentBackendLabels.value.custom
|
|
123
|
+
})
|
|
73
124
|
const cs = reactive({
|
|
74
125
|
basePath: '',
|
|
75
126
|
region: '',
|
|
@@ -84,7 +135,10 @@ const savingStorage = ref(false)
|
|
|
84
135
|
|
|
85
136
|
function hydrateStorage() {
|
|
86
137
|
const cfg = store.view?.config?.contentStorage
|
|
87
|
-
csBackend.value =
|
|
138
|
+
csBackend.value =
|
|
139
|
+
cfg?.backend === 'custom' && cfg.custom?.storeId
|
|
140
|
+
? `${CUSTOM_OPTION_PREFIX}${cfg.custom.storeId}`
|
|
141
|
+
: (cfg?.backend ?? storageCapability.value?.defaultBackend ?? 'off')
|
|
88
142
|
cs.basePath = cfg?.fs?.basePath ?? ''
|
|
89
143
|
cs.region = cfg?.s3?.region ?? ''
|
|
90
144
|
cs.bucket = cfg?.s3?.bucket ?? ''
|
|
@@ -113,8 +167,16 @@ onMounted(async () => {
|
|
|
113
167
|
})
|
|
114
168
|
|
|
115
169
|
async function saveStorage() {
|
|
116
|
-
const
|
|
117
|
-
const
|
|
170
|
+
const selected = csBackend.value
|
|
171
|
+
const customStoreId = selected.startsWith(CUSTOM_OPTION_PREFIX)
|
|
172
|
+
? selected.slice(CUSTOM_OPTION_PREFIX.length)
|
|
173
|
+
: null
|
|
174
|
+
const backend: ContentStorageBackend = customStoreId
|
|
175
|
+
? 'custom'
|
|
176
|
+
: (selected as ContentStorageBackend)
|
|
177
|
+
const config: ContentStorageConfig = customStoreId
|
|
178
|
+
? { backend, custom: { storeId: customStoreId } }
|
|
179
|
+
: { backend }
|
|
118
180
|
if (backend === 'fs' && cs.basePath.trim()) {
|
|
119
181
|
config.fs = { basePath: cs.basePath.trim() }
|
|
120
182
|
}
|
|
@@ -677,11 +739,10 @@ async function clearWeb() {
|
|
|
677
739
|
size="xs"
|
|
678
740
|
>
|
|
679
741
|
{{
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
:
|
|
683
|
-
|
|
684
|
-
})
|
|
742
|
+
configuredStorageLabel ??
|
|
743
|
+
t('layout.accountDeployment.contentStorage.default', {
|
|
744
|
+
backend: contentBackendLabels[storageCapability.defaultBackend],
|
|
745
|
+
})
|
|
685
746
|
}}
|
|
686
747
|
</UBadge>
|
|
687
748
|
</div>
|
|
@@ -691,6 +752,16 @@ async function clearWeb() {
|
|
|
691
752
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
692
753
|
<USelect v-model="csBackend" :items="backendItems" value-key="value" size="sm" />
|
|
693
754
|
</div>
|
|
755
|
+
<p v-if="selectedCustomStore?.summary" class="text-[11px] text-slate-400">
|
|
756
|
+
{{ selectedCustomStore.summary }}
|
|
757
|
+
</p>
|
|
758
|
+
<p v-if="unregisteredStoreId" class="text-[11px] text-amber-400">
|
|
759
|
+
{{
|
|
760
|
+
t('layout.accountDeployment.contentStorage.unregisteredStoreWarning', {
|
|
761
|
+
store: unregisteredStoreId,
|
|
762
|
+
})
|
|
763
|
+
}}
|
|
764
|
+
</p>
|
|
694
765
|
|
|
695
766
|
<!-- Filesystem -->
|
|
696
767
|
<div v-if="csBackend === 'fs'" class="grid grid-cols-1 gap-2">
|
|
@@ -72,6 +72,14 @@ const dynamicIntegrationCommands = computed<Command[]>(() => {
|
|
|
72
72
|
run: () => ui.openSlack(),
|
|
73
73
|
})
|
|
74
74
|
}
|
|
75
|
+
list.push({
|
|
76
|
+
id: 'notification-settings',
|
|
77
|
+
label: t('layout.commandBar.cmd.notificationSettings'),
|
|
78
|
+
group: groupIntegrations,
|
|
79
|
+
icon: 'i-lucide-bell',
|
|
80
|
+
keywords: t('layout.commandBar.keywords.notificationSettings'),
|
|
81
|
+
run: () => ui.openNotificationSettings(),
|
|
82
|
+
})
|
|
75
83
|
if (documents.available) {
|
|
76
84
|
for (const src of documents.sources) {
|
|
77
85
|
list.push({
|
|
@@ -150,6 +150,16 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
150
150
|
onClick: () => go(ui.openSlack),
|
|
151
151
|
})
|
|
152
152
|
}
|
|
153
|
+
// The notification manager is always listed: it configures the channels every deployment has
|
|
154
|
+
// (the in-app inbox, and email once an account connects a sender), so unlike the integrations
|
|
155
|
+
// around it there is no connection to probe before it is useful.
|
|
156
|
+
comms.push({
|
|
157
|
+
key: 'notificationSettings',
|
|
158
|
+
icon: 'i-lucide-bell',
|
|
159
|
+
label: t('layout.integrationsHub.items.notificationSettings.label'),
|
|
160
|
+
description: t('layout.integrationsHub.items.notificationSettings.description'),
|
|
161
|
+
onClick: () => go(ui.openNotificationSettings),
|
|
162
|
+
})
|
|
153
163
|
if (comms.length)
|
|
154
164
|
out.push({ title: t('layout.integrationsHub.groups.communication'), items: comms })
|
|
155
165
|
|
|
@@ -16,6 +16,13 @@ const props = defineProps<{
|
|
|
16
16
|
referenceId: string | null | undefined
|
|
17
17
|
blobs: ArtifactBlobs
|
|
18
18
|
busy?: boolean
|
|
19
|
+
/**
|
|
20
|
+
* Where the reference came from, when the caller knows. A frame rendered from a linked design
|
|
21
|
+
* and a mock a person attached are different claims about what "the reference" is, so the
|
|
22
|
+
* caption says which; absent leaves the plain label, which is what an unattributed reference
|
|
23
|
+
* honestly is.
|
|
24
|
+
*/
|
|
25
|
+
referenceOrigin?: 'upload' | 'design' | null
|
|
19
26
|
}>()
|
|
20
27
|
|
|
21
28
|
const emit = defineEmits<{
|
|
@@ -196,6 +203,9 @@ function onRefInput(e: Event) {
|
|
|
196
203
|
<figure class="space-y-1">
|
|
197
204
|
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
198
205
|
{{ t('media.compare.reference') }}
|
|
206
|
+
<span v-if="referenceOrigin === 'design'" class="text-amber-300/80">
|
|
207
|
+
{{ t('media.compare.fromLinkedDesign') }}
|
|
208
|
+
</span>
|
|
199
209
|
</figcaption>
|
|
200
210
|
<button
|
|
201
211
|
v-if="refUrl"
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The notification manager: which notification types this board delivers on which channel.
|
|
3
|
+
//
|
|
4
|
+
// One row per notification type, one switch per routed channel. The switches render the
|
|
5
|
+
// RESOLVED routing (a workspace's override, else the shipped default) through the same
|
|
6
|
+
// `isNotificationRouted` the delivery path uses, so what the toggle says is what the engine
|
|
7
|
+
// does. Saving writes only the cells that differ from their default, which is what makes
|
|
8
|
+
// "put this back the way it ships" expressible at all — and what lets a later change to a
|
|
9
|
+
// default reach every board that never opted out of it.
|
|
10
|
+
//
|
|
11
|
+
// Slack and the outbound webhooks are deliberately absent: each answers "which types" where
|
|
12
|
+
// its DESTINATION is declared (a Slack route's channel, a webhook endpoint's own filter), so
|
|
13
|
+
// a second switch here would be a place to look that does not decide. The footer says so and
|
|
14
|
+
// links to the Slack panel.
|
|
15
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
16
|
+
import {
|
|
17
|
+
NOTIFICATION_DELIVERY_CHANNELS,
|
|
18
|
+
defaultNotificationRoute,
|
|
19
|
+
isNotificationRouted,
|
|
20
|
+
notificationTypeSchema,
|
|
21
|
+
} from '@cat-factory/contracts'
|
|
22
|
+
import type {
|
|
23
|
+
NotificationDeliveryChannel,
|
|
24
|
+
NotificationRoutingMatrix,
|
|
25
|
+
NotificationType,
|
|
26
|
+
} from '~/types/notifications'
|
|
27
|
+
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
28
|
+
|
|
29
|
+
const ui = useUiStore()
|
|
30
|
+
const notifications = useNotificationsStore()
|
|
31
|
+
const slack = useSlackStore()
|
|
32
|
+
const toast = useToast()
|
|
33
|
+
const { t } = useI18n()
|
|
34
|
+
|
|
35
|
+
const open = computed({
|
|
36
|
+
get: () => ui.notificationSettingsOpen,
|
|
37
|
+
set: (v: boolean) => (v ? ui.openNotificationSettings() : ui.closeNotificationSettings()),
|
|
38
|
+
})
|
|
39
|
+
const back = useIntegrationBack(open)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every notification type, in the picklist's own order, taken from the SCHEMA rather than a
|
|
43
|
+
* hand-kept list: a type added to the vocabulary appears here with its shipped default instead
|
|
44
|
+
* of being invisible to the only surface that can change it.
|
|
45
|
+
*/
|
|
46
|
+
const TYPES = notificationTypeSchema.options as readonly NotificationType[]
|
|
47
|
+
|
|
48
|
+
/** The editable grid: `resolved[type][channel]` is the switch's state. */
|
|
49
|
+
const resolved = reactive(
|
|
50
|
+
Object.fromEntries(
|
|
51
|
+
TYPES.map((type) => [
|
|
52
|
+
type,
|
|
53
|
+
Object.fromEntries(
|
|
54
|
+
NOTIFICATION_DELIVERY_CHANNELS.map((channel) => [
|
|
55
|
+
channel,
|
|
56
|
+
defaultNotificationRoute(type, channel),
|
|
57
|
+
]),
|
|
58
|
+
) as Record<NotificationDeliveryChannel, boolean>,
|
|
59
|
+
]),
|
|
60
|
+
) as Record<NotificationType, Record<NotificationDeliveryChannel, boolean>>,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const busy = ref(false)
|
|
64
|
+
|
|
65
|
+
function applyMatrix(matrix: NotificationRoutingMatrix | null | undefined) {
|
|
66
|
+
for (const type of TYPES) {
|
|
67
|
+
for (const channel of NOTIFICATION_DELIVERY_CHANNELS) {
|
|
68
|
+
resolved[type][channel] = isNotificationRouted(matrix, type, channel)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function notifyError(title: string, e: unknown) {
|
|
74
|
+
toast.add({
|
|
75
|
+
title,
|
|
76
|
+
description: e instanceof Error ? e.message : String(e),
|
|
77
|
+
icon: 'i-lucide-triangle-alert',
|
|
78
|
+
color: 'error',
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The grid is editable only once the board's OWN matrix is in hand.
|
|
84
|
+
*
|
|
85
|
+
* The switches start on the shipped defaults, which is right while loading and wrong as a
|
|
86
|
+
* statement about the board: on a failed read the panel used to render them as the current
|
|
87
|
+
* configuration, and save is a full replace, so one press would write that guess over whatever
|
|
88
|
+
* overrides were stored. `failed` therefore gets its own state with a retry and no save, kept
|
|
89
|
+
* distinct from the settled `unavailable`.
|
|
90
|
+
*/
|
|
91
|
+
const editable = computed(() => notifications.settingsStatus === 'ready')
|
|
92
|
+
|
|
93
|
+
async function load() {
|
|
94
|
+
try {
|
|
95
|
+
await notifications.loadSettings()
|
|
96
|
+
applyMatrix(notifications.settings?.matrix)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
notifyError(t('notificationSettings.error.load'), e)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
watch(
|
|
103
|
+
() => open.value,
|
|
104
|
+
async (isOpen) => {
|
|
105
|
+
if (!isOpen) return
|
|
106
|
+
await load()
|
|
107
|
+
},
|
|
108
|
+
// Lazy v-if mount: the panel mounts with `open` already true, so load immediately.
|
|
109
|
+
{ immediate: true },
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async function save() {
|
|
113
|
+
// Store only what DIFFERS from the shipped default. A full grid would freeze today's
|
|
114
|
+
// defaults onto every board that saved once, so a later change to what "high impact" means
|
|
115
|
+
// would reach nobody who had ever opened this panel.
|
|
116
|
+
const matrix: NotificationRoutingMatrix = {}
|
|
117
|
+
for (const type of TYPES) {
|
|
118
|
+
const overrides: Partial<Record<NotificationDeliveryChannel, boolean>> = {}
|
|
119
|
+
for (const channel of NOTIFICATION_DELIVERY_CHANNELS) {
|
|
120
|
+
if (resolved[type][channel] !== defaultNotificationRoute(type, channel)) {
|
|
121
|
+
overrides[channel] = resolved[type][channel]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(overrides).length > 0) matrix[type] = overrides
|
|
125
|
+
}
|
|
126
|
+
busy.value = true
|
|
127
|
+
try {
|
|
128
|
+
await notifications.updateSettings(matrix)
|
|
129
|
+
toast.add({
|
|
130
|
+
title: t('notificationSettings.toast.saved'),
|
|
131
|
+
icon: 'i-lucide-check',
|
|
132
|
+
color: 'success',
|
|
133
|
+
})
|
|
134
|
+
} catch (e) {
|
|
135
|
+
notifyError(t('notificationSettings.error.save'), e)
|
|
136
|
+
} finally {
|
|
137
|
+
busy.value = false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Restore every type to its shipped routing (the switches; nothing is written until save). */
|
|
142
|
+
function resetToDefaults() {
|
|
143
|
+
applyMatrix({})
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<UModal
|
|
149
|
+
v-model:open="open"
|
|
150
|
+
:title="t('notificationSettings.panel.title')"
|
|
151
|
+
:ui="{ content: 'max-w-2xl' }"
|
|
152
|
+
>
|
|
153
|
+
<template #title>
|
|
154
|
+
<IntegrationBackTitle :title="t('notificationSettings.panel.title')" @back="back" />
|
|
155
|
+
</template>
|
|
156
|
+
<template #body>
|
|
157
|
+
<div class="space-y-4">
|
|
158
|
+
<p class="text-xs text-slate-400">{{ t('notificationSettings.panel.intro') }}</p>
|
|
159
|
+
|
|
160
|
+
<div
|
|
161
|
+
v-if="notifications.settingsStatus === 'unavailable'"
|
|
162
|
+
class="rounded-lg border border-slate-700 bg-slate-800/40 p-3 text-xs text-slate-400"
|
|
163
|
+
>
|
|
164
|
+
{{ t('notificationSettings.unavailable') }}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div
|
|
168
|
+
v-else-if="notifications.settingsStatus === 'failed'"
|
|
169
|
+
class="space-y-3 rounded-lg border border-amber-700/60 bg-amber-950/30 p-3 text-xs text-amber-200"
|
|
170
|
+
>
|
|
171
|
+
<p>{{ t('notificationSettings.loadFailed') }}</p>
|
|
172
|
+
<UButton color="neutral" variant="soft" size="xs" icon="i-lucide-rotate-cw" @click="load">
|
|
173
|
+
{{ t('common.retry') }}
|
|
174
|
+
</UButton>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div
|
|
178
|
+
v-else-if="!editable"
|
|
179
|
+
class="rounded-lg border border-slate-700 bg-slate-800/40 p-3 text-xs text-slate-400"
|
|
180
|
+
>
|
|
181
|
+
{{ t('common.loading') }}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<template v-else>
|
|
185
|
+
<div
|
|
186
|
+
class="flex items-center gap-3 px-2 text-[10px] uppercase tracking-wide text-slate-500"
|
|
187
|
+
>
|
|
188
|
+
<span class="flex-1">{{ t('notificationSettings.column.event') }}</span>
|
|
189
|
+
<span class="w-16 text-center">{{ t('notificationSettings.column.inApp') }}</span>
|
|
190
|
+
<span class="w-16 text-center">{{ t('notificationSettings.column.email') }}</span>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="max-h-[50vh] space-y-1 overflow-y-auto pr-1">
|
|
194
|
+
<div
|
|
195
|
+
v-for="type in TYPES"
|
|
196
|
+
:key="type"
|
|
197
|
+
class="flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-2"
|
|
198
|
+
>
|
|
199
|
+
<span class="flex-1 text-sm text-slate-300">
|
|
200
|
+
{{ t(`notificationSettings.type.${type}`) }}
|
|
201
|
+
</span>
|
|
202
|
+
<div class="flex w-16 justify-center">
|
|
203
|
+
<USwitch v-model="resolved[type]!.in_app" size="sm" />
|
|
204
|
+
</div>
|
|
205
|
+
<div class="flex w-16 justify-center">
|
|
206
|
+
<USwitch v-model="resolved[type]!.email" size="sm" />
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<p class="text-[11px] text-slate-500">{{ t('notificationSettings.panel.inAppNote') }}</p>
|
|
212
|
+
<p class="text-[11px] text-slate-500">{{ t('notificationSettings.panel.emailNote') }}</p>
|
|
213
|
+
<p class="text-[11px] text-slate-500">
|
|
214
|
+
{{ t('notificationSettings.panel.otherChannelsNote') }}
|
|
215
|
+
<UButton
|
|
216
|
+
v-if="slack.available"
|
|
217
|
+
color="neutral"
|
|
218
|
+
variant="link"
|
|
219
|
+
size="xs"
|
|
220
|
+
class="px-1"
|
|
221
|
+
@click="ui.openSlack()"
|
|
222
|
+
>
|
|
223
|
+
{{ t('notificationSettings.panel.openSlack') }}
|
|
224
|
+
</UButton>
|
|
225
|
+
</p>
|
|
226
|
+
|
|
227
|
+
<div class="flex justify-between">
|
|
228
|
+
<UButton
|
|
229
|
+
color="neutral"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size="xs"
|
|
232
|
+
icon="i-lucide-rotate-ccw"
|
|
233
|
+
@click="resetToDefaults"
|
|
234
|
+
>
|
|
235
|
+
{{ t('notificationSettings.action.reset') }}
|
|
236
|
+
</UButton>
|
|
237
|
+
<UButton
|
|
238
|
+
color="primary"
|
|
239
|
+
variant="soft"
|
|
240
|
+
size="xs"
|
|
241
|
+
icon="i-lucide-save"
|
|
242
|
+
:loading="busy || notifications.savingSettings"
|
|
243
|
+
@click="save"
|
|
244
|
+
>
|
|
245
|
+
{{ t('notificationSettings.action.save') }}
|
|
246
|
+
</UButton>
|
|
247
|
+
</div>
|
|
248
|
+
</template>
|
|
249
|
+
</div>
|
|
250
|
+
</template>
|
|
251
|
+
</UModal>
|
|
252
|
+
</template>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (per-view notes + a freeform box, composed into the Tester's fixer findings), or recapture.
|
|
9
9
|
// References can be dropped straight onto a pair, or uploaded for any view below.
|
|
10
10
|
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
|
11
|
-
import type { VisualConfirmStepState } from '~/types/execution'
|
|
11
|
+
import type { VisualConfirmDesignGapReason, VisualConfirmStepState } from '~/types/execution'
|
|
12
12
|
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
13
13
|
import ImageCompare from '~/components/media/ImageCompare.vue'
|
|
14
14
|
import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
@@ -63,6 +63,23 @@ const OUTCOME_LABELS = computed<Record<'completed' | 'failed', string>>(() => ({
|
|
|
63
63
|
failed: t('visualConfirm.outcome.failed'),
|
|
64
64
|
}))
|
|
65
65
|
|
|
66
|
+
// What the task's LINKED DESIGNS contributed. Present whenever a design is linked, including
|
|
67
|
+
// when everything worked: a reviewer comparing a screen against a Figma frame needs to know the
|
|
68
|
+
// frame is the design's own, and one seeing no design frames needs to know whether a design is
|
|
69
|
+
// linked at all. Absent ⇒ the task links none, which this panel must not invent a line about.
|
|
70
|
+
const design = computed(() => vc.value?.designReferences ?? null)
|
|
71
|
+
|
|
72
|
+
// Exhaustive map of the gap vocabulary → copy, literal-keyed for the same drift-guard reason as
|
|
73
|
+
// the outcome labels above. Each names a DIFFERENT fix, which is why the backend keeps them apart
|
|
74
|
+
// instead of collapsing them into one "no images" absence.
|
|
75
|
+
const DESIGN_GAP_LABELS = computed<Record<VisualConfirmDesignGapReason, string>>(() => ({
|
|
76
|
+
partial: t('visualConfirm.design.gap.partial'),
|
|
77
|
+
failed: t('visualConfirm.design.gap.failed'),
|
|
78
|
+
none: t('visualConfirm.design.gap.none'),
|
|
79
|
+
storage_unavailable: t('visualConfirm.design.gap.storage_unavailable'),
|
|
80
|
+
not_retained: t('visualConfirm.design.gap.not_retained'),
|
|
81
|
+
}))
|
|
82
|
+
|
|
66
83
|
// Resolve every pair's artifacts (the gallery + the lightbox share this one cache).
|
|
67
84
|
watch(
|
|
68
85
|
pairs,
|
|
@@ -212,6 +229,35 @@ async function onFilePicked(e: Event) {
|
|
|
212
229
|
{{ vc.degradedReason }}
|
|
213
230
|
</p>
|
|
214
231
|
|
|
232
|
+
<!-- What the linked designs contributed. Rendered even when nothing is missing, so a
|
|
233
|
+
reference the reviewer is judging against is never anonymous. -->
|
|
234
|
+
<section
|
|
235
|
+
v-if="design"
|
|
236
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 text-[12px] text-slate-300"
|
|
237
|
+
>
|
|
238
|
+
<p class="flex items-center gap-1.5">
|
|
239
|
+
<UIcon name="i-lucide-figma" class="h-3.5 w-3.5 shrink-0 text-amber-300" />
|
|
240
|
+
<span>{{
|
|
241
|
+
t('visualConfirm.design.summary', { count: design.images }, design.images)
|
|
242
|
+
}}</span>
|
|
243
|
+
<span v-if="design.dropped" class="text-slate-500">
|
|
244
|
+
{{ t('visualConfirm.design.dropped', { count: design.dropped }, design.dropped) }}
|
|
245
|
+
</span>
|
|
246
|
+
</p>
|
|
247
|
+
<!-- One line per short design, carrying both ways it can fall short: what its source
|
|
248
|
+
kept, and what this gallery's shared ceiling cut from it. A design the ceiling shut
|
|
249
|
+
out entirely reads as one with no frames unless it is named here. -->
|
|
250
|
+
<ul v-if="design.gaps?.length" class="mt-1.5 space-y-1 text-[11px] text-amber-300/90">
|
|
251
|
+
<li v-for="gap in design.gaps" :key="`${gap.title}-${gap.reason ?? 'capped'}`">
|
|
252
|
+
{{ t('visualConfirm.design.gapLine', { title: gap.title }) }}
|
|
253
|
+
<template v-if="gap.reason">{{ DESIGN_GAP_LABELS[gap.reason] }}</template>
|
|
254
|
+
<template v-if="gap.dropped">{{
|
|
255
|
+
t('visualConfirm.design.gapDropped', { count: gap.dropped }, gap.dropped)
|
|
256
|
+
}}</template>
|
|
257
|
+
</li>
|
|
258
|
+
</ul>
|
|
259
|
+
</section>
|
|
260
|
+
|
|
215
261
|
<p
|
|
216
262
|
v-if="working"
|
|
217
263
|
class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
|
|
@@ -228,6 +274,7 @@ async function onFilePicked(e: Event) {
|
|
|
228
274
|
:view="p.view"
|
|
229
275
|
:actual-id="p.actualArtifactId"
|
|
230
276
|
:reference-id="p.referenceArtifactId"
|
|
277
|
+
:reference-origin="p.referenceOrigin"
|
|
231
278
|
:blobs="blobs"
|
|
232
279
|
:busy="busy"
|
|
233
280
|
@expand="expand"
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
actNotificationContract,
|
|
3
3
|
dismissNotificationContract,
|
|
4
|
+
getNotificationSettingsContract,
|
|
4
5
|
listNotificationsContract,
|
|
6
|
+
updateNotificationSettingsContract,
|
|
5
7
|
} from '@cat-factory/contracts'
|
|
6
8
|
import type { ReviewEffort } from '~/types/merge'
|
|
9
|
+
import type { NotificationRoutingMatrix } from '~/types/notifications'
|
|
7
10
|
import type { ApiContext } from './context'
|
|
8
11
|
|
|
9
|
-
/** The human-actionable notification inbox (act / dismiss). */
|
|
12
|
+
/** The human-actionable notification inbox (act / dismiss) + the per-workspace manager. */
|
|
10
13
|
export function notificationsApi({ send, ws }: ApiContext) {
|
|
11
14
|
return {
|
|
12
15
|
// ---- notifications (human-actionable board items) ---------------------
|
|
@@ -29,5 +32,14 @@ export function notificationsApi({ send, ws }: ApiContext) {
|
|
|
29
32
|
pathPrefix: ws(workspaceId),
|
|
30
33
|
pathParams: { notificationId: id },
|
|
31
34
|
}),
|
|
35
|
+
|
|
36
|
+
// ---- the notification manager (per-workspace channel routing) ---------
|
|
37
|
+
// Which types this board delivers on which channel. The read is member-visible; the
|
|
38
|
+
// write is admin-tier (a 403 from the backend, which the panel surfaces).
|
|
39
|
+
getNotificationSettings: (workspaceId: string) =>
|
|
40
|
+
send(getNotificationSettingsContract, { pathPrefix: ws(workspaceId) }),
|
|
41
|
+
|
|
42
|
+
updateNotificationSettings: (workspaceId: string, matrix: NotificationRoutingMatrix) =>
|
|
43
|
+
send(updateNotificationSettingsContract, { pathPrefix: ws(workspaceId), body: { matrix } }),
|
|
32
44
|
}
|
|
33
45
|
}
|
|
@@ -335,6 +335,7 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
|
|
|
335
335
|
'errors.unavailable.description.foundational_builtins_unreachable',
|
|
336
336
|
connection_credentials_unreadable:
|
|
337
337
|
'errors.unavailable.description.connection_credentials_unreadable',
|
|
338
|
+
vcs_capability_unsupported: 'errors.unavailable.description.vcs_capability_unsupported',
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
/**
|
package/app/pages/index.vue
CHANGED
|
@@ -66,6 +66,9 @@ const AddServiceFromRepoModal = defineAsyncComponent(
|
|
|
66
66
|
)
|
|
67
67
|
const GitHubPanel = defineAsyncComponent(() => import('~/components/github/GitHubPanel.vue'))
|
|
68
68
|
const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPanel.vue'))
|
|
69
|
+
const NotificationSettingsPanel = defineAsyncComponent(
|
|
70
|
+
() => import('~/components/notifications/NotificationSettingsPanel.vue'),
|
|
71
|
+
)
|
|
69
72
|
const FragmentLibraryPanel = defineAsyncComponent(
|
|
70
73
|
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
71
74
|
)
|
|
@@ -480,6 +483,7 @@ watch(
|
|
|
480
483
|
<AddServiceFromRepoModal v-if="ui.addServiceOpen" />
|
|
481
484
|
<GitHubPanel v-if="ui.githubOpen" />
|
|
482
485
|
<SlackPanel v-if="ui.slackOpen" />
|
|
486
|
+
<NotificationSettingsPanel v-if="ui.notificationSettingsOpen" />
|
|
483
487
|
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
484
488
|
<FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
|
|
485
489
|
<PipelineHealthModal v-if="ui.pipelineHealthOpen" />
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { ApiError } from '~/composables/api/errors'
|
|
3
|
+
import { useNotificationsStore } from '~/stores/notifications'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
// The notification manager's LOAD outcome, which decides whether the settings panel may offer a
|
|
7
|
+
// save at all. Its write is a full replace of the board's overrides, and the grid it saves is
|
|
8
|
+
// pre-filled with the shipped defaults, so a load that ended in anything but `ready` must be
|
|
9
|
+
// distinguishable: rendering an unknown configuration as the current one turns one press of Save
|
|
10
|
+
// into a silent wipe of every override the board had.
|
|
11
|
+
|
|
12
|
+
describe('notifications store: manager settings load outcome', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('is `ready` with the board matrix once the read succeeds', async () => {
|
|
18
|
+
const settings = { matrix: { merge_review: { email: false } }, updatedAt: 7 }
|
|
19
|
+
vi.stubGlobal('useApi', () => ({
|
|
20
|
+
getNotificationSettings: () => Promise.resolve(settings),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const store = useNotificationsStore()
|
|
24
|
+
await store.loadSettings()
|
|
25
|
+
|
|
26
|
+
expect(store.settingsStatus).toBe('ready')
|
|
27
|
+
expect(store.settings).toEqual(settings)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is `unavailable` (settled, not an error) when the deployment wired no routing store', async () => {
|
|
31
|
+
vi.stubGlobal('useApi', () => ({
|
|
32
|
+
getNotificationSettings: () =>
|
|
33
|
+
Promise.reject(
|
|
34
|
+
new ApiError(503, { error: { code: 'unavailable', message: 'no routing store' } }),
|
|
35
|
+
),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const store = useNotificationsStore()
|
|
39
|
+
// A 503 is the opt-in shape, so the caller is not asked to report it.
|
|
40
|
+
await expect(store.loadSettings()).resolves.toBeUndefined()
|
|
41
|
+
|
|
42
|
+
expect(store.settingsStatus).toBe('unavailable')
|
|
43
|
+
expect(store.settings).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('is `failed`, NOT `unavailable`, on a transient read fault, and still surfaces it', async () => {
|
|
47
|
+
vi.stubGlobal('useApi', () => ({
|
|
48
|
+
getNotificationSettings: () =>
|
|
49
|
+
Promise.reject(
|
|
50
|
+
new ApiError(500, { error: { code: 'internal', message: 'upstream exploded' } }),
|
|
51
|
+
),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const store = useNotificationsStore()
|
|
55
|
+
await expect(store.loadSettings()).rejects.toThrow('upstream exploded')
|
|
56
|
+
|
|
57
|
+
// The distinction IS the fix: `unavailable` means the shipped defaults are the whole truth,
|
|
58
|
+
// while `failed` means the board's real configuration is unknown and must not be written over.
|
|
59
|
+
expect(store.settingsStatus).toBe('failed')
|
|
60
|
+
expect(store.settings).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('leaves no stale matrix behind when a reload fails after a good load', async () => {
|
|
64
|
+
let fail = false
|
|
65
|
+
vi.stubGlobal('useApi', () => ({
|
|
66
|
+
getNotificationSettings: () =>
|
|
67
|
+
fail
|
|
68
|
+
? Promise.reject(new ApiError(500, { error: { code: 'internal', message: 'gone' } }))
|
|
69
|
+
: Promise.resolve({ matrix: { ci_failed: { in_app: false } }, updatedAt: 1 }),
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
const store = useNotificationsStore()
|
|
73
|
+
await store.loadSettings()
|
|
74
|
+
fail = true
|
|
75
|
+
await expect(store.loadSettings()).rejects.toThrow()
|
|
76
|
+
|
|
77
|
+
expect(store.settingsStatus).toBe('failed')
|
|
78
|
+
expect(store.settings).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|