Skip to content

Commit 3350d14

Browse files
committed
feat(backlog): add item action menu
1 parent 5438adb commit 3350d14

10 files changed

Lines changed: 267 additions & 21 deletions

File tree

frontend/src/lib/api/items.js

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,54 @@ function fetchItemDetailSummaryByKey(workspaceKey, itemNumber, options = {}) {
5858
);
5959
}
6060

61+
function fetchBacklog(
62+
workspaceId,
63+
ql = null,
64+
collectionId = null,
65+
/** @type {any} */ { page, limit, sub_ql, omit_descriptions, include_watermark } = {}
66+
) {
67+
const params = new URLSearchParams();
68+
if (collectionId) {
69+
params.append('collection_id', collectionId);
70+
} else if (workspaceId) {
71+
params.append('workspace_id', workspaceId);
72+
}
73+
if (ql) params.append('ql', ql);
74+
if (sub_ql) params.append('sub_ql', sub_ql);
75+
if (omit_descriptions) params.append('omit_descriptions', 'true');
76+
if (include_watermark) params.append('include_watermark', 'true');
77+
if (page) params.append('page', page);
78+
if (limit) params.append('limit', limit);
79+
return fetchAPI(`/items/backlog?${params}`);
80+
}
81+
82+
async function fetchBacklogBoundary(workspaceId, collectionId, subQL, boundary) {
83+
const options = {
84+
page: 1,
85+
limit: 1,
86+
sub_ql: subQL || undefined,
87+
omit_descriptions: true,
88+
};
89+
90+
for (let attempt = 0; attempt < 2; attempt++) {
91+
const firstPage = await fetchBacklog(workspaceId, null, collectionId, options);
92+
const firstItems = firstPage?.items ?? (Array.isArray(firstPage) ? firstPage : []);
93+
if (boundary === 'start' || firstItems.length === 0) return firstItems[0] ?? null;
94+
95+
const total = firstPage?.pagination?.total ?? firstItems.length;
96+
if (total <= 1) return firstItems[0] ?? null;
97+
98+
const lastPage = await fetchBacklog(workspaceId, null, collectionId, {
99+
...options,
100+
page: total,
101+
});
102+
const lastItems = lastPage?.items ?? (Array.isArray(lastPage) ? lastPage : []);
103+
if (lastItems.length > 0) return lastItems[0];
104+
}
105+
106+
return null;
107+
}
108+
61109
export const items = {
62110
getAll: (filters = {}, requestOptions = {}) => {
63111
return fetchAPI(`/items${buildQueryString(filters)}`, requestOptions);
@@ -177,26 +225,8 @@ export const items = {
177225
}),
178226
'reorder'
179227
),
180-
getBacklog: (
181-
workspaceId,
182-
ql = null,
183-
collectionId = null,
184-
/** @type {any} */ { page, limit, sub_ql, omit_descriptions, include_watermark } = {}
185-
) => {
186-
const params = new URLSearchParams();
187-
if (collectionId) {
188-
params.append('collection_id', collectionId);
189-
} else if (workspaceId) {
190-
params.append('workspace_id', workspaceId);
191-
}
192-
if (ql) params.append('ql', ql);
193-
if (sub_ql) params.append('sub_ql', sub_ql);
194-
if (omit_descriptions) params.append('omit_descriptions', 'true');
195-
if (include_watermark) params.append('include_watermark', 'true');
196-
if (page) params.append('page', page);
197-
if (limit) params.append('limit', limit);
198-
return fetchAPI(`/items/backlog?${params}`);
199-
},
228+
getBacklog: fetchBacklog,
229+
getBacklogBoundary: fetchBacklogBoundary,
200230
getChildren: (itemId, requestOptions = {}) =>
201231
fetchAPI(`/items/${itemId}/children`, requestOptions),
202232
getAncestors: (itemId, requestOptions = {}) =>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script>
2+
import { ArrowDownToLine, ArrowUpToLine, CalendarDays, MoreHorizontal } from '@lucide/svelte';
3+
import DropdownMenu from '../../layout/DropdownMenu.svelte';
4+
import { t } from '../../stores/i18n.svelte.js';
5+
6+
let {
7+
item,
8+
iterations = [],
9+
disabled = false,
10+
onMoveToBoundary,
11+
onAssignIteration,
12+
} = $props();
13+
14+
let iterationOptions = $derived(
15+
iterations
16+
.filter((iteration) => iteration.id !== item.iteration_id)
17+
.map((iteration) => ({
18+
id: `assign-${item.id}-${iteration.id}`,
19+
title: iteration.name,
20+
subtitle: iteration.status,
21+
onClick: () => onAssignIteration?.(item, iteration),
22+
testid: `backlog-assign-iteration-${item.id}-${iteration.id}`,
23+
})),
24+
);
25+
26+
let menuItems = $derived.by(() => {
27+
/** @type {any[]} */
28+
const actions = [
29+
{
30+
id: `move-start-${item.id}`,
31+
title: t('collections.toBeginningOfBacklog'),
32+
icon: ArrowUpToLine,
33+
onClick: () => onMoveToBoundary?.(item, 'start'),
34+
testid: `backlog-move-start-${item.id}`,
35+
},
36+
{
37+
id: `move-end-${item.id}`,
38+
title: t('collections.sendToEndOfBacklog'),
39+
icon: ArrowDownToLine,
40+
onClick: () => onMoveToBoundary?.(item, 'end'),
41+
testid: `backlog-move-end-${item.id}`,
42+
},
43+
];
44+
45+
if (iterationOptions.length > 0) {
46+
actions.push(
47+
{ id: `iteration-divider-${item.id}`, type: 'divider' },
48+
{
49+
id: `assign-iteration-${item.id}`,
50+
type: 'accordion',
51+
title: t('collections.assignToIteration'),
52+
icon: CalendarDays,
53+
subItems: iterationOptions,
54+
testid: `backlog-assign-iteration-menu-${item.id}`,
55+
},
56+
);
57+
}
58+
59+
return actions;
60+
});
61+
</script>
62+
63+
<DropdownMenu
64+
items={menuItems}
65+
placement="bottom-end"
66+
maxWidth="max-w-xs"
67+
triggerIcon={MoreHorizontal}
68+
triggerClass="p-1 rounded hover:bg-[var(--ds-background-neutral-hovered)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ds-border-focused)]"
69+
triggerStyle="color: var(--ctx-text-subtle, var(--ds-text-subtle));"
70+
iconOnly
71+
showChevron={false}
72+
{disabled}
73+
triggerLabel={t('collections.backlogItemActions', { title: item.title })}
74+
triggerTestid={`backlog-item-menu-${item.id}`}
75+
/>

frontend/src/lib/features/collections/BacklogIterationSection.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import WorkItemRow from '../items/WorkItemRow.svelte';
77
import DropIndicator from '../../layout/DropIndicator.svelte';
88
import LazyRender from '../../components/LazyRender.svelte';
9+
import BacklogItemActions from './BacklogItemActions.svelte';
910
1011
let {
1112
iteration = null,
@@ -20,8 +21,12 @@
2021
backlogRowGap = 2,
2122
isGlobalAdded = false,
2223
sectionHighlight = false,
24+
assignableIterations = [],
25+
pendingActionItemIds = new Set(),
2326
onToggleCollapse,
2427
onOpenItem,
28+
onMoveItemToBoundary = null,
29+
onAssignItemToIteration = null,
2530
onStartIteration = null,
2631
onCompleteIteration = null,
2732
onRemoveGlobal = null,
@@ -189,6 +194,15 @@
189194
<GripVertical class="w-4 h-4" />
190195
</div>
191196
{/snippet}
197+
{#snippet trailing()}
198+
<BacklogItemActions
199+
{item}
200+
iterations={assignableIterations}
201+
disabled={pendingActionItemIds.has(item.id)}
202+
onMoveToBoundary={onMoveItemToBoundary}
203+
onAssignIteration={onAssignItemToIteration}
204+
/>
205+
{/snippet}
192206
</WorkItemRow>
193207
{/snippet}
194208
</LazyRender>

frontend/src/lib/features/collections/CollectionBacklog.svelte

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import ItemPicker from '../../pickers/ItemPicker.svelte';
1919
import { backlogStore, workspaceDataStore } from '../../stores/index.js';
2020
import { useWorkItemPoller } from '../../composables/useWorkItemPoller.svelte.js';
21-
import { successToast, warningToast } from '../../stores/toasts.svelte.js';
21+
import { errorToast, successToast, warningToast } from '../../stores/toasts.svelte.js';
2222
import { getIncompleteIterationItems } from './iterationCompletion.js';
2323
import CompleteIterationDialog from '../../dialogs/CompleteIterationDialog.svelte';
2424
@@ -48,6 +48,7 @@
4848
let addedGlobalIds = $state(new Set());
4949
let collapsedSections = $state(new Set());
5050
let sectionDropHighlight = $state(new Map()); // iterationId|'unassigned' -> boolean
51+
let pendingActionItemIds = $state(new Set());
5152
5253
// --- Complete Iteration dialog state ---
5354
let completeIterationShow = $state(false);
@@ -82,6 +83,9 @@
8283
// Derived iteration groupings
8384
let localIterations = $derived(allIterations.filter(i => !i.is_global));
8485
let addedGlobalIterations = $derived(allIterations.filter(i => i.is_global && addedGlobalIds.has(i.id)));
86+
let assignableIterations = $derived(
87+
allIterations.filter(i => i.status === 'planned' || i.status === 'active')
88+
);
8589
8690
// Sort order: active first, then planned, then completed/cancelled
8791
const statusOrder = { active: 0, planned: 1, completed: 2, cancelled: 3 };
@@ -287,6 +291,73 @@
287291
persistGlobalIds();
288292
}
289293
294+
function setItemActionPending(itemId, pending) {
295+
const next = new Set(pendingActionItemIds);
296+
if (pending) next.add(itemId);
297+
else next.delete(itemId);
298+
pendingActionItemIds = next;
299+
}
300+
301+
async function moveItemToBoundary(item, boundary) {
302+
if (pendingActionItemIds.has(item.id)) return;
303+
setItemActionPending(item.id, true);
304+
305+
try {
306+
const boundaryItem = await api.items.getBacklogBoundary(
307+
workspaceId,
308+
collectionId,
309+
collectionStore.subFilterQL,
310+
boundary,
311+
);
312+
if (!boundaryItem || boundaryItem.id === item.id) return;
313+
314+
await api.items.updateFracIndex(item.id, boundary === 'start'
315+
? { prev_item_id: null, next_item_id: boundaryItem.id }
316+
: { prev_item_id: boundaryItem.id, next_item_id: null });
317+
318+
const otherItems = collectionStore.backlogItems.filter(i => i.id !== item.id);
319+
collectionStore.backlogItems = boundary === 'start'
320+
? [item, ...otherItems]
321+
: [...otherItems, item];
322+
successToast(t(boundary === 'start'
323+
? 'collections.movedToBeginningOfBacklog'
324+
: 'collections.sentToEndOfBacklog', { title: item.title }));
325+
reloadCollection();
326+
} catch (error) {
327+
console.error(`Failed to move backlog item to ${boundary}:`, error);
328+
errorToast(t('collections.backlogActionFailed'));
329+
} finally {
330+
setItemActionPending(item.id, false);
331+
}
332+
}
333+
334+
async function assignItemToIteration(item, iteration) {
335+
if (pendingActionItemIds.has(item.id)) return;
336+
setItemActionPending(item.id, true);
337+
338+
try {
339+
if (iteration.status === 'active') {
340+
warningToast(t('iterations.activeScopeWarning'));
341+
}
342+
await api.items.update(item.id, { iteration_id: iteration.id });
343+
collectionStore.backlogItems = collectionStore.backlogItems.map(i =>
344+
i.id === item.id
345+
? { ...i, iteration_id: iteration.id, iteration_name: iteration.name }
346+
: i
347+
);
348+
successToast(t('collections.assignedToIteration', {
349+
title: item.title,
350+
iteration: iteration.name,
351+
}));
352+
reloadCollection();
353+
} catch (error) {
354+
console.error('Failed to assign backlog item to iteration:', error);
355+
errorToast(t('collections.backlogActionFailed'));
356+
} finally {
357+
setItemActionPending(item.id, false);
358+
}
359+
}
360+
290361
// --- Drag and Drop ---
291362
292363
// Get items belonging to a specific section
@@ -654,10 +725,14 @@
654725
{styles}
655726
{dragState}
656727
{backlogRowGap}
728+
{assignableIterations}
729+
{pendingActionItemIds}
657730
isGlobalAdded={addedGlobalIds.has(section.iteration.id)}
658731
sectionHighlight={sectionDropHighlight.get(String(section.iteration.id)) || false}
659732
onToggleCollapse={toggleCollapse}
660733
onOpenItem={openItem}
734+
onMoveItemToBoundary={moveItemToBoundary}
735+
onAssignItemToIteration={assignItemToIteration}
661736
onStartIteration={startIteration}
662737
onCompleteIteration={completeIteration}
663738
onRemoveGlobal={removeGlobalIteration}
@@ -676,9 +751,13 @@
676751
{styles}
677752
{dragState}
678753
{backlogRowGap}
754+
{assignableIterations}
755+
{pendingActionItemIds}
679756
sectionHighlight={sectionDropHighlight.get('unassigned') || false}
680757
onToggleCollapse={toggleCollapse}
681758
onOpenItem={openItem}
759+
onMoveItemToBoundary={moveItemToBoundary}
760+
onAssignItemToIteration={assignItemToIteration}
682761
/>
683762
684763
<!-- Load More -->

frontend/src/lib/locales/ar/workspace.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,14 @@ export default {
424424
noItemsInBacklogDesc: 'جميع عناصر العمل إما مكتملة أو لا توجد عناصر بعد.',
425425
showingItemsFromBacklog: 'عرض {count} عناصر من قائمة الانتظار',
426426
dragItemsHere: 'اسحب العناصر هنا لإضافتها إلى هذا السبرنت',
427+
backlogItemActions: 'إجراءات قائمة الانتظار لـ {title}',
428+
toBeginningOfBacklog: 'إلى بداية قائمة الانتظار',
429+
sendToEndOfBacklog: 'إرسال إلى نهاية قائمة الانتظار',
430+
assignToIteration: 'تعيين إلى تكرار…',
431+
movedToBeginningOfBacklog: 'تم نقل ”{title}“ إلى بداية قائمة الانتظار',
432+
sentToEndOfBacklog: 'تم إرسال ”{title}“ إلى نهاية قائمة الانتظار',
433+
assignedToIteration: 'تم تعيين ”{title}“ إلى {iteration}',
434+
backlogActionFailed: 'تعذر تحديث عنصر قائمة الانتظار. يرجى المحاولة مرة أخرى.',
427435
loadingStoryMap: 'جارٍ تحميل خريطة القصص...',
428436
rootLevel: 'المستوى الجذري',
429437
currentLevel: 'المستوى الحالي',

frontend/src/lib/locales/de/workspace.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,14 @@ export default {
415415
noItemsInBacklog: 'Keine Einträge im Backlog',
416416
noItemsInBacklogDesc: 'Alle Vorgänge sind entweder abgeschlossen oder es existieren noch keine Einträge.',
417417
showingItemsFromBacklog: '{count} Einträge aus dem Backlog anzeigen',
418+
backlogItemActions: 'Backlog-Aktionen für {title}',
419+
toBeginningOfBacklog: 'An den Anfang des Backlogs',
420+
sendToEndOfBacklog: 'Ans Ende des Backlogs verschieben',
421+
assignToIteration: 'Iteration zuweisen…',
422+
movedToBeginningOfBacklog: '„{title}“ wurde an den Anfang des Backlogs verschoben',
423+
sentToEndOfBacklog: '„{title}“ wurde ans Ende des Backlogs verschoben',
424+
assignedToIteration: '„{title}“ wurde {iteration} zugewiesen',
425+
backlogActionFailed: 'Der Backlog-Eintrag konnte nicht aktualisiert werden. Bitte erneut versuchen.',
418426
loadingStoryMap: 'Story Map wird geladen...',
419427
rootLevel: 'Stammebene',
420428
currentLevel: 'Aktuelle Ebene',

frontend/src/lib/locales/en/workspace.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,14 @@ export default {
507507
noItemsInBacklogDesc: 'All work items are either completed or no items exist yet.',
508508
showingItemsFromBacklog: 'Showing {count} items from backlog',
509509
dragItemsHere: 'Drag items here to add to this sprint',
510+
backlogItemActions: 'Backlog actions for {title}',
511+
toBeginningOfBacklog: 'To beginning of backlog',
512+
sendToEndOfBacklog: 'Send to end of backlog',
513+
assignToIteration: 'Assign to iteration…',
514+
movedToBeginningOfBacklog: 'Moved “{title}” to the beginning of the backlog',
515+
sentToEndOfBacklog: 'Sent “{title}” to the end of the backlog',
516+
assignedToIteration: 'Assigned “{title}” to {iteration}',
517+
backlogActionFailed: 'Could not update the backlog item. Please try again.',
510518

511519
// Map view
512520
loadingStoryMap: 'Loading story map...',

frontend/src/lib/locales/es/workspace.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,14 @@ export default {
424424
noItemsInBacklogDesc: 'Todos los elementos de trabajo están completados o no existen elementos aún.',
425425
showingItemsFromBacklog: 'Mostrando {count} elementos del backlog',
426426
dragItemsHere: 'Arrastre elementos aquí para agregarlos a este sprint',
427+
backlogItemActions: 'Acciones del backlog para {title}',
428+
toBeginningOfBacklog: 'Al principio del backlog',
429+
sendToEndOfBacklog: 'Enviar al final del backlog',
430+
assignToIteration: 'Asignar a una iteración…',
431+
movedToBeginningOfBacklog: 'Se movió “{title}” al principio del backlog',
432+
sentToEndOfBacklog: 'Se envió “{title}” al final del backlog',
433+
assignedToIteration: 'Se asignó “{title}” a {iteration}',
434+
backlogActionFailed: 'No se pudo actualizar el elemento del backlog. Inténtelo de nuevo.',
427435
loadingStoryMap: 'Cargando mapa de historias...',
428436
rootLevel: 'Nivel raíz',
429437
currentLevel: 'Nivel actual',

0 commit comments

Comments
 (0)