@cat-factory/app 0.311.2 → 0.311.3
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 +65 -4
- package/app/app.config.ts +7 -0
- package/app/components/assistant/AssistantModal.vue +2 -5
- package/app/components/auth/AuthGate.vue +2 -1
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +14 -15
- package/app/components/binaryOutput/StoredAssetView.vue +1 -3
- package/app/components/board/AddTaskModal.vue +3 -10
- package/app/components/board/AgentFailureCard.vue +4 -5
- package/app/components/board/AgentStopButton.vue +5 -7
- package/app/components/board/BoardCanvas.vue +31 -30
- package/app/components/board/CreateInitiativeModal.vue +2 -4
- package/app/components/board/RecurringPipelineModal.vue +2 -7
- package/app/components/board/nodes/AgentChip.vue +3 -2
- package/app/components/board/nodes/BlockNode.vue +14 -11
- package/app/components/board/nodes/InitiativeCard.vue +6 -3
- package/app/components/bootstrap/AdoptionReviewModal.vue +2 -5
- package/app/components/bootstrap/BootstrapModal.vue +6 -15
- package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
- package/app/components/brainstorm/BrainstormWindow.vue +32 -26
- package/app/components/bugFishing/BugFishingWindow.vue +15 -13
- package/app/components/clarity/ClarityReviewWindow.vue +31 -26
- package/app/components/common/ClarificationItem.vue +3 -1
- package/app/components/common/EmptyState.vue +41 -19
- package/app/components/common/InterviewGateNotice.vue +3 -1
- package/app/components/common/Spinner.vue +28 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
- package/app/components/docs/DocInterviewWindow.vue +6 -6
- package/app/components/documents/ContextDocumentPicker.vue +2 -2
- package/app/components/documents/DocumentImportModal.vue +9 -8
- package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
- package/app/components/documents/DocumentTemplatesModal.vue +13 -7
- package/app/components/documents/RepoContextDocPicker.vue +2 -2
- package/app/components/documents/SpawnPreviewModal.vue +4 -6
- package/app/components/documents/StartFromDesignModal.vue +2 -1
- package/app/components/documents/TaskContextDocs.vue +4 -5
- package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
- package/app/components/environments/steps/EnvPickStep.vue +7 -3
- package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
- package/app/components/focus/BlockFocusView.vue +6 -5
- package/app/components/followUp/FollowUpWindow.vue +7 -9
- package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
- package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
- package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
- package/app/components/foundational/FoundationalServiceSources.vue +20 -22
- package/app/components/foundational/FoundationalSuppressions.vue +2 -2
- package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
- package/app/components/fragments/FragmentLibraryManager.vue +36 -34
- package/app/components/fragments/FragmentSelector.vue +7 -3
- package/app/components/gates/GateResultView.vue +6 -9
- package/app/components/github/AddServiceFromRepoModal.vue +6 -10
- package/app/components/github/GitHubConnect.vue +10 -17
- package/app/components/github/GitHubPanel.vue +44 -47
- package/app/components/github/RepoTreeBrowser.vue +9 -5
- package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
- package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
- package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
- package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
- package/app/components/humanTest/HumanTestWindow.vue +2 -1
- package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
- package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
- package/app/components/judge/JudgeResultView.vue +2 -7
- package/app/components/kaizen/KaizenPanel.vue +20 -5
- package/app/components/kaizen/KaizenStepStatus.vue +2 -1
- package/app/components/layout/AccountAuditLog.vue +11 -3
- package/app/components/layout/AccountDeploymentSettings.vue +17 -62
- package/app/components/layout/AccountFailureKindRules.vue +6 -5
- package/app/components/layout/AccountModelPolicySettings.vue +2 -6
- package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
- package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
- package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
- package/app/components/layout/AccountTeamSettings.vue +12 -9
- package/app/components/layout/BoardSwitcher.vue +2 -2
- package/app/components/layout/BoardToolbar.vue +3 -11
- package/app/components/layout/CommandBar.vue +7 -3
- package/app/components/layout/ConnectionStatusBanner.vue +2 -1
- package/app/components/layout/IntegrationsHub.vue +7 -3
- package/app/components/layout/ModelProvidersHub.vue +7 -3
- package/app/components/layout/NotificationsInbox.vue +3 -11
- package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
- package/app/components/media/ArtifactLightbox.vue +4 -3
- package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
- package/app/components/observability/ToolCallList.vue +12 -17
- package/app/components/panels/AgentStepDetail.vue +5 -5
- package/app/components/panels/GenericStructuredResultView.vue +6 -9
- package/app/components/panels/InspectorPanel.vue +2 -1
- package/app/components/panels/MergerResultView.vue +5 -6
- package/app/components/panels/ObservabilityPanel.vue +26 -41
- package/app/components/panels/OperatorDashboardPanel.vue +29 -17
- package/app/components/panels/ReportsPanel.vue +17 -11
- package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
- package/app/components/panels/RunDetailLoadState.vue +4 -3
- package/app/components/panels/StepContainerStatus.vue +4 -3
- package/app/components/panels/StepDelegatedStatus.vue +3 -2
- package/app/components/panels/StepMetadataCard.vue +6 -8
- package/app/components/panels/StepRunMeta.vue +2 -5
- package/app/components/panels/inspector/ContainerSummary.vue +7 -1
- package/app/components/panels/inspector/EpicChildren.vue +7 -3
- package/app/components/panels/inspector/FrontendConfig.vue +7 -3
- package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
- package/app/components/panels/inspector/ServiceConnections.vue +7 -3
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
- package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
- package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
- package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
- package/app/components/panels/inspector/TaskExecution.vue +5 -6
- package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
- package/app/components/panels/inspector/TaskStructure.vue +0 -1
- package/app/components/pipeline/AgentPromptEditor.vue +25 -18
- package/app/components/pipeline/PipelineBuilder.vue +14 -24
- package/app/components/pipeline/PipelineProgress.vue +16 -17
- package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
- package/app/components/prReview/PrReviewWindow.vue +16 -20
- package/app/components/providers/ApiKeysSection.vue +2 -6
- package/app/components/providers/PersonalCredentialModal.vue +2 -6
- package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
- package/app/components/providers/VendorCredentialsModal.vue +2 -6
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
- package/app/components/ralph/RalphLoopResultView.vue +2 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
- package/app/components/sandbox/SandboxPanel.vue +37 -25
- package/app/components/settings/AccountSettingsPanel.vue +6 -3
- package/app/components/settings/ApiTokensPanel.vue +2 -6
- package/app/components/settings/BudgetSettings.vue +2 -2
- package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
- package/app/components/settings/CloudflareHandlerSection.vue +3 -7
- package/app/components/settings/ConsensusGroupsSection.vue +7 -3
- package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
- package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
- package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
- package/app/components/settings/IssueTrackerPanel.vue +9 -9
- package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
- package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
- package/app/components/settings/McpAuthorizeScreen.vue +8 -3
- package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
- package/app/components/settings/ModelConfigurationPanel.vue +45 -17
- package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
- package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
- package/app/components/settings/PackageRegistriesPanel.vue +2 -6
- package/app/components/settings/ProviderConnectionTab.vue +3 -7
- package/app/components/settings/RiskPolicyPanel.vue +4 -16
- package/app/components/settings/SharedStacksPanel.vue +8 -18
- package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
- package/app/components/settings/UsageSettings.vue +11 -2
- package/app/components/settings/UserSecretsSection.vue +5 -7
- package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
- package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
- package/app/components/skills/ReviewSkillQueue.vue +7 -3
- package/app/components/skills/SkillLibraryManager.vue +20 -18
- package/app/components/slack/SlackPanel.vue +5 -8
- package/app/components/spec/ServiceSpecWindow.vue +32 -29
- package/app/components/tasks/BugHuntModal.vue +23 -19
- package/app/components/tasks/ContextIssuePicker.vue +2 -2
- package/app/components/tasks/TaskContextIssues.vue +4 -5
- package/app/components/tasks/TaskImportModal.vue +10 -8
- package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
- package/app/components/testing/TestReportWindow.vue +6 -12
- package/app/components/tutorial/TutorialCatalogue.vue +7 -3
- package/app/components/tutorial/TutorialOverlay.vue +2 -1
- package/app/components/tutorial/TutorialPrompt.vue +7 -3
- package/app/components/vcs/GitLabConnect.vue +2 -6
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
- package/app/composables/toastTitleKeys.spec.ts +143 -0
- package/app/composables/useActionToast.spec.ts +71 -0
- package/app/composables/useActionToast.ts +101 -0
- package/app/composables/useBlockDeletion.ts +3 -6
- package/app/composables/useConfirmAction.ts +5 -3
- package/app/composables/useContextLinking.spec.ts +3 -0
- package/app/composables/useContextLinking.ts +11 -16
- package/app/composables/useCopyToClipboard.ts +4 -3
- package/app/composables/useNavContributions.ts +4 -5
- package/app/composables/usePipelineErrorToast.spec.ts +24 -0
- package/app/composables/usePipelineErrorToast.ts +26 -2
- package/app/composables/usePipelineLibraryActions.ts +8 -9
- package/app/pages/index.vue +3 -2
- package/app/stores/board/context.ts +4 -3
- package/app/stores/board/placement.ts +8 -20
- package/app/stores/board/removal.ts +4 -13
- package/app/stores/board.spec.ts +10 -1
- package/app/stores/board.ts +3 -2
- package/app/utils/bootstrapSteps.ts +11 -2
- package/app/utils/pipelineRender.ts +21 -8
- package/i18n/locales/de.json +12 -30
- package/i18n/locales/en.json +12 -30
- package/i18n/locales/es.json +12 -30
- package/i18n/locales/fr.json +12 -30
- package/i18n/locales/he.json +12 -30
- package/i18n/locales/it.json +12 -30
- package/i18n/locales/ja.json +12 -30
- package/i18n/locales/pl.json +12 -30
- package/i18n/locales/tr.json +12 -30
- package/i18n/locales/uk.json +12 -30
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { hasI18nKey } from '../../test/i18nKeys'
|
|
6
|
+
|
|
7
|
+
// The two toast funnels take a title KEY, resolved inside the composable. Typed message keys and
|
|
8
|
+
// `vue-i18n-extract` only see a key written literally inside a `t()` call, so a key handed to a funnel is
|
|
9
|
+
// invisible to both: a typo renders its own key path in the toast, and deleting a live key reads
|
|
10
|
+
// as a clean removal (`i18n:check` even lists these keys as unused). This spec closes that gap by
|
|
11
|
+
// reading every literal key passed to a funnel and resolving it against the base catalog.
|
|
12
|
+
|
|
13
|
+
const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
|
+
|
|
15
|
+
function* sources(dir: string): Generator<string> {
|
|
16
|
+
for (const entry of readdirSync(dir)) {
|
|
17
|
+
const abs = join(dir, entry)
|
|
18
|
+
if (statSync(abs).isDirectory()) yield* sources(abs)
|
|
19
|
+
else if (/\.(vue|ts)$/.test(entry) && !entry.endsWith('.spec.ts')) yield abs
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The call heads whose FIRST argument is the title key, and those whose SECOND argument is: the
|
|
24
|
+
// error funnel's two (after the error or the reported detail) and `useInterviewDrafts`' `flushThen`,
|
|
25
|
+
// which forwards its key to `present`.
|
|
26
|
+
const FIRST = /actionToast\.(?:success|info|warning|error)\(\s*/g
|
|
27
|
+
const SECOND = /\b(?:present|presentReported|flushThen)\(/g
|
|
28
|
+
// Options that carry a key to a funnel through a helper. Only a LITERAL value is read: the same
|
|
29
|
+
// names also appear in type declarations and in the helpers that forward them.
|
|
30
|
+
const OPTION = /\b(?:titleKey|failureTitleKeys)\s*:\s*/g
|
|
31
|
+
// A dotted key literal. A conditional argument (`ok ? 'a.b' : 'a.c'`) yields both.
|
|
32
|
+
const KEY = /'([a-zA-Z][\w-]*(?:\.[\w-]+)+)'/g
|
|
33
|
+
// A tone passed around instead of called (`const show = ok ? actionToast.success : …`): every key
|
|
34
|
+
// then reaches the toast through the alias, where no call head above can read it.
|
|
35
|
+
const ALIAS = /actionToast\.(?:success|info|warning|error)\b(?!\s*\()/g
|
|
36
|
+
|
|
37
|
+
// Files whose funnel call takes its key from somewhere other than a literal argument. A prefix
|
|
38
|
+
// means the file spells its keys out in a table or in calls to a local helper, and every literal
|
|
39
|
+
// under that prefix in the file is resolved instead. `null` means the file only forwards a key
|
|
40
|
+
// its callers name, and those callers are read through `SECOND` and `OPTION`. A new indirect call
|
|
41
|
+
// fails until it is either made literal or listed here.
|
|
42
|
+
const INDIRECT: Record<string, string | null> = {
|
|
43
|
+
'composables/useConfirmAction.ts': 'common.toast.',
|
|
44
|
+
'components/github/GitHubPanel.vue': 'vcs.panel.pulls.',
|
|
45
|
+
'components/pipeline/PipelineHealthModal.vue': 'pipeline.health.toast.',
|
|
46
|
+
'components/settings/RiskPolicyPanel.vue': 'settings.riskPolicy.toast.',
|
|
47
|
+
'composables/useInterviewDrafts.ts': null,
|
|
48
|
+
'composables/useContextLinking.ts': null,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The funnels themselves: they DEFINE the calls above (a signature reads like a call head), so
|
|
52
|
+
// they hold no key to check.
|
|
53
|
+
const FUNNELS =
|
|
54
|
+
/^composables\/(?:useActionToast|usePipelineErrorToast)\.ts$|^composables\/pipelineErrorToast\//
|
|
55
|
+
|
|
56
|
+
/** The argument starting at `from`: up to the first top-level `,` or the closing `)`. */
|
|
57
|
+
function argumentAt(text: string, from: number): string {
|
|
58
|
+
let depth = 0
|
|
59
|
+
for (let i = from; i < text.length; i++) {
|
|
60
|
+
const char = text[i]
|
|
61
|
+
if (char === '(' || char === '[' || char === '{') depth++
|
|
62
|
+
else if (char === ')' || char === ']' || char === '}') {
|
|
63
|
+
if (depth === 0) return text.slice(from, i)
|
|
64
|
+
depth--
|
|
65
|
+
} else if (char === ',' && depth === 0) return text.slice(from, i)
|
|
66
|
+
}
|
|
67
|
+
return text.slice(from)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every key argument in `text`, each with the literal keys it contains (none for an indirect one). */
|
|
71
|
+
function keyArguments(text: string): { arg: string; keys: string[] }[] {
|
|
72
|
+
const args: string[] = []
|
|
73
|
+
for (const m of text.matchAll(FIRST)) args.push(argumentAt(text, m.index + m[0].length))
|
|
74
|
+
for (const m of text.matchAll(SECOND)) {
|
|
75
|
+
const first = argumentAt(text, m.index + m[0].length)
|
|
76
|
+
const comma = m.index + m[0].length + first.length
|
|
77
|
+
// `present(error)` with no key takes the funnel's own default title.
|
|
78
|
+
if (text[comma] === ',') args.push(argumentAt(text, comma + 1).trim())
|
|
79
|
+
}
|
|
80
|
+
const keyed = args.map((arg) => ({
|
|
81
|
+
arg: arg.trim(),
|
|
82
|
+
keys: [...arg.matchAll(KEY)].map(([, k]) => k!),
|
|
83
|
+
}))
|
|
84
|
+
for (const m of text.matchAll(OPTION)) {
|
|
85
|
+
const keys = [...argumentAt(text, m.index + m[0].length).matchAll(KEY)].map(([, k]) => k!)
|
|
86
|
+
if (keys.length) keyed.push({ arg: m[0], keys })
|
|
87
|
+
}
|
|
88
|
+
return keyed
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('toast title keys', () => {
|
|
92
|
+
it('reads the key argument of every call shape', () => {
|
|
93
|
+
const keys = (src: string) => keyArguments(src).flatMap((a) => a.keys)
|
|
94
|
+
expect(keys(`actionToast.success('a.saved', { params: { n } })`)).toEqual(['a.saved'])
|
|
95
|
+
expect(keys(`present(error, 'b.failed')`)).toEqual(['b.failed'])
|
|
96
|
+
expect(keys(`presentReported(job.error, 'b.reported', { descriptionKey: 'x' })`)).toEqual([
|
|
97
|
+
'b.reported',
|
|
98
|
+
])
|
|
99
|
+
expect(keys(`actionToast.info(ok ? 'c.yes' : 'c.no')`)).toEqual(['c.yes', 'c.no'])
|
|
100
|
+
expect(keys(`presentLinkFailures(f, id, { titleKey: 'd.linkFailed' })`)).toEqual([
|
|
101
|
+
'd.linkFailed',
|
|
102
|
+
])
|
|
103
|
+
expect(keys(`flushThen(submit, 'e.submitFailed')`)).toEqual(['e.submitFailed'])
|
|
104
|
+
expect(keys(`failureTitleKeys: { one: 'f.one', many: 'f.many' }`)).toEqual(['f.one', 'f.many'])
|
|
105
|
+
// A declaration or a forward names no key, and is not a call to check.
|
|
106
|
+
expect(keyArguments(`titleKey: string`)).toEqual([])
|
|
107
|
+
expect(keyArguments(`present(e)`)).toEqual([])
|
|
108
|
+
expect(keyArguments(`actionToast.success(meta.toastKey)`)).toEqual([
|
|
109
|
+
{ arg: 'meta.toastKey', keys: [] },
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('resolves every key a funnel is handed against the base catalog', () => {
|
|
114
|
+
const missing: string[] = []
|
|
115
|
+
const unreadable: string[] = []
|
|
116
|
+
let seen = 0
|
|
117
|
+
for (const file of sources(appRoot)) {
|
|
118
|
+
const rel = relative(appRoot, file).replaceAll('\\', '/')
|
|
119
|
+
if (FUNNELS.test(rel)) continue
|
|
120
|
+
const text = readFileSync(file, 'utf8')
|
|
121
|
+
for (const alias of text.matchAll(ALIAS)) unreadable.push(`${rel}: ${alias[0]} passed around`)
|
|
122
|
+
for (const { arg, keys } of keyArguments(text)) {
|
|
123
|
+
if (!keys.length && !(rel in INDIRECT)) unreadable.push(`${rel}: ${arg}`)
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
seen++
|
|
126
|
+
if (!hasI18nKey(key)) missing.push(`${rel}: ${key}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const prefix = INDIRECT[rel]
|
|
130
|
+
if (prefix !== undefined && prefix !== null) {
|
|
131
|
+
for (const [, key] of text.matchAll(KEY)) {
|
|
132
|
+
if (!key!.startsWith(prefix)) continue
|
|
133
|
+
seen++
|
|
134
|
+
if (!hasI18nKey(key!)) missing.push(`${rel}: ${key}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// A scan that matched nothing would pass vacuously, so it must have read some keys.
|
|
139
|
+
expect(seen).toBeGreaterThan(0)
|
|
140
|
+
expect(unreadable).toEqual([])
|
|
141
|
+
expect(missing).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
3
|
+
|
|
4
|
+
// `test/setup.ts` stubs `useNuxtApp().$i18n.t` as a key echo; this spec swaps in a `t` that also
|
|
5
|
+
// records its params, and a `useToast` whose `add` it can read back.
|
|
6
|
+
const add = vi.fn()
|
|
7
|
+
const t = vi.fn((key: string, params?: Record<string, unknown>, plural?: number) =>
|
|
8
|
+
[key, params && Object.keys(params).length ? JSON.stringify(params) : '', plural ?? '']
|
|
9
|
+
.filter((part) => part !== '')
|
|
10
|
+
.join('|'),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
add.mockReset()
|
|
15
|
+
t.mockClear()
|
|
16
|
+
vi.stubGlobal('useToast', () => ({ add }))
|
|
17
|
+
vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t } }))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const lastToast = () => add.mock.calls.at(-1)?.[0]
|
|
21
|
+
|
|
22
|
+
describe('useActionToast', () => {
|
|
23
|
+
it('renders every success toast the same, whatever the call site', () => {
|
|
24
|
+
const toast = useActionToast()
|
|
25
|
+
toast.success('a.saved')
|
|
26
|
+
const first = lastToast()
|
|
27
|
+
toast.success('b.connected', { params: { name: 'x' } })
|
|
28
|
+
const second = lastToast()
|
|
29
|
+
for (const key of ['color', 'icon', 'duration'] as const) expect(second[key]).toBe(first[key])
|
|
30
|
+
expect(first.color).toBe('success')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('auto-dismisses success and info, and keeps warning and error until closed', () => {
|
|
34
|
+
const toast = useActionToast()
|
|
35
|
+
toast.success('k')
|
|
36
|
+
expect(lastToast().duration).toBeUndefined()
|
|
37
|
+
toast.info('k')
|
|
38
|
+
expect(lastToast().duration).toBeUndefined()
|
|
39
|
+
toast.warning('k')
|
|
40
|
+
expect(lastToast().duration).toBe(0)
|
|
41
|
+
toast.error('k')
|
|
42
|
+
expect(lastToast().duration).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resolves the title key with its params and plural count', () => {
|
|
46
|
+
useActionToast().info('x.found', { params: { count: 3 }, plural: 3, description: 'body' })
|
|
47
|
+
expect(lastToast().title).toBe('x.found|{"count":3}|3')
|
|
48
|
+
expect(lastToast().description).toBe('body')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('holds an undo toast exactly as long as the window and leads with the Undo action', () => {
|
|
52
|
+
const run = vi.fn()
|
|
53
|
+
const extra = { label: 'Open', onClick: vi.fn() }
|
|
54
|
+
useActionToast().success('board.toast.deleted', {
|
|
55
|
+
undo: { run, windowMs: 6000 },
|
|
56
|
+
actions: [extra],
|
|
57
|
+
})
|
|
58
|
+
const shown = lastToast()
|
|
59
|
+
expect(shown.duration).toBe(6000)
|
|
60
|
+
expect(shown.actions).toHaveLength(2)
|
|
61
|
+
expect(shown.actions[0].label).toBe('common.undo')
|
|
62
|
+
shown.actions[0].onClick()
|
|
63
|
+
expect(run).toHaveBeenCalledOnce()
|
|
64
|
+
expect(shown.actions[1]).toBe(extra)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sends no empty actions array', () => {
|
|
68
|
+
useActionToast().success('k')
|
|
69
|
+
expect(lastToast()).not.toHaveProperty('actions')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toast for everything that is NOT a failed call: the second of the SPA's two toast funnels.
|
|
3
|
+
* A failed call goes to `usePipelineErrorToast().present(error, titleKey)`; every other toast comes
|
|
4
|
+
* from here, so `toast.add` is called from exactly those two composables
|
|
5
|
+
* (`scripts/check-frontend-feedback.mjs` enforces it).
|
|
6
|
+
*
|
|
7
|
+
* Each tone fixes its colour, its icon and whether it auto-dismisses, so two toasts of one tone
|
|
8
|
+
* render identically wherever they come from. The call site picks a TONE and supplies COPY; it
|
|
9
|
+
* cannot restyle the toast. Before this, some success toasts carried `color: 'success'` and some
|
|
10
|
+
* none, and the icon was a choice made per site (`i-lucide-check`, `-link`, `-unplug`, `-copy`,
|
|
11
|
+
* `-rocket`, or nothing).
|
|
12
|
+
*
|
|
13
|
+
* - `success`: the action the user asked for completed. Dismisses after Nuxt UI's default.
|
|
14
|
+
* - `info`: progress or a state report that asks nothing of the reader ("drafting…", "up to
|
|
15
|
+
* date"). Dismisses after the default.
|
|
16
|
+
* - `warning`: the action ran, but the outcome is partial or nothing changed in a way the reader
|
|
17
|
+
* should know about. Stays until closed, because it asks the reader to look at something.
|
|
18
|
+
* - `error`: a refusal in TRANSLATED copy that is not a failed call: a client-side validation that
|
|
19
|
+
* never left the browser, or a recognised refusal reason the site maps to its own key. It has no
|
|
20
|
+
* envelope, so `present` would misread it as a network fault (README, "Every failure toast goes
|
|
21
|
+
* through ONE funnel"). Stays until closed, like every failure. A failure the backend reported
|
|
22
|
+
* as DATA with its own prose (a job row's `error`) is `presentReported` on the failure funnel
|
|
23
|
+
* instead, which keeps that prose as copyable detail rather than the headline.
|
|
24
|
+
*
|
|
25
|
+
* Titles are i18n KEYS, resolved here (the same contract as `present`), so a call site cannot hand
|
|
26
|
+
* in untranslated prose as the headline. A `description` is already-resolved text, because it
|
|
27
|
+
* often carries data (a repo name, a count line) built from several keys. A key handed in here is
|
|
28
|
+
* invisible to typed message keys and to `vue-i18n-extract`, which only read a key written literally inside a `t()` call, so
|
|
29
|
+
* `toastTitleKeys.spec.ts` resolves every literal key passed to either funnel against the catalog.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** One button on the toast. A call-to-action is content, so the call site may add one. */
|
|
33
|
+
export interface ActionToastAction {
|
|
34
|
+
label: string
|
|
35
|
+
icon?: string
|
|
36
|
+
onClick: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ActionToastOptions {
|
|
40
|
+
/** Interpolation for the title key. */
|
|
41
|
+
params?: Record<string, unknown>
|
|
42
|
+
/** The plural count for the title key, when it has plural forms. */
|
|
43
|
+
plural?: number
|
|
44
|
+
/** Resolved body text. */
|
|
45
|
+
description?: string
|
|
46
|
+
actions?: ActionToastAction[]
|
|
47
|
+
/**
|
|
48
|
+
* Offer an Undo for a write whose real effect is deferred. The toast then stays exactly as long
|
|
49
|
+
* as the window the caller holds the write open for, because an Undo button that outlives the
|
|
50
|
+
* window would undo nothing.
|
|
51
|
+
*/
|
|
52
|
+
undo?: { run: () => void; windowMs: number }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Tone = 'success' | 'info' | 'warning' | 'error'
|
|
56
|
+
|
|
57
|
+
// The only place a tone's look is decided. `duration: 0` keeps the toast until the reader closes
|
|
58
|
+
// it; `undefined` takes Nuxt UI's default (5s).
|
|
59
|
+
const TONES: Record<Tone, { color: Tone; icon: string; duration: number | undefined }> = {
|
|
60
|
+
success: { color: 'success', icon: 'i-lucide-circle-check', duration: undefined },
|
|
61
|
+
info: { color: 'info', icon: 'i-lucide-info', duration: undefined },
|
|
62
|
+
warning: { color: 'warning', icon: 'i-lucide-triangle-alert', duration: 0 },
|
|
63
|
+
error: { color: 'error', icon: 'i-lucide-triangle-alert', duration: 0 },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useActionToast() {
|
|
67
|
+
const toast = useToast()
|
|
68
|
+
// The Nuxt app's global i18n instance, not `useI18n()`: this is reached from store setup (the
|
|
69
|
+
// board store's undo toasts) and from `useCopyToClipboard`, which the error funnel calls from
|
|
70
|
+
// store setup. Same reason as `usePipelineErrorToast`.
|
|
71
|
+
const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
72
|
+
|
|
73
|
+
function show(tone: Tone, titleKey: string, opts: ActionToastOptions = {}): void {
|
|
74
|
+
const look = TONES[tone]
|
|
75
|
+
const params = opts.params ?? {}
|
|
76
|
+
const title = opts.plural === undefined ? t(titleKey, params) : t(titleKey, params, opts.plural)
|
|
77
|
+
const actions = [
|
|
78
|
+
...(opts.undo
|
|
79
|
+
? [{ label: t('common.undo'), icon: 'i-lucide-undo-2', onClick: opts.undo.run }]
|
|
80
|
+
: []),
|
|
81
|
+
...(opts.actions ?? []),
|
|
82
|
+
]
|
|
83
|
+
toast.add({
|
|
84
|
+
title,
|
|
85
|
+
description: opts.description,
|
|
86
|
+
color: look.color,
|
|
87
|
+
icon: look.icon,
|
|
88
|
+
duration: opts.undo ? opts.undo.windowMs : look.duration,
|
|
89
|
+
...(actions.length ? { actions } : {}),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
success: (titleKey: string, opts?: ActionToastOptions) => show('success', titleKey, opts),
|
|
95
|
+
info: (titleKey: string, opts?: ActionToastOptions) => show('info', titleKey, opts),
|
|
96
|
+
warning: (titleKey: string, opts?: ActionToastOptions) => show('warning', titleKey, opts),
|
|
97
|
+
error: (titleKey: string, opts?: ActionToastOptions) => show('error', titleKey, opts),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ActionToast = ReturnType<typeof useActionToast>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Block } from '~/types/domain'
|
|
2
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The single, confirm-gated block deletion used by BOTH the inspector's Delete button and
|
|
@@ -15,7 +16,7 @@ export function useBlockDeletion() {
|
|
|
15
16
|
const execution = useExecutionStore()
|
|
16
17
|
const ui = useUiStore()
|
|
17
18
|
const recurring = useRecurringPipelinesStore()
|
|
18
|
-
const
|
|
19
|
+
const actionToast = useActionToast()
|
|
19
20
|
const { present } = usePipelineErrorToast()
|
|
20
21
|
const { confirm } = useConfirm()
|
|
21
22
|
const { t } = useI18n()
|
|
@@ -52,11 +53,7 @@ export function useBlockDeletion() {
|
|
|
52
53
|
ui.select(null)
|
|
53
54
|
try {
|
|
54
55
|
await board.archiveService(block.id)
|
|
55
|
-
toast.
|
|
56
|
-
title: t('board.toast.archived', { name: block.title }),
|
|
57
|
-
icon: 'i-lucide-archive',
|
|
58
|
-
color: 'neutral',
|
|
59
|
-
})
|
|
56
|
+
actionToast.success('board.toast.archived', { params: { name: block.title } })
|
|
60
57
|
} catch (e) {
|
|
61
58
|
present(e, 'board.toast.archiveFailed')
|
|
62
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Confirm-gate + success-toast for the recurring destructive actions that aren't board
|
|
3
3
|
* blocks (disconnect a connection, remove/revoke a credential, clear a config, destroy an
|
|
4
|
-
* environment). Built on the same `useConfirm()` singleton + `
|
|
4
|
+
* environment). Built on the same `useConfirm()` singleton + `useActionToast()` the board delete
|
|
5
5
|
* path uses, so every destructive affordance across the settings/connection surfaces routes
|
|
6
6
|
* through ONE confirm-then-mutate + toast path rather than each re-inventing its own copy.
|
|
7
7
|
*
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* per surface. `name` is a short noun for the target (a brand like "Slack", a data value like
|
|
20
20
|
* the invite email, or a feature noun like "the test environment").
|
|
21
21
|
*/
|
|
22
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
23
|
+
|
|
22
24
|
type ConfirmShape = 'disconnect' | 'remove' | 'revoke' | 'clear' | 'destroy'
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -71,7 +73,7 @@ const SHAPE_META: Record<
|
|
|
71
73
|
|
|
72
74
|
export function useConfirmAction() {
|
|
73
75
|
const { confirm } = useConfirm()
|
|
74
|
-
const
|
|
76
|
+
const actionToast = useActionToast()
|
|
75
77
|
const { t } = useI18n()
|
|
76
78
|
|
|
77
79
|
/** Prompt before a destructive action against `name`. Resolves `true` only if confirmed. */
|
|
@@ -89,7 +91,7 @@ export function useConfirmAction() {
|
|
|
89
91
|
/** Toast the completed destructive action (call only on real success). */
|
|
90
92
|
function toastDone(shape: ConfirmShape, name: string): void {
|
|
91
93
|
const meta = SHAPE_META[shape]
|
|
92
|
-
|
|
94
|
+
actionToast.success(meta.toastKey, { params: { name } })
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
return { confirmAction, toastDone }
|
|
@@ -105,6 +105,7 @@ describe('resolvePending', () => {
|
|
|
105
105
|
vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
|
|
106
106
|
vi.stubGlobal('useToast', () => ({ add: vi.fn() }))
|
|
107
107
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
108
|
+
vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t: (key: string) => key } }))
|
|
108
109
|
vi.stubGlobal('useCopyToClipboard', () => ({ copyAction: () => ({ label: 'copy' }) }))
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -192,6 +193,7 @@ describe('presentLinkFailures', () => {
|
|
|
192
193
|
vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
|
|
193
194
|
vi.stubGlobal('useToast', () => ({ add }))
|
|
194
195
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
196
|
+
vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t: (key: string) => key } }))
|
|
195
197
|
vi.stubGlobal('useCopyToClipboard', () => ({ copyAction }))
|
|
196
198
|
return { add, copyAction }
|
|
197
199
|
}
|
|
@@ -221,6 +223,7 @@ describe('presentLinkFailures', () => {
|
|
|
221
223
|
const toast = add.mock.calls[0]![0]
|
|
222
224
|
// Sticky so the cause stays readable, titled by the count key, and per-item reason shown.
|
|
223
225
|
expect(toast.title).toBe('board.addTask.linkFailed')
|
|
226
|
+
expect(toast.color).toBe('warning')
|
|
224
227
|
expect(toast.duration).toBe(0)
|
|
225
228
|
expect(toast.description).toContain('GitHub denied access')
|
|
226
229
|
expect(toast.actions).toHaveLength(1)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
|
|
2
2
|
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
3
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
3
4
|
|
|
4
5
|
// Shared model + orchestration for attaching external context (imported docs and
|
|
5
6
|
// tracker issues) to a board block. A "pending" item is something the user has
|
|
@@ -121,7 +122,7 @@ export function useContextLinking() {
|
|
|
121
122
|
const documents = useDocumentsStore()
|
|
122
123
|
const tasks = useTasksStore()
|
|
123
124
|
const workspace = useWorkspaceStore()
|
|
124
|
-
const
|
|
125
|
+
const actionToast = useActionToast()
|
|
125
126
|
const { t } = useI18n()
|
|
126
127
|
const { copyAction } = useCopyToClipboard()
|
|
127
128
|
|
|
@@ -240,14 +241,12 @@ export function useContextLinking() {
|
|
|
240
241
|
/**
|
|
241
242
|
* Surface link failures as a single actionable toast: the specific per-item
|
|
242
243
|
* reasons as the body, and a "Copy details" action that puts the full diagnostic
|
|
243
|
-
* report ({@link buildLinkFailureReport}) on the clipboard.
|
|
244
|
-
*
|
|
244
|
+
* report ({@link buildLinkFailureReport}) on the clipboard. A warning, so it stays until
|
|
245
|
+
* closed and the cause stays readable long enough to act on. No-op when nothing failed.
|
|
245
246
|
*
|
|
246
|
-
* `opts.
|
|
247
|
-
* "Initiative created, but …"). It is a
|
|
248
|
-
*
|
|
249
|
-
* would make it a variable, which defeats both the typed-message-key check and the extractor's
|
|
250
|
-
* static scan — and the plural choice has to be made against the same count.
|
|
247
|
+
* `opts.titleKey` names what WAS created, which differs per host ("Task added, but …" vs
|
|
248
|
+
* "Initiative created, but …"). It is a plural key, resolved against the failure count;
|
|
249
|
+
* `toastTitleKeys.spec.ts` checks the literal each caller passes, which typed keys cannot see.
|
|
251
250
|
*/
|
|
252
251
|
/**
|
|
253
252
|
* A failure's line in the toast: TRANSLATED copy where the backend named a reason we have a
|
|
@@ -269,7 +268,7 @@ export function useContextLinking() {
|
|
|
269
268
|
function presentLinkFailures(
|
|
270
269
|
failures: LinkFailure[],
|
|
271
270
|
blockId?: string,
|
|
272
|
-
opts: {
|
|
271
|
+
opts: { titleKey?: string } = {},
|
|
273
272
|
): void {
|
|
274
273
|
if (failures.length === 0) return
|
|
275
274
|
const description = failures.map((f) => `${f.item.title}: ${describeFailure(f)}`).join('\n')
|
|
@@ -278,14 +277,10 @@ export function useContextLinking() {
|
|
|
278
277
|
blockId,
|
|
279
278
|
when: new Date().toISOString(),
|
|
280
279
|
})
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
t('board.addTask.linkFailed', { count: failures.length }, failures.length),
|
|
280
|
+
actionToast.warning(opts.titleKey ?? 'board.addTask.linkFailed', {
|
|
281
|
+
params: { count: failures.length },
|
|
282
|
+
plural: failures.length,
|
|
285
283
|
description,
|
|
286
|
-
icon: 'i-lucide-triangle-alert',
|
|
287
|
-
color: 'warning',
|
|
288
|
-
duration: 0,
|
|
289
284
|
actions: [copyAction(report)],
|
|
290
285
|
})
|
|
291
286
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// silent no-op the user couldn't tell apart from success. Routing every copy through this seam
|
|
8
8
|
// makes the outcome always visible.
|
|
9
9
|
import { useClipboard } from '@vueuse/core'
|
|
10
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
10
11
|
|
|
11
12
|
export function useCopyToClipboard() {
|
|
12
13
|
// Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which requires an
|
|
@@ -16,7 +17,7 @@ export function useCopyToClipboard() {
|
|
|
16
17
|
// component. `useI18n()` there throws `MUST_BE_CALL_SETUP_TOP`, and a throw in a store body takes
|
|
17
18
|
// the whole app to Nuxt's error page. Same reason, and the same fix, as `usePipelineErrorToast`.
|
|
18
19
|
const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
19
|
-
const
|
|
20
|
+
const actionToast = useActionToast()
|
|
20
21
|
const { copy: writeClipboard, isSupported } = useClipboard()
|
|
21
22
|
|
|
22
23
|
async function copy(text: string) {
|
|
@@ -25,9 +26,9 @@ export function useCopyToClipboard() {
|
|
|
25
26
|
try {
|
|
26
27
|
if (!isSupported.value) throw new Error('clipboard unsupported')
|
|
27
28
|
await writeClipboard(text)
|
|
28
|
-
|
|
29
|
+
actionToast.success('common.copied')
|
|
29
30
|
} catch {
|
|
30
|
-
|
|
31
|
+
actionToast.error('common.copyFailed')
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -4,6 +4,7 @@ import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contribu
|
|
|
4
4
|
import { EXTERNAL_TOOL_UNAVAILABLE_KEYS, projectExternalTools } from '~/modular/external-tools'
|
|
5
5
|
import { toMetadataBag } from '~/modular/workspace-metadata'
|
|
6
6
|
import type { ExternalToolContext, ExternalToolContribution } from '~/modular/external-tools'
|
|
7
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
7
8
|
import type {
|
|
8
9
|
AppSlots,
|
|
9
10
|
CommandGroup,
|
|
@@ -30,7 +31,7 @@ export function useNavContributions() {
|
|
|
30
31
|
// an active component instance) — the same handle, and the same reason, as
|
|
31
32
|
// `usePipelineErrorToast`: nothing about this composable should depend on WHERE it is called.
|
|
32
33
|
const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
33
|
-
const
|
|
34
|
+
const actionToast = useActionToast()
|
|
34
35
|
const auth = useAuthStore()
|
|
35
36
|
const workspace = useWorkspaceStore()
|
|
36
37
|
const workspaceSettings = useWorkspaceSettingsStore()
|
|
@@ -148,13 +149,11 @@ export function useNavContributions() {
|
|
|
148
149
|
resolution.cause,
|
|
149
150
|
)
|
|
150
151
|
}
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
actionToast.error('externalTools.unavailable.title', {
|
|
153
|
+
params: { tool: tool.title },
|
|
153
154
|
description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
|
|
154
155
|
fields: resolution.missing.join(', '),
|
|
155
156
|
}),
|
|
156
|
-
icon: 'i-lucide-triangle-alert',
|
|
157
|
-
color: 'warning',
|
|
158
157
|
})
|
|
159
158
|
},
|
|
160
159
|
},
|
|
@@ -272,6 +272,30 @@ describe('usePipelineErrorToast', () => {
|
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
274
|
|
|
275
|
+
describe('presentReported', () => {
|
|
276
|
+
it('keeps a failure reported as data behind the disclosure, under translated copy', () => {
|
|
277
|
+
// A job row's `error` is backend prose: it must be copyable detail, never the headline, and it
|
|
278
|
+
// must not be read as a network fault the way `present` reads a bare string.
|
|
279
|
+
usePipelineErrorToast().presentReported('stack exited with code 1', 'x.startFailed')
|
|
280
|
+
const arg = add.mock.calls[0]![0]
|
|
281
|
+
expect(arg.title).toBe('x.startFailed')
|
|
282
|
+
expect(arg.description).toBe('errors.reported.description')
|
|
283
|
+
expect(arg.duration).toBe(0)
|
|
284
|
+
arg.actions[0].onClick()
|
|
285
|
+
expect(update).toHaveBeenCalledWith('toast-1', {
|
|
286
|
+
description: 'stack exited with code 1',
|
|
287
|
+
actions: [expect.objectContaining({ label: 'common.copyDetails' })],
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('takes a site description key, and offers no disclosure when nothing was reported', () => {
|
|
292
|
+
usePipelineErrorToast().presentReported(null, 'x.failed', { descriptionKey: 'x.fallback' })
|
|
293
|
+
const arg = add.mock.calls[0]![0]
|
|
294
|
+
expect(arg.description).toBe('x.fallback')
|
|
295
|
+
expect(arg.actions).toEqual([expect.objectContaining({ label: 'common.copyDetails' })])
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
275
299
|
describe('describeGenericFailure', () => {
|
|
276
300
|
it('maps each known status class to its own description key', () => {
|
|
277
301
|
for (const code of [
|
|
@@ -683,7 +683,12 @@ export function usePipelineErrorToast() {
|
|
|
683
683
|
// ships in the base `en` catalog, so a locale missing it renders English via `fallbackLocale`
|
|
684
684
|
// (better than the raw prose this replaced) and a bare key can never leak.
|
|
685
685
|
const title = t(fallbackTitleKey, titleParams ?? {})
|
|
686
|
-
|
|
686
|
+
showFailure(title, t(failure.descriptionKey), detail)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The failure toast both generic paths render: translated title and description, the raw
|
|
690
|
+
* detail behind "Show details", and the whole report copyable. */
|
|
691
|
+
function showFailure(title: string, description: string, detail: string): void {
|
|
687
692
|
const report = [title, description, detail].filter((part) => part.length > 0).join('\n')
|
|
688
693
|
const added = toast.add({
|
|
689
694
|
title,
|
|
@@ -713,6 +718,25 @@ export function usePipelineErrorToast() {
|
|
|
713
718
|
})
|
|
714
719
|
}
|
|
715
720
|
|
|
721
|
+
/**
|
|
722
|
+
* A failure the backend reported as DATA rather than as a failed call: a job row with
|
|
723
|
+
* `status: 'failed'` and its `error` text, a probe verdict with a `message`. There is no
|
|
724
|
+
* envelope, so `present` would misread it as a network fault. The reported text is backend prose
|
|
725
|
+
* and so DETAIL, never the headline: it goes behind "Show details" and into the copied report,
|
|
726
|
+
* under a translated description (`descriptionKey`, or the generic "it reported a failure").
|
|
727
|
+
*/
|
|
728
|
+
function presentReported(
|
|
729
|
+
detail: string | null | undefined,
|
|
730
|
+
titleKey: string,
|
|
731
|
+
opts: { params?: Record<string, unknown>; descriptionKey?: string } = {},
|
|
732
|
+
): void {
|
|
733
|
+
showFailure(
|
|
734
|
+
t(titleKey, opts.params ?? {}),
|
|
735
|
+
t(opts.descriptionKey ?? 'errors.reported.description'),
|
|
736
|
+
detail?.trim() ?? '',
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
716
740
|
/**
|
|
717
741
|
* Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
|
|
718
742
|
* non-conflict failures and any conflict reason without a dedicated title; `titleParams` carries
|
|
@@ -732,5 +756,5 @@ export function usePipelineErrorToast() {
|
|
|
732
756
|
presentGenericFailure(error, fallbackTitleKey, titleParams)
|
|
733
757
|
}
|
|
734
758
|
|
|
735
|
-
return { present }
|
|
759
|
+
return { present, presentReported }
|
|
736
760
|
}
|
|
@@ -2,6 +2,7 @@ import type { RunDefaultScope } from '@cat-factory/contracts'
|
|
|
2
2
|
import type { Pipeline } from '~/types/domain'
|
|
3
3
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
4
4
|
import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
|
|
5
|
+
import { useActionToast } from '~/composables/useActionToast'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The actions a row of the saved-pipeline LIBRARY offers: archive, promote to a scope default,
|
|
@@ -14,7 +15,7 @@ import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
|
|
|
14
15
|
*/
|
|
15
16
|
export function usePipelineLibraryActions() {
|
|
16
17
|
const pipelines = usePipelinesStore()
|
|
17
|
-
const
|
|
18
|
+
const actionToast = useActionToast()
|
|
18
19
|
const { t } = useI18n()
|
|
19
20
|
const { present } = usePipelineErrorToast()
|
|
20
21
|
const { confirm } = useConfirm()
|
|
@@ -24,8 +25,8 @@ export function usePipelineLibraryActions() {
|
|
|
24
25
|
try {
|
|
25
26
|
if (p.archived) await pipelines.unarchive(p.id)
|
|
26
27
|
else await pipelines.archive(p.id)
|
|
27
|
-
} catch {
|
|
28
|
-
|
|
28
|
+
} catch (error) {
|
|
29
|
+
present(error, 'pipeline.builder.toast.updateFailed')
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -68,13 +69,11 @@ export function usePipelineLibraryActions() {
|
|
|
68
69
|
async function clone(p: Pipeline) {
|
|
69
70
|
try {
|
|
70
71
|
const copy = await pipelines.clonePipeline(p.id)
|
|
71
|
-
toast.
|
|
72
|
-
|
|
73
|
-
color: 'success',
|
|
74
|
-
icon: 'i-lucide-copy',
|
|
72
|
+
actionToast.success('pipeline.builder.toast.cloned', {
|
|
73
|
+
params: { name: p.name, copy: copy.name },
|
|
75
74
|
})
|
|
76
|
-
} catch {
|
|
77
|
-
|
|
75
|
+
} catch (error) {
|
|
76
|
+
present(error, 'pipeline.builder.toast.cloneFailed')
|
|
78
77
|
}
|
|
79
78
|
}
|
|
80
79
|
|