@cat-factory/app 0.82.1 → 0.82.2
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/composables/useBlockDeletion.ts +21 -5
- package/app/composables/useBlockQueries.ts +18 -0
- package/app/stores/board.spec.ts +196 -1
- package/app/stores/board.ts +156 -22
- package/app/stores/execution.ts +7 -3
- package/i18n/locales/en.json +7 -3
- package/i18n/locales/es.json +7 -3
- package/i18n/locales/fr.json +7 -3
- package/i18n/locales/he.json +7 -3
- package/i18n/locales/ja.json +7 -3
- package/i18n/locales/pl.json +7 -3
- package/i18n/locales/tr.json +7 -3
- package/i18n/locales/uk.json +7 -3
- package/package.json +1 -1
|
@@ -28,10 +28,23 @@ export function useBlockDeletion() {
|
|
|
28
28
|
: block.level === 'module'
|
|
29
29
|
? 'module'
|
|
30
30
|
: 'service'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const title = t(`panels.inspector.confirmDelete.${kind}.title`)
|
|
32
|
+
// For a container (service/module) state the exact cascade size so the blast radius is
|
|
33
|
+
// explicit — "and everything inside it" hides how many tasks/modules go with it.
|
|
34
|
+
if (kind === 'module' || kind === 'service') {
|
|
35
|
+
const count = board.descendantsOf(block.id).length
|
|
36
|
+
if (count > 0) {
|
|
37
|
+
return {
|
|
38
|
+
title,
|
|
39
|
+
body: t(
|
|
40
|
+
'panels.inspector.confirmDelete.containerBodyWithCount',
|
|
41
|
+
{ name: block.title, count },
|
|
42
|
+
count,
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
34
46
|
}
|
|
47
|
+
return { title, body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }) }
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
|
|
@@ -54,8 +67,11 @@ export function useBlockDeletion() {
|
|
|
54
67
|
void recurring.remove(schedule.id)
|
|
55
68
|
return true
|
|
56
69
|
}
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
// Cancelling the run is irreversible, so defer it into the delete's commit: it fires only
|
|
71
|
+
// once the (deferred) delete actually lands, so an undo within the window leaves a running
|
|
72
|
+
// pipeline intact rather than restoring a block whose run was already torn down. Target the
|
|
73
|
+
// workspace the block was deleted from in case the user switched mid-window.
|
|
74
|
+
void board.removeBlock(block.id, { onCommit: (wsId) => execution.cancel(block.id, wsId) })
|
|
59
75
|
return true
|
|
60
76
|
}
|
|
61
77
|
|
|
@@ -71,6 +71,23 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
71
71
|
return [...direct, ...nested]
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Every block nested under a container (transitive), excluding the container
|
|
76
|
+
* itself — the exact set a delete cascades over. Used to state the blast radius
|
|
77
|
+
* in the delete confirmation. Structural children only (via `parentId`), so a
|
|
78
|
+
* non-structural epic membership is not counted.
|
|
79
|
+
*/
|
|
80
|
+
function descendantsOf(id: string): Block[] {
|
|
81
|
+
const out: Block[] = []
|
|
82
|
+
const stack = [...childrenOf(id)]
|
|
83
|
+
while (stack.length > 0) {
|
|
84
|
+
const b = stack.pop()!
|
|
85
|
+
out.push(b)
|
|
86
|
+
stack.push(...childrenOf(b.id))
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/** The top-level service a block ultimately belongs to. */
|
|
75
92
|
function serviceOf(block: Block): Block | undefined {
|
|
76
93
|
let cur: Block | undefined = block
|
|
@@ -200,6 +217,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
200
217
|
modulesOf,
|
|
201
218
|
initiativesOf,
|
|
202
219
|
allTasksUnder,
|
|
220
|
+
descendantsOf,
|
|
203
221
|
serviceOf,
|
|
204
222
|
unmetDeps,
|
|
205
223
|
isRunnable,
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
2
|
import { setActivePinia, createPinia } from 'pinia'
|
|
3
3
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
4
4
|
import { useBoardStore } from '~/stores/board'
|
|
@@ -89,6 +89,26 @@ describe('board store read getters', () => {
|
|
|
89
89
|
).toEqual(['t2', 't3'])
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
it('descendantsOf returns the transitive structural subtree, excluding the root', () => {
|
|
93
|
+
store.hydrate([
|
|
94
|
+
frame('f1'),
|
|
95
|
+
moduleBlock('m1', 'f1'),
|
|
96
|
+
task('t1', 'f1'),
|
|
97
|
+
task('t2', 'm1'),
|
|
98
|
+
frame('f2'),
|
|
99
|
+
task('t3', 'f2'),
|
|
100
|
+
])
|
|
101
|
+
expect(
|
|
102
|
+
store
|
|
103
|
+
.descendantsOf('f1')
|
|
104
|
+
.map((b) => b.id)
|
|
105
|
+
.sort(),
|
|
106
|
+
).toEqual(['m1', 't1', 't2'])
|
|
107
|
+
// a leaf task has no descendants; unknown ids are a safe empty
|
|
108
|
+
expect(store.descendantsOf('t1')).toEqual([])
|
|
109
|
+
expect(store.descendantsOf('missing')).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
|
|
92
112
|
it('epicMembers groups blocks by their epicId (indexed lookup)', () => {
|
|
93
113
|
store.hydrate([
|
|
94
114
|
frame('f1'),
|
|
@@ -289,4 +309,179 @@ describe('board store optimistic rollback', () => {
|
|
|
289
309
|
expect(store.getBlock('t1')?.title).toBe('orig')
|
|
290
310
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
291
311
|
})
|
|
312
|
+
|
|
313
|
+
it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
|
|
314
|
+
vi.stubGlobal('useApi', () => ({
|
|
315
|
+
reparentBlock: async (
|
|
316
|
+
_ws: string,
|
|
317
|
+
id: string,
|
|
318
|
+
body: { parentId: string; position: unknown },
|
|
319
|
+
) => task(id, body.parentId, { position: body.position as { x: number; y: number } }),
|
|
320
|
+
}))
|
|
321
|
+
interface ToastAction {
|
|
322
|
+
onClick: () => void
|
|
323
|
+
}
|
|
324
|
+
const actions: ToastAction[] = []
|
|
325
|
+
vi.stubGlobal('useToast', () => ({
|
|
326
|
+
add: (t: { actions?: ToastAction[] }) => {
|
|
327
|
+
if (t.actions) actions.push(...t.actions)
|
|
328
|
+
},
|
|
329
|
+
}))
|
|
330
|
+
setActivePinia(createPinia())
|
|
331
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
332
|
+
const store = useBoardStore()
|
|
333
|
+
store.hydrate([
|
|
334
|
+
frame('f1'),
|
|
335
|
+
moduleBlock('m1', 'f1'),
|
|
336
|
+
task('t1', 'f1', { position: { x: 1, y: 2 } }),
|
|
337
|
+
])
|
|
338
|
+
await store.reparentBlock('t1', 'm1', { x: 5, y: 6 })
|
|
339
|
+
expect(store.getBlock('t1')?.parentId).toBe('m1')
|
|
340
|
+
// the undo action returns the block to its original parent + position
|
|
341
|
+
expect(actions).toHaveLength(1)
|
|
342
|
+
actions[0]!.onClick()
|
|
343
|
+
await vi.waitFor(() => {
|
|
344
|
+
expect(store.getBlock('t1')?.parentId).toBe('f1')
|
|
345
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 1, y: 2 })
|
|
346
|
+
})
|
|
347
|
+
// the undo move is itself non-undoable, so no second toast is queued
|
|
348
|
+
expect(actions).toHaveLength(1)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('board store deferred delete + undo', () => {
|
|
353
|
+
interface ToastAction {
|
|
354
|
+
onClick: () => void
|
|
355
|
+
}
|
|
356
|
+
/** Build a store with a stubbed api/toast, capturing the undo action offered on delete. */
|
|
357
|
+
function setup(removeImpl: () => Promise<void>) {
|
|
358
|
+
const removeSpy = vi.fn(removeImpl)
|
|
359
|
+
const addSpy = vi.fn()
|
|
360
|
+
const actions: ToastAction[] = []
|
|
361
|
+
vi.stubGlobal('useApi', () => ({ removeBlock: removeSpy }))
|
|
362
|
+
vi.stubGlobal('useToast', () => ({
|
|
363
|
+
add: (t: { actions?: ToastAction[] }) => {
|
|
364
|
+
addSpy(t)
|
|
365
|
+
if (t.actions) actions.push(...t.actions)
|
|
366
|
+
},
|
|
367
|
+
}))
|
|
368
|
+
setActivePinia(createPinia())
|
|
369
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
370
|
+
return { store: useBoardStore(), removeSpy, addSpy, actions }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
vi.useFakeTimers()
|
|
375
|
+
})
|
|
376
|
+
afterEach(() => {
|
|
377
|
+
vi.useRealTimers()
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('hides the subtree immediately but defers the backend delete', () => {
|
|
381
|
+
const { store, removeSpy } = setup(async () => {})
|
|
382
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
|
|
383
|
+
store.removeBlock('f1')
|
|
384
|
+
// the whole subtree disappears at once…
|
|
385
|
+
expect(store.getBlock('f1')).toBeUndefined()
|
|
386
|
+
expect(store.getBlock('m1')).toBeUndefined()
|
|
387
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
388
|
+
// …but nothing is deleted server-side yet.
|
|
389
|
+
expect(removeSpy).not.toHaveBeenCalled()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('keeps a pending-delete subtree hidden across a coarse refresh, and prunes its edges', () => {
|
|
393
|
+
const { store } = setup(async () => {})
|
|
394
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
|
|
395
|
+
store.removeBlock('t1')
|
|
396
|
+
// A full re-hydrate (e.g. a `board` live event) that still carries the deleted block and
|
|
397
|
+
// the now-dangling dependency edge must not resurrect either.
|
|
398
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
|
|
399
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
400
|
+
expect(store.getBlock('t2')?.dependsOn).toEqual([])
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('ignores a live upsert for a block awaiting its deferred delete', () => {
|
|
404
|
+
const { store } = setup(async () => {})
|
|
405
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
406
|
+
store.removeBlock('t1')
|
|
407
|
+
store.upsert(task('t1', 'f1', { title: 'resurrected' }))
|
|
408
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('undo cancels the pending delete and restores the subtree', async () => {
|
|
412
|
+
const { store, removeSpy, actions } = setup(async () => {})
|
|
413
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
|
|
414
|
+
store.removeBlock('f1')
|
|
415
|
+
expect(actions).toHaveLength(1)
|
|
416
|
+
actions[0]!.onClick()
|
|
417
|
+
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
418
|
+
expect(store.getBlock('t1')?.id).toBe('t1')
|
|
419
|
+
// the deferred delete never fires after an undo
|
|
420
|
+
await vi.runAllTimersAsync()
|
|
421
|
+
expect(removeSpy).not.toHaveBeenCalled()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('fires the backend delete for the captured workspace once the window elapses', async () => {
|
|
425
|
+
const { store, removeSpy } = setup(async () => {})
|
|
426
|
+
store.hydrate([frame('f1')])
|
|
427
|
+
store.removeBlock('f1')
|
|
428
|
+
await vi.runAllTimersAsync()
|
|
429
|
+
expect(removeSpy).toHaveBeenCalledWith('ws1', 'f1')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('restores the subtree and toasts an error if the deferred delete fails', async () => {
|
|
433
|
+
const { store, addSpy } = setup(() => Promise.reject(new Error('boom')))
|
|
434
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
435
|
+
store.removeBlock('f1')
|
|
436
|
+
await vi.runAllTimersAsync()
|
|
437
|
+
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
438
|
+
expect(store.getBlock('t1')?.id).toBe('t1')
|
|
439
|
+
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('does not reattach a failed deferred delete onto a different workspace', async () => {
|
|
443
|
+
const { store } = setup(() => Promise.reject(new Error('boom')))
|
|
444
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
445
|
+
store.removeBlock('f1')
|
|
446
|
+
// The user switches workspace during the undo window; when the delete then fails, the
|
|
447
|
+
// ws1 subtree must NOT be injected onto the ws2 board now on screen.
|
|
448
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
449
|
+
await vi.runAllTimersAsync()
|
|
450
|
+
expect(store.getBlock('f1')).toBeUndefined()
|
|
451
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('runs onCommit with the captured workspace only when the window elapses', async () => {
|
|
455
|
+
const { store } = setup(async () => {})
|
|
456
|
+
const onCommit = vi.fn(async () => {})
|
|
457
|
+
store.hydrate([frame('f1')])
|
|
458
|
+
store.removeBlock('f1', { onCommit })
|
|
459
|
+
await vi.runAllTimersAsync()
|
|
460
|
+
expect(onCommit).toHaveBeenCalledWith('ws1')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('skips onCommit (the irreversible side effect) when the delete is undone', async () => {
|
|
464
|
+
const { store, actions } = setup(async () => {})
|
|
465
|
+
const onCommit = vi.fn(async () => {})
|
|
466
|
+
store.hydrate([frame('f1')])
|
|
467
|
+
store.removeBlock('f1', { onCommit })
|
|
468
|
+
actions[0]!.onClick() // undo before the window elapses
|
|
469
|
+
await vi.runAllTimersAsync()
|
|
470
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('undo re-adds pruned edges without clobbering ones gained during the window', () => {
|
|
474
|
+
const { store, actions } = setup(async () => {})
|
|
475
|
+
store.hydrate([
|
|
476
|
+
frame('f1'),
|
|
477
|
+
task('t1', 'f1'),
|
|
478
|
+
task('t2', 'f1', { dependsOn: ['t1'] }),
|
|
479
|
+
task('t3', 'f1'),
|
|
480
|
+
])
|
|
481
|
+
store.removeBlock('t1')
|
|
482
|
+
// A live event adds a new dependency to the survivor mid-window (t1's edge was pruned).
|
|
483
|
+
store.upsert(task('t2', 'f1', { dependsOn: ['t3'] }))
|
|
484
|
+
actions[0]!.onClick() // undo restores t1 and its edge, keeping the newly-added t3 edge
|
|
485
|
+
expect(store.getBlock('t2')?.dependsOn.slice().sort()).toEqual(['t1', 't3'])
|
|
486
|
+
})
|
|
292
487
|
})
|
package/app/stores/board.ts
CHANGED
|
@@ -36,13 +36,55 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
36
36
|
// global i18n instance (the same handle `plugins/locale.client.ts` uses) rather than
|
|
37
37
|
// `useI18n()`, which requires an active component instance.
|
|
38
38
|
const nuxtApp = useNuxtApp()
|
|
39
|
-
const tr = (key: string
|
|
39
|
+
const tr = (key: string, params?: Record<string, unknown>): string =>
|
|
40
|
+
(nuxtApp.$i18n as { t: (k: string, p?: Record<string, unknown>) => string }).t(
|
|
41
|
+
key,
|
|
42
|
+
params ?? {},
|
|
43
|
+
)
|
|
40
44
|
const blocks = ref<Block[]>([])
|
|
41
45
|
|
|
42
46
|
// Pure derivations (hierarchy, status/progress, sizing) live in the composable.
|
|
43
47
|
const queries = useBlockQueries(blocks)
|
|
44
48
|
const { getBlock } = queries
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* How long a deleted block stays undoable. The backend delete is DEFERRED for this
|
|
52
|
+
* window (a real "undo", not a client illusion) — the block is hidden immediately but
|
|
53
|
+
* only actually deleted once the window elapses, so undo just cancels the pending call.
|
|
54
|
+
*/
|
|
55
|
+
const UNDO_WINDOW_MS = 6000
|
|
56
|
+
/**
|
|
57
|
+
* Blocks hidden by an optimistic delete whose backend call hasn't fired yet, keyed by
|
|
58
|
+
* the deleted root's id. Their subtree stays filtered out of every incoming server
|
|
59
|
+
* snapshot (`hydrate`) and single-block live event (`upsert`) for the undo window, so a
|
|
60
|
+
* coarse refresh or a stray event can't resurrect a block the user just deleted.
|
|
61
|
+
*/
|
|
62
|
+
const pendingRemovals = new Map<
|
|
63
|
+
string,
|
|
64
|
+
{ snap: RemovalSnapshot; timer: ReturnType<typeof setTimeout>; wsId: string }
|
|
65
|
+
>()
|
|
66
|
+
// Flat set of every id in a pending removal (root + descendants), for O(1) checks in the
|
|
67
|
+
// hot upsert path. Kept in lockstep with `pendingRemovals`.
|
|
68
|
+
const pendingDoomed = new Set<string>()
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drop any pending-removal subtree from a reconciled block list and prune survivors'
|
|
72
|
+
* edges to it — the same detach the backend will perform once the deferred delete fires.
|
|
73
|
+
* Applied to every hydrate so the undo window survives a full refresh.
|
|
74
|
+
*/
|
|
75
|
+
function applyPendingRemovals(list: Block[]): Block[] {
|
|
76
|
+
if (pendingDoomed.size === 0) return list
|
|
77
|
+
const survivors = list.filter((b) => !pendingDoomed.has(b.id))
|
|
78
|
+
for (const b of survivors) {
|
|
79
|
+
if (b.dependsOn.some((d) => pendingDoomed.has(d))) {
|
|
80
|
+
b.dependsOn = b.dependsOn.filter((d) => !pendingDoomed.has(d))
|
|
81
|
+
}
|
|
82
|
+
if (b.epicId != null && pendingDoomed.has(b.epicId)) b.epicId = null
|
|
83
|
+
if (b.initiativeId != null && pendingDoomed.has(b.initiativeId)) b.initiativeId = null
|
|
84
|
+
}
|
|
85
|
+
return survivors
|
|
86
|
+
}
|
|
87
|
+
|
|
46
88
|
/**
|
|
47
89
|
* Reconcile the cached blocks against a server snapshot, reusing the existing
|
|
48
90
|
* object for any block whose content is unchanged. The server stays authoritative
|
|
@@ -67,14 +109,18 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
67
109
|
}
|
|
68
110
|
function hydrate(next: Block[]) {
|
|
69
111
|
const prev = new Map(blocks.value.map((b) => [b.id, b]))
|
|
70
|
-
|
|
112
|
+
const reconciled = next.map((n) => {
|
|
71
113
|
const existing = prev.get(n.id)
|
|
72
114
|
return existing && jsonFor(existing) === jsonFor(n) ? existing : n
|
|
73
115
|
})
|
|
116
|
+
// Keep blocks the user just deleted hidden while their delete is still pending.
|
|
117
|
+
blocks.value = applyPendingRemovals(reconciled)
|
|
74
118
|
}
|
|
75
119
|
|
|
76
120
|
/** Insert or replace a block returned by the backend. */
|
|
77
121
|
function upsert(block: Block) {
|
|
122
|
+
// A live event for a block awaiting its deferred delete must not resurrect it.
|
|
123
|
+
if (pendingDoomed.has(block.id)) return
|
|
78
124
|
const i = blocks.value.findIndex((b) => b.id === block.id)
|
|
79
125
|
if (i >= 0) blocks.value[i] = block
|
|
80
126
|
else blocks.value.push(block)
|
|
@@ -199,11 +245,16 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
199
245
|
return block
|
|
200
246
|
}
|
|
201
247
|
|
|
202
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
250
|
+
* silently on a small overshoot, so a successful move (into a *different* container,
|
|
251
|
+
* not an undo of one) offers a one-click undo back to its previous home.
|
|
252
|
+
*/
|
|
203
253
|
async function reparentBlock(
|
|
204
254
|
id: string,
|
|
205
255
|
newParentId: string,
|
|
206
256
|
position: { x: number; y: number },
|
|
257
|
+
opts: { undoable?: boolean } = { undoable: true },
|
|
207
258
|
) {
|
|
208
259
|
const b = getBlock(id)
|
|
209
260
|
const parent = getBlock(newParentId)
|
|
@@ -217,6 +268,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
217
268
|
// block in the wrong container (a structural lie that survives until re-hydrate).
|
|
218
269
|
const prevParentId = b.parentId
|
|
219
270
|
const prevPosition = b.position
|
|
271
|
+
const name = b.title
|
|
220
272
|
b.parentId = newParentId
|
|
221
273
|
b.position = position
|
|
222
274
|
try {
|
|
@@ -226,6 +278,24 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
226
278
|
position,
|
|
227
279
|
}),
|
|
228
280
|
)
|
|
281
|
+
// Offer an undo back to the previous container (a drag overshoot is easy). The undo
|
|
282
|
+
// move is itself non-undoable so the toast doesn't ping-pong.
|
|
283
|
+
if (opts.undoable && prevParentId) {
|
|
284
|
+
toast.add({
|
|
285
|
+
title: tr('board.toast.moved', { name }),
|
|
286
|
+
icon: 'i-lucide-move',
|
|
287
|
+
color: 'neutral',
|
|
288
|
+
duration: UNDO_WINDOW_MS,
|
|
289
|
+
actions: [
|
|
290
|
+
{
|
|
291
|
+
label: tr('common.undo'),
|
|
292
|
+
icon: 'i-lucide-undo-2',
|
|
293
|
+
onClick: () =>
|
|
294
|
+
void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
})
|
|
298
|
+
}
|
|
229
299
|
} catch (e) {
|
|
230
300
|
b.parentId = prevParentId
|
|
231
301
|
b.position = prevPosition
|
|
@@ -291,35 +361,99 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
291
361
|
/** Re-insert a detached subtree and restore its broken edges (delete rollback). */
|
|
292
362
|
function reattach(snap: RemovalSnapshot) {
|
|
293
363
|
for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
|
|
364
|
+
const restored = new Set(snap.removed.map((b) => b.id))
|
|
294
365
|
for (const e of snap.edges) {
|
|
295
366
|
const b = getBlock(e.id)
|
|
296
|
-
if (b)
|
|
297
|
-
|
|
298
|
-
|
|
367
|
+
if (!b) continue
|
|
368
|
+
// Re-establish only the links that pointed at a now-restored block, merged with
|
|
369
|
+
// whatever the survivor gained meanwhile — so a delayed undo (the delete is deferred by
|
|
370
|
+
// a window during which a live event may add edges) doesn't clobber a newer dependency /
|
|
371
|
+
// epic / initiative link with the stale detach-time snapshot.
|
|
372
|
+
const readd = e.dependsOn.filter((d) => restored.has(d) && !b.dependsOn.includes(d))
|
|
373
|
+
if (readd.length) b.dependsOn = [...b.dependsOn, ...readd]
|
|
374
|
+
if (b.epicId == null && e.epicId != null && restored.has(e.epicId)) b.epicId = e.epicId
|
|
375
|
+
if (b.initiativeId == null && e.initiativeId != null && restored.has(e.initiativeId)) {
|
|
299
376
|
b.initiativeId = e.initiativeId
|
|
300
377
|
}
|
|
301
378
|
}
|
|
302
379
|
}
|
|
303
380
|
|
|
304
381
|
/**
|
|
305
|
-
* Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board
|
|
306
|
-
*
|
|
307
|
-
* toast
|
|
382
|
+
* Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board feels
|
|
383
|
+
* instant, and the real backend delete is DEFERRED by {@link UNDO_WINDOW_MS} so the
|
|
384
|
+
* accompanying "Deleted — Undo" toast can cancel it in place (a genuine undo, since
|
|
385
|
+
* nothing was destroyed server-side yet). The subtree stays filtered out of any
|
|
386
|
+
* hydrate/upsert in the meantime (see {@link applyPendingRemovals}). If the deferred
|
|
387
|
+
* call ultimately fails we put the subtree back and surface an error toast.
|
|
308
388
|
*/
|
|
309
|
-
|
|
389
|
+
function removeBlock(
|
|
390
|
+
id: string,
|
|
391
|
+
opts: { onCommit?: (wsId: string) => void | Promise<void> } = {},
|
|
392
|
+
) {
|
|
393
|
+
const block = getBlock(id)
|
|
310
394
|
const snap = detach(id)
|
|
311
|
-
if (!snap) return
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
395
|
+
if (!block || !snap) return
|
|
396
|
+
// Capture the workspace now: the deferred delete must target the workspace the block
|
|
397
|
+
// was deleted from even if the user has since switched.
|
|
398
|
+
const wsId = useWorkspaceStore().requireId()
|
|
399
|
+
for (const b of snap.removed) pendingDoomed.add(b.id)
|
|
400
|
+
|
|
401
|
+
const finalize = async () => {
|
|
402
|
+
const pending = pendingRemovals.get(id)
|
|
403
|
+
if (!pending) return
|
|
404
|
+
pendingRemovals.delete(id)
|
|
405
|
+
try {
|
|
406
|
+
// Any irreversible side effect the delete implies (e.g. cancelling the block's run)
|
|
407
|
+
// is deferred to here so it fires only once the delete truly commits — undo within
|
|
408
|
+
// the window then leaves the run untouched instead of restoring an already-cancelled one.
|
|
409
|
+
await opts.onCommit?.(pending.wsId)
|
|
410
|
+
await api.removeBlock(pending.wsId, id)
|
|
411
|
+
// Stop filtering the subtree only after the server has actually dropped it, so a
|
|
412
|
+
// snapshot that raced the in-flight delete can't briefly resurrect it.
|
|
413
|
+
for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
|
|
414
|
+
} catch (e) {
|
|
415
|
+
for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
|
|
416
|
+
// Only restore into the board the block was deleted from; a mid-window workspace
|
|
417
|
+
// switch must not inject the old subtree onto the workspace now on screen (it
|
|
418
|
+
// re-hydrates from the server there on the next refresh anyway).
|
|
419
|
+
if (useWorkspaceStore().workspaceId === pending.wsId) reattach(pending.snap)
|
|
420
|
+
toast.add({
|
|
421
|
+
title: tr('board.toast.deleteFailed'),
|
|
422
|
+
description: e instanceof Error ? e.message : String(e),
|
|
423
|
+
icon: 'i-lucide-triangle-alert',
|
|
424
|
+
color: 'error',
|
|
425
|
+
})
|
|
426
|
+
}
|
|
322
427
|
}
|
|
428
|
+
const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
|
|
429
|
+
pendingRemovals.set(id, { snap, timer, wsId })
|
|
430
|
+
|
|
431
|
+
toast.add({
|
|
432
|
+
title: tr('board.toast.deleted', { name: block.title }),
|
|
433
|
+
icon: 'i-lucide-trash-2',
|
|
434
|
+
color: 'neutral',
|
|
435
|
+
duration: UNDO_WINDOW_MS,
|
|
436
|
+
actions: [
|
|
437
|
+
{
|
|
438
|
+
label: tr('common.undo'),
|
|
439
|
+
icon: 'i-lucide-undo-2',
|
|
440
|
+
onClick: () => undoRemove(id),
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Cancel a still-pending delete and restore the hidden subtree. */
|
|
447
|
+
function undoRemove(id: string) {
|
|
448
|
+
const pending = pendingRemovals.get(id)
|
|
449
|
+
if (!pending) return
|
|
450
|
+
clearTimeout(pending.timer)
|
|
451
|
+
pendingRemovals.delete(id)
|
|
452
|
+
for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
|
|
453
|
+
// Don't resurrect the subtree into a different workspace if the user navigated away
|
|
454
|
+
// mid-window; the delete was already cancelled, which is the safe outcome there.
|
|
455
|
+
if (useWorkspaceStore().workspaceId !== pending.wsId) return
|
|
456
|
+
reattach(pending.snap)
|
|
323
457
|
}
|
|
324
458
|
|
|
325
459
|
/**
|
|
@@ -357,7 +491,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
357
491
|
// spot the server never stored (a lie that survives until the next re-hydrate).
|
|
358
492
|
b.position = prevPosition
|
|
359
493
|
toast.add({
|
|
360
|
-
title: '
|
|
494
|
+
title: tr('board.toast.moveFailed'),
|
|
361
495
|
description: e instanceof Error ? e.message : String(e),
|
|
362
496
|
icon: 'i-lucide-triangle-alert',
|
|
363
497
|
color: 'error',
|
package/app/stores/execution.ts
CHANGED
|
@@ -304,10 +304,14 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Cancel the execution running against a block and reset it to planned. `workspaceId`
|
|
309
|
+
* defaults to the current workspace but can be pinned by callers that cancel a run for a
|
|
310
|
+
* board the user may have since navigated away from (e.g. a deferred delete's commit).
|
|
311
|
+
*/
|
|
312
|
+
async function cancel(blockId: string, workspaceId?: string) {
|
|
309
313
|
const ws = useWorkspaceStore()
|
|
310
|
-
await api.cancelExecution(ws.requireId(), blockId)
|
|
314
|
+
await api.cancelExecution(workspaceId ?? ws.requireId(), blockId)
|
|
311
315
|
instances.value = instances.value.filter((e) => e.blockId !== blockId)
|
|
312
316
|
await ws.refresh()
|
|
313
317
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"@clear": {
|
|
61
61
|
"description": "Verb meaning empty / reset a stored value (a config, a saved connection), NOT the adjective 'transparent / obvious'. Used as a destructive-action button label."
|
|
62
|
-
}
|
|
62
|
+
},
|
|
63
|
+
"undo": "Undo"
|
|
63
64
|
},
|
|
64
65
|
"nav": {
|
|
65
66
|
"menu": "Navigation menu",
|
|
@@ -95,7 +96,9 @@
|
|
|
95
96
|
"moveFailed": "Could not move",
|
|
96
97
|
"deleteFailed": "Could not delete",
|
|
97
98
|
"linkFailed": "Could not link tasks",
|
|
98
|
-
"recurringDeleteFailed": "Could not delete recurring pipeline"
|
|
99
|
+
"recurringDeleteFailed": "Could not delete recurring pipeline",
|
|
100
|
+
"deleted": "Deleted \"{name}\"",
|
|
101
|
+
"moved": "Moved \"{name}\""
|
|
99
102
|
},
|
|
100
103
|
"repoTypes": {
|
|
101
104
|
"service": "Service",
|
|
@@ -985,7 +988,8 @@
|
|
|
985
988
|
"recurring": {
|
|
986
989
|
"title": "Delete this recurring pipeline?",
|
|
987
990
|
"body": "\"{name}\", its schedule and its run history will be removed. This can't be undone."
|
|
988
|
-
}
|
|
991
|
+
},
|
|
992
|
+
"containerBodyWithCount": "\"{name}\" and the {count} item inside it will be removed. This can't be undone. | \"{name}\" and the {count} items inside it will be removed. This can't be undone."
|
|
989
993
|
},
|
|
990
994
|
"titlePlaceholder": "Title…",
|
|
991
995
|
"reworkedRequirements": "Reworked requirements",
|
package/i18n/locales/es.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} revocado",
|
|
51
51
|
"cleared": "{name} borrado",
|
|
52
52
|
"destroyed": "{name} destruido"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "Deshacer"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "Menú de navegación",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "No se pudo mover",
|
|
81
82
|
"deleteFailed": "No se pudo eliminar",
|
|
82
83
|
"linkFailed": "No se pudieron vincular las tareas",
|
|
83
|
-
"recurringDeleteFailed": "No se pudo eliminar el pipeline recurrente"
|
|
84
|
+
"recurringDeleteFailed": "No se pudo eliminar el pipeline recurrente",
|
|
85
|
+
"deleted": "\"{name}\" eliminado",
|
|
86
|
+
"moved": "\"{name}\" movido"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "Servicio",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "¿Eliminar este pipeline recurrente?",
|
|
962
965
|
"body": "Se eliminarán \"{name}\", su programación y su historial de ejecución. Esta acción no se puede deshacer."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "Se eliminará \"{name}\" y el {count} elemento que contiene. Esta acción no se puede deshacer. | Se eliminará \"{name}\" y los {count} elementos que contiene. Esta acción no se puede deshacer."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/fr.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} révoqué",
|
|
51
51
|
"cleared": "{name} effacé",
|
|
52
52
|
"destroyed": "{name} détruit"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "Annuler"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "Menu de navigation",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "Impossible de déplacer",
|
|
81
82
|
"deleteFailed": "Impossible de supprimer",
|
|
82
83
|
"linkFailed": "Impossible de lier les tâches",
|
|
83
|
-
"recurringDeleteFailed": "Impossible de supprimer le pipeline récurrent"
|
|
84
|
+
"recurringDeleteFailed": "Impossible de supprimer le pipeline récurrent",
|
|
85
|
+
"deleted": "\"{name}\" supprimé",
|
|
86
|
+
"moved": "\"{name}\" déplacé"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "Service",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "Supprimer ce pipeline récurrent ?",
|
|
962
965
|
"body": "\"{name}\", sa planification et son historique d'exécution seront supprimés. Cette action est irréversible."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "\"{name}\" et le {count} élément qu'il contient seront supprimés. Cette action est irréversible. | \"{name}\" et les {count} éléments qu'il contient seront supprimés. Cette action est irréversible."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/he.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} בוטל",
|
|
51
51
|
"cleared": "{name} נוקה",
|
|
52
52
|
"destroyed": "{name} הושמד"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "בטל"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "תפריט ניווט",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "לא ניתן היה להעביר",
|
|
81
82
|
"deleteFailed": "לא ניתן היה למחוק",
|
|
82
83
|
"linkFailed": "לא ניתן היה לקשר משימות",
|
|
83
|
-
"recurringDeleteFailed": "לא ניתן היה למחוק את הצינור החוזר"
|
|
84
|
+
"recurringDeleteFailed": "לא ניתן היה למחוק את הצינור החוזר",
|
|
85
|
+
"deleted": "\"{name}\" נמחק",
|
|
86
|
+
"moved": "\"{name}\" הועבר"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "שירות",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "למחוק את ה־pipeline החוזר הזה?",
|
|
962
965
|
"body": "\"{name}\", התזמון שלו והיסטוריית ההרצות יימחקו. לא ניתן לבטל פעולה זו."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "\"{name}\" ו-{count} פריט שבתוכו יימחקו. לא ניתן לבטל פעולה זו. | \"{name}\" ו-{count} פריטים שבתוכו יימחקו. לא ניתן לבטל פעולה זו."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/ja.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} を取り消しました",
|
|
51
51
|
"cleared": "{name} をクリアしました",
|
|
52
52
|
"destroyed": "{name} を破棄しました"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "元に戻す"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "ナビゲーションメニュー",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "移動できませんでした",
|
|
81
82
|
"deleteFailed": "削除できませんでした",
|
|
82
83
|
"linkFailed": "タスクをリンクできませんでした",
|
|
83
|
-
"recurringDeleteFailed": "定期パイプラインを削除できませんでした"
|
|
84
|
+
"recurringDeleteFailed": "定期パイプラインを削除できませんでした",
|
|
85
|
+
"deleted": "\"{name}\" を削除しました",
|
|
86
|
+
"moved": "\"{name}\" を移動しました"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "サービス",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "この定期パイプラインを削除しますか?",
|
|
962
965
|
"body": "「{name}」、そのスケジュール、実行履歴が削除されます。この操作は取り消せません。"
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "「{name}」とその中の {count} 件の項目が削除されます。この操作は取り消せません。 | 「{name}」とその中の {count} 件の項目が削除されます。この操作は取り消せません。"
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/pl.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "Unieważniono {name}",
|
|
51
51
|
"cleared": "Wyczyszczono {name}",
|
|
52
52
|
"destroyed": "Zniszczono {name}"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "Cofnij"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "Menu nawigacji",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "Nie udało się przenieść",
|
|
81
82
|
"deleteFailed": "Nie udało się usunąć",
|
|
82
83
|
"linkFailed": "Nie udało się powiązać zadań",
|
|
83
|
-
"recurringDeleteFailed": "Nie udało się usunąć cyklicznego pipeline'u"
|
|
84
|
+
"recurringDeleteFailed": "Nie udało się usunąć cyklicznego pipeline'u",
|
|
85
|
+
"deleted": "Usunięto \"{name}\"",
|
|
86
|
+
"moved": "Przeniesiono \"{name}\""
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "Usługa",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "Usunąć ten cykliczny pipeline?",
|
|
962
965
|
"body": "\"{name}\", jego harmonogram i historia uruchomień zostaną usunięte. Tej operacji nie można cofnąć."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "\"{name}\" i {count} element w środku zostaną usunięte. Tej operacji nie można cofnąć. | \"{name}\" i {count} elementy w środku zostaną usunięte. Tej operacji nie można cofnąć. | \"{name}\" i {count} elementów w środku zostanie usuniętych. Tej operacji nie można cofnąć."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/tr.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} iptal edildi",
|
|
51
51
|
"cleared": "{name} temizlendi",
|
|
52
52
|
"destroyed": "{name} yok edildi"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "Geri al"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "Gezinme menüsü",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "Taşınamadı",
|
|
81
82
|
"deleteFailed": "Silinemedi",
|
|
82
83
|
"linkFailed": "Görevler bağlanamadı",
|
|
83
|
-
"recurringDeleteFailed": "Yinelenen pipeline silinemedi"
|
|
84
|
+
"recurringDeleteFailed": "Yinelenen pipeline silinemedi",
|
|
85
|
+
"deleted": "\"{name}\" silindi",
|
|
86
|
+
"moved": "\"{name}\" taşındı"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "Servis",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "Bu yinelenen pipeline silinsin mi?",
|
|
962
965
|
"body": "\"{name}\", zamanlaması ve çalıştırma geçmişi kaldırılacak. Bu işlem geri alınamaz."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "\"{name}\" ve içindeki {count} öğe kaldırılacak. Bu işlem geri alınamaz. | \"{name}\" ve içindeki {count} öğe kaldırılacak. Bu işlem geri alınamaz."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/i18n/locales/uk.json
CHANGED
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"revoked": "{name} відкликано",
|
|
51
51
|
"cleared": "{name} очищено",
|
|
52
52
|
"destroyed": "{name} знищено"
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"undo": "Відмінити"
|
|
54
55
|
},
|
|
55
56
|
"nav": {
|
|
56
57
|
"menu": "Меню навігації",
|
|
@@ -80,7 +81,9 @@
|
|
|
80
81
|
"moveFailed": "Не вдалося перемістити",
|
|
81
82
|
"deleteFailed": "Не вдалося видалити",
|
|
82
83
|
"linkFailed": "Не вдалося звʼязати завдання",
|
|
83
|
-
"recurringDeleteFailed": "Не вдалося видалити повторюваний пайплайн"
|
|
84
|
+
"recurringDeleteFailed": "Не вдалося видалити повторюваний пайплайн",
|
|
85
|
+
"deleted": "\"{name}\" видалено",
|
|
86
|
+
"moved": "\"{name}\" переміщено"
|
|
84
87
|
},
|
|
85
88
|
"repoTypes": {
|
|
86
89
|
"service": "Сервіс",
|
|
@@ -960,7 +963,8 @@
|
|
|
960
963
|
"recurring": {
|
|
961
964
|
"title": "Видалити цей повторюваний pipeline?",
|
|
962
965
|
"body": "\"{name}\", його розклад та історію запусків буде видалено. Цю дію не можна скасувати."
|
|
963
|
-
}
|
|
966
|
+
},
|
|
967
|
+
"containerBodyWithCount": "\"{name}\" і {count} елемент усередині буде видалено. Цю дію не можна скасувати. | \"{name}\" і {count} елементи всередині буде видалено. Цю дію не можна скасувати. | \"{name}\" і {count} елементів усередині буде видалено. Цю дію не можна скасувати."
|
|
964
968
|
}
|
|
965
969
|
}
|
|
966
970
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.82.
|
|
3
|
+
"version": "0.82.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|