From 08181d6187762980887e8094a8e972bbab7211bd Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Mon, 18 May 2026 18:44:13 -0400 Subject: [PATCH 1/4] add column config sidebar --- .vscode/settings.json | 4 + packages/boxel-ui/addon/src/components.ts | 2 + .../kanban/column-config-sidebar.gts | 315 ++++++++++ .../addon/src/components/kanban/index.gts | 1 + .../addon/src/components/kanban/usage.gts | 150 +++++ .../kanban-column-config-sidebar-test.gts | 249 ++++++++ .../software-factory/realm/issue-tracker.gts | 182 ++++-- .../software-factory/realm/kanban-board.gts | 545 ++++++++++-------- 8 files changed, 1159 insertions(+), 289 deletions(-) create mode 100644 packages/boxel-ui/addon/src/components/kanban/column-config-sidebar.gts create mode 100644 packages/boxel-ui/test-app/tests/integration/components/kanban-column-config-sidebar-test.gts diff --git a/.vscode/settings.json b/.vscode/settings.json index f246aa88ed7..9c4a3241ce4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,10 @@ { "cSpell.words": ["boxel"], "editor.formatOnSave": true, + "files.associations": { + "*.gts": "glimmer-ts", + "*.gjs": "glimmer-js" + }, "[glimmer-js]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true 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..7fa2ed527af --- /dev/null +++ b/packages/boxel-ui/addon/src/components/kanban/column-config-sidebar.gts @@ -0,0 +1,315 @@ +// 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 7eafd8d8548..1913e84973e 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; } @@ -157,6 +196,21 @@ export default class KanbanUsage extends Component { this.hideEmpty = false; } + @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( @@ -333,6 +387,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 d30f678273c..932f32c518e 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,21 +1052,23 @@ export class Project extends CardDef { // ── IssueTrackerIsolated ────────────────────────────────────────────── class IssueTrackerIsolated extends Component { + @tracked isSidebarOpen = false; + get columns(): KanbanColumnConfig[] { - let persistedColumns = this.args.model?.columns ?? []; - return buildColumnsFromStatusOptions( - getProjectIssueStatusOptions(this.args.model?.project), - ).map((col) => { - let persisted = persistedColumns.find((stored) => stored.key === col.key); - return { - key: col.key ?? null, - label: col.label ?? null, - color: col.color ?? null, - collapsed: persisted?.collapsed ?? col.collapsed ?? null, - sortOrder: col.sortOrder ?? null, - wipLimit: col.wipLimit ?? null, - }; - }); + 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, + collapsed: col.collapsed ?? null, + sortOrder: col.sortOrder ?? null, + wipLimit: col.wipLimit ?? null, + })); } get statusColor(): string | undefined { @@ -1122,6 +1126,28 @@ class IssueTrackerIsolated extends Component { this.args.model.hideEmptyColumns = false; }; + 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, + sortOrder: cfg.sortOrder, + wipLimit: cfg.wipLimit, + }), + ); + }; + + toggleSidebar = (): void => { + this.isSidebarOpen = !this.isSidebarOpen; + }; + + closeSidebar = (): void => { + this.isSidebarOpen = false; + }; + + openCard = (index: number): void => { let card = this.args.model.cards?.[index]; if (card) { @@ -1269,41 +1295,61 @@ 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; } From 38b6ba39d7749a19b80a72f7646b1686c283706e Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Tue, 19 May 2026 13:39:10 -0400 Subject: [PATCH 2/4] use boxel components in sidebar config --- .../src/components/field-container/index.gts | 29 +++ .../src/components/field-container/usage.gts | 89 +++++++-- .../addon/src/components/input/index.gts | 56 +++++- .../addon/src/components/input/usage.gts | 9 +- .../kanban/column-config-sidebar.gts | 187 ++++++------------ .../addon/src/components/kanban/usage.gts | 1 + .../software-factory/realm/issue-tracker.gts | 85 ++++---- 7 files changed, 257 insertions(+), 199 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/field-container/index.gts b/packages/boxel-ui/addon/src/components/field-container/index.gts index f939bef0ff1..c614e40205f 100644 --- a/packages/boxel-ui/addon/src/components/field-container/index.gts +++ b/packages/boxel-ui/addon/src/components/field-container/index.gts @@ -14,6 +14,7 @@ export interface Signature { icon?: Icon; iconHeight?: string; iconWidth?: string; + inline?: boolean; label: string; labelFontSize?: BoxelLabelFontSize; tag?: keyof HTMLElementTagNameMap; @@ -33,6 +34,7 @@ const FieldContainer: TemplateOnlyComponent = , ); - 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 + .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'); + 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'); }); @@ -136,7 +169,11 @@ module( 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'); + assert.strictEqual( + result![1]!.label, + 'In Progress', + 'other cols unchanged', + ); }); test('WIP input: updates limit; clamps negative values to 0', async function (assert) { @@ -238,11 +275,56 @@ module( assert.dom('[data-test-col-config-label="0"]').hasValue('Backlog'); - state.cols = [{ ...BASE_COLUMNS[0]!, label: 'Queue' }, ...BASE_COLUMNS.slice(1)]; + 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'); }); + + test('label input accumulates all typed characters without losing focus between keystrokes', async function (assert) { + let result: KanbanColumnConfig[] | undefined; + const onChange = (cols: KanbanColumnConfig[]) => { + result = cols; + }; + + await render( + , + ); + + let input = document.querySelector( + '[data-test-col-config-label="0"]', + ) as HTMLInputElement; + + await focus(input); + assert.strictEqual( + document.activeElement, + input, + 'input is focused before typing', + ); + + // Clear the pre-filled value so typeIn appends to an empty field + input.value = ''; + await typeIn(input, 'New Label'); + + assert.strictEqual( + document.activeElement, + input, + 'input retains focus after typing all characters', + ); + assert.strictEqual( + result?.[0]?.label, + 'New Label', + 'full typed string is accumulated in the onChange result', + ); + }); }, ); diff --git a/packages/boxel-ui/test-app/tests/integration/components/kanban-plane-test.gts b/packages/boxel-ui/test-app/tests/integration/components/kanban-plane-test.gts index df4b354fbe2..297ff8cd6ca 100644 --- a/packages/boxel-ui/test-app/tests/integration/components/kanban-plane-test.gts +++ b/packages/boxel-ui/test-app/tests/integration/components/kanban-plane-test.gts @@ -174,79 +174,4 @@ module('Integration | Component | kanban-plane', function (hooks) { assert.dom('[data-kanban-column]').exists({ count: 2 }); assert.dom('[data-test-hidden-columns]').doesNotExist(); }); - - test('restoring a collapsed empty column also clears the hideEmpty filter', async function (assert) { - class State { - @tracked hideEmpty = true; - @tracked columns: KanbanColumnConfig[] = [ - { - key: 'todo', - label: 'Todo', - color: null, - wipLimit: null, - collapsed: true, - sortOrder: 0, - }, - { - key: 'doing', - label: 'Doing', - color: null, - wipLimit: null, - collapsed: null, - sortOrder: 1, - }, - ]; - @tracked placements: KanbanPlacement[] = [ - { index: 0, column: 1, sortOrder: 1 }, - ]; - - toggleCollapsed = ( - columnKey: string | null, - collapsed: boolean, - ): void => { - this.columns = this.columns.map((col) => - col.key === columnKey ? { ...col, collapsed } : col, - ); - }; - - showEmptyColumns = (): void => { - this.hideEmpty = false; - }; - } - - let state = new State(); - - await render( - , - ); - - // Todo is collapsed and has no cards — hidden by both collapsed and hideEmpty - assert.dom('[data-kanban-column]').exists({ count: 1 }); - assert.dom('[data-test-hidden-column-count]').hasText('1'); - assert.dom('[aria-label="Show Todo"]').exists(); - - await click('[aria-label="Show Todo"]'); - - // Both collapsed=false and hideEmpty=false must have been applied — - // the Todo column should now be visible despite having no cards - assert.dom('[data-kanban-column]').exists({ count: 2 }); - assert.dom('[data-kanban-column="0"]').exists(); - assert.dom('[data-test-empty-column="0"]').hasText('No cards'); - assert.dom('[data-test-hidden-columns]').doesNotExist(); - }); }); diff --git a/packages/software-factory/realm/issue-tracker.test.gts b/packages/software-factory/realm/issue-tracker.test.gts index ef21473dc21..c177e0b56b1 100644 --- a/packages/software-factory/realm/issue-tracker.test.gts +++ b/packages/software-factory/realm/issue-tracker.test.gts @@ -3,6 +3,7 @@ import { click, fillIn, settled, + triggerEvent, triggerKeyEvent, waitFor, } from '@ember/test-helpers'; @@ -15,6 +16,7 @@ import { setupAcceptanceTestRealm, SYSTEM_CARD_FIXTURE_CONTENTS, visitOperatorMode, + type TestContextWithSave, } from '@cardstack/host/tests/helpers'; import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; @@ -157,7 +159,7 @@ export function runTests() { .doesNotExist('review column hidden'); }); - test('collapsing a column updates the persisted collapsed state', async function (assert) { + test('collapsing a column updates the persisted collapsed state', async function (assert) { let savedBoardDocPromise = new Promise((resolve) => { this.onSave((url, doc) => { if (url.href === boardId) { @@ -196,6 +198,333 @@ export function runTests() { 'backlog collapsed state is persisted on the board model', ); }); + + test('sidebar visibility toggle persists collapsed state and can reveal the column again', async function (assert) { + let savedBoardDocs: any[] = []; + this.onSave((url, doc) => { + if (url.href === boardId) { + savedBoardDocs.push(doc); + } + }); + + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + await click('[data-test-configure-columns-btn]'); + await click('[data-test-col-config-visible="0"]'); + await waitFor('[aria-label="Show Backlog"]'); + + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .doesNotExist( + 'backlog column is hidden after collapsing from the sidebar', + ); + + let collapsedSave = savedBoardDocs[savedBoardDocs.length - 1]; + let collapsedBacklog = collapsedSave.data.attributes.columns.find( + (column: { key: string }) => column.key === 'backlog', + ); + assert.true( + collapsedBacklog?.collapsed, + 'sidebar collapse persists backlog as collapsed', + ); + + await click('[data-test-col-config-visible="0"]'); + await waitFor(`[data-kanban-column="${COL.backlog}"]`); + + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .exists( + 'backlog column is shown again after revealing from the sidebar', + ); + + let revealedSave = savedBoardDocs[savedBoardDocs.length - 1]; + let revealedBacklog = revealedSave.data.attributes.columns.find( + (column: { key: string }) => column.key === 'backlog', + ); + assert.false( + revealedBacklog?.collapsed, + 'sidebar reveal persists backlog as expanded', + ); + }); + + test('turning hide empty off reveals empty columns even if they were hidden from the sidebar', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + assert + .dom(`[data-kanban-column="${COL.blocked}"]`) + .exists('blocked starts visible while hide-empty is off'); + + await click('[data-test-configure-columns-btn]'); + await click('[data-test-col-config-visible="2"]'); + await waitFor('[aria-label="Show Blocked"]'); + + assert + .dom(`[data-kanban-column="${COL.blocked}"]`) + .doesNotExist('blocked is hidden after sidebar toggle'); + + await click('.column-visibility-toggle input[role="switch"]'); + await click('.column-visibility-toggle input[role="switch"]'); + + assert + .dom(`[data-kanban-column="${COL.blocked}"]`) + .exists( + 'blocked is visible again after hide-empty is turned back off', + ); + }); + + test('turning hide-empty off saves hideEmptyColumns as false', async function (assert) { + let boardSaves: any[] = []; + this.onSave((url, doc) => { + if (url.href === boardId) { + boardSaves.push(doc); + } + }); + + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + // Turn on hide-empty — blocked and review (empty) disappear + await click('.column-visibility-toggle input[role="switch"]'); + await waitFor('[data-test-hidden-columns]'); + + assert + .dom('.column-visibility-toggle [data-test-switch-checked]') + .hasAttribute('data-test-switch-checked', 'on'); + + // Turn hide-empty back off + await click('.column-visibility-toggle input[role="switch"]'); + await settled(); + + assert + .dom('.column-visibility-toggle [data-test-switch-checked]') + .hasAttribute('data-test-switch-checked', 'off', 'switch is off'); + assert + .dom('[data-kanban-column]') + .exists({ count: 5 }, 'all columns visible again'); + assert + .dom('[data-test-hidden-columns]') + .doesNotExist('hidden tray gone'); + + let lastSave = boardSaves[boardSaves.length - 1]; + assert.false( + lastSave?.data.attributes.hideEmptyColumns, + 'hideEmptyColumns persisted as false after turning off the filter', + ); + }); + + test('collapsing one column from the header leaves all other columns visible', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + assert.dom('[data-kanban-column]').exists({ count: 5 }); + + await click( + `[data-kanban-column="${COL.backlog}"] [data-test-column-collapse-button]`, + ); + await waitFor('[aria-label="Show Backlog"]'); + + assert + .dom('[data-kanban-column]') + .exists({ count: 4 }, 'only the collapsed column is removed'); + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .doesNotExist('backlog is hidden'); + assert + .dom(`[data-kanban-column="${COL.in_progress}"]`) + .exists('in_progress still visible'); + assert + .dom(`[data-kanban-column="${COL.done}"]`) + .exists('done still visible'); + }); + + test('can hide a column from the header and reveal it from the sidebar, and vice versa', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + // Hide backlog from the column header + await click( + `[data-kanban-column="${COL.backlog}"] [data-test-column-collapse-button]`, + ); + await waitFor('[aria-label="Show Backlog"]'); + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .doesNotExist('backlog hidden via column header'); + + // Reveal backlog from the sidebar toggle + await click('[data-test-configure-columns-btn]'); + await click('[data-test-col-config-visible="0"]'); + await waitFor(`[data-kanban-column="${COL.backlog}"]`); + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .exists('backlog revealed via sidebar toggle'); + + // Hide in-progress from the sidebar toggle + await click('[data-test-col-config-visible="1"]'); + await waitFor('[aria-label="Show In Progress"]'); + assert + .dom(`[data-kanban-column="${COL.in_progress}"]`) + .doesNotExist('in-progress hidden via sidebar toggle'); + + // Reveal in-progress from the hidden-columns tray in the header + await click('[aria-label="Show In Progress"]'); + await waitFor(`[data-kanban-column="${COL.in_progress}"]`); + assert + .dom(`[data-kanban-column="${COL.in_progress}"]`) + .exists('in-progress revealed via hidden-columns tray'); + assert.dom('[data-test-hidden-columns]').doesNotExist('tray gone'); + }); + + test('after hiding columns from both header and sidebar, hide-empty toggle is on; turning it off reveals only the empty-hidden columns', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-test-issue-id]'); + + // Turn on hide-empty — blocked (col 2) and review (col 3) are empty + await click('.column-visibility-toggle input[role="switch"]'); + await waitFor('[data-test-hidden-columns]'); + + assert + .dom('.column-visibility-toggle [data-test-switch-checked]') + .hasAttribute('data-test-switch-checked', 'on', 'switch is on'); + assert + .dom('[data-kanban-column]') + .exists({ count: 3 }, 'two empty columns hidden'); + + // Also collapse backlog from the column header + await click( + `[data-kanban-column="${COL.backlog}"] [data-test-column-collapse-button]`, + ); + await waitFor('[data-test-hidden-column-count]'); + assert + .dom('[data-test-hidden-column-count]') + .hasText('3', '3 columns hidden total'); + + // Also collapse in-progress from the sidebar + await click('[data-test-configure-columns-btn]'); + await click('[data-test-col-config-visible="1"]'); + await waitFor('[aria-label="Show In Progress"]'); + assert + .dom('[data-test-hidden-column-count]') + .hasText('4', '4 columns hidden total'); + + // Switch must still appear ON + assert + .dom('.column-visibility-toggle [data-test-switch-checked]') + .hasAttribute('data-test-switch-checked', 'on', 'switch still on'); + + // Turn off hide-empty — reveals only the empty-hidden columns + await click('.column-visibility-toggle input[role="switch"]'); + await settled(); + + assert + .dom('.column-visibility-toggle [data-test-switch-checked]') + .hasAttribute('data-test-switch-checked', 'off', 'switch now off'); + assert + .dom(`[data-kanban-column="${COL.blocked}"]`) + .exists('blocked is visible again (was empty-hidden)'); + assert + .dom(`[data-kanban-column="${COL.review}"]`) + .exists('review is visible again (was empty-hidden)'); + assert + .dom(`[data-kanban-column="${COL.backlog}"]`) + .doesNotExist( + 'backlog stays hidden (was manually collapsed, has cards)', + ); + assert + .dom(`[data-kanban-column="${COL.in_progress}"]`) + .doesNotExist( + 'in-progress stays hidden (was manually collapsed, has cards)', + ); + }); + }); + + // ── column config sync ──────────────────────────────────────────────────── + module('column config sync', function (hooks) { + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeProject([ + { value: 'todo', label: 'To Do' }, + { value: 'doing', label: 'Doing' }, + { value: 'done', label: 'Done' }, + ]), + ...makeBoard(), + }, + }); + }); + + test('renaming a column in the sidebar updates the matching project issueStatusOption label, and recoloring updates its color', async function (assert) { + let resolveLabelSave: (doc: any) => void; + let labelSavePromise = new Promise((r) => { + resolveLabelSave = r; + }); + let resolveColorSave: (doc: any) => void; + let colorSavePromise = new Promise((r) => { + resolveColorSave = r; + }); + let labelSaveSeen = false; + this.onSave((url, doc) => { + if (url.href !== projectId) return; + if (!labelSaveSeen) { + labelSaveSeen = true; + resolveLabelSave!(doc); + } else { + resolveColorSave!(doc); + } + }); + + await visitOperatorMode({ + stacks: [[{ id: boardId, format: 'isolated' }]], + }); + await waitFor('[data-kanban-column]'); + + // Open sidebar and rename "To Do" → "Planning" + await click('[data-test-configure-columns-btn]'); + await fillIn('[data-test-col-config-label="0"]', 'Planning'); + + let labelSaveDoc = await labelSavePromise; + let updatedOptions = labelSaveDoc.data.attributes.issueStatusOptions; + assert.strictEqual( + updatedOptions[0].label, + 'Planning', + 'project issueStatusOption label updated to match sidebar rename', + ); + assert.strictEqual( + updatedOptions[1].label, + 'Doing', + 'other project options unchanged', + ); + + // Recolor the first column and verify the project option color syncs + let colorInput = document.querySelector( + '[data-test-col-config-color="0"]', + ) as HTMLInputElement; + colorInput.value = '#ff0000'; + await triggerEvent(colorInput, 'change'); + + let colorSaveDoc = await colorSavePromise; + assert.strictEqual( + colorSaveDoc.data.attributes.issueStatusOptions[0].color, + '#ff0000', + 'project issueStatusOption color updated to match sidebar recolor', + ); + }); }); // ── unknown status ────────────────────────────────────────────────────────