Compare commits

...

2 Commits

8 changed files with 170 additions and 52 deletions
@@ -19,7 +19,6 @@ type defaultBoardFieldValue struct {
} }
var defaultBoardFieldValues = []defaultBoardFieldValue{ var defaultBoardFieldValues = []defaultBoardFieldValue{
{Field: "status", Value: "", Label: "Backlog", Color: "#9e9e9e"},
{Field: "status", Value: "todo", Label: "To Do", Color: "#1976d2"}, {Field: "status", Value: "todo", Label: "To Do", Color: "#1976d2"},
{Field: "status", Value: "in_progress", Label: "In Progress", Color: "#f59e0b"}, {Field: "status", Value: "in_progress", Label: "In Progress", Color: "#f59e0b"},
{Field: "status", Value: "done", Label: "Done", Color: "#16a34a"}, {Field: "status", Value: "done", Label: "Done", Color: "#16a34a"},
@@ -20,13 +20,11 @@ CREATE INDEX idx_board_field_values_board_field ON board_field_values(board_id,
-- Seed default status values for every existing board -- Seed default status values for every existing board
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at) INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
SELECT lower(hex(randomblob(8))), b.id, 'status', '', 'Backlog', '#9e9e9e', 0, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0; SELECT lower(hex(randomblob(8))), b.id, 'status', 'todo', 'To Do', '#1976d2', 0, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0;
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at) INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
SELECT lower(hex(randomblob(8))), b.id, 'status', 'todo', 'To Do', '#1976d2', 1, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0; SELECT lower(hex(randomblob(8))), b.id, 'status', 'in_progress','In Progress', '#f59e0b', 1, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0;
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at) INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
SELECT lower(hex(randomblob(8))), b.id, 'status', 'in_progress','In Progress', '#f59e0b', 2, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0; SELECT lower(hex(randomblob(8))), b.id, 'status', 'done', 'Done', '#16a34a', 2, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0;
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
SELECT lower(hex(randomblob(8))), b.id, 'status', 'done', 'Done', '#16a34a', 3, strftime('%s','now'), strftime('%s','now') FROM boards b WHERE b.delete_at = 0;
-- Seed default type values -- Seed default type values
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at) INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
+1 -1
View File
@@ -13,7 +13,7 @@ import FormDialogContent from './FormDialogContent'
import ModalDialog from './ModalDialog' import ModalDialog from './ModalDialog'
export const CARD_STATUSES = [ export const CARD_STATUSES = [
{ value: '', label: 'Backlog' }, { value: '', label: 'Unassigned' },
{ value: 'todo', label: 'To Do' }, { value: 'todo', label: 'To Do' },
{ value: 'in_progress', label: 'In Progress' }, { value: 'in_progress', label: 'In Progress' },
{ value: 'done', label: 'Done' }, { value: 'done', label: 'Done' },
+13 -4
View File
@@ -21,6 +21,7 @@ import {
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api, Block, BlockProperties, BlockPropertiesPayload, BoardFieldValue, GroupBy } from '../api' import { api, Block, BlockProperties, BlockPropertiesPayload, BoardFieldValue, GroupBy } from '../api'
@@ -297,10 +298,18 @@ function KanbanColumn({
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
sx={{ sx={{
minWidth: 280, flex: '0 0 280px', display: 'flex', flexDirection: 'column', gap: 1, minWidth: 280, flex: '0 0 280px', display: 'flex', flexDirection: 'column', gap: 1,
p: 0.5, borderRadius: 1, p: 1,
borderRadius: 2,
border: '1px solid',
borderColor: showCardDropHighlight ? 'primary.main' : 'divider',
opacity: isDragging ? 0 : 1, opacity: isDragging ? 0 : 1,
bgcolor: showCardDropHighlight ? 'action.hover' : 'transparent', bgcolor: showCardDropHighlight
transition: 'background-color 0.15s', ? 'action.hover'
: (theme: Theme) => theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.045)'
: 'rgba(15,23,42,0.065)',
boxShadow: showCardDropHighlight ? 2 : 0,
transition: 'background-color 0.15s, border-color 0.15s, box-shadow 0.15s',
}} }}
> >
{/* Column header — drag handle for column reorder */} {/* Column header — drag handle for column reorder */}
@@ -640,7 +649,7 @@ export default function BoardKanbanView({
return ( return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}> <DndContext sensors={sensors} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
<Box sx={{ overflowX: 'auto', pb: 2, display: 'flex' }}> <Box sx={{ overflowX: 'auto', display: 'flex', flex: 1, minHeight: 0 }}>
<SortableContext items={colSortableIds} strategy={horizontalListSortingStrategy}> <SortableContext items={colSortableIds} strategy={horizontalListSortingStrategy}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start', mx: 'auto' }}> <Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start', mx: 'auto' }}>
{orderedColumns.map((col) => { {orderedColumns.map((col) => {
+3 -3
View File
@@ -45,7 +45,7 @@ function getGroupKey(p: BlockProperties, groupBy: GroupBy): string {
function getGroupLabel(key: string, groupBy: GroupBy, valueByKey: Record<string, BoardFieldValue>): string { function getGroupLabel(key: string, groupBy: GroupBy, valueByKey: Record<string, BoardFieldValue>): string {
if (!key) { if (!key) {
switch (groupBy) { switch (groupBy) {
case 'status': return 'Backlog' case 'status': return 'Unassigned'
case 'sprint': return 'No Sprint' case 'sprint': return 'No Sprint'
default: return 'None' default: return 'None'
} }
@@ -121,9 +121,9 @@ function buildColumns(
label: 'Status', label: 'Status',
defaultWidth: 120, defaultWidth: 120,
minWidth: 60, minWidth: 60,
getValue: (r) => resolveLabel(r.props.status, statusValues, 'Backlog'), getValue: (r) => resolveLabel(r.props.status, statusValues, 'Unassigned'),
renderCell: (r) => { renderCell: (r) => {
const label = resolveLabel(r.props.status, statusValues, 'Backlog') const label = resolveLabel(r.props.status, statusValues, 'Unassigned')
const color = resolveColor(r.props.status, statusValues) const color = resolveColor(r.props.status, statusValues)
return <Chip label={label} size="small" sx={color ? { bgcolor: color, color: '#fff' } : undefined} /> return <Chip label={label} size="small" sx={color ? { bgcolor: color, color: '#fff' } : undefined} />
}, },
+1 -1
View File
@@ -260,7 +260,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const sprintValues = fieldValues['sprint'] ?? [] const sprintValues = fieldValues['sprint'] ?? []
const typeOptions = fieldOptions(typeValues, 'None') const typeOptions = fieldOptions(typeValues, 'None')
const statusOptions = fieldOptions(statusValues, 'Backlog') const statusOptions = fieldOptions(statusValues, 'Unassigned')
const priorityOptions = fieldOptions(priorityValues, 'None') const priorityOptions = fieldOptions(priorityValues, 'None')
const currentTypeColor = typeValues.find((v) => v.value === props_.card_type)?.color const currentTypeColor = typeValues.find((v) => v.value === props_.card_type)?.color
+14 -2
View File
@@ -1,4 +1,5 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { SxProps, Theme } from '@mui/material/styles'
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
import { api, Project } from '../api' import { api, Project } from '../api'
import ContextToolbar from './ContextToolbar' import ContextToolbar from './ContextToolbar'
@@ -17,14 +18,23 @@ type ProjectPageFrameProps = {
showContext?: boolean showContext?: boolean
contextToolbar?: ReactNode contextToolbar?: ReactNode
repoSubNav?: ReactNode repoSubNav?: ReactNode
fillHeight?: boolean
contentSx?: SxProps<Theme>
children: ReactNode children: ReactNode
} }
export default function ProjectPageFrame(props: ProjectPageFrameProps) { export default function ProjectPageFrame(props: ProjectPageFrameProps) {
const { projectId, project, parentItems, currentLabel, showContext, contextToolbar, repoSubNav, children } = props const { projectId, project, parentItems, currentLabel, showContext, contextToolbar, repoSubNav, fillHeight, contentSx, children } = props
const [loadedProject, setLoadedProject] = useState<Project | null>(null) const [loadedProject, setLoadedProject] = useState<Project | null>(null)
const projectItem: Project | null = project === undefined ? loadedProject : project const projectItem: Project | null = project === undefined ? loadedProject : project
const items: ProjectPageFrameItem[] = parentItems ?? [] const items: ProjectPageFrameItem[] = parentItems ?? []
const rootSx: SxProps<Theme> = fillHeight ? {
display: 'flex',
flexDirection: 'column',
height: 'calc(100dvh - 76px)',
minHeight: 0,
minWidth: 0,
} : {}
useEffect(() => { useEffect(() => {
if (project !== undefined || !projectId) return if (project !== undefined || !projectId) return
@@ -35,12 +45,14 @@ export default function ProjectPageFrame(props: ProjectPageFrameProps) {
}, [project, projectId]) }, [project, projectId])
return ( return (
<Box> <Box sx={rootSx}>
<ProjectNavBar projectId={projectId} projectName={projectItem?.name} sx={{ mb: 0.75 }} /> <ProjectNavBar projectId={projectId} projectName={projectItem?.name} sx={{ mb: 0.75 }} />
{contextToolbar ?? (showContext !== false ? ( {contextToolbar ?? (showContext !== false ? (
<ContextToolbar items={items} label={currentLabel} nav={repoSubNav} /> <ContextToolbar items={items} label={currentLabel} nav={repoSubNav} />
) : repoSubNav)} ) : repoSubNav)}
<Box sx={contentSx}>
{children} {children}
</Box> </Box>
</Box>
) )
} }
+116 -16
View File
@@ -8,6 +8,7 @@ import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
import { import {
Box, Box,
IconButton, IconButton,
Link,
Menu, Menu,
MenuItem, MenuItem,
Select, Select,
@@ -16,7 +17,7 @@ import {
ToggleButtonGroup, ToggleButtonGroup,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { MouseEvent, useEffect, useState } from 'react' import { MouseEvent, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api' import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api'
import BoardFormDialog from '../components/BoardFormDialog' import BoardFormDialog from '../components/BoardFormDialog'
@@ -40,13 +41,8 @@ const FIELD_LABELS: Record<GroupBy, string> = {
const FALLBACK_DOT = '#9e9e9e' const FALLBACK_DOT = '#9e9e9e'
function fieldValuesToCols(values: BoardFieldValue[], noneLabel: string): ColDef[] { function fieldValuesToCols(values: BoardFieldValue[]): ColDef[] {
const cols = values.map((v) => ({ value: v.value, label: v.label, dotColor: v.color || FALLBACK_DOT })) return values.map((v: BoardFieldValue) => ({ value: v.value, label: v.label, dotColor: v.color || FALLBACK_DOT }))
// Only add the empty-string bucket if no defined value already covers it (status seeds '' as Backlog)
if (!values.some((v) => v.value === '')) {
cols.push({ value: '', label: noneLabel, dotColor: FALLBACK_DOT })
}
return cols
} }
function getCardGroupValue(block: Block, groupBy: GroupBy, p: BlockProperties | undefined): string { function getCardGroupValue(block: Block, groupBy: GroupBy, p: BlockProperties | undefined): string {
@@ -68,6 +64,32 @@ const GROUP_BY_OPTIONS: { value: GroupBy; label: string }[] = [
{ value: 'priority', label: 'Priority' }, { value: 'priority', label: 'Priority' },
] ]
type StandardFieldValueSeed = {
value: string
label: string
color: string
}
const STANDARD_FIELD_VALUES: Partial<Record<GroupBy, StandardFieldValueSeed[]>> = {
status: [
{ value: 'todo', label: 'To Do', color: '#1976d2' },
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
{ value: 'done', label: 'Done', color: '#16a34a' },
],
type: [
{ value: 'task', label: 'Task', color: '#1976d2' },
{ value: 'bug', label: 'Bug', color: '#d32f2f' },
{ value: 'story', label: 'Story', color: '#388e3c' },
{ value: 'epic', label: 'Epic', color: '#7b1fa2' },
],
priority: [
{ value: 'low', label: 'Low', color: '#4caf50' },
{ value: 'medium', label: 'Medium', color: '#2196f3' },
{ value: 'high', label: 'High', color: '#ff9800' },
{ value: 'urgent', label: 'Urgent', color: '#f44336' },
],
}
// All field values keyed by field name // All field values keyed by field name
type FieldValuesMap = Record<string, BoardFieldValue[]> type FieldValuesMap = Record<string, BoardFieldValue[]>
@@ -95,6 +117,8 @@ export default function BoardPage() {
const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null) const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null)
const [fieldsMenuAnchor, setFieldsMenuAnchor] = useState<HTMLElement | null>(null) const [fieldsMenuAnchor, setFieldsMenuAnchor] = useState<HTMLElement | null>(null)
const [boardActionsAnchor, setBoardActionsAnchor] = useState<HTMLElement | null>(null) const [boardActionsAnchor, setBoardActionsAnchor] = useState<HTMLElement | null>(null)
const [creatingStandardFieldValues, setCreatingStandardFieldValues] = useState(false)
const creatingStandardFieldValuesRef = useRef(false)
const [selectedCard, setSelectedCard] = useState<Block | null>(null) const [selectedCard, setSelectedCard] = useState<Block | null>(null)
@@ -195,6 +219,57 @@ export default function BoardPage() {
cancelAddCard() cancelAddCard()
} }
async function handleCreateStandardFieldValues() {
const seeds: StandardFieldValueSeed[] = STANDARD_FIELD_VALUES[groupBy] ?? []
const currentBoardId: string | undefined = boardId
const currentGroupBy: GroupBy = groupBy
let existingValues: BoardFieldValue[]
let createdValues: BoardFieldValue[]
let finalValues: BoardFieldValue[]
let seed: StandardFieldValueSeed
if (creatingStandardFieldValuesRef.current) return
if (!currentBoardId || seeds.length === 0) return
creatingStandardFieldValuesRef.current = true
setCreatingStandardFieldValues(true)
setLoadError(null)
try {
existingValues = await api.listBoardFieldValues(currentBoardId, currentGroupBy)
if ((existingValues || []).length > 0) {
setFieldValues((prev: FieldValuesMap) => ({ ...prev, [currentGroupBy]: existingValues || [] }))
return
}
createdValues = []
for (seed of seeds) {
createdValues.push(await api.createBoardFieldValue(currentBoardId, currentGroupBy, {
value: seed.value,
label: seed.label,
color: seed.color,
start_date: '',
end_date: '',
}))
}
finalValues = await api.listBoardFieldValues(currentBoardId, currentGroupBy)
setFieldValues((prev: FieldValuesMap) => ({ ...prev, [currentGroupBy]: finalValues || createdValues }))
if ((finalValues || createdValues).length > 0) setLoadError(null)
} catch (err) {
setLoadError(err instanceof Error ? err.message : String(err))
try {
finalValues = await api.listBoardFieldValues(currentBoardId, currentGroupBy)
setFieldValues((prev: FieldValuesMap) => ({ ...prev, [currentGroupBy]: finalValues || [] }))
if ((finalValues || []).length > 0) setLoadError(null)
} catch (reloadErr) {
setLoadError(reloadErr instanceof Error ? reloadErr.message : String(reloadErr))
}
} finally {
creatingStandardFieldValuesRef.current = false
setCreatingStandardFieldValues(false)
}
}
const propsByBlockId = allProperties.reduce<Record<string, BlockProperties>>((acc, p) => { const propsByBlockId = allProperties.reduce<Record<string, BlockProperties>>((acc, p) => {
acc[p.block_id] = p acc[p.block_id] = p
return acc return acc
@@ -202,14 +277,7 @@ export default function BoardPage() {
const activeFieldValues = fieldValues[groupBy] ?? [] const activeFieldValues = fieldValues[groupBy] ?? []
// Columns: defined values + a trailing "None" bucket for unassigned cards const columns: ColDef[] = fieldValuesToCols(activeFieldValues)
const noneLabelByField: Record<GroupBy, string> = {
status: 'Backlog',
type: 'No Type',
priority: 'No Priority',
sprint: 'No Sprint',
}
const columns: ColDef[] = fieldValuesToCols(activeFieldValues, noneLabelByField[groupBy])
const blocksByGroup = columns.reduce<Record<string, Block[]>>((acc, col) => { const blocksByGroup = columns.reduce<Record<string, Block[]>>((acc, col) => {
acc[col.value] = blocks.filter((b) => getCardGroupValue(b, groupBy, propsByBlockId[b.id]) === col.value) acc[col.value] = blocks.filter((b) => getCardGroupValue(b, groupBy, propsByBlockId[b.id]) === col.value)
@@ -223,6 +291,8 @@ export default function BoardPage() {
projectId={projectId ?? ''} projectId={projectId ?? ''}
parentItems={[{ label: 'Boards', to: `/projects/${projectId}/boards` }]} parentItems={[{ label: 'Boards', to: `/projects/${projectId}/boards` }]}
currentLabel={board?.title ?? 'Board'} currentLabel={board?.title ?? 'Board'}
fillHeight
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
contextToolbar={ contextToolbar={
<ContextToolbar <ContextToolbar
kind="Board" kind="Board"
@@ -320,6 +390,34 @@ export default function BoardPage() {
} }
> >
<PageAlert message={loadError} onClose={() => setLoadError(null)} /> <PageAlert message={loadError} onClose={() => setLoadError(null)} />
{view === 'board' && columns.length === 0 ? (
<PageAlert severity="warning">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2">
No {FIELD_LABELS[groupBy].toLowerCase()} values are defined for this board. Create columns first.
</Typography>
{(STANDARD_FIELD_VALUES[groupBy] ?? []).length > 0 ? (
<Link
component="button"
type="button"
variant="body2"
underline="hover"
onClick={() => {
handleCreateStandardFieldValues()
}}
aria-disabled={creatingStandardFieldValues}
sx={{
fontWeight: 700,
cursor: creatingStandardFieldValues ? 'default' : 'pointer',
pointerEvents: creatingStandardFieldValues ? 'none' : 'auto',
}}
>
{creatingStandardFieldValues ? 'Creating standard values...' : 'Create standard values'}
</Link>
) : null}
</Box>
</PageAlert>
) : null}
{board?.description ? ( {board?.description ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{board.description} {board.description}
@@ -349,6 +447,7 @@ export default function BoardPage() {
{/* Kanban columns */} {/* Kanban columns */}
{view === 'board' ? ( {view === 'board' ? (
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
<BoardKanbanView <BoardKanbanView
boardId={boardId ?? ''} boardId={boardId ?? ''}
columns={columns} columns={columns}
@@ -367,6 +466,7 @@ export default function BoardPage() {
onBlocksChanged={() => { loadBlocks(); loadAllProperties() }} onBlocksChanged={() => { loadBlocks(); loadAllProperties() }}
onColumnsReordered={() => loadFieldValues(groupBy)} onColumnsReordered={() => loadFieldValues(groupBy)}
/> />
</Box>
) : null} ) : null}
{/* Board edit dialog */} {/* Board edit dialog */}