From 7e3a23a9937608e5c3d7d072f821e71d91e92fb5 Mon Sep 17 00:00:00 2001 From: Daniel Raab Date: Mon, 12 Oct 2026 17:48:04 +0200 Subject: [PATCH] feat(frontend): implement ItemStatusPicker for status updates in item details and linked items --- .../collections/ListCellRenderer.svelte | 48 +++----------- .../src/lib/features/items/ItemDetail.svelte | 11 ++++ .../features/items/ItemDetailContent.svelte | 6 ++ .../lib/features/items/ItemDetailLinks.svelte | 57 +++++++++++++--- .../src/lib/pickers/ItemStatusPicker.svelte | 65 +++++++++++++++++++ frontend/src/lib/pickers/pickerConfigs.js | 2 +- 6 files changed, 140 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/pickers/ItemStatusPicker.svelte diff --git a/frontend/src/lib/features/collections/ListCellRenderer.svelte b/frontend/src/lib/features/collections/ListCellRenderer.svelte index 5bb45d4cb..7e6370efe 100644 --- a/frontend/src/lib/features/collections/ListCellRenderer.svelte +++ b/frontend/src/lib/features/collections/ListCellRenderer.svelte @@ -10,20 +10,19 @@ import MilestoneCombobox from '../../pickers/MilestoneCombobox.svelte'; import ItemKey from '../../components/ItemKey.svelte'; import ColorDot from '../../components/ColorDot.svelte'; - import Lozenge from '../../components/Lozenge.svelte'; import Checkbox from '../../components/Checkbox.svelte'; import ListCustomFieldCell from './ListCustomFieldCell.svelte'; import { Calendar, User, Target, FolderKanban } from '@lucide/svelte'; import ItemTypeIcon from '../../components/ItemTypeIcon.svelte'; import { formatDate, formatDateOnly, formatDateTimeLocale } from '../../utils/dateFormatter.js'; import { - createStatusPickerConfig, priorityPickerConfig as priorityConfig, projectPickerConfig as projectConfig, } from '../../pickers/pickerConfigs.js'; import UserCellValue from './UserCellValue.svelte'; import IterationCellEditor from './IterationCellEditor.svelte'; import MilestoneCellValue from './MilestoneCellValue.svelte'; + import ItemStatusPicker from '../../pickers/ItemStatusPicker.svelte'; let { item, @@ -109,8 +108,6 @@ } } - // Configs for pickers - const statusConfig = $derived(createStatusPickerConfig(statusCategories)); {#if column.field_type === 'workspace'} @@ -178,40 +175,15 @@ disabled={!canEdit} /> {:else} - {@const selectedStatus = [...editorOptions.statuses, ...statuses].find(s => s.id === item.status_id) || (item.status_name ? { name: item.status_name, category_id: null } : null)} - {@const statusCategory = selectedStatus ? statusCategories.find(sc => sc.id === selectedStatus.category_id) : null} - {#if canEdit} - collectionEditorOptions.load(item.workspace_id, 'statuses')} - onSelect={async (selected) => { - const statusId = selected?.id; - if (statusId && statusId !== item.status_id) { - await handleItemUpdate('status_id', statusId); - } - }} - > - {#snippet children()} - - - - {/snippet} - - {:else} - - {/if} + handleItemUpdate('status_id', statusId)} + /> {/if} {:else if column.field_identifier === 'priority'} diff --git a/frontend/src/lib/features/items/ItemDetail.svelte b/frontend/src/lib/features/items/ItemDetail.svelte index 8179aaf78..c16e77ed7 100644 --- a/frontend/src/lib/features/items/ItemDetail.svelte +++ b/frontend/src/lib/features/items/ItemDetail.svelte @@ -726,6 +726,16 @@ import NativeSelect from '../../components/NativeSelect.svelte'; } } + // A linked or child item's status changed from the detail view; refresh + // only the list that row came from. + async function handleRelatedStatusChanged({ kind }) { + if (kind === 'child-item') { + await itemDetailStore.loadChildItems(); + } else { + await itemDetailStore.loadLinks(); + } + } + async function handleParentChanged() { // Reload the item data to get updated parent hierarchy try { @@ -1431,6 +1441,7 @@ import NativeSelect from '../../components/NativeSelect.svelte'; ondiagramSaved={handleDiagramSaved} onexecuteAction={handleExecuteAction} onreorderChildren={handleReorderChildren} + onrelatedStatusChanged={handleRelatedStatusChanged} onclose={closeModal} {recurrenceRule} onsetupRecurrence={handleSetupRecurrence} diff --git a/frontend/src/lib/features/items/ItemDetailContent.svelte b/frontend/src/lib/features/items/ItemDetailContent.svelte index 0daeaedd9..e860a32e7 100644 --- a/frontend/src/lib/features/items/ItemDetailContent.svelte +++ b/frontend/src/lib/features/items/ItemDetailContent.svelte @@ -110,6 +110,7 @@ onexecuteAction = null, onaiAction = null, onreorderChildren = null, + onrelatedStatusChanged = null, canCreate = false, onclose = null, recurrenceRule = null, @@ -302,6 +303,10 @@ onreorderChildren?.(); } + function handleRelatedStatusChanged(data) { + return onrelatedStatusChanged?.(data); + } + function handleAIAction(data) { onaiAction?.(data); } @@ -421,6 +426,7 @@ onviewtestcase={handleViewTestCase} onshowlinkmodal={handleShowLinkModal} onreorderchildren={handleReorderChildren} + onstatuschanged={handleRelatedStatusChanged} />
diff --git a/frontend/src/lib/features/items/ItemDetailLinks.svelte b/frontend/src/lib/features/items/ItemDetailLinks.svelte index 1e579a12d..1a744b91c 100644 --- a/frontend/src/lib/features/items/ItemDetailLinks.svelte +++ b/frontend/src/lib/features/items/ItemDetailLinks.svelte @@ -8,10 +8,12 @@ import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import DropIndicator from '../../layout/DropIndicator.svelte'; import { api } from '../../api.js'; - import { objectDisplayName } from '../../utils/systemLabels.js'; import { t } from '../../stores/i18n.svelte.js'; import ItemKey from '../../components/ItemKey.svelte'; import StatusBadge from '../../components/StatusBadge.svelte'; + import ItemStatusPicker from '../../pickers/ItemStatusPicker.svelte'; + import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js'; + import { errorToast } from '../../stores/toasts.svelte.js'; let { item, @@ -33,6 +35,7 @@ onremovelink = undefined, onshowlinkmodal = undefined, onreorderchildren = undefined, + onstatuschanged = undefined, } = $props(); const TEST_LINK_TYPE_ID = 1; @@ -90,6 +93,25 @@ onshowlinkmodal?.({ preselectLinkTypeId }); } + // Rows with a status transition in flight; their picker stays locked until + // the refreshed list arrives. + let pendingStatusItemIds = $state(new Set()); + + async function changeStatus(rowItemId, statusId, kind) { + if (pendingStatusItemIds.has(rowItemId)) return; + pendingStatusItemIds = new Set(pendingStatusItemIds).add(rowItemId); + try { + await api.items.transition(rowItemId, statusId); + await onstatuschanged?.({ itemId: rowItemId, kind }); + } catch (error) { + errorToast(t('dialogs.alerts.failedToUpdate', { error: `status: ${error.message || error}` })); + } finally { + const next = new Set(pendingStatusItemIds); + next.delete(rowItemId); + pendingStatusItemIds = next; + } + } + // Drag and drop state for child items let dragState = $state(new Map()); let setupElements = new Map(); @@ -251,6 +273,24 @@ }); + +{#snippet statusControl(rowItemId, rowWorkspaceId, statusId, statusName, statusBuiltinKey, statusColor, kind)} + {#if statusId && rowWorkspaceId} + + changeStatus(rowItemId, nextStatusId, kind)} + /> + + {/if} +{/snippet} + {#if workItemLinks.length > 0}
@@ -299,6 +339,7 @@ {@const linkedItemTypeColor = isCurrentSource ? link.target_item_type_color : link.source_item_type_color} {@const linkedItemStatusName = isCurrentSource ? link.target_status_name : link.source_status_name} {@const linkedItemStatusColor = isCurrentSource ? link.target_status_color : link.source_status_color} + {@const linkedItemStatusId = isCurrentSource ? link.target_status_id : link.source_status_id} {@const linkTypeName = link.link_type_name} {@const linkTypeLabel = getLinkLabel(link)} {@const linkTypeColor = link.link_type_color || 'var(--ds-text-subtle)'} @@ -352,7 +393,9 @@ {linkTypeLabel} {/if} - {#if linkedItemStatusName} + {#if linkedItemType === 'item'} + {@render statusControl(linkedItemId, linkedItemWorkspaceId, linkedItemStatusId, linkedItemStatusName, '', linkedItemStatusColor, 'linked-item')} + {:else if linkedItemStatusName}
- +
- {#if childItem.status_name} - - {/if} + {@render statusControl(childItem.id, childItem.workspace_id || workspaceId, childItem.status_id, childItem.status_name, childItem.status_builtin_key, childItem.status_color, 'child-item')}
{/each} diff --git a/frontend/src/lib/pickers/ItemStatusPicker.svelte b/frontend/src/lib/pickers/ItemStatusPicker.svelte new file mode 100644 index 000000000..ff983344d --- /dev/null +++ b/frontend/src/lib/pickers/ItemStatusPicker.svelte @@ -0,0 +1,65 @@ + + +{#if canEdit} + collectionEditorOptions.load(workspaceId, 'statuses')} + onSelect={async (selected) => { + const nextStatusId = selected?.id; + if (nextStatusId && nextStatusId !== statusId) { + await onselect?.(nextStatusId); + } + }} + > + {#snippet children()} + + + + {/snippet} + +{:else} + +{/if} diff --git a/frontend/src/lib/pickers/pickerConfigs.js b/frontend/src/lib/pickers/pickerConfigs.js index 7036bc7a5..b7f5a9286 100644 --- a/frontend/src/lib/pickers/pickerConfigs.js +++ b/frontend/src/lib/pickers/pickerConfigs.js @@ -42,7 +42,7 @@ export function createStatusPickerConfig(statusCategories) { type: 'color-dot', source: (status) => { const category = statusCategories.find((sc) => sc.id === status.category_id); - return category?.color || '#6b7280'; + return category?.color || status.category_color || '#6b7280'; }, size: 'w-2 h-2', },