Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,34 @@
"boardTitle": null,
"placements": [
{
"itemId": "http://localhost:4201/experiments/Issue/hbr-1",
"itemIndex": 0,
"columnKey": "backlog",
"sortOrder": 0
},
{
"itemId": "http://localhost:4201/experiments/Issue/hbr-2",
"itemIndex": 1,
"columnKey": "in_progress",
"sortOrder": 1
"sortOrder": 0
},
{
"itemId": "http://localhost:4201/experiments/Issue/hbr-3",
"itemIndex": 2,
"columnKey": "blocked",
"sortOrder": 1
"sortOrder": 0
},
{
"itemId": "http://localhost:4201/experiments/Issue/hbr-4",
"columnKey": "blocked",
"sortOrder": 2
"itemIndex": 3,
"columnKey": "in_progress",
"sortOrder": 1
},
{
"itemId": "http://localhost:4201/experiments/Issue/hbr-5",
"itemIndex": 4,
"columnKey": "review",
"sortOrder": 4
"sortOrder": 0
},
{
"itemId": "http://localhost:4201/experiments/Issue/hbs-6",
"itemIndex": 5,
"columnKey": "done",
"sortOrder": 5
"sortOrder": 0
}
],
"hideEmptyColumns": false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,17 @@
"boardTitle": "Generic Kanban Board",
"placements": [
{
"itemId": "http://localhost:4201/experiments/Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7",
"itemIndex": 0,
"columnKey": "author",
"sortOrder": 1
},
{
"itemId": "http://localhost:4201/experiments/Author/3a655a91-98b5-4f33-a071-b62d39218b33",
"columnKey": "author",
"sortOrder": 2
"itemIndex": 1,
"columnKey": "blog-post",
"sortOrder": 1
},
{
"itemId": "http://localhost:4201/experiments/Representative/880c1d41-2563-43da-999d-ef577fa3eac9",
"itemIndex": 2,
"columnKey": "representative",
"sortOrder": 1
}
Expand Down Expand Up @@ -89,4 +89,4 @@
}
}
}
}
}
65 changes: 45 additions & 20 deletions packages/software-factory/realm/issue-tracker.gts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,14 +1108,27 @@ class IssueTrackerIsolated extends Component<typeof IssueTracker> {
},
);
if (cardId) {
let existing = this.args.model.placements ?? [];
let nextOrder = existing.length
? Math.max(...existing.map((p) => p.sortOrder ?? 0)) + 1
: 0;
let cards = this.args.model?.cards ?? [];
let newIndex = cards.length;
// Use the fully-resolved computed placements so that cards without stored
// placements have their sortOrders materialized before we append the new one.
// Reading only this.args.model.placements would leave those cards unplaced,
// causing them to be re-ordered after the new card.
let resolved = this.placements;
let nextOrder =
resolved
.filter((p) => this.columns[p.column]?.key === columnKey)
.reduce((max, p) => Math.max(max, p.sortOrder), -1) + 1;
this.args.model.placements = [
...existing,
...resolved.map((p) =>
Object.assign(new KanbanBoardPlacement(), {
itemIndex: p.index,
columnKey: this.columns[p.column]?.key ?? '',
sortOrder: p.sortOrder,
}),
),
Object.assign(new KanbanBoardPlacement(), {
itemId: cardId,
itemIndex: newIndex,
columnKey,
sortOrder: nextOrder,
}),
Expand All @@ -1132,7 +1145,7 @@ class IssueTrackerIsolated extends Component<typeof IssueTracker> {
card.status = columnKey;
}
return Object.assign(new KanbanBoardPlacement(), {
itemId: card?.id ?? '',
itemIndex: p.index,
columnKey,
sortOrder: p.sortOrder,
});
Expand All @@ -1143,11 +1156,12 @@ class IssueTrackerIsolated extends Component<typeof IssueTracker> {
let stored = this.args.model?.placements;
let cards = this.args.model?.cards ?? [];
if (stored?.length) {
let placedCardIds = new Set(stored.map((p) => p.itemId));
let placedIndices = new Set(stored.map((p) => p.itemIndex));
let resolved = stored
.map((p) => {
let cardIdx = cards.findIndex((c) => (c as any).id === p.itemId);
if (cardIdx === -1) return null;
let cardIdx = p.itemIndex;
if (cardIdx == null || cardIdx < 0 || cardIdx >= cards.length)
return null;
let card = cards[cardIdx] as any;
let effectiveKey = card?.status ?? p.columnKey;
let colIdx = this.columns.findIndex((c) => c.key === effectiveKey);
Expand All @@ -1161,30 +1175,41 @@ class IssueTrackerIsolated extends Component<typeof IssueTracker> {
};
})
.filter((p): p is KanbanPlacement => p !== null);
let maxOrder = resolved.length
? Math.max(...resolved.map((p) => p.sortOrder))
: -1;
let colMaxOrder = new Map<number, number>();
for (let p of resolved) {
let cur = colMaxOrder.get(p.column) ?? -1;
if (p.sortOrder > cur) colMaxOrder.set(p.column, p.sortOrder);
}
let colUnplacedCounts = new Map<number, number>();
let unplaced = cards
.map((card, idx) => ({ card, idx }))
.filter(({ card }) => !placedCardIds.has((card as any).id))
.map(({ card, idx }, i) => {
.filter(({ idx }) => !placedIndices.has(idx))
.map(({ card, idx }) => {
let status = (card as any).status ?? 'backlog';
let colIdx = this.columns.findIndex((c) => c.key === status);
let effectiveColIdx = colIdx === -1 ? 0 : colIdx;
let base = colMaxOrder.get(effectiveColIdx) ?? -1;
let offset = colUnplacedCounts.get(effectiveColIdx) ?? 0;
colUnplacedCounts.set(effectiveColIdx, offset + 1);
return {
column: colIdx === -1 ? 0 : colIdx,
column: effectiveColIdx,
index: idx,
sortOrder: maxOrder + 1 + i,
sortOrder: base + 1 + offset,
};
});
return [...resolved, ...unplaced];
}
let colCounts = new Map<number, number>();
return cards.map((card, idx) => {
let status = (card as any).status ?? 'backlog';
let colIdx = this.columns.findIndex((c) => c.key === status);
let effectiveColIdx = colIdx === -1 ? 0 : colIdx;
let order = colCounts.get(effectiveColIdx) ?? 0;
colCounts.set(effectiveColIdx, order + 1);
return {
column: colIdx === -1 ? 0 : colIdx,
column: effectiveColIdx,
index: idx,
sortOrder: idx,
sortOrder: order,
};
});
}
Expand Down Expand Up @@ -1357,7 +1382,7 @@ class IssueTrackerIsolated extends Component<typeof IssueTracker> {
// ── IssueTracker ──────────────────────────────────────────────────────

export class IssueTracker extends KanbanBoard {
static displayName = 'Issue Tracker Board';
static displayName = 'Issue Tracker';

@field project = linksTo(() => Project);

Expand Down
121 changes: 120 additions & 1 deletion packages/software-factory/realm/issue-tracker.test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,67 @@ export function runTests() {
});
});

// ── initial sortOrder ─────────────────────────────────────────────────────
module('initial sortOrder', function (hooks) {
hooks.beforeEach(async function () {
await setupAcceptanceTestRealm({
realmURL: testRealmURL,
mockMatrixUtils,
contents: {
...SYSTEM_CARD_FIXTURE_CONTENTS,
...makeProject(),
// Two backlog cards (indices 0, 1) and two in_progress cards (indices 2, 3)
...makeIssue('IT-1', 'backlog', 'Issues/issue-1.json'),
...makeIssue('IT-2', 'backlog', 'Issues/issue-2.json'),
...makeIssue('IT-3', 'in_progress', 'Issues/issue-3.json'),
...makeIssue('IT-4', 'in_progress', 'Issues/issue-4.json'),
...makeBoard(),
},
});
});

test('each column assigns sortOrder starting at 0, preserving declaration order', async function (assert) {
await visitOperatorMode({
stacks: [[{ id: boardId, format: 'isolated' }]],
});
await waitFor('[data-test-issue-id]');

let backlogCards = document.querySelectorAll(
`[data-kanban-column="${COL.backlog}"] [data-test-issue-tracker-card]`,
);
assert.strictEqual(backlogCards.length, 2, 'backlog has 2 cards');
assert.strictEqual(
backlogCards[0]?.getAttribute('data-test-issue-tracker-card'),
'0',
'IT-1 (card index 0) is first in backlog',
);
assert.strictEqual(
backlogCards[1]?.getAttribute('data-test-issue-tracker-card'),
'1',
'IT-2 (card index 1) is second in backlog',
);

let inProgressCards = document.querySelectorAll(
`[data-kanban-column="${COL.in_progress}"] [data-test-issue-tracker-card]`,
);
assert.strictEqual(
inProgressCards.length,
2,
'in_progress has 2 cards',
);
assert.strictEqual(
inProgressCards[0]?.getAttribute('data-test-issue-tracker-card'),
'2',
'IT-3 (card index 2) is first in in_progress — sortOrder starts at 0 for each column',
);
assert.strictEqual(
inProgressCards[1]?.getAttribute('data-test-issue-tracker-card'),
'3',
'IT-4 (card index 3) is second in in_progress',
);
});
});

// ── unknown status ────────────────────────────────────────────────────────
module('unknown status', function (hooks) {
hooks.beforeEach(async function () {
Expand Down Expand Up @@ -327,7 +388,65 @@ export function runTests() {
});

// ── add card button ───────────────────────────────────────────────────────
module('"add card" button', function (hooks) {
module('"add card" button | new card after existing', function (hooks) {
hooks.beforeEach(async function () {
await setupAcceptanceTestRealm({
realmURL: testRealmURL,
mockMatrixUtils,
contents: {
...SYSTEM_CARD_FIXTURE_CONTENTS,
...makeProject(),
...makeIssue('IT-1', 'in_progress', 'Issues/issue-1.json'),
...makeBoard(),
},
});
});

test('new card is placed after existing cards in the same column', async function (assert) {
await visitOperatorMode({
stacks: [[{ id: boardId, format: 'isolated' }]],
});
await waitFor('[data-test-issue-id]');

assert
.dom(
`[data-kanban-column="${COL.in_progress}"] [data-test-issue-tracker-card]`,
)
.exists({ count: 1 }, 'IT-1 is already in the in_progress column');

await click(
`[data-kanban-column="${COL.in_progress}"] [data-test-column-add-button]`,
);
await waitFor('[data-test-stack-card-index="1"]');
await fillIn('[data-test-summary-field] input', 'New Card');
await click('[data-test-close-button]');
await settled();

assert
.dom(
`[data-kanban-column="${COL.in_progress}"] [data-test-issue-tracker-card]`,
)
.exists({ count: 2 }, 'both cards are in the in_progress column');

let column = document.querySelector(
`[data-kanban-column="${COL.in_progress}"]`,
)!;
let cards = column.querySelectorAll('[data-test-issue-tracker-card]');

assert.strictEqual(
cards[0]?.getAttribute('data-test-issue-tracker-card'),
'0',
'IT-1 (index 0) appears first — existing card keeps its position',
);
assert.strictEqual(
cards[1]?.getAttribute('data-test-issue-tracker-card'),
'1',
'new card (index 1) appears second — sorted after existing cards',
);
});
});

module('"add card" button | empty board', function (hooks) {
hooks.beforeEach(async function () {
await setupAcceptanceTestRealm({
realmURL: testRealmURL,
Expand Down
2 changes: 1 addition & 1 deletion packages/software-factory/realm/kanban-board-placement.gts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import NumberField from 'https://cardstack.com/base/number';
export class KanbanBoardPlacement extends FieldDef {
static displayName = 'Kanban Board Placement';

@field itemId = contains(StringField);
@field itemIndex = contains(NumberField);
@field columnKey = contains(StringField);
@field sortOrder = contains(NumberField);
}
13 changes: 9 additions & 4 deletions packages/software-factory/realm/kanban-board.gts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ export class KanbanBoard extends CardDef {
return raw
.map((p) => {
let colIdx = this.columns.findIndex((c) => c.key === p.columnKey);
let cardIdx = cards.findIndex((c) => (c as any).id === p.itemId);
if (colIdx === -1 || cardIdx === -1) return null;
let cardIdx = p.itemIndex;
if (
colIdx === -1 ||
cardIdx == null ||
cardIdx < 0 ||
cardIdx >= cards.length
)
return null;
return {
column: colIdx,
index: cardIdx,
Expand All @@ -80,10 +86,9 @@ export class KanbanBoard extends CardDef {
}

handleChange = (newPlacements: KanbanPlacement[]) => {
let cards = this.args.model.cards ?? [];
this.args.model.placements = newPlacements.map((p) =>
Object.assign(new KanbanBoardPlacement(), {
itemId: (cards[p.index] as any)?.id ?? '',
itemIndex: p.index,
columnKey: this.columns[p.column]?.key ?? '',
sortOrder: p.sortOrder,
}),
Expand Down