Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 10 additions & 38 deletions frontend/src/lib/features/collections/ListCellRenderer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,8 +108,6 @@
}
}

// Configs for pickers
const statusConfig = $derived(createStatusPickerConfig(statusCategories));
</script>

{#if column.field_type === 'workspace'}
Expand Down Expand Up @@ -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}
<ItemPicker
value={item.status_id}
items={editorOptions.statuses}
loading={editorOptions.loading.statuses}
config={statusConfig}
placeholder="Set status"
showUnassigned={false}
allowClear={false}
onOpen={() => 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()}
<span class="cursor-pointer">
<Lozenge
text={selectedStatus ? selectedStatus.name : 'Set status'}
customBg={statusCategory?.color || '#6b7280'}
/>
</span>
{/snippet}
</ItemPicker>
{:else}
<Lozenge
text={selectedStatus ? selectedStatus.name : '-'}
customBg={statusCategory?.color || '#6b7280'}
/>
{/if}
<ItemStatusPicker
workspaceId={item.workspace_id}
statusId={item.status_id}
statusName={item.status_name}
{statuses}
{statusCategories}
{canEdit}
onselect={(statusId) => handleItemUpdate('status_id', statusId)}
/>
{/if}

{:else if column.field_identifier === 'priority'}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/features/items/ItemDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1431,6 +1441,7 @@ import NativeSelect from '../../components/NativeSelect.svelte';
ondiagramSaved={handleDiagramSaved}
onexecuteAction={handleExecuteAction}
onreorderChildren={handleReorderChildren}
onrelatedStatusChanged={handleRelatedStatusChanged}
onclose={closeModal}
{recurrenceRule}
onsetupRecurrence={handleSetupRecurrence}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/features/items/ItemDetailContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
onexecuteAction = null,
onaiAction = null,
onreorderChildren = null,
onrelatedStatusChanged = null,
canCreate = false,
onclose = null,
recurrenceRule = null,
Expand Down Expand Up @@ -302,6 +303,10 @@
onreorderChildren?.();
}

function handleRelatedStatusChanged(data) {
return onrelatedStatusChanged?.(data);
}

function handleAIAction(data) {
onaiAction?.(data);
}
Expand Down Expand Up @@ -421,6 +426,7 @@
onviewtestcase={handleViewTestCase}
onshowlinkmodal={handleShowLinkModal}
onreorderchildren={handleReorderChildren}
onstatuschanged={handleRelatedStatusChanged}
/>

<br />
Expand Down
57 changes: 47 additions & 10 deletions frontend/src/lib/features/items/ItemDetailLinks.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +35,7 @@
onremovelink = undefined,
onshowlinkmodal = undefined,
onreorderchildren = undefined,
onstatuschanged = undefined,
} = $props();

const TEST_LINK_TYPE_ID = 1;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -251,6 +273,24 @@
});
</script>

<!-- Linked and child items can live in another workspace, so the edit
permission and status options follow the row's own workspace. -->
{#snippet statusControl(rowItemId, rowWorkspaceId, statusId, statusName, statusBuiltinKey, statusColor, kind)}
{#if statusId && rowWorkspaceId}
<span data-testid="{kind}-status">
<ItemStatusPicker
workspaceId={rowWorkspaceId}
{statusId}
{statusName}
{statusBuiltinKey}
{statusColor}
canEdit={workspacePermissions.canEdit(rowWorkspaceId) && !pendingStatusItemIds.has(rowItemId)}
onselect={(nextStatusId) => changeStatus(rowItemId, nextStatusId, kind)}
/>
</span>
{/if}
{/snippet}

<!-- Links Section (work items, test cases, assets) -->
{#if workItemLinks.length > 0}
<div class="mt-6">
Expand Down Expand Up @@ -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)'}
Expand Down Expand Up @@ -352,7 +393,9 @@
{linkTypeLabel}
</span>
{/if}
{#if linkedItemStatusName}
{#if linkedItemType === 'item'}
{@render statusControl(linkedItemId, linkedItemWorkspaceId, linkedItemStatusId, linkedItemStatusName, '', linkedItemStatusColor, 'linked-item')}
{:else if linkedItemStatusName}
<StatusBadge
status={{ label: linkedItemStatusName, categoryColor: linkedItemStatusColor }}
uppercase={false}
Expand Down Expand Up @@ -496,15 +539,9 @@
{childItem.title}
</LinkComponent>
</div>
<!-- Right side: status badge -->
<!-- Right side: status picker -->
<div class="flex items-center gap-2 flex-shrink-0">
{#if childItem.status_name}
<StatusBadge
status={{ label: objectDisplayName({ name: childItem.status_name, builtin_key: childItem.status_builtin_key }, 'status'), categoryColor: childItem.status_color }}
uppercase={false}
showDot={false}
/>
{/if}
{@render statusControl(childItem.id, childItem.workspace_id || workspaceId, childItem.status_id, childItem.status_name, childItem.status_builtin_key, childItem.status_color, 'child-item')}
</div>
</div>
{/each}
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/lib/pickers/ItemStatusPicker.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script>
import { collectionEditorOptions } from '../stores/collectionEditorOptions.svelte.js';
import { objectDisplayName } from '../utils/systemLabels.js';
import ItemPicker from './ItemPicker.svelte';
import Lozenge from '../components/Lozenge.svelte';
import { createStatusPickerConfig } from './pickerConfigs.js';

// Inline status editor shared by the list/tree cells and the item detail
// link and child rows. Options are loaded lazily per owning workspace, so a
// row from another workspace never offers this workspace's statuses.
let {
workspaceId,
statusId,
statusName = '',
statusBuiltinKey = '',
// Category color the row already carries, used until the options load.
statusColor = null,
statuses = [],
statusCategories = [],
canEdit = true,
emptyText = '-',
onselect = undefined,
} = $props();

let editorOptions = $derived(collectionEditorOptions.get(workspaceId));
let selectedStatus = $derived(
[...editorOptions.statuses, ...statuses].find((s) => s.id === statusId) ||
(statusName ? { name: statusName, builtin_key: statusBuiltinKey, category_id: null } : null)
);
let color = $derived(
statusCategories.find((sc) => sc.id === selectedStatus?.category_id)?.color ||
selectedStatus?.category_color ||
statusColor ||
'#6b7280'
);
let label = $derived(selectedStatus ? objectDisplayName(selectedStatus, 'status') : null);
const statusConfig = $derived(createStatusPickerConfig(statusCategories));
</script>

{#if canEdit}
<ItemPicker
value={statusId}
items={editorOptions.statuses}
loading={editorOptions.loading.statuses}
config={statusConfig}
placeholder="Set status"
showUnassigned={false}
allowClear={false}
onOpen={() => collectionEditorOptions.load(workspaceId, 'statuses')}
onSelect={async (selected) => {
const nextStatusId = selected?.id;
if (nextStatusId && nextStatusId !== statusId) {
await onselect?.(nextStatusId);
}
}}
>
{#snippet children()}
<span class="cursor-pointer">
<Lozenge text={label ?? 'Set status'} customBg={color} />
</span>
{/snippet}
</ItemPicker>
{:else}
<Lozenge text={label ?? emptyText} customBg={color} />
{/if}
2 changes: 1 addition & 1 deletion frontend/src/lib/pickers/pickerConfigs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
Loading