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
4 changes: 4 additions & 0 deletions services/jtype-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,9 @@ pub struct BoardCardInfo {
pub task_total: i64,
pub icon: Option<String>,
pub excerpt: Option<String>,
/// Full frontmatter key/values, so the board can surface user-defined custom
/// fields declared in the `.board` config without re-reading the file.
pub properties: HashMap<String, String>,
/// Card slugs this card is blocked by (frontmatter `blocked_by`).
pub blocked_by: Vec<String>,
/// Card slugs this card blocks (frontmatter `blocks`).
Expand Down Expand Up @@ -1505,6 +1508,7 @@ fn scan_board_cards_inner(
task_total,
icon: fm.get("icon").cloned().filter(|v| !v.is_empty()),
excerpt: body_excerpt(&content),
properties: fm.clone(),
blocked_by: fm.get("blocked_by").map(|v| parse_card_links(v)).unwrap_or_default(),
blocks: fm.get("blocks").map(|v| parse_card_links(v)).unwrap_or_default(),
relates: fm.get("relates").map(|v| parse_card_links(v)).unwrap_or_default(),
Expand Down
18 changes: 13 additions & 5 deletions services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from '../api'
import { useConfirm, usePrompt } from '@shared/components/PromptDialogContext'
import { BoardSurface, type BoardActions } from '@shared/components/board'
import { countTasks, bodyExcerpt, type BoardViewCard, type BoardViewConfig } from '@shared/lib/board'
import { countTasks, bodyExcerpt, pickCustomFields, type BoardViewCard, type BoardViewConfig } from '@shared/lib/board'
import { useWorkspaceSocket } from '../hooks/useWorkspaceSocket'

const VIEW_KEY = (boardId: string) => `kanban-view:${boardId}`
Expand Down Expand Up @@ -126,6 +126,9 @@ export function Kanban() {
.filter(c => !c.archivedAt)
.map(c => {
const tasks = countTasks(c.description ?? '')
const propsObj = c.propertiesExtra && typeof c.propertiesExtra === 'object' ? (c.propertiesExtra as Record<string, unknown>) : {}
const propsStr: Record<string, string> = {}
for (const [k, v] of Object.entries(propsObj)) if (typeof v === 'string') propsStr[k] = v
return {
id: c.id,
columnKey: c.columnId,
Expand All @@ -140,9 +143,10 @@ export function Kanban() {
taskDone: tasks.done,
taskTotal: tasks.total,
excerpt: bodyExcerpt(c.description ?? ''),
custom: pickCustomFields(propsStr, view.fields),
}
})
}, [board, memberName])
}, [board, memberName, view.fields])

const viewConfig: BoardViewConfig = useMemo(
() =>
Expand All @@ -152,6 +156,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',
fields: view.fields,
swimlaneBy: view.swimlaneBy,
colorColumns: view.colorColumns,
doneColumn: view.doneColumn,
Expand Down Expand Up @@ -247,10 +252,13 @@ export function Kanban() {
if (patch.due !== undefined) body.dueAt = patch.due ? `${patch.due} 00:00:00` : null
if (patch.tags !== undefined) body.labelIds = patch.tags.map(t => t.id).filter(Boolean) as string[]
if (patch.notes !== undefined) body.description = patch.notes
if (patch.icon !== undefined) {
if (patch.icon !== undefined || patch.custom !== undefined) {
const cur = raw.propertiesExtra && typeof raw.propertiesExtra === 'object' ? { ...(raw.propertiesExtra as Record<string, unknown>) } : {}
if (patch.icon) cur.icon = patch.icon
else delete cur.icon
if (patch.icon !== undefined) {
if (patch.icon) cur.icon = patch.icon
else delete cur.icon
}
if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) { if (v) cur[k] = v; else delete cur[k] }
body.propertiesExtra = cur
}
try {
Expand Down
5 changes: 5 additions & 0 deletions services/jtype-web/frontend/src/pages/WebBoardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
countTasks,
parseLinks,
parseTagList,
pickCustomFields,
serializeLinks,
slugify,
type BoardViewCard,
Expand All @@ -25,6 +26,7 @@ type BoardConfigJSON = {
doneColumn?: string
colorColumns?: boolean
viewType?: 'board' | 'table'
fields?: { key: string; label: string; type?: 'text' | 'number' | 'date' }[]
swimlaneBy?: 'status' | 'priority' | 'assignee'
}

Expand Down Expand Up @@ -95,6 +97,7 @@ export function WebBoardView({
taskDone: tasks.done,
taskTotal: tasks.total,
excerpt: bodyExcerpt(fm.body),
custom: pickCustomFields(fm.data, cfg.fields),
blockedBy: fm.data.blocked_by ? parseLinks(fm.data.blocked_by) : [],
blocks: fm.data.blocks ? parseLinks(fm.data.blocks) : [],
relates: fm.data.relates ? parseLinks(fm.data.relates) : [],
Expand Down Expand Up @@ -181,6 +184,7 @@ export function WebBoardView({
doneColumn: config.doneColumn,
colorColumns: config.colorColumns,
viewType: config.viewType,
fields: config.fields,
swimlaneBy: config.swimlaneBy as BoardViewConfig['swimlaneBy'],
groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status',
}
Expand Down Expand Up @@ -222,6 +226,7 @@ export function WebBoardView({
if (patch.due !== undefined) next.due = patch.due ?? ''
if (patch.icon !== undefined) next.icon = patch.icon ?? ''
if (patch.tags !== undefined) next.tags = patch.tags.map((t) => t.label).join(', ')
if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) next[k] = v ?? ''
if (patch.blockedBy !== undefined) next.blocked_by = serializeLinks(patch.blockedBy)
if (patch.blocks !== undefined) next.blocks = serializeLinks(patch.blocks)
if (patch.relates !== undefined) next.relates = serializeLinks(patch.relates)
Expand Down
46 changes: 44 additions & 2 deletions shared/components/board/BoardPeek.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, ChatBubbleLeftIcon, ClockIcon } from "@heroicons/react/24/outline";
import { renderToContainer } from "../../lib/markdown";
import { PRIORITIES, type BoardViewCard } from "../../lib/board";
import { fieldCls, EmojiField, ListboxSelect, TagMultiSelect } from "./controls";
import type { BoardOption } from "./types";
import type { BoardTag, BoardComment, BoardActivityEvent } from "../../lib/board";
import type { BoardTag, BoardFieldDef, BoardComment, BoardActivityEvent } from "../../lib/board";

/**
* Side peek for editing a card without leaving the board. Platform-agnostic: it
Expand All @@ -18,6 +18,8 @@ export function BoardPeek({
statusOptions,
assigneeOptions,
tagOptions,
fields,
onAddField,
dependencyCards,
loadNotes,
loadComments,
Expand All @@ -34,6 +36,10 @@ export function BoardPeek({
statusOptions: BoardOption[];
assigneeOptions?: BoardOption[];
tagOptions?: BoardTag[];
/** Board-level custom field definitions to render as editable inputs. */
fields?: BoardFieldDef[];
/** Add a new custom field to the board (collected inline). */
onAddField?: (label: string) => void;
/** Sibling cards (excluding this one) offered as dependency targets. */
dependencyCards?: { slug: string; title: string }[];
loadNotes?: (id: string) => Promise<string>;
Expand All @@ -47,6 +53,7 @@ export function BoardPeek({
onDelete: () => void;
onOpenFull?: () => void;
}) {
const [newField, setNewField] = useState("");
const [draft, setDraft] = useState<BoardViewCard>(card);
const [notes, setNotes] = useState(card.notes ?? "");
const [mode, setMode] = useState<"write" | "preview">("write");
Expand Down Expand Up @@ -292,6 +299,41 @@ export function BoardPeek({
/>
)}

{fields?.map((f) => (
<Fragment key={f.key}>
<span className="truncate text-xs text-brand-gray" title={f.label}>
{f.label}
</span>
<input
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
className={fieldCls}
value={draft.custom?.[f.key] ?? ""}
onChange={(e) => setField({ custom: { ...(draft.custom ?? {}), [f.key]: e.target.value } }, f.type === "date" || f.type === "number")}
/>
</Fragment>
))}
{onAddField && (
<>
<span className="text-xs text-brand-gray" />
<form
onSubmit={(e) => {
e.preventDefault();
const label = newField.trim();
if (label) {
onAddField(label);
setNewField("");
}
}}
>
<input
className={fieldCls}
placeholder={t`+ Add field`}
value={newField}
onChange={(e) => setNewField(e.target.value)}
/>
</form>
</>
)}
{dependencyCards && (
<>
<span className="text-xs text-brand-gray">
Expand Down
15 changes: 15 additions & 0 deletions shared/components/board/BoardSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
cardSlug,
effectiveColumns,
groupValueOf,
RESERVED_CARD_KEYS,
slugify,
sortCards as sortCardsFn,
todayStr,
visibleCards as visibleCardsFn,
Expand Down Expand Up @@ -881,6 +883,19 @@ export function BoardSurface({
statusOptions={config.columns.map((c) => ({ value: c.key, label: c.name }))}
assigneeOptions={assigneeOptions}
tagOptions={tagOptions}
fields={config.fields}
onAddField={(label) => {
// Seed with the reserved card keys so a custom field can never
// collide with (and clobber) a core frontmatter attribute.
const existing = new Set([...RESERVED_CARD_KEYS, ...(config.fields ?? []).map((f) => f.key)]);
let key = slugify(label);
if (existing.has(key)) {
let n = 2;
while (existing.has(`${key}-${n}`)) n += 1;
key = `${key}-${n}`;
}
void actions.setConfig({ fields: [...(config.fields ?? []), { key, label }] });
}}
dependencyCards={cards.filter((c) => c.id !== selected.id).map((c) => ({ slug: cardSlug(c), title: c.title }))}
loadNotes={loadNotes}
loadComments={loadComments}
Expand Down
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…\"],\"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\"]}");
/*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\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}");
4 changes: 4 additions & 0 deletions shared/i18n/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ msgstr ""
msgid "← Back to list"
msgstr "← Back to list"

#: shared/components/board/BoardPeek.tsx
msgid "+ Add field"
msgstr "+ Add field"

#. placeholder {0}: conflicts.length
#. placeholder {1}: conflicts.length > 1 ? "s" : ""
#: shared/components/ConflictResolver.tsx
Expand Down
Loading
Loading