@cat-factory/app 0.261.3 → 0.261.5
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/README.md +40 -0
- package/app/components/board/AddTaskModal.vue +10 -6
- package/app/components/board/AgentStopButton.vue +2 -5
- package/app/components/board/CreateInitiativeModal.vue +2 -7
- package/app/components/board/RecurringPipelineModal.vue +10 -6
- package/app/components/bootstrap/BootstrapModal.vue +5 -24
- package/app/components/brainstorm/BrainstormWindow.vue +7 -15
- package/app/components/clarity/ClarityReviewWindow.vue +7 -15
- package/app/components/documents/DocumentImportModal.vue +2 -6
- package/app/components/documents/DocumentSourceConnectModal.vue +3 -12
- package/app/components/documents/DocumentSyncState.vue +2 -7
- package/app/components/documents/DocumentTemplatesModal.vue +4 -19
- package/app/components/documents/SpawnPreviewModal.vue +3 -12
- package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -11
- package/app/components/foundational/FoundationalServiceRegistry.vue +3 -11
- package/app/components/foundational/FoundationalServiceSources.vue +5 -13
- package/app/components/foundational/FoundationalSuppressions.vue +2 -6
- package/app/components/fragments/FragmentLibraryManager.vue +12 -20
- package/app/components/github/AddServiceFromRepoModal.vue +3 -12
- package/app/components/github/GitHubConnect.vue +4 -12
- package/app/components/github/GitHubPanel.vue +9 -17
- package/app/components/github/RepoTreeBrowser.vue +2 -7
- package/app/components/humanTest/HumanTestWindow.vue +2 -7
- package/app/components/initiative/InitiativeTrackerWindow.vue +2 -3
- package/app/components/layout/AccountAuditLog.vue +2 -7
- package/app/components/layout/AccountDeploymentSettings.vue +11 -51
- package/app/components/layout/AccountModelPolicySettings.vue +2 -5
- package/app/components/layout/AccountPlatformAlertSettings.vue +2 -5
- package/app/components/layout/AccountRunCredentialSettings.vue +2 -5
- package/app/components/layout/AccountTeamSettings.vue +9 -18
- package/app/components/layout/BoardSwitcher.vue +6 -14
- package/app/components/layout/BoardToolbar.vue +3 -10
- package/app/components/layout/NotificationsInbox.vue +3 -13
- package/app/components/layout/WorkspaceMembersSettings.vue +6 -15
- package/app/components/notifications/NotificationSettingsPanel.vue +3 -11
- package/app/components/panels/DecisionModal.vue +2 -7
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +4 -20
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +3 -11
- package/app/components/panels/inspector/ServiceTestSecrets.vue +3 -11
- package/app/components/panels/inspector/ServiceValidationConfig.vue +4 -12
- package/app/components/pipeline/PipelineBuilder.vue +4 -7
- package/app/components/providers/ApiKeysSection.vue +4 -15
- package/app/components/providers/PersonalCredentialModal.vue +2 -5
- package/app/components/providers/PersonalSubscriptionSection.vue +3 -10
- package/app/components/providers/VendorCredentialsModal.vue +5 -20
- package/app/components/requirements/RequirementsReviewWindow.vue +11 -19
- package/app/components/sandbox/SandboxPanel.vue +4 -18
- package/app/components/settings/ApiTokensPanel.vue +4 -12
- package/app/components/settings/BudgetSettings.vue +2 -5
- package/app/components/settings/CloudflareHandlerSection.vue +3 -12
- package/app/components/settings/ConsensusGroupsSection.vue +3 -12
- package/app/components/settings/CustomManifestTypeEditor.vue +3 -13
- package/app/components/settings/DefaultProvisionTypeSection.vue +2 -5
- package/app/components/settings/InfraHandlersConfigurator.vue +7 -14
- package/app/components/settings/InfrastructureBackendPicker.vue +2 -7
- package/app/components/settings/IssueTrackerPanel.vue +4 -18
- package/app/components/settings/KubernetesEngineForm.vue +71 -26
- package/app/components/settings/KubernetesEnvironmentForm.vue +35 -8
- package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
- package/app/components/settings/LocalModelEndpointsPanel.vue +3 -11
- package/app/components/settings/ModelConfigurationPanel.vue +13 -16
- package/app/components/settings/ModelPresetHealthModal.vue +3 -13
- package/app/components/settings/ObservabilityConnectionPanel.vue +6 -14
- package/app/components/settings/OpenRouterCatalogPanel.vue +3 -12
- package/app/components/settings/PackageRegistriesPanel.vue +4 -12
- package/app/components/settings/ProviderConnectionTab.vue +5 -13
- package/app/components/settings/RiskPolicyHealthModal.vue +2 -7
- package/app/components/settings/RiskPolicyPanel.vue +5 -13
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +2 -7
- package/app/components/settings/SharedStacksPanel.vue +10 -20
- package/app/components/settings/TaskTypeSuppressionsPanel.vue +3 -12
- package/app/components/settings/UserSecretsSection.vue +3 -11
- package/app/components/settings/WorkspaceMetadataSettings.vue +2 -6
- package/app/components/settings/WorkspaceSettingsPanel.vue +2 -6
- package/app/components/skills/SkillLibraryManager.vue +5 -13
- package/app/components/slack/SlackPanel.vue +7 -15
- package/app/components/tasks/BugHuntModal.vue +2 -6
- package/app/components/tasks/TaskImportModal.vue +2 -6
- package/app/components/tasks/TaskSourceConnectModal.vue +4 -18
- package/app/composables/useBlockDeletion.ts +2 -6
- package/app/composables/useCopyToClipboard.ts +7 -1
- package/app/composables/usePipelineErrorToast.spec.ts +76 -16
- package/app/composables/usePipelineErrorToast.ts +109 -50
- package/app/stores/board/context.ts +9 -2
- package/app/stores/board/dependencies.ts +3 -13
- package/app/stores/board/mutations.ts +2 -7
- package/app/stores/board/placement.ts +12 -25
- package/app/stores/board/removal.ts +2 -7
- package/app/stores/board.spec.ts +15 -8
- package/app/stores/board.ts +11 -1
- package/app/stores/recurringPipelines.ts +5 -11
- package/app/stores/ui/k3sDeepLink.spec.ts +43 -1
- package/app/stores/ui/modals.ts +24 -0
- package/i18n/locales/de.json +2 -0
- package/i18n/locales/en.json +2 -0
- package/i18n/locales/es.json +2 -0
- package/i18n/locales/fr.json +2 -0
- package/i18n/locales/he.json +2 -0
- package/i18n/locales/it.json +2 -0
- package/i18n/locales/ja.json +2 -0
- package/i18n/locales/pl.json +2 -0
- package/i18n/locales/tr.json +2 -0
- package/i18n/locales/uk.json +2 -0
- package/package.json +2 -2
|
@@ -7,6 +7,25 @@ import {
|
|
|
7
7
|
import { ApiError } from '~/composables/api/errors'
|
|
8
8
|
import en from '../../i18n/locales/en.json'
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The clipboard seam is mocked so the COPIED TEXT is assertable (and so the spec needs no
|
|
12
|
+
* `navigator.clipboard`). What the copy action carries is the whole point of it: a reader pastes
|
|
13
|
+
* that string into a bug report, and if the `requestId` is missing from it there is nothing joining
|
|
14
|
+
* the report to the one server log line that explains the failure.
|
|
15
|
+
*/
|
|
16
|
+
const copied: string[] = []
|
|
17
|
+
vi.mock('~/composables/useCopyToClipboard', () => ({
|
|
18
|
+
useCopyToClipboard: () => ({
|
|
19
|
+
copy: async (text: string) => void copied.push(text),
|
|
20
|
+
copyAction: (text: string) => ({
|
|
21
|
+
label: 'common.copyDetails',
|
|
22
|
+
icon: 'i-lucide-clipboard',
|
|
23
|
+
onClick: () => void copied.push(text),
|
|
24
|
+
}),
|
|
25
|
+
isSupported: { value: true },
|
|
26
|
+
}),
|
|
27
|
+
}))
|
|
28
|
+
|
|
10
29
|
/**
|
|
11
30
|
* The i18n pilot: the pipeline-error toast resolves user-facing copy from
|
|
12
31
|
* `errors.conflict.*` message KEYS by the backend's machine-readable `reason` — both the
|
|
@@ -93,21 +112,30 @@ describe('usePipelineErrorToast', () => {
|
|
|
93
112
|
expect(arg.description).toBe('errors.conflict.description.dependencies_unmet')
|
|
94
113
|
})
|
|
95
114
|
|
|
96
|
-
it('
|
|
115
|
+
it('keeps raw prose as DETAIL for an UNKNOWN reason, never as the headline', () => {
|
|
116
|
+
// An unmapped reason is the path a reason this build has never heard of takes, so it is the
|
|
117
|
+
// one that most needs the funnel's two properties rather than the least: translated copy up
|
|
118
|
+
// front, the backend's untranslated prose behind the disclosure.
|
|
97
119
|
usePipelineErrorToast().present(
|
|
98
120
|
conflict('totally_unknown_reason', {}, 'raw detail'),
|
|
99
121
|
'errors.action.retryFailed',
|
|
100
122
|
)
|
|
101
123
|
const arg = add.mock.calls[0]![0]
|
|
102
124
|
expect(arg.title).toBe('errors.action.retryFailed')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
expect(
|
|
125
|
+
expect(arg.description).toBe('errors.conflict.fallbackMessage')
|
|
126
|
+
arg.actions[0].onClick()
|
|
127
|
+
expect(update).toHaveBeenCalledWith(
|
|
128
|
+
'toast-1',
|
|
129
|
+
expect.objectContaining({ description: 'raw detail' }),
|
|
130
|
+
)
|
|
106
131
|
})
|
|
107
132
|
|
|
108
133
|
it('shows the fallback message for an unknown reason with no backend message', () => {
|
|
109
134
|
usePipelineErrorToast().present(conflict('totally_unknown_reason'))
|
|
110
|
-
|
|
135
|
+
const arg = add.mock.calls[0]![0]
|
|
136
|
+
expect(arg.description).toBe('errors.conflict.fallbackMessage')
|
|
137
|
+
// Nothing to disclose ⇒ no reveal, but the copy action is still the point.
|
|
138
|
+
expect(arg.actions).toHaveLength(1)
|
|
111
139
|
})
|
|
112
140
|
|
|
113
141
|
it('offers a jump action for a reason with a UI remedy (github_not_connected → connect GitHub)', () => {
|
|
@@ -122,11 +150,15 @@ describe('usePipelineErrorToast', () => {
|
|
|
122
150
|
expect(ui.openGitHub).toHaveBeenCalledOnce()
|
|
123
151
|
})
|
|
124
152
|
|
|
125
|
-
it('
|
|
153
|
+
it('keeps a conflict on screen and copyable even with no UI remedy to jump to', () => {
|
|
154
|
+
// A conflict is a failure like any other: the reader has to be able to finish it, quote it,
|
|
155
|
+
// and paste it into a report. A remedy-less reason used to be the one that auto-dismissed.
|
|
126
156
|
usePipelineErrorToast().present(conflict('dependencies_unmet'))
|
|
127
157
|
const arg = add.mock.calls[0]![0]
|
|
128
|
-
expect(arg.duration).
|
|
129
|
-
expect(arg.actions).
|
|
158
|
+
expect(arg.duration).toBe(0)
|
|
159
|
+
expect(arg.actions.map((a: { label: string }) => a.label)).toEqual(['common.copyDetails'])
|
|
160
|
+
arg.actions[0].onClick()
|
|
161
|
+
expect(copied.at(-1)).toContain('errors.conflict.description.dependencies_unmet')
|
|
130
162
|
})
|
|
131
163
|
|
|
132
164
|
it('interpolates the model list for providers_unconfigured and offers the AI setup jump', () => {
|
|
@@ -159,24 +191,48 @@ describe('usePipelineErrorToast', () => {
|
|
|
159
191
|
expect(add.mock.calls[0]![0].description).toBe('errors.generic.description.unavailable')
|
|
160
192
|
})
|
|
161
193
|
|
|
162
|
-
it('reveals the raw detail in place when "Show details" is clicked
|
|
194
|
+
it('reveals the raw detail in place when "Show details" is clicked', () => {
|
|
163
195
|
usePipelineErrorToast().present(
|
|
164
196
|
new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
|
|
165
197
|
)
|
|
166
198
|
const arg = add.mock.calls[0]![0]
|
|
167
|
-
// Auto-dismissing until the user asks for detail: no `duration` override up front.
|
|
168
|
-
expect(arg.duration).toBeUndefined()
|
|
169
199
|
expect(arg.actions[0].label).toBe('errors.generic.showDetail')
|
|
170
200
|
arg.actions[0].onClick()
|
|
171
|
-
//
|
|
201
|
+
// The SAME toast, not a second one, so the two readings can't sit on screen disagreeing. The
|
|
202
|
+
// disclosure button is dropped (it would now be a no-op) and the copy action is re-passed,
|
|
203
|
+
// because `update` merges over the existing toast and copying is still the point.
|
|
172
204
|
expect(add).toHaveBeenCalledTimes(1)
|
|
173
205
|
expect(update).toHaveBeenCalledWith('toast-1', {
|
|
174
206
|
description: 'Task sources not configured',
|
|
175
|
-
|
|
176
|
-
actions: [],
|
|
207
|
+
actions: [expect.objectContaining({ label: 'common.copyDetails' })],
|
|
177
208
|
})
|
|
178
209
|
})
|
|
179
210
|
|
|
211
|
+
it('does NOT auto-dismiss, and can be copied whole in one click', () => {
|
|
212
|
+
// Both properties are the reason a failure goes through this funnel at all: the toast a user
|
|
213
|
+
// needs to read, quote or act on used to vanish after ~5s, and its detail could only be
|
|
214
|
+
// retyped off the screen. `duration: 0` keeps it until dismissed (it still has a close
|
|
215
|
+
// button); the copy action carries the failed action, the failure class, the backend prose and
|
|
216
|
+
// the requestId in one string.
|
|
217
|
+
usePipelineErrorToast().present(
|
|
218
|
+
new ApiError(503, {
|
|
219
|
+
error: { code: 'unavailable', message: 'Task sources not configured', requestId: 'req-9' },
|
|
220
|
+
}),
|
|
221
|
+
'errors.action.startFailed',
|
|
222
|
+
)
|
|
223
|
+
const arg = add.mock.calls[0]![0]
|
|
224
|
+
expect(arg.duration).toBe(0)
|
|
225
|
+
const copy = arg.actions.find((a: { label: string }) => a.label === 'common.copyDetails')
|
|
226
|
+
copy.onClick()
|
|
227
|
+
expect(copied[copied.length - 1]).toBe(
|
|
228
|
+
[
|
|
229
|
+
'errors.action.startFailed',
|
|
230
|
+
'errors.generic.description.unavailable',
|
|
231
|
+
'Task sources not configured · errors.generic.requestId',
|
|
232
|
+
].join('\n'),
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
180
236
|
it('folds validation issues and the requestId into the revealed detail', () => {
|
|
181
237
|
usePipelineErrorToast().present(
|
|
182
238
|
new ApiError(400, {
|
|
@@ -199,7 +255,7 @@ describe('usePipelineErrorToast', () => {
|
|
|
199
255
|
)
|
|
200
256
|
})
|
|
201
257
|
|
|
202
|
-
it('offers no disclosure when there is no detail to reveal', () => {
|
|
258
|
+
it('offers no disclosure when there is no detail to reveal, but still offers the copy', () => {
|
|
203
259
|
usePipelineErrorToast().present(new ApiError(503, { error: { code: 'unavailable' } }))
|
|
204
260
|
const arg = add.mock.calls[0]![0]
|
|
205
261
|
// `ApiError` synthesises `Request failed (HTTP 503)` when the envelope carries no message,
|
|
@@ -207,7 +263,11 @@ describe('usePipelineErrorToast', () => {
|
|
|
207
263
|
expect(arg.description).toBe('errors.generic.description.unavailable')
|
|
208
264
|
expect(arg.actions[0].label).toBe('errors.generic.showDetail')
|
|
209
265
|
usePipelineErrorToast().present(null)
|
|
210
|
-
|
|
266
|
+
// A disclosure that reveals nothing is worse than none, but the two translated lines are still
|
|
267
|
+
// worth copying: they name the action that failed and the class of failure.
|
|
268
|
+
expect(add.mock.calls[1]![0].actions).toEqual([
|
|
269
|
+
expect.objectContaining({ label: 'common.copyDetails' }),
|
|
270
|
+
])
|
|
211
271
|
})
|
|
212
272
|
})
|
|
213
273
|
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { createBespokeConflictToasts } from '~/composables/pipelineErrorToast/bespokeConflicts'
|
|
28
|
+
// Imported by path rather than left to Nuxt's auto-import: this module is also loaded directly by
|
|
29
|
+
// unit tests (and from store setup), where the auto-import globals are not installed.
|
|
30
|
+
import { useCopyToClipboard } from '~/composables/useCopyToClipboard'
|
|
28
31
|
import type { ApiErrorCode, ConflictReason, UnavailableReason } from '@cat-factory/contracts'
|
|
29
32
|
import { apiErrorEnvelope, apiErrorReason, apiErrorStatus } from './api/errors'
|
|
30
33
|
|
|
@@ -424,6 +427,9 @@ export function usePipelineErrorToast() {
|
|
|
424
427
|
// tier 1 only sees literal keys written in a `<script setup>`, never in a `.ts` composable —
|
|
425
428
|
// the drift guard here is the exhaustive `CONFLICT_INFO` / `ApiErrorCode` records above.
|
|
426
429
|
const { t, te } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
430
|
+
// The shared clipboard seam (feedback toast included), so "Copy details" behaves like every other
|
|
431
|
+
// copy affordance in the product instead of silently failing where the API is unavailable.
|
|
432
|
+
const { copyAction } = useCopyToClipboard()
|
|
427
433
|
|
|
428
434
|
// The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
|
|
429
435
|
// live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
|
|
@@ -434,43 +440,63 @@ export function usePipelineErrorToast() {
|
|
|
434
440
|
* Per-reason copy from the exhaustive map: a translated title + description, and a jump
|
|
435
441
|
* action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
|
|
436
442
|
* so a key missing from the active locale falls back rather than leaking a raw key: the
|
|
437
|
-
* title falls to the caller's key, the description to the
|
|
438
|
-
*
|
|
443
|
+
* title falls to the caller's key, the description to the generic conflict line.
|
|
444
|
+
*
|
|
445
|
+
* A conflict is a FAILURE, so it carries the two properties `presentGenericFailure` documents
|
|
446
|
+
* below and for the same reasons: it does not auto-dismiss, and its detail is copyable. The
|
|
447
|
+
* backend's own prose is DETAIL here rather than the headline, which matters most on the
|
|
448
|
+
* unknown-reason path: that is where a reason this SPA build has never heard of lands, so
|
|
449
|
+
* leaving it as the description meant the newest failures were the ones shown untranslated,
|
|
450
|
+
* uncopyable, and gone in five seconds.
|
|
439
451
|
*/
|
|
440
|
-
function presentMappedConflict(
|
|
452
|
+
function presentMappedConflict(
|
|
453
|
+
conflict: ParsedConflict,
|
|
454
|
+
fallbackTitleKey: string,
|
|
455
|
+
titleParams?: Record<string, unknown>,
|
|
456
|
+
): void {
|
|
441
457
|
const info = conflict.reason
|
|
442
458
|
? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
|
|
443
459
|
: undefined
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
duration: 0,
|
|
457
|
-
actions: [
|
|
458
|
-
{
|
|
459
|
-
label: t(info.action.labelKey),
|
|
460
|
-
icon: info.action.icon,
|
|
461
|
-
onClick: () => info.action?.run(ui),
|
|
462
|
-
},
|
|
463
|
-
],
|
|
464
|
-
}
|
|
465
|
-
: {}),
|
|
466
|
-
})
|
|
467
|
-
return
|
|
468
|
-
}
|
|
469
|
-
toast.add({
|
|
470
|
-
title: t(fallbackTitleKey),
|
|
471
|
-
description: conflict.message ?? t('errors.conflict.fallbackMessage'),
|
|
460
|
+
const title =
|
|
461
|
+
info && te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey, titleParams ?? {})
|
|
462
|
+
const description =
|
|
463
|
+
info && te(info.descriptionKey)
|
|
464
|
+
? t(info.descriptionKey)
|
|
465
|
+
: t('errors.conflict.fallbackMessage')
|
|
466
|
+
// The backend's prose, kept only when it adds something the translated line doesn't already say.
|
|
467
|
+
const detail = conflict.message && conflict.message !== description ? conflict.message : ''
|
|
468
|
+
const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
|
|
469
|
+
const added = toast.add({
|
|
470
|
+
title,
|
|
471
|
+
description,
|
|
472
472
|
color: 'warning',
|
|
473
473
|
icon: 'i-lucide-triangle-alert',
|
|
474
|
+
duration: 0,
|
|
475
|
+
ui: { description: 'select-text' },
|
|
476
|
+
actions: [
|
|
477
|
+
// A reason with a jump action leads with its one-click remedy; the detail reveal and the
|
|
478
|
+
// copy follow it, so the primary affordance stays first in the row.
|
|
479
|
+
...(info?.action
|
|
480
|
+
? [
|
|
481
|
+
{
|
|
482
|
+
label: t(info.action.labelKey),
|
|
483
|
+
icon: info.action.icon,
|
|
484
|
+
onClick: () => info.action?.run(ui),
|
|
485
|
+
},
|
|
486
|
+
]
|
|
487
|
+
: []),
|
|
488
|
+
...(detail
|
|
489
|
+
? [
|
|
490
|
+
{
|
|
491
|
+
label: t('errors.generic.showDetail'),
|
|
492
|
+
icon: 'i-lucide-info',
|
|
493
|
+
onClick: () =>
|
|
494
|
+
toast.update(added.id, { description: detail, actions: [copyAction(report)] }),
|
|
495
|
+
},
|
|
496
|
+
]
|
|
497
|
+
: []),
|
|
498
|
+
copyAction(report),
|
|
499
|
+
],
|
|
474
500
|
})
|
|
475
501
|
}
|
|
476
502
|
|
|
@@ -479,15 +505,32 @@ export function usePipelineErrorToast() {
|
|
|
479
505
|
* behind a "Show details" button that swaps it into the same toast (G2).
|
|
480
506
|
*
|
|
481
507
|
* The reveal is an UPDATE rather than a second toast so the two readings can't sit on screen
|
|
482
|
-
* disagreeing
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
508
|
+
* disagreeing. `actions: []` is passed explicitly on the reveal: `update` merges over the
|
|
509
|
+
* existing toast, so an omitted `actions` would leave a "Show details" button that is now a
|
|
510
|
+
* no-op. The COPY action is re-passed, because it is still the point after the reveal.
|
|
511
|
+
*
|
|
512
|
+
* Two properties of this toast are the whole reason a failure goes through this funnel rather
|
|
513
|
+
* than a hand-built `toast.add`, and both were learned from someone trying to report a bug:
|
|
514
|
+
*
|
|
515
|
+
* - It does NOT auto-dismiss. A failure is the one toast a user needs to finish reading, quote,
|
|
516
|
+
* or act on, and a ~5s dismissal took the detail away mid-sentence. It carries a close button
|
|
517
|
+
* like every other toast, so staying is a decision the reader makes.
|
|
518
|
+
* - The detail is COPYABLE with one click, through the shared clipboard seam (so the copy's own
|
|
519
|
+
* success/failure is reported rather than silently no-op'ing in an insecure context). Selecting
|
|
520
|
+
* text inside a toast is fiddly at the best of times and impossible once it has gone, and what
|
|
521
|
+
* the person needs to paste is the whole of it: the failed action, the class of failure, the
|
|
522
|
+
* backend's prose, and above all the `requestId` that joins it to the one server log line
|
|
523
|
+
* explaining it. Retyping that id off a screenshot is how a report arrives without it.
|
|
486
524
|
*
|
|
487
525
|
* No detail worth showing (a network fault with an unhelpful `message` and no correlation id)
|
|
488
|
-
* ⇒ no
|
|
526
|
+
* ⇒ no disclosure and nothing to copy beyond the two translated lines, which the copy action
|
|
527
|
+
* still carries.
|
|
489
528
|
*/
|
|
490
|
-
function presentGenericFailure(
|
|
529
|
+
function presentGenericFailure(
|
|
530
|
+
error: unknown,
|
|
531
|
+
fallbackTitleKey: string,
|
|
532
|
+
titleParams?: Record<string, unknown>,
|
|
533
|
+
): void {
|
|
491
534
|
const failure = describeGenericFailure(error)
|
|
492
535
|
const detail = [
|
|
493
536
|
failure.message,
|
|
@@ -499,38 +542,54 @@ export function usePipelineErrorToast() {
|
|
|
499
542
|
// No `te` guard: the key comes from a Record exhaustive over the wire union and every entry
|
|
500
543
|
// ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
|
|
501
544
|
// (better than the raw prose this replaced) and a bare key can never leak.
|
|
545
|
+
const title = t(fallbackTitleKey, titleParams ?? {})
|
|
546
|
+
const description = t(failure.descriptionKey)
|
|
547
|
+
const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
|
|
502
548
|
const added = toast.add({
|
|
503
|
-
title
|
|
504
|
-
description
|
|
549
|
+
title,
|
|
550
|
+
description,
|
|
505
551
|
color: 'error',
|
|
506
552
|
icon: 'i-lucide-triangle-alert',
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
553
|
+
duration: 0,
|
|
554
|
+
// Selectable in place too: the copy button is the reliable path, but a reader who only wants
|
|
555
|
+
// the request id should be able to drag over it.
|
|
556
|
+
ui: { description: 'select-text' },
|
|
557
|
+
actions: [
|
|
558
|
+
...(detail
|
|
559
|
+
? [
|
|
510
560
|
{
|
|
511
561
|
label: t('errors.generic.showDetail'),
|
|
512
562
|
icon: 'i-lucide-info',
|
|
513
563
|
onClick: () =>
|
|
514
|
-
toast.update(added.id, {
|
|
564
|
+
toast.update(added.id, {
|
|
565
|
+
description: detail,
|
|
566
|
+
actions: [copyAction(report)],
|
|
567
|
+
}),
|
|
515
568
|
},
|
|
516
|
-
]
|
|
517
|
-
|
|
518
|
-
|
|
569
|
+
]
|
|
570
|
+
: []),
|
|
571
|
+
copyAction(report),
|
|
572
|
+
],
|
|
519
573
|
})
|
|
520
574
|
}
|
|
521
575
|
|
|
522
576
|
/**
|
|
523
577
|
* Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
|
|
524
|
-
* non-conflict failures and any conflict reason without a dedicated title
|
|
578
|
+
* non-conflict failures and any conflict reason without a dedicated title; `titleParams` carries
|
|
579
|
+
* that key's interpolation when it takes any (a title naming the thing that failed).
|
|
525
580
|
*/
|
|
526
|
-
function present(
|
|
581
|
+
function present(
|
|
582
|
+
error: unknown,
|
|
583
|
+
fallbackTitleKey = 'common.actionFailed',
|
|
584
|
+
titleParams?: Record<string, unknown>,
|
|
585
|
+
): void {
|
|
527
586
|
const conflict = parseConflict(error)
|
|
528
587
|
if (conflict) {
|
|
529
588
|
if (presentBespokeConflict(conflict)) return
|
|
530
|
-
presentMappedConflict(conflict, fallbackTitleKey)
|
|
589
|
+
presentMappedConflict(conflict, fallbackTitleKey, titleParams)
|
|
531
590
|
return
|
|
532
591
|
}
|
|
533
|
-
presentGenericFailure(error, fallbackTitleKey)
|
|
592
|
+
presentGenericFailure(error, fallbackTitleKey, titleParams)
|
|
534
593
|
}
|
|
535
594
|
|
|
536
595
|
return { present }
|
|
@@ -31,8 +31,8 @@ export const UNDO_WINDOW_MS = 6000
|
|
|
31
31
|
* the `board` store setup and threaded into {@link createBoardMutations} / {@link createBoardRemoval}
|
|
32
32
|
* so the split operations stay behaviourally identical to the original single-closure store — the
|
|
33
33
|
* factories are cohesive extractions purely to keep each function within the size budget, not new
|
|
34
|
-
* seams. `api`/`toast`/`tr` are the store's own resolved handles (a store runs outside a
|
|
35
|
-
* `setup`, so `tr` bridges to the Nuxt app's global i18n instance).
|
|
34
|
+
* seams. `api`/`toast`/`tr`/`present` are the store's own resolved handles (a store runs outside a
|
|
35
|
+
* component `setup`, so `tr` bridges to the Nuxt app's global i18n instance).
|
|
36
36
|
*/
|
|
37
37
|
export interface BoardWriteContext {
|
|
38
38
|
blocks: Ref<Block[]>
|
|
@@ -43,4 +43,11 @@ export interface BoardWriteContext {
|
|
|
43
43
|
api: ReturnType<typeof useApi>
|
|
44
44
|
toast: ReturnType<typeof useToast>
|
|
45
45
|
tr: (key: string, params?: Record<string, unknown>) => string
|
|
46
|
+
/**
|
|
47
|
+
* The SPA's one failure-toast funnel (`usePipelineErrorToast().present`), threaded like `toast`
|
|
48
|
+
* because it is resolved in the store setup. Every rolled-back write reports through it rather
|
|
49
|
+
* than building its own `{ title, description: err.message }`, so a board failure reads the same
|
|
50
|
+
* as every other one: translated copy, and the raw detail copyable behind the disclosure.
|
|
51
|
+
*/
|
|
52
|
+
present: (error: unknown, titleKey?: string) => void
|
|
46
53
|
}
|
|
@@ -8,7 +8,7 @@ import type { BoardWriteContext } from './context'
|
|
|
8
8
|
* the split is purely to keep every function within the size budget.
|
|
9
9
|
*/
|
|
10
10
|
export function createBoardDependencies(ctx: BoardWriteContext) {
|
|
11
|
-
const { getBlock, upsert, api,
|
|
11
|
+
const { getBlock, upsert, api, present } = ctx
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Toggle a dependency edge target -> source (target dependsOn source). The backend
|
|
@@ -20,12 +20,7 @@ export function createBoardDependencies(ctx: BoardWriteContext) {
|
|
|
20
20
|
try {
|
|
21
21
|
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
22
22
|
} catch (e) {
|
|
23
|
-
toast.
|
|
24
|
-
title: tr('board.toast.linkFailed'),
|
|
25
|
-
description: e instanceof Error ? e.message : String(e),
|
|
26
|
-
icon: 'i-lucide-triangle-alert',
|
|
27
|
-
color: 'error',
|
|
28
|
-
})
|
|
23
|
+
present(e, 'board.toast.linkFailed')
|
|
29
24
|
}
|
|
30
25
|
}
|
|
31
26
|
|
|
@@ -39,12 +34,7 @@ export function createBoardDependencies(ctx: BoardWriteContext) {
|
|
|
39
34
|
} catch (e) {
|
|
40
35
|
// Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
|
|
41
36
|
// than rejecting unhandled with no feedback.
|
|
42
|
-
toast.
|
|
43
|
-
title: tr('board.toast.unlinkFailed'),
|
|
44
|
-
description: e instanceof Error ? e.message : String(e),
|
|
45
|
-
icon: 'i-lucide-triangle-alert',
|
|
46
|
-
color: 'error',
|
|
47
|
-
})
|
|
37
|
+
present(e, 'board.toast.unlinkFailed')
|
|
48
38
|
}
|
|
49
39
|
}
|
|
50
40
|
|
|
@@ -17,7 +17,7 @@ import type { BoardWriteContext } from './context'
|
|
|
17
17
|
* {@link createBoardPlacement}.
|
|
18
18
|
*/
|
|
19
19
|
export function createBoardMutations(ctx: BoardWriteContext) {
|
|
20
|
-
const { getBlock, upsert, api,
|
|
20
|
+
const { getBlock, upsert, api, present } = ctx
|
|
21
21
|
|
|
22
22
|
async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
|
|
23
23
|
const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
|
|
@@ -127,12 +127,7 @@ export function createBoardMutations(ctx: BoardWriteContext) {
|
|
|
127
127
|
upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
|
|
128
128
|
} catch (e) {
|
|
129
129
|
t.epicId = prev
|
|
130
|
-
toast.
|
|
131
|
-
title: tr('board.toast.epicFailed'),
|
|
132
|
-
description: e instanceof Error ? e.message : String(e),
|
|
133
|
-
icon: 'i-lucide-triangle-alert',
|
|
134
|
-
color: 'error',
|
|
135
|
-
})
|
|
130
|
+
present(e, 'board.toast.epicFailed')
|
|
136
131
|
}
|
|
137
132
|
}
|
|
138
133
|
|
|
@@ -34,7 +34,7 @@ function blockAsPatchable(block: Block): PatchSnapshot {
|
|
|
34
34
|
* in-closure functions, and the split is purely to keep every function within the size budget.
|
|
35
35
|
*/
|
|
36
36
|
export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
37
|
-
const { blocks, getBlock, upsert, api, toast, tr } = ctx
|
|
37
|
+
const { blocks, getBlock, upsert, api, toast, tr, present } = ctx
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
@@ -94,12 +94,14 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
94
94
|
// can act on rather than a fault. The backend sends the machine-readable reason and no
|
|
95
95
|
// translated prose, so map it here; anything else keeps the raw message as the last resort.
|
|
96
96
|
const refusal = moveRefusalKey(e)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
if (refusal) {
|
|
98
|
+
toast.add({
|
|
99
|
+
title: tr('board.toast.moveFailed'),
|
|
100
|
+
description: tr(refusal),
|
|
101
|
+
icon: 'i-lucide-triangle-alert',
|
|
102
|
+
color: 'error',
|
|
103
|
+
})
|
|
104
|
+
} else present(e, 'board.toast.moveFailed')
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -137,12 +139,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
137
139
|
// Restore the pre-drag position — a rejected move must not leave the block at a
|
|
138
140
|
// spot the server never stored (a lie that survives until the next re-hydrate).
|
|
139
141
|
b.position = prevPosition
|
|
140
|
-
toast.
|
|
141
|
-
title: tr('board.toast.moveFailed'),
|
|
142
|
-
description: e instanceof Error ? e.message : String(e),
|
|
143
|
-
icon: 'i-lucide-triangle-alert',
|
|
144
|
-
color: 'error',
|
|
145
|
-
})
|
|
142
|
+
present(e, 'board.toast.moveFailed')
|
|
146
143
|
}
|
|
147
144
|
}
|
|
148
145
|
|
|
@@ -198,12 +195,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
198
195
|
upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
|
|
199
196
|
} catch (e) {
|
|
200
197
|
previewResize(id, from.position, from.size)
|
|
201
|
-
toast.
|
|
202
|
-
title: tr('board.toast.resizeFailed'),
|
|
203
|
-
description: e instanceof Error ? e.message : String(e),
|
|
204
|
-
icon: 'i-lucide-triangle-alert',
|
|
205
|
-
color: 'error',
|
|
206
|
-
})
|
|
198
|
+
present(e, 'board.toast.resizeFailed')
|
|
207
199
|
}
|
|
208
200
|
}
|
|
209
201
|
|
|
@@ -244,12 +236,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
244
236
|
if (cur[key] === patch[key]) cur[key] = prev[key]
|
|
245
237
|
}
|
|
246
238
|
}
|
|
247
|
-
toast.
|
|
248
|
-
title: tr('board.toast.updateFailed'),
|
|
249
|
-
description: e instanceof Error ? e.message : String(e),
|
|
250
|
-
icon: 'i-lucide-triangle-alert',
|
|
251
|
-
color: 'error',
|
|
252
|
-
})
|
|
239
|
+
present(e, 'board.toast.updateFailed')
|
|
253
240
|
return false
|
|
254
241
|
}
|
|
255
242
|
}
|
|
@@ -11,7 +11,7 @@ import { UNDO_WINDOW_MS } from './context'
|
|
|
11
11
|
* stays internal — it is only wired into the delete toast's undo action.
|
|
12
12
|
*/
|
|
13
13
|
export function createBoardRemoval(ctx: BoardWriteContext) {
|
|
14
|
-
const { blocks, getBlock, pendingRemovals, pendingDoomed, api, toast, tr } = ctx
|
|
14
|
+
const { blocks, getBlock, pendingRemovals, pendingDoomed, api, toast, tr, present } = ctx
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Optimistically drop a block and its descendants from the cache, returning a
|
|
@@ -142,12 +142,7 @@ export function createBoardRemoval(ctx: BoardWriteContext) {
|
|
|
142
142
|
// switch must not inject the old subtree onto the workspace now on screen (it
|
|
143
143
|
// re-hydrates from the server there on the next refresh anyway).
|
|
144
144
|
if (useWorkspaceStore().workspaceId === pending.wsId) reattach(pending.snap)
|
|
145
|
-
toast.
|
|
146
|
-
title: tr('board.toast.deleteFailed'),
|
|
147
|
-
description: e instanceof Error ? e.message : String(e),
|
|
148
|
-
icon: 'i-lucide-triangle-alert',
|
|
149
|
-
color: 'error',
|
|
150
|
-
})
|
|
145
|
+
present(e, 'board.toast.deleteFailed')
|
|
151
146
|
}
|
|
152
147
|
}
|
|
153
148
|
const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -246,11 +246,15 @@ describe('board store read getters', () => {
|
|
|
246
246
|
expect(() => store.previewMove('missing', { x: 1, y: 1 })).not.toThrow()
|
|
247
247
|
})
|
|
248
248
|
|
|
249
|
-
it('updateBlock restores the patched fields and
|
|
250
|
-
// Capture
|
|
251
|
-
//
|
|
249
|
+
it('updateBlock restores the patched fields and reports the failure when the write fails', async () => {
|
|
250
|
+
// Capture what the store surfaces on failure. Every rolled-back write drains into the shared
|
|
251
|
+
// failure funnel (`usePipelineErrorToast().present`) rather than building its own toast, so the
|
|
252
|
+
// assertion is on the funnel and on the TITLE KEY it was given. Re-stub before creating the
|
|
253
|
+
// store: it resolves both handles once at setup.
|
|
252
254
|
const addSpy = vi.fn()
|
|
255
|
+
const presentSpy = vi.fn()
|
|
253
256
|
vi.stubGlobal('useToast', () => ({ add: addSpy }))
|
|
257
|
+
vi.stubGlobal('usePipelineErrorToast', () => ({ present: presentSpy }))
|
|
254
258
|
setActivePinia(createPinia())
|
|
255
259
|
const s = useBoardStore()
|
|
256
260
|
s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
|
|
@@ -263,7 +267,7 @@ describe('board store read getters', () => {
|
|
|
263
267
|
)
|
|
264
268
|
expect(s.getBlock('f1')?.title).toBe('Original')
|
|
265
269
|
expect(s.getBlock('f1')?.description).toBe('orig')
|
|
266
|
-
expect(
|
|
270
|
+
expect(presentSpy).toHaveBeenCalledWith(expect.any(Error), 'board.toast.updateFailed')
|
|
267
271
|
})
|
|
268
272
|
|
|
269
273
|
it('updateBlock reports a no-op for a block that is not on the board', async () => {
|
|
@@ -428,6 +432,7 @@ describe('board store deferred delete + undo', () => {
|
|
|
428
432
|
function setup(removeImpl: () => Promise<void>) {
|
|
429
433
|
const removeSpy = vi.fn(removeImpl)
|
|
430
434
|
const addSpy = vi.fn()
|
|
435
|
+
const presentSpy = vi.fn()
|
|
431
436
|
const actions: ToastAction[] = []
|
|
432
437
|
vi.stubGlobal('useApi', () => ({ removeBlock: removeSpy }))
|
|
433
438
|
vi.stubGlobal('useToast', () => ({
|
|
@@ -436,9 +441,10 @@ describe('board store deferred delete + undo', () => {
|
|
|
436
441
|
if (t.actions) actions.push(...t.actions)
|
|
437
442
|
},
|
|
438
443
|
}))
|
|
444
|
+
vi.stubGlobal('usePipelineErrorToast', () => ({ present: presentSpy }))
|
|
439
445
|
setActivePinia(createPinia())
|
|
440
446
|
useWorkspaceStore().workspaceId = 'ws1'
|
|
441
|
-
return { store: useBoardStore(), removeSpy, addSpy, actions }
|
|
447
|
+
return { store: useBoardStore(), removeSpy, addSpy, presentSpy, actions }
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
beforeEach(() => {
|
|
@@ -500,14 +506,15 @@ describe('board store deferred delete + undo', () => {
|
|
|
500
506
|
expect(removeSpy).toHaveBeenCalledWith('ws1', 'f1')
|
|
501
507
|
})
|
|
502
508
|
|
|
503
|
-
it('restores the subtree and
|
|
504
|
-
const { store,
|
|
509
|
+
it('restores the subtree and reports the failure if the deferred delete fails', async () => {
|
|
510
|
+
const { store, presentSpy } = setup(() => Promise.reject(new Error('boom')))
|
|
505
511
|
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
506
512
|
store.removeBlock('f1')
|
|
507
513
|
await vi.runAllTimersAsync()
|
|
508
514
|
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
509
515
|
expect(store.getBlock('t1')?.id).toBe('t1')
|
|
510
|
-
|
|
516
|
+
// Through the shared funnel, named by its title key (see `updateBlock`'s case above).
|
|
517
|
+
expect(presentSpy).toHaveBeenCalledWith(expect.any(Error), 'board.toast.deleteFailed')
|
|
511
518
|
})
|
|
512
519
|
|
|
513
520
|
it('does not reattach a failed deferred delete onto a different workspace', async () => {
|
package/app/stores/board.ts
CHANGED
|
@@ -141,7 +141,17 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
141
141
|
// The write operations, split into cohesive factories sharing the state above (a size-only
|
|
142
142
|
// extraction — behaviour is identical to the former in-closure functions). `undoRemove` stays
|
|
143
143
|
// internal to the removal factory (only its delete toast wires it), so it is NOT re-exported.
|
|
144
|
-
const context = {
|
|
144
|
+
const context = {
|
|
145
|
+
blocks,
|
|
146
|
+
getBlock,
|
|
147
|
+
upsert,
|
|
148
|
+
pendingRemovals,
|
|
149
|
+
pendingDoomed,
|
|
150
|
+
api,
|
|
151
|
+
toast,
|
|
152
|
+
tr,
|
|
153
|
+
present: usePipelineErrorToast().present,
|
|
154
|
+
}
|
|
145
155
|
const mutations = createBoardMutations(context)
|
|
146
156
|
const placement = createBoardPlacement(context)
|
|
147
157
|
const { detach, reattach, removeBlock } = createBoardRemoval(context)
|