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
121 changes: 121 additions & 0 deletions frontend/src/lib/features/collections/BacklogFieldSelector.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<script>
import { createPopover, melt } from '@melt-ui/svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import X from '@lucide/svelte/icons/x';
import Checkbox from '../../components/Checkbox.svelte';
import { t } from '../../stores/i18n.svelte.js';
import { BACKLOG_FIELD_OPTIONS, normalizeBacklogFields } from './backlogFields.js';

let {
fields = [],
onchange = null,
} = $props();

const {
elements: { trigger, content },
states: { open },
} = createPopover({
forceVisible: true,
positioning: {
strategy: 'fixed',
fitViewport: true,
placement: 'bottom-start',
},
portal: 'body',
});

let draftFields = $state([]);

$effect(() => {
draftFields = normalizeBacklogFields(fields);
});

function setField(fieldId, checked) {
if (checked) {
draftFields = normalizeBacklogFields([...draftFields, fieldId]);
} else {
draftFields = draftFields.filter((id) => id !== fieldId);
}
}

function cancel() {
draftFields = normalizeBacklogFields(fields);
open.set(false);
}

function apply() {
onchange?.(normalizeBacklogFields(draftFields));
open.set(false);
}
</script>

<button
use:melt={$trigger}
type="button"
class="flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-ds-text-subtle transition-colors"
title={t('fields.configureFields')}
aria-label={t('fields.configureFields')}
data-testid="backlog-field-selector-trigger"
>
<SlidersHorizontal class="w-4 h-4" />
<span>{t('fields.configureFields')}</span>
<ChevronDown class="w-3 h-3" />
</button>

{#if $open}
<div
use:melt={$content}
class="w-72 rounded-lg shadow-xl border border-ds-border bg-ds-surface-raised z-[60] overflow-hidden"
data-testid="backlog-field-selector-menu"
>
<div
class="flex items-center justify-between border-b border-ds-border px-4 py-3"
>
<span class="text-sm font-medium text-ds-text">
{t('fields.configureFields')}
</span>
<button
type="button"
class="rounded p-1 text-ds-text-subtle transition-colors hover:bg-[var(--ds-background-neutral-hovered)]"
aria-label={t('common.close')}
onclick={cancel}
>
<X class="w-4 h-4" />
</button>
</div>

<div class="flex flex-col gap-1 p-2">
{#each BACKLOG_FIELD_OPTIONS as field (field.id)}
<div class="rounded px-2 py-2 hover:bg-[var(--ds-background-neutral-hovered)]">
<Checkbox
checked={draftFields.includes(field.id)}
label={t(field.labelKey)}
onchange={(checked) => setField(field.id, checked)}
dataTestid={`backlog-field-${field.id}`}
/>
</div>
{/each}
</div>

<div
class="flex items-center justify-end gap-2 border-t border-ds-border px-4 py-3"
>
<button
type="button"
class="rounded px-3 py-1.5 text-sm text-ds-text-subtle transition-colors hover:bg-[var(--ds-background-neutral-hovered)]"
onclick={cancel}
>
{t('common.cancel')}
</button>
<button
type="button"
class="rounded bg-ds-interactive px-3 py-1.5 text-sm text-ds-text-inverse transition-colors hover:bg-ds-interactive-hovered"
onclick={apply}
data-testid="backlog-field-selector-apply"
>
{t('common.apply')}
</button>
</div>
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
sectionHighlight = false,
assignableIterations = [],
pendingActionItemIds = new Set(),
visibleFields = [],
onToggleCollapse,
onOpenItem,
onMoveItemToBoundary = null,
Expand Down Expand Up @@ -61,6 +62,7 @@
let canStart = $derived(iteration && !iteration.is_global && iteration.status === 'planned');
let canComplete = $derived(iteration && !iteration.is_global && iteration.status === 'active');
let sectionId = $derived(iteration ? iteration.id : 'unassigned');
let visibleFieldSet = $derived(new Set(visibleFields));

let headerClass = $derived(
`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors select-none iteration-header` +
Expand Down Expand Up @@ -201,8 +203,13 @@
{statuses}
{statusCategories}
onclick={(e) => onOpenItem?.(item.id, e)}
showStatus={true}
showStoryPoints={storyPointsConfiguredForItem?.(item) ?? false}
showStatus={visibleFieldSet.has('status')}
showPriority={visibleFieldSet.has('priority')}
showParent={visibleFieldSet.has('parent')}
showAssignee={visibleFieldSet.has('assignee')}
showDueDate={visibleFieldSet.has('due_date')}
showMilestones={visibleFieldSet.has('milestone')}
showStoryPoints={visibleFieldSet.has('story_points') && (storyPointsConfiguredForItem?.(item) ?? false)}
storyPointsSaving={storyPointsPendingItemIds.has(item.id)}
onStoryPointsChange={(value) => onUpdateStoryPoints?.(item, value)}
>
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/lib/features/collections/CollectionBacklog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import CompleteIterationDialog from '../../dialogs/CompleteIterationDialog.svelte';
import { formatItemCount } from '../../utils/itemCount.js';
import { isSystemFieldAvailableInEffectiveConfig } from '../../utils/screenFields.js';
import BacklogFieldSelector from './BacklogFieldSelector.svelte';
import { DEFAULT_BACKLOG_FIELDS, normalizeBacklogFields } from './backlogFields.js';

let { workspaceId, collectionId = null } = $props();

Expand Down Expand Up @@ -64,6 +66,7 @@
let sectionDropHighlight = $state(new Map()); // iterationId|'unassigned' -> boolean
let pendingActionItemIds = $state(new Set());
let pendingStoryPointsItemIds = $state(new Set());
let visibleFields = $state([...DEFAULT_BACKLOG_FIELDS]);

// --- Complete Iteration dialog state ---
let completeIterationShow = $state(false);
Expand Down Expand Up @@ -116,6 +119,14 @@
if (typeof storedShowCompleted === 'boolean') {
showCompletedIterations = storedShowCompleted;
}

const storedVisibleFields = collectionPreferences.viewStateValue(
workspaceId,
collectionId,
'backlog',
'visible_fields'
);
visibleFields = normalizeBacklogFields(storedVisibleFields);
}

function setShowCompletedIterations(value) {
Expand All @@ -125,6 +136,13 @@
});
}

function setVisibleFields(fields) {
visibleFields = normalizeBacklogFields(fields);
collectionPreferences.patchViewState(workspaceId, collectionId, 'backlog', {
visible_fields: visibleFields,
});
}

function persistGlobalIds() {
collectionPreferences.patchViewState(workspaceId, collectionId, 'backlog', {
global_iteration_ids: [...addedGlobalIds],
Expand Down Expand Up @@ -318,11 +336,10 @@
} catch (error) {
console.error('Failed to load iterations:', error);
}

restorePersistedState();
} else {
await workspaceDataStore.initializeGlobal();
}
restorePersistedState();
loading = false;
});

Expand Down Expand Up @@ -703,6 +720,7 @@
<!-- Controls Bar -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<SubFilterBar {workspaceId} {collectionId} showCompletionToggle={false} />
<BacklogFieldSelector fields={visibleFields} onchange={setVisibleFields} />
<Toggle
checked={showCompletedIterations}
onchange={setShowCompletedIterations}
Expand Down Expand Up @@ -737,6 +755,7 @@
{backlogRowGap}
{assignableIterations}
{pendingActionItemIds}
{visibleFields}
isGlobalAdded={addedGlobalIds.has(section.iteration.id)}
sectionHighlight={sectionDropHighlight.get(String(section.iteration.id)) || false}
onToggleCollapse={toggleCollapse}
Expand Down Expand Up @@ -768,6 +787,7 @@
{backlogRowGap}
{assignableIterations}
{pendingActionItemIds}
{visibleFields}
sectionHighlight={sectionDropHighlight.get('unassigned') || false}
onToggleCollapse={toggleCollapse}
onOpenItem={openItem}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/lib/features/collections/backlogFields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const BACKLOG_FIELD_OPTIONS = [
{ id: 'parent', labelKey: 'items.parent' },
{ id: 'milestone', labelKey: 'common.milestone' },
{ id: 'status', labelKey: 'common.status' },
{ id: 'priority', labelKey: 'common.priority' },
{ id: 'assignee', labelKey: 'common.assignee' },
{ id: 'due_date', labelKey: 'common.dueDate' },
{ id: 'story_points', labelKey: 'items.storyPoints' },
];

export const DEFAULT_BACKLOG_FIELDS = ['parent', 'status', 'story_points'];

const backlogFieldIds = new Set(BACKLOG_FIELD_OPTIONS.map((field) => field.id));

export function normalizeBacklogFields(fields) {
if (!Array.isArray(fields)) return [...DEFAULT_BACKLOG_FIELDS];
return [...new Set(fields.filter((field) => backlogFieldIds.has(field)))];
}
19 changes: 19 additions & 0 deletions frontend/src/lib/features/collections/backlogFields.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_BACKLOG_FIELDS, normalizeBacklogFields } from './backlogFields.js';

describe('backlog field configuration', () => {
it('uses the current backlog metadata as the default', () => {
expect(normalizeBacklogFields(undefined)).toEqual(DEFAULT_BACKLOG_FIELDS);
});

it('allows every field to be hidden', () => {
expect(normalizeBacklogFields([])).toEqual([]);
});

it('drops unknown and duplicate stored fields', () => {
expect(normalizeBacklogFields(['parent', 'unknown', 'parent', 'assignee'])).toEqual([
'parent',
'assignee',
]);
});
});
Loading
Loading