diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index ae2cc978620..aee2d35cff2 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -46,6 +46,7 @@ import { cardsInColumn, columnCount as kanbanColumnCount, findInsertionFromPointer, + KanbanColumnConfigSidebar, KanbanDragManager, KanbanPlane, resolveInsertion, @@ -138,6 +139,7 @@ export { GridContainer, Header, IconButton, + KanbanColumnConfigSidebar, kanbanColumnCount, KanbanDragManager, KanbanPlane, diff --git a/packages/boxel-ui/addon/src/components/kanban/column-config-sidebar.gts b/packages/boxel-ui/addon/src/components/kanban/column-config-sidebar.gts new file mode 100644 index 00000000000..a8f7f2fc2cc --- /dev/null +++ b/packages/boxel-ui/addon/src/components/kanban/column-config-sidebar.gts @@ -0,0 +1,318 @@ +// KanbanColumnConfigSidebar — Inline config panel for kanban column settings. +import ChevronDown from '@cardstack/boxel-icons/chevron-down'; +import ChevronUp from '@cardstack/boxel-icons/chevron-up'; +import XIcon from '@cardstack/boxel-icons/x'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +import IconButton from '../icon-button/index.gts'; +import Switch from '../switch/index.gts'; +import type { KanbanColumnConfig } from './engine.ts'; + +interface Signature { + Args: { + columns: KanbanColumnConfig[]; + onClose?: () => void; + onColumnsChange: (columns: KanbanColumnConfig[]) => void; + }; + Element: HTMLElement; +} + +export class KanbanColumnConfigSidebar extends Component { + @action update(index: number, patch: Partial): void { + this.args.onColumnsChange( + this.args.columns.map((col, i) => + i === index ? { ...col, ...patch } : col, + ), + ); + } + + @action moveUp(index: number): void { + if (index === 0) return; + let cols = [...this.args.columns]; + [cols[index - 1], cols[index]] = [cols[index]!, cols[index - 1]!]; + this.args.onColumnsChange( + cols.map((col, i) => ({ ...col, sortOrder: i + 1 })), + ); + } + + @action moveDown(index: number): void { + if (index >= this.args.columns.length - 1) return; + let cols = [...this.args.columns]; + [cols[index], cols[index + 1]] = [cols[index + 1]!, cols[index]!]; + this.args.onColumnsChange( + cols.map((col, i) => ({ ...col, sortOrder: i + 1 })), + ); + } + + @action onColorChange(index: number, event: Event): void { + this.update(index, { + color: (event.target as HTMLInputElement).value, + }); + } + + @action onLabelInput(index: number, event: Event): void { + this.update(index, { + label: (event.target as HTMLInputElement).value, + }); + } + + @action onWipInput(index: number, event: Event): void { + let raw = parseInt((event.target as HTMLInputElement).value, 10); + this.update(index, { wipLimit: isNaN(raw) || raw < 0 ? 0 : raw }); + } + + @action toggleVisible(index: number): void { + let col = this.args.columns[index]; + if (!col) return; + this.update(index, { collapsed: !col.collapsed }); + } + + isFirst = (index: number): boolean => index === 0; + isLast = (index: number): boolean => index >= this.args.columns.length - 1; + + +} diff --git a/packages/boxel-ui/addon/src/components/kanban/index.gts b/packages/boxel-ui/addon/src/components/kanban/index.gts index ebf9991ddd2..81748faef82 100644 --- a/packages/boxel-ui/addon/src/components/kanban/index.gts +++ b/packages/boxel-ui/addon/src/components/kanban/index.gts @@ -1,4 +1,5 @@ export { KanbanCard } from './card.gts'; +export { KanbanColumnConfigSidebar } from './column-config-sidebar.gts'; export { KanbanColumnHeader } from './column-header.gts'; export { KanbanDragManager } from './drag.gts'; export { diff --git a/packages/boxel-ui/addon/src/components/kanban/usage.gts b/packages/boxel-ui/addon/src/components/kanban/usage.gts index d6df7c78e33..7a597ad4c59 100644 --- a/packages/boxel-ui/addon/src/components/kanban/usage.gts +++ b/packages/boxel-ui/addon/src/components/kanban/usage.gts @@ -14,6 +14,7 @@ import CardContainer from '../card-container/index.gts'; import Switch from '../switch/index.gts'; import type { ViewItem } from '../view-selector/index.gts'; import ViewSelector from '../view-selector/index.gts'; +import { KanbanColumnConfigSidebar } from './column-config-sidebar.gts'; import { type KanbanColumnConfig, type KanbanPlacement, @@ -27,6 +28,23 @@ interface DemoCard { title: string; } +interface DemoKeyedPlacement { + cardIndex: number; + columnKey: string | null; + sortOrder: number; +} + +function toKeyedPlacements( + placements: KanbanPlacement[], + columns: KanbanColumnConfig[], +): DemoKeyedPlacement[] { + return placements.map((p) => ({ + columnKey: columns[p.column]?.key ?? null, + cardIndex: p.index, + sortOrder: p.sortOrder, + })); +} + const INITIAL_COLUMNS: KanbanColumnConfig[] = [ { key: 'backlog', @@ -101,6 +119,27 @@ export default class KanbanUsage extends Component { @tracked cardSizeView = 'tile'; @tracked cardSize: FittedFormatId = 'regular-tile'; + // Column config sidebar demo state + @tracked sidebarColumns = INITIAL_COLUMNS; + @tracked sidebarKeyedPlacements: DemoKeyedPlacement[] = toKeyedPlacements( + autoPlaceKanban(INITIAL_CARDS.length, 4), + INITIAL_COLUMNS, + ); + @tracked showSidebar = true; + + get sidebarPlacements(): KanbanPlacement[] { + return this.sidebarKeyedPlacements + .map((p) => { + let colIdx = this.sidebarColumns.findIndex( + (c) => c.key === p.columnKey, + ); + return colIdx === -1 + ? null + : { column: colIdx, index: p.cardIndex, sortOrder: p.sortOrder }; + }) + .filter((p): p is KanbanPlacement => p !== null); + } + @action handlePlacementsChange(placements: KanbanPlacement[]): void { this.placements = placements; } @@ -144,6 +183,21 @@ export default class KanbanUsage extends Component { this.hideEmpty = !this.hideEmpty; } + @action handleSidebarColumnsChange(columns: KanbanColumnConfig[]): void { + this.sidebarColumns = columns; + } + + @action handleSidebarPlacementsChange(placements: KanbanPlacement[]): void { + this.sidebarKeyedPlacements = toKeyedPlacements( + placements, + this.sidebarColumns, + ); + } + + @action toggleSidebar(): void { + this.showSidebar = !this.showSidebar; + } + @action addCard(columnKey: string | null): void { let nextIndex = this.cards.length; let columnIndex = this.columns.findIndex( @@ -301,6 +355,82 @@ export default class KanbanUsage extends Component { + + <:description> +

+ KanbanColumnConfigSidebar + renders a panel where users can rename columns, change their color, + set a WIP limit (max cards), reorder them with up/down controls, and + toggle individual column visibility. +

+

+ It is a stateless component: every change is reported via + onColumnsChange + and the caller owns the resulting array. +

+ + <:example> + + + <:api as |Args|> + + + + +
+ } diff --git a/packages/boxel-ui/test-app/tests/integration/components/kanban-column-config-sidebar-test.gts b/packages/boxel-ui/test-app/tests/integration/components/kanban-column-config-sidebar-test.gts new file mode 100644 index 00000000000..d16aa44d473 --- /dev/null +++ b/packages/boxel-ui/test-app/tests/integration/components/kanban-column-config-sidebar-test.gts @@ -0,0 +1,249 @@ +import { module, test } from 'qunit'; +import { click, fillIn, render, triggerEvent } from '@ember/test-helpers'; +import { setupRenderingTest } from 'test-app/tests/helpers'; +import { tracked } from '@glimmer/tracking'; +import { + KanbanColumnConfigSidebar, + type KanbanColumnConfig, +} from '@cardstack/boxel-ui/components'; + +const BASE_COLUMNS: KanbanColumnConfig[] = [ + { + key: 'backlog', + label: 'Backlog', + color: '#64748b', + wipLimit: 0, + collapsed: false, + sortOrder: 1, + }, + { + key: 'in-progress', + label: 'In Progress', + color: '#d97706', + wipLimit: 2, + collapsed: false, + sortOrder: 2, + }, + { + key: 'done', + label: 'Done', + color: '#15803d', + wipLimit: null, + collapsed: false, + sortOrder: 3, + }, +]; + +module( + 'Integration | Component | kanban-column-config-sidebar', + function (hooks) { + setupRenderingTest(hooks); + + test('renders one row per column with label and wip values', async function (assert) { + await render( + , + ); + + assert.dom('[data-test-col-config-row]').exists({ count: 3 }); + assert.dom('[data-test-col-config-label="0"]').hasValue('Backlog'); + assert.dom('[data-test-col-config-label="1"]').hasValue('In Progress'); + assert.dom('[data-test-col-config-label="2"]').hasValue('Done'); + assert.dom('[data-test-col-config-wip="0"]').hasValue('0'); + assert.dom('[data-test-col-config-wip="1"]').hasValue('2'); + }); + + test('close button is hidden without onClose; shown and functional with it', async function (assert) { + let closed = false; + const onClose = () => { + closed = true; + }; + + await render( + , + ); + assert.dom('.sidebar-close').doesNotExist('hidden when no onClose'); + + await render( + , + ); + assert.dom('[aria-label="Close column settings"]').exists('shown when onClose provided'); + await click('[aria-label="Close column settings"]'); + assert.true(closed, 'onClose invoked on click'); + }); + + test('reorder buttons: disabled at boundaries; move-down and move-up swap correctly', async function (assert) { + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + assert.dom('[data-test-col-config-row="0"] [aria-label="Move column up"]').isDisabled('first row: up disabled'); + assert.dom('[data-test-col-config-row="0"] [aria-label="Move column down"]').isNotDisabled('first row: down enabled'); + assert.dom('[data-test-col-config-row="2"] [aria-label="Move column up"]').isNotDisabled('last row: up enabled'); + assert.dom('[data-test-col-config-row="2"] [aria-label="Move column down"]').isDisabled('last row: down disabled'); + + await click('[data-test-col-config-row="0"] [aria-label="Move column down"]'); + assert.strictEqual(result![0]!.key, 'in-progress', 'move-down: in-progress is now first'); + assert.strictEqual(result![1]!.key, 'backlog', 'move-down: backlog moved to second'); + assert.strictEqual(result![0]!.sortOrder, 1, 'sortOrders renumbered'); + assert.strictEqual(result![1]!.sortOrder, 2); + + await click('[data-test-col-config-row="1"] [aria-label="Move column up"]'); + assert.strictEqual(result![0]!.key, 'in-progress', 'move-up: same result from opposite direction'); + assert.strictEqual(result![1]!.key, 'backlog'); + }); + + test('label input fires onColumnsChange with the updated label only', async function (assert) { + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + await fillIn('[data-test-col-config-label="0"]', 'Queue'); + + assert.strictEqual(result![0]!.label, 'Queue'); + assert.strictEqual(result![1]!.label, 'In Progress', 'other cols unchanged'); + }); + + test('WIP input: updates limit; clamps negative values to 0', async function (assert) { + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + await fillIn('[data-test-col-config-wip="1"]', '5'); + assert.strictEqual(result![1]!.wipLimit, 5, 'valid value accepted'); + + await fillIn('[data-test-col-config-wip="0"]', '-3'); + assert.strictEqual(result![0]!.wipLimit, 0, 'negative clamped to 0'); + }); + + test('visibility toggle flips collapsed in both directions', async function (assert) { + const collapsedFirst: KanbanColumnConfig[] = BASE_COLUMNS.map((c, i) => + i === 0 ? { ...c, collapsed: true } : c, + ); + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + await click('[data-test-col-config-visible="0"]'); + assert.true(result![0]!.collapsed, 'visible → hidden'); + assert.false(result![1]!.collapsed, 'other columns unaffected'); + + await render( + , + ); + + await click('[data-test-col-config-visible="0"]'); + assert.false(result![0]!.collapsed, 'hidden → visible'); + }); + + test('color change event fires onColumnsChange with the new color', async function (assert) { + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + let input = document.querySelector( + '[data-test-col-config-color="0"]', + ) as HTMLInputElement; + input.value = '#ff0000'; + await triggerEvent(input, 'change'); + + assert.strictEqual(result![0]!.color, '#ff0000'); + assert.strictEqual(result![1]!.color, '#d97706', 'other cols unchanged'); + }); + + test('re-renders when the columns arg is updated externally', async function (assert) { + class State { + @tracked cols = BASE_COLUMNS; + } + const state = new State(); + + await render( + , + ); + + assert.dom('[data-test-col-config-label="0"]').hasValue('Backlog'); + + state.cols = [{ ...BASE_COLUMNS[0]!, label: 'Queue' }, ...BASE_COLUMNS.slice(1)]; + await new Promise((r) => requestAnimationFrame(r)); + + assert.dom('[data-test-col-config-label="0"]').hasValue('Queue'); + }); + }, +); + +function noop() {} diff --git a/packages/software-factory/realm/issue-tracker.gts b/packages/software-factory/realm/issue-tracker.gts index cf47e793717..cae146edfa3 100644 --- a/packages/software-factory/realm/issue-tracker.gts +++ b/packages/software-factory/realm/issue-tracker.gts @@ -22,6 +22,7 @@ import NumberField from 'https://cardstack.com/base/number'; import { FieldContainer, + KanbanColumnConfigSidebar, KanbanPlane, ContextButton, Pill, @@ -33,6 +34,7 @@ import { eq } from '@cardstack/boxel-ui/helpers'; import LayoutSidebarRightCollapse from '@cardstack/boxel-icons/layout-sidebar-right-collapse'; import LayoutSidebarRightExpand from '@cardstack/boxel-icons/layout-sidebar-right-expand'; +import SlidersHorizontal from '@cardstack/boxel-icons/sliders-horizontal'; import SquareKanban from '@cardstack/boxel-icons/square-kanban'; import { realmURL, type ResolvedCodeRef } from '@cardstack/runtime-common'; @@ -1050,10 +1052,16 @@ export class Project extends CardDef { // ── IssueTrackerIsolated ────────────────────────────────────────────── class IssueTrackerIsolated extends Component { + @tracked isSidebarOpen = false; + get columns(): KanbanColumnConfig[] { - return buildColumnsFromStatusOptions( - getProjectIssueStatusOptions(this.args.model?.project), - ).map((col) => ({ + let stored = this.args.model.columns ?? []; + let source = stored.length + ? stored + : buildColumnsFromStatusOptions( + getProjectIssueStatusOptions(this.args.model?.project), + ); + return source.map((col) => ({ key: col.key ?? null, label: col.label ?? null, color: col.color ?? null, @@ -1078,6 +1086,27 @@ class IssueTrackerIsolated extends Component { this.args.model.hideEmptyColumns = !this.args.model?.hideEmptyColumns; }; + handleColumnsChange = (newColumns: KanbanColumnConfig[]): void => { + this.args.model.columns = newColumns.map((cfg) => + Object.assign(new KanbanColumnField(), { + key: cfg.key ?? '', + label: cfg.label ?? '', + color: cfg.color ?? '', + collapsed: cfg.collapsed ?? false, + sortOrder: cfg.sortOrder ?? 0, + wipLimit: cfg.wipLimit ?? 0, + }), + ); + }; + + toggleSidebar = (): void => { + this.isSidebarOpen = !this.isSidebarOpen; + }; + + closeSidebar = (): void => { + this.isSidebarOpen = false; + }; + openCard = (index: number): void => { let card = this.args.model.cards?.[index]; if (card) { @@ -1225,39 +1254,59 @@ class IssueTrackerIsolated extends Component { @label='Hide empty columns' /> + -
- - <:card as |placement|> - {{#let (get @fields.cards placement.index) as |CardField|}} - {{#if CardField}} -
- -
- {{/if}} - {{/let}} - - <:ghost as |dragIdx|> - {{#let (get @fields.cards dragIdx) as |CardField|}} - {{#if CardField}} -
- -
- {{/if}} - {{/let}} - -
+
+
+ + <:card as |placement|> + {{#let (get @fields.cards placement.index) as |CardField|}} + {{#if CardField}} +
+ +
+ {{/if}} + {{/let}} + + <:ghost as |dragIdx|> + {{#let (get @fields.cards dragIdx) as |CardField|}} + {{#if CardField}} +
+ +
+ {{/if}} + {{/let}} + +
+
+ + {{#if this.isSidebarOpen}} + + {{/if}}
- - }; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + background-color: var(--board-bg); + color: var(--board-fg); + } + .kanban-board-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + border-bottom: 1px solid var(--board-border); + background: var(--board-card-bg); + color: var(--board-card-fg); + flex-shrink: 0; + } + .toolbar-left { + display: flex; + align-items: center; + gap: 0.5rem; + } + .toolbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + } + .kanban-heading { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .kanban-title { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: var(--boxel-font-size); + font-weight: 600; + margin: 0; + letter-spacing: -0.01em; + } + .kanban-card-count { + font-size: 0.75rem; + color: var(--board-muted-fg); + padding: 0.125rem 0.5rem; + background: var(--board-muted-bg); + border-radius: 4px; + } + .kanban-header-label { + font-size: 0.75rem; + color: var(--board-muted-fg); + white-space: nowrap; + } + .kanban-column-visibility-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + } + .kanban-configure-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.3125rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + font-family: inherit; + border: 1px solid var(--board-border); + border-radius: var(--radius, var(--boxel-border-radius-sm)); + background: transparent; + color: var(--board-card-fg); + cursor: pointer; + transition: background-color 100ms ease; + } + .kanban-configure-btn:hover { + background: var(--board-muted-bg); + } + .kanban-configure-btn[aria-pressed='true'] { + background: var(--board-muted-bg); + border-color: color-mix( + in oklch, + var(--primary, var(--boxel-highlight)) 60%, + transparent + ); + color: var(--primary, var(--boxel-highlight)); + } + .kanban-body { + flex: 1; + min-height: 0; + display: flex; + overflow: hidden; + } + .kanban-area { + flex: 1; + min-width: 0; + overflow: hidden; + } + .kanban-card-wrap { + width: 100%; + height: 100%; + overflow: hidden; + border-radius: inherit; + } + .kanban-empty-state { + height: 100%; + display: grid; + place-items: center; + padding: 2rem; + } + .kanban-empty-copy { + max-width: 24rem; + text-align: center; + display: grid; + gap: 0.5rem; + padding: 1.5rem; + border: 1px solid var(--board-border); + border-radius: 0.75rem; + background: var(--board-card-bg); + color: var(--board-card-fg); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); + } + .kanban-empty-copy h2, + .kanban-empty-copy p { + margin: 0; + } + .kanban-empty-copy h2 { + font-size: 1rem; + font-weight: 600; + } + .kanban-empty-copy p { + font-size: 0.875rem; + line-height: 1.5; + color: var(--board-muted-fg); + } + .kanban-empty-state :deep(svg) { + width: 1.5rem; + height: 1.5rem; + margin: 0 auto 0.25rem; + color: var(--board-muted-fg); + } + + +} + +export class KanbanBoard extends CardDef { + static displayName = 'Kanban Board'; + static prefersWideFormat = true; + + @field boardKey = contains(StringField); + @field boardTitle = contains(StringField); + @field cards = linksToMany(CardDef); + @field hideEmptyColumns = contains(BooleanField); + @field columns = containsMany(KanbanColumnField); + @field placements = containsMany(KanbanBoardPlacement); + + @field cardTitle = contains(StringField, { + computeVia: function (this: KanbanBoard) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.boardTitle ?? 'Kanban Board'); + }, + }); + + static isolated: BaseDefComponent = KanbanBoardIsolated; }