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
1 change: 1 addition & 0 deletions services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export function Kanban() {
columns: board.columns.slice().sort((a, b) => a.position - b.position).map(c => ({ key: c.id, name: c.name, color: c.color, limit: c.wipLimit })),
groupBy: (view.groupBy as BoardViewConfig['groupBy']) ?? 'status',
viewType: view.viewType ?? 'board',
swimlaneBy: view.swimlaneBy,
colorColumns: view.colorColumns,
doneColumn: view.doneColumn,
}
Expand Down
2 changes: 2 additions & 0 deletions services/jtype-web/frontend/src/pages/WebBoardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type BoardConfigJSON = {
doneColumn?: string
colorColumns?: boolean
viewType?: 'board' | 'table'
swimlaneBy?: 'status' | 'priority' | 'assignee'
}

function rand() {
Expand Down Expand Up @@ -180,6 +181,7 @@ export function WebBoardView({
doneColumn: config.doneColumn,
colorColumns: config.colorColumns,
viewType: config.viewType,
swimlaneBy: config.swimlaneBy as BoardViewConfig['swimlaneBy'],
groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status',
}
: { title: boardDir, columns: [] },
Expand Down
42 changes: 42 additions & 0 deletions shared/components/board/BoardSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
MagnifyingGlassIcon,
FunnelIcon,
BarsArrowDownIcon,
Bars3Icon,
RectangleGroupIcon,
XMarkIcon,
TableCellsIcon,
Expand All @@ -49,6 +50,7 @@ import {
} from "../../lib/board";
import { BoardPeek } from "./BoardPeek";
import { BoardTable } from "./BoardTable";
import { BoardSwimlanes } from "./BoardSwimlanes";
import type { BoardSurfaceProps } from "./types";

type DropTarget = { col: string; index: number };
Expand Down Expand Up @@ -118,6 +120,15 @@ export function BoardSurface({
() => effectiveColumns(config, cards, groupKey, t`Unassigned`),
[config, cards, groupKey],
);
// Swimlanes: a second grouping dimension rendered as rows (must differ from
// the column dimension). Only meaningful in the board view.
const swimlaneKey: BoardGroupKey | null =
config.swimlaneBy && config.swimlaneBy !== groupKey ? config.swimlaneBy : null;
const swimlaneActive = viewType === "board" && !!swimlaneKey;
const lanes = useMemo(
() => (swimlaneKey ? effectiveColumns(config, cards, swimlaneKey, t`Unassigned`) : []),
[config, cards, swimlaneKey],
);
const vis = useMemo(() => visibleCardsFn(cards, search, filter), [cards, search, filter]);
// Blocker counts resolve against ALL cards (a blocker may be filtered out of view).
const blockers = useMemo(() => blockedCounts(cards, config.doneColumn), [cards, config.doneColumn]);
Expand Down Expand Up @@ -344,6 +355,25 @@ export function BoardSurface({
</select>
</label>
)}
{viewType === "board" && (
<label className="inline-flex items-center gap-1 text-xs text-brand-gray">
<Bars3Icon className="h-3.5 w-3.5" />
<select
className={ctrlCls}
value={swimlaneKey ?? ""}
onChange={(e) => void actions.setConfig({ swimlaneBy: (e.target.value || undefined) as BoardGroupKey | undefined })}
>
<option value="">{t`Swimlane: None`}</option>
{(["status", "priority", "assignee"] as BoardGroupKey[])
.filter((k) => k !== groupKey)
.map((k) => (
<option key={k} value={k}>
{k === "status" ? t`Swimlane: Status` : k === "priority" ? t`Swimlane: Priority` : t`Swimlane: Assignee`}
</option>
))}
</select>
</label>
)}
<label className="inline-flex items-center gap-1 text-xs text-brand-gray">
<BarsArrowDownIcon className="h-3.5 w-3.5" />
<select className={ctrlCls} value={sortBy} onChange={(e) => setSortBy(e.target.value as BoardSortKey)}>
Expand Down Expand Up @@ -439,6 +469,18 @@ export function BoardSurface({
selectedId={selected?.id}
onSelect={(c) => setSelectedId(c.id)}
/>
) : swimlaneActive && swimlaneKey ? (
<BoardSwimlanes
cards={vis}
columns={columns}
lanes={lanes}
groupKey={groupKey}
swimlaneKey={swimlaneKey}
today={today}
doneKey={doneKey}
selectedId={selected?.id}
onSelect={(c) => setSelectedId(c.id)}
/>
) : (
<div className="flex min-h-0 flex-1 items-stretch gap-3 overflow-x-auto p-4">
{columns.map((col, colIndex) => {
Expand Down
125 changes: 125 additions & 0 deletions shared/components/board/BoardSwimlanes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
import {
PRIORITY_STYLE,
groupValueOf,
partitionSwimlanes,
sortCards,
type BoardGroupKey,
type BoardViewCard,
type BoardViewColumn,
} from "../../lib/board";

/**
* Two-dimensional board view: the primary grouping as columns and a second
* dimension as horizontal swimlanes (rows). A read-friendly overview built from
* the same cards as the kanban — clicking a card opens the same peek (where its
* status / swimlane attribute can be changed). Drag stays in the 1-D board view.
*/
export function BoardSwimlanes({
cards,
columns,
lanes,
groupKey,
swimlaneKey,
today,
doneKey,
selectedId,
onSelect,
}: {
cards: BoardViewCard[];
columns: BoardViewColumn[];
lanes: BoardViewColumn[];
groupKey: BoardGroupKey;
swimlaneKey: BoardGroupKey;
today: string;
doneKey: string;
selectedId?: string;
onSelect: (card: BoardViewCard) => void;
}) {
const grid = partitionSwimlanes(cards, groupKey, swimlaneKey);
const gridCols = { gridTemplateColumns: `repeat(${Math.max(columns.length, 1)}, minmax(11rem, 1fr))` };

const chip = (card: BoardViewCard) => {
const overdue = card.due && card.due < today && card.columnKey !== doneKey;
return (
<button
key={card.id}
type="button"
onClick={() => onSelect(card)}
title={card.title}
className={`block w-full rounded-lg bg-white p-2 text-left text-sm shadow-sm ring-1 transition hover:ring-brand/30 ${
selectedId === card.id ? "ring-brand/60" : "ring-black/[0.04]"
}`}
>
<span className="block truncate text-stone-800">
{card.icon && <span className="mr-1">{card.icon}</span>}
{card.title}
</span>
{((card.priority && card.priority !== "none") || card.due || (card.taskTotal ?? 0) > 0) && (
<span className="mt-1 flex flex-wrap items-center gap-1.5">
{card.priority && card.priority !== "none" && (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${PRIORITY_STYLE[card.priority] ?? "bg-stone-100 text-stone-500"}`}>{card.priority}</span>
)}
{(card.taskTotal ?? 0) > 0 && (
<span className={`inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium ${card.taskDone === card.taskTotal ? "bg-emerald-50 text-emerald-600" : "bg-stone-100 text-stone-500"}`}>
<CheckCircleIcon className="h-3 w-3" />
{card.taskDone}/{card.taskTotal}
</span>
)}
{card.due && (
<span className={`inline-flex items-center gap-0.5 text-[11px] ${overdue ? "font-medium text-red-600" : "text-brand-gray"}`}>
<CalendarDaysIcon className="h-3 w-3" />
{card.due}
</span>
)}
</span>
)}
</button>
);
};

return (
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="min-w-max">
{/* Column header row */}
<div className="sticky top-0 z-10 grid gap-3 bg-[#fbfdfb] pb-1.5" style={gridCols}>
{columns.map((col) => (
<div key={col.key} className="px-1 text-[11px] font-medium uppercase tracking-wide text-brand-gray">
{col.name}
</div>
))}
</div>

{lanes.map((lane) => {
const row = grid.get(lane.key);
const laneTotal = row ? [...row.values()].reduce((n, cell) => n + cell.length, 0) : 0;
return (
<div key={lane.key} className="mb-2">
<div className="flex items-center gap-2 py-1.5">
<span className="text-xs font-semibold text-brand-dark">{lane.name}</span>
<span className="rounded-full bg-stone-100 px-1.5 text-[11px] text-stone-400">{laneTotal}</span>
</div>
<div className="grid items-start gap-3" style={gridCols}>
{columns.map((col) => {
const cell = sortCards((row?.get(col.key) ?? []).filter((c) => groupValueOf(c, groupKey) === col.key), "manual");
return (
<div key={col.key} className="min-h-[3rem] space-y-2 rounded-lg bg-[#f6faf7] p-1.5">
{cell.map((card) => chip(card))}
</div>
);
})}
</div>
</div>
);
})}

{lanes.length === 0 && (
<div className="px-3 py-8 text-center text-sm text-stone-400">
<Trans>No cards</Trans>
</div>
)}
</div>
</div>
);
}
1 change: 1 addition & 0 deletions shared/components/board/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { BoardSurface } from "./BoardSurface";
export { BoardPeek } from "./BoardPeek";
export { BoardTable } from "./BoardTable";
export { BoardSwimlanes } from "./BoardSwimlanes";
export { EmojiField, ListboxSelect, TagMultiSelect, fieldCls } from "./controls";
export type { BoardActions, BoardSurfaceProps, BoardOption } from "./types";
2 changes: 1 addition & 1 deletion shared/i18n/locales/en/messages.mjs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}");
/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"ucJg3u\":[\"Swimlane: Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}");
17 changes: 17 additions & 0 deletions shared/i18n/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ msgstr "Mermaid source"
msgid "New card"
msgstr "New card"

#: shared/components/board/BoardSwimlanes.tsx
#: shared/components/board/BoardTable.tsx
msgid "No cards"
msgstr "No cards"
Expand Down Expand Up @@ -419,6 +420,22 @@ msgstr "Split"
msgid "Status"
msgstr "Status"

#: shared/components/board/BoardSurface.tsx
msgid "Swimlane: Assignee"
msgstr "Swimlane: Assignee"

#: shared/components/board/BoardSurface.tsx
msgid "Swimlane: None"
msgstr "Swimlane: None"

#: shared/components/board/BoardSurface.tsx
msgid "Swimlane: Priority"
msgstr "Swimlane: Priority"

#: shared/components/board/BoardSurface.tsx
msgid "Swimlane: Status"
msgstr "Swimlane: Status"

#: shared/components/board/BoardSurface.tsx
#: shared/components/EditorToolbar.tsx
msgid "Table"
Expand Down
Loading
Loading