Skip to content

Commit 8400286

Browse files
committed
Add inline Story Points editing to backlog
1 parent 5eb1278 commit 8400286

21 files changed

Lines changed: 616 additions & 2 deletions

frontend/src/lib/components/Select.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* menuWidth?: string,
1717
* portalOwner?: string,
1818
* placeholder?: string,
19+
* ariaLabel?: string,
1920
* onchange?: (e?: any) => void,
2021
* onfocus?: (e?: any) => void,
2122
* onblur?: (e?: any) => void,
@@ -32,6 +33,7 @@
3233
menuWidth = '',
3334
portalOwner = undefined,
3435
placeholder = '',
36+
ariaLabel = undefined,
3537
onchange = undefined,
3638
onfocus = undefined,
3739
onblur = undefined
@@ -178,6 +180,7 @@
178180
aria-required={required || undefined}
179181
aria-haspopup="listbox"
180182
aria-expanded={$open}
183+
aria-label={ariaLabel}
181184
class="w-full rounded border transition-all duration-200 flex items-center justify-between gap-2
182185
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50
183186
disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer text-left {sizeClasses}"

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
onStartIteration = null,
3131
onCompleteIteration = null,
3232
onRemoveGlobal = null,
33+
storyPointsConfiguredForItem = null,
34+
storyPointsPendingItemIds = new Set(),
35+
onUpdateStoryPoints = null,
3336
} = $props();
3437
3538
const statusColors = {
@@ -189,6 +192,9 @@
189192
{statusCategories}
190193
onclick={(e) => onOpenItem?.(item.id, e)}
191194
showStatus={true}
195+
showStoryPoints={storyPointsConfiguredForItem?.(item) ?? false}
196+
storyPointsSaving={storyPointsPendingItemIds.has(item.id)}
197+
onStoryPointsChange={(value) => onUpdateStoryPoints?.(item, value)}
192198
>
193199
{#snippet leading()}
194200
<div class="cursor-grab active:cursor-grabbing" style="{styles.dragHandleStyle}">

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import { errorToast, successToast, warningToast } from '../../stores/toasts.svelte.js';
2222
import { getIncompleteIterationItems } from './iterationCompletion.js';
2323
import CompleteIterationDialog from '../../dialogs/CompleteIterationDialog.svelte';
24+
import { workspacesStore } from '../../stores/workspaces.svelte.js';
25+
import { isSystemFieldAvailableForItem } from '../../utils/screenFields.js';
2426
2527
let { workspaceId, collectionId = null } = $props();
2628
@@ -49,6 +51,12 @@
4951
let collapsedSections = $state(new Set());
5052
let sectionDropHighlight = $state(new Map()); // iterationId|'unassigned' -> boolean
5153
let pendingActionItemIds = $state(new Set());
54+
let pendingStoryPointsItemIds = $state(new Set());
55+
let storyPointsScreenConfiguration = $state({
56+
ready: false,
57+
configSetsByWorkspaceId: new Map(),
58+
screensById: new Map(),
59+
});
5260
5361
// --- Complete Iteration dialog state ---
5462
let completeIterationShow = $state(false);
@@ -138,6 +146,101 @@
138146
addIterationPickerValue = null;
139147
}
140148
149+
async function loadStoryPointsScreenConfiguration() {
150+
const workspaceRecords = workspaceId
151+
? [{
152+
id: workspaceDataStore.workspace?.id ?? Number(workspaceId),
153+
configuration_set_id: workspaceDataStore.workspace?.configuration_set_id ?? null,
154+
}]
155+
: (await workspacesStore.load()).map((availableWorkspace) => ({
156+
id: availableWorkspace.id,
157+
configuration_set_id: availableWorkspace.configuration_set_id ?? null,
158+
}));
159+
160+
const configSetIds = [...new Set(
161+
workspaceRecords
162+
.map((record) => record.configuration_set_id)
163+
.filter((configSetId) => configSetId != null)
164+
.map((configSetId) => String(configSetId))
165+
)];
166+
167+
const [screensOutcome, configSetOutcomes] = await Promise.all([
168+
api.screens.getAllWithFields()
169+
.then((screens) => ({ status: 'fulfilled', value: screens }))
170+
.catch((error) => ({ status: 'rejected', reason: error })),
171+
Promise.allSettled(configSetIds.map((configSetId) => api.configurationSets.get(configSetId))),
172+
]);
173+
174+
if (screensOutcome.status === 'rejected') {
175+
console.error('Failed to load screens for backlog fields:', screensOutcome.reason);
176+
}
177+
178+
const screensById = new Map(
179+
(Array.isArray(screensOutcome.value) ? screensOutcome.value : [])
180+
.filter((screen) => screen?.id != null)
181+
.map((screen) => [screen.id, screen])
182+
);
183+
const configSetsById = new Map();
184+
configSetOutcomes.forEach((outcome, index) => {
185+
if (outcome.status === 'fulfilled' && outcome.value) {
186+
configSetsById.set(configSetIds[index], outcome.value);
187+
} else if (outcome.status === 'rejected') {
188+
console.error(`Failed to load configuration set ${configSetIds[index]} for backlog fields:`, outcome.reason);
189+
}
190+
});
191+
192+
const configSetsByWorkspaceId = new Map();
193+
workspaceRecords.forEach((record) => {
194+
if (record?.id == null) return;
195+
const workspaceKey = String(record.id);
196+
const configSetId = record.configuration_set_id;
197+
configSetsByWorkspaceId.set(
198+
workspaceKey,
199+
configSetId == null ? null : configSetsById.get(String(configSetId))
200+
);
201+
});
202+
203+
storyPointsScreenConfiguration = {
204+
ready: true,
205+
configSetsByWorkspaceId,
206+
screensById,
207+
};
208+
}
209+
210+
function storyPointsConfiguredForItem(item) {
211+
if (!storyPointsScreenConfiguration.ready) return false;
212+
return isSystemFieldAvailableForItem(
213+
item,
214+
'story_points',
215+
storyPointsScreenConfiguration.configSetsByWorkspaceId,
216+
storyPointsScreenConfiguration.screensById
217+
);
218+
}
219+
220+
function setStoryPointsPending(itemId, pending) {
221+
const next = new Set(pendingStoryPointsItemIds);
222+
if (pending) next.add(itemId);
223+
else next.delete(itemId);
224+
pendingStoryPointsItemIds = next;
225+
}
226+
227+
async function updateStoryPoints(item, value) {
228+
if (pendingStoryPointsItemIds.has(item.id)) return;
229+
setStoryPointsPending(item.id, true);
230+
231+
try {
232+
await api.items.update(item.id, { story_points: value });
233+
collectionStore.backlogItems = collectionStore.backlogItems.map((backlogItem) =>
234+
backlogItem.id === item.id ? { ...backlogItem, story_points: value } : backlogItem
235+
);
236+
} catch (error) {
237+
console.error('Failed to update story points:', error);
238+
errorToast(t('collections.backlogActionFailed'));
239+
} finally {
240+
setStoryPointsPending(item.id, false);
241+
}
242+
}
243+
141244
// Total item count across all sections
142245
let totalItemCount = $derived(collectionStore.backlogPagination?.total ?? backlogItems.length);
143246
@@ -169,6 +272,7 @@
169272
if (workspaceId) {
170273
await loadWorkspaceGradient(workspaceId);
171274
await workspaceDataStore.initialize(workspaceId);
275+
await loadStoryPointsScreenConfiguration();
172276
173277
// Load iterations for this workspace
174278
try {
@@ -184,6 +288,7 @@
184288
restorePersistedState();
185289
} else {
186290
await workspaceDataStore.initializeGlobal();
291+
await loadStoryPointsScreenConfiguration();
187292
}
188293
loading = false;
189294
});
@@ -736,6 +841,9 @@
736841
onStartIteration={startIteration}
737842
onCompleteIteration={completeIteration}
738843
onRemoveGlobal={removeGlobalIteration}
844+
storyPointsConfiguredForItem={storyPointsConfiguredForItem}
845+
storyPointsPendingItemIds={pendingStoryPointsItemIds}
846+
onUpdateStoryPoints={updateStoryPoints}
739847
/>
740848
{/each}
741849
@@ -758,6 +866,9 @@
758866
onOpenItem={openItem}
759867
onMoveItemToBoundary={moveItemToBoundary}
760868
onAssignItemToIteration={assignItemToIteration}
869+
storyPointsConfiguredForItem={storyPointsConfiguredForItem}
870+
storyPointsPendingItemIds={pendingStoryPointsItemIds}
871+
onUpdateStoryPoints={updateStoryPoints}
761872
/>
762873
763874
<!-- Load More -->

frontend/src/lib/features/items/WorkItemRow.svelte

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ItemCard from './ItemCard.svelte';
66
import Lozenge from '../../components/Lozenge.svelte';
77
import { getStatusCategory } from '../../utils/statusColors.js';
8+
import { t } from '../../stores/i18n.svelte.js';
89
910
/**
1011
* Reusable list-row props. Lookup arrays enrich the item; leading and trailing
@@ -28,10 +29,17 @@
2829
timestamp = null,
2930
formatTimestamp = null,
3031
compact = false,
32+
showStoryPoints = false,
33+
storyPointsSaving = false,
34+
onStoryPointsChange = null,
3135
leading = null,
3236
trailing = null,
3337
} = $props();
3438
39+
let editingStoryPoints = $state(false);
40+
let storyPointsEditValue = $state('');
41+
let storyPointsError = $state(false);
42+
3543
// Compute the display key - prefer item.workspace_key, fallback to workspace.key
3644
const displayKey = $derived.by(() => {
3745
const key = item.workspace_key || workspace?.key;
@@ -91,6 +99,51 @@
9199
// Resolve the status badge color, preferring a pre-resolved color on the item
92100
// (e.g. status_color from the homepage activity API where statuses arrays aren't loaded)
93101
const statusColor = $derived(item.status_color || statusCategory?.color || '#6b7280');
102+
103+
function startEditingStoryPoints(event) {
104+
event?.stopPropagation();
105+
if (storyPointsSaving) return;
106+
storyPointsEditValue = item.story_points == null ? '' : String(item.story_points);
107+
storyPointsError = false;
108+
editingStoryPoints = true;
109+
}
110+
111+
function cancelStoryPointsEdit(event) {
112+
event?.stopPropagation();
113+
editingStoryPoints = false;
114+
storyPointsError = false;
115+
}
116+
117+
function saveStoryPoints(event) {
118+
event?.stopPropagation();
119+
const raw = String(storyPointsEditValue ?? '').trim();
120+
const parsed = raw === '' ? null : Number(raw);
121+
if (parsed !== null && (!Number.isFinite(parsed) || parsed < 0)) {
122+
storyPointsError = true;
123+
return;
124+
}
125+
126+
if (parsed === (item.story_points ?? null)) {
127+
editingStoryPoints = false;
128+
storyPointsError = false;
129+
return;
130+
}
131+
132+
editingStoryPoints = false;
133+
storyPointsError = false;
134+
onStoryPointsChange?.(parsed);
135+
}
136+
137+
function handleStoryPointsKeydown(event) {
138+
event.stopPropagation();
139+
if (event.key === 'Enter') {
140+
event.preventDefault();
141+
saveStoryPoints(event);
142+
} else if (event.key === 'Escape') {
143+
event.preventDefault();
144+
cancelStoryPointsEdit(event);
145+
}
146+
}
94147
</script>
95148
96149
<ItemCard href={itemHref} {onclick} {compact}>
@@ -149,6 +202,51 @@
149202
<Lozenge text={status.name.replace(/_/g, ' ')} customBg={statusColor} />
150203
{/if}
151204
205+
{#if showStoryPoints}
206+
<div
207+
class="flex-shrink-0"
208+
data-testid={`backlog-story-points-${item.id}`}
209+
>
210+
{#if editingStoryPoints}
211+
<input
212+
type="number"
213+
min="0"
214+
step="0.5"
215+
aria-label={t('items.storyPoints')}
216+
aria-invalid={storyPointsError}
217+
aria-describedby={storyPointsError ? `backlog-story-points-error-${item.id}` : undefined}
218+
data-testid={`backlog-story-points-input-${item.id}`}
219+
class="w-16 rounded border px-2 py-1 text-xs tabular-nums outline-none"
220+
style="background-color: var(--ds-surface-card); border-color: {storyPointsError ? 'var(--ds-text-danger)' : 'var(--ds-border-focused)'}; color: var(--ds-text);"
221+
bind:value={storyPointsEditValue}
222+
disabled={storyPointsSaving}
223+
onclick={(event) => event.stopPropagation()}
224+
onblur={saveStoryPoints}
225+
onkeydown={handleStoryPointsKeydown}
226+
/>
227+
{#if storyPointsError}
228+
<span id={`backlog-story-points-error-${item.id}`} class="sr-only">
229+
{t('items.enterField', { field: t('items.storyPoints') })}
230+
</span>
231+
{/if}
232+
{:else}
233+
<button
234+
type="button"
235+
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs tabular-nums transition-colors hover:bg-black/5 dark:hover:bg-white/10 disabled:cursor-wait disabled:opacity-60"
236+
style="color: var(--ds-text-subtle);"
237+
title={t('items.setField', { field: t('items.storyPoints') })}
238+
aria-label={t('items.setField', { field: t('items.storyPoints') })}
239+
data-testid={`backlog-story-points-button-${item.id}`}
240+
disabled={storyPointsSaving}
241+
onclick={startEditingStoryPoints}
242+
>
243+
<span style="color: var(--ds-text);">{item.story_points ?? t('items.notSet')}</span>
244+
<span>SP</span>
245+
</button>
246+
{/if}
247+
</div>
248+
{/if}
249+
152250
{#if trailing}{@render trailing()}{/if}
153251
</div>
154252
{/snippet}

frontend/src/lib/layout/DashboardCustomizationSidebar.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script>
22
import { useEventListener } from 'runed';
3-
import { Sparkles, CheckSquare, Compass, GripVertical, Bell, Clock, Eye, Target, Briefcase, Grip, ListChecks } from '@lucide/svelte';
3+
import { Sparkles, CheckSquare, Compass, GripVertical, Bell, Clock, Eye, Target, Briefcase, Grip, ListChecks, Search } from '@lucide/svelte';
44
import {
55
DASHBOARD_GRID_COLUMNS,
66
dashboardWidgetCategories,
@@ -23,6 +23,7 @@
2323
Briefcase,
2424
Grip,
2525
ListChecks,
26+
Search,
2627
};
2728
2829
const categories = [

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,18 @@ export default {
406406
createdChart: {
407407
emptyMessage: 'لا تتوفر بيانات الإنشاء',
408408
},
409+
savedSearch: {
410+
loadingCollections: 'جارٍ تحميل المجموعات المحفوظة...',
411+
setupTitle: 'اختر مجموعة محفوظة',
412+
setupSubtitle: 'حدد مجموعة لعرض عناصر العمل الخاصة بها هنا.',
413+
selectCollection: 'تحديد مجموعة',
414+
noCollections: 'لا توجد مجموعات محفوظة متاحة',
415+
collectionUnavailable: 'المجموعة المحفوظة غير متاحة',
416+
itemCount: '{count} عناصر',
417+
emptyTitle: 'لا توجد عناصر عمل مطابقة',
418+
emptySubtitle: 'لا تحتوي هذه المجموعة المحفوظة على عناصر مطابقة',
419+
loadError: 'فشل تحميل المجموعة المحفوظة',
420+
},
409421
milestoneProgress: {
410422
emptyTitle: 'لا توجد معالم',
411423
emptySubtitle: 'أنشئ معالم لتتبع التقدم',

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ export default {
404404
createdChart: {
405405
emptyMessage: 'Keine Erstellungsdaten verfügbar',
406406
},
407+
savedSearch: {
408+
loadingCollections: 'Gespeicherte Sammlungen werden geladen...',
409+
setupTitle: 'Gespeicherte Sammlung auswählen',
410+
setupSubtitle: 'Wähle eine Sammlung, deren Arbeitselemente hier angezeigt werden.',
411+
selectCollection: 'Sammlung auswählen',
412+
noCollections: 'Keine gespeicherten Sammlungen verfügbar',
413+
collectionUnavailable: 'Gespeicherte Sammlung nicht verfügbar',
414+
itemCount: '{count} Einträge',
415+
emptyTitle: 'Keine passenden Arbeitselemente',
416+
emptySubtitle: 'Diese gespeicherte Sammlung enthält keine passenden Elemente',
417+
loadError: 'Gespeicherte Sammlung konnte nicht geladen werden',
418+
},
407419
milestoneProgress: {
408420
emptyTitle: 'Keine Meilensteine',
409421
emptySubtitle: 'Erstellen Sie Meilensteine, um den Fortschritt zu verfolgen',

0 commit comments

Comments
 (0)