Compare commits
2 Commits
6a09f6a6a2
...
0823947e33
| Author | SHA1 | Date | |
|---|---|---|---|
| 0823947e33 | |||
| b5abc4e553 |
@@ -19,7 +19,6 @@ type defaultBoardFieldValue struct {
|
||||
}
|
||||
|
||||
var defaultBoardFieldValues = []defaultBoardFieldValue{
|
||||
{Field: "status", Value: "", Label: "Backlog", Color: "#9e9e9e"},
|
||||
{Field: "status", Value: "todo", Label: "To Do", Color: "#1976d2"},
|
||||
{Field: "status", Value: "in_progress", Label: "In Progress", Color: "#f59e0b"},
|
||||
{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
|
||||
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)
|
||||
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)
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
|
||||
-- Seed default type values
|
||||
INSERT INTO board_field_values (public_id, board_id, field, value, label, color, display_order, created_at, updated_at)
|
||||
|
||||
@@ -13,7 +13,7 @@ import FormDialogContent from './FormDialogContent'
|
||||
import ModalDialog from './ModalDialog'
|
||||
|
||||
export const CARD_STATUSES = [
|
||||
{ value: '', label: 'Backlog' },
|
||||
{ value: '', label: 'Unassigned' },
|
||||
{ value: 'todo', label: 'To Do' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, Block, BlockProperties, BlockPropertiesPayload, BoardFieldValue, GroupBy } from '../api'
|
||||
|
||||
@@ -297,10 +298,18 @@ function KanbanColumn({
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
sx={{
|
||||
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,
|
||||
bgcolor: showCardDropHighlight ? 'action.hover' : 'transparent',
|
||||
transition: 'background-color 0.15s',
|
||||
bgcolor: showCardDropHighlight
|
||||
? '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 */}
|
||||
@@ -640,7 +649,7 @@ export default function BoardKanbanView({
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start', mx: 'auto' }}>
|
||||
{orderedColumns.map((col) => {
|
||||
|
||||
@@ -45,7 +45,7 @@ function getGroupKey(p: BlockProperties, groupBy: GroupBy): string {
|
||||
function getGroupLabel(key: string, groupBy: GroupBy, valueByKey: Record<string, BoardFieldValue>): string {
|
||||
if (!key) {
|
||||
switch (groupBy) {
|
||||
case 'status': return 'Backlog'
|
||||
case 'status': return 'Unassigned'
|
||||
case 'sprint': return 'No Sprint'
|
||||
default: return 'None'
|
||||
}
|
||||
@@ -121,9 +121,9 @@ function buildColumns(
|
||||
label: 'Status',
|
||||
defaultWidth: 120,
|
||||
minWidth: 60,
|
||||
getValue: (r) => resolveLabel(r.props.status, statusValues, 'Backlog'),
|
||||
getValue: (r) => resolveLabel(r.props.status, statusValues, 'Unassigned'),
|
||||
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)
|
||||
return <Chip label={label} size="small" sx={color ? { bgcolor: color, color: '#fff' } : undefined} />
|
||||
},
|
||||
|
||||
@@ -260,7 +260,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
const sprintValues = fieldValues['sprint'] ?? []
|
||||
|
||||
const typeOptions = fieldOptions(typeValues, 'None')
|
||||
const statusOptions = fieldOptions(statusValues, 'Backlog')
|
||||
const statusOptions = fieldOptions(statusValues, 'Unassigned')
|
||||
const priorityOptions = fieldOptions(priorityValues, 'None')
|
||||
|
||||
const currentTypeColor = typeValues.find((v) => v.value === props_.card_type)?.color
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { SxProps, Theme } from '@mui/material/styles'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { api, Project } from '../api'
|
||||
import ContextToolbar from './ContextToolbar'
|
||||
@@ -17,14 +18,23 @@ type ProjectPageFrameProps = {
|
||||
showContext?: boolean
|
||||
contextToolbar?: ReactNode
|
||||
repoSubNav?: ReactNode
|
||||
fillHeight?: boolean
|
||||
contentSx?: SxProps<Theme>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
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 projectItem: Project | null = project === undefined ? loadedProject : project
|
||||
const items: ProjectPageFrameItem[] = parentItems ?? []
|
||||
const rootSx: SxProps<Theme> = fillHeight ? {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100dvh - 76px)',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} : {}
|
||||
|
||||
useEffect(() => {
|
||||
if (project !== undefined || !projectId) return
|
||||
@@ -35,12 +45,14 @@ export default function ProjectPageFrame(props: ProjectPageFrameProps) {
|
||||
}, [project, projectId])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={rootSx}>
|
||||
<ProjectNavBar projectId={projectId} projectName={projectItem?.name} sx={{ mb: 0.75 }} />
|
||||
{contextToolbar ?? (showContext !== false ? (
|
||||
<ContextToolbar items={items} label={currentLabel} nav={repoSubNav} />
|
||||
) : repoSubNav)}
|
||||
{children}
|
||||
<Box sx={contentSx}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Select,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { MouseEvent, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api'
|
||||
import BoardFormDialog from '../components/BoardFormDialog'
|
||||
@@ -40,13 +41,8 @@ const FIELD_LABELS: Record<GroupBy, string> = {
|
||||
|
||||
const FALLBACK_DOT = '#9e9e9e'
|
||||
|
||||
function fieldValuesToCols(values: BoardFieldValue[], noneLabel: string): ColDef[] {
|
||||
const cols = values.map((v) => ({ 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 fieldValuesToCols(values: BoardFieldValue[]): ColDef[] {
|
||||
return values.map((v: BoardFieldValue) => ({ value: v.value, label: v.label, dotColor: v.color || FALLBACK_DOT }))
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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
|
||||
type FieldValuesMap = Record<string, BoardFieldValue[]>
|
||||
|
||||
@@ -95,6 +117,8 @@ export default function BoardPage() {
|
||||
const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null)
|
||||
const [fieldsMenuAnchor, setFieldsMenuAnchor] = 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)
|
||||
|
||||
@@ -195,6 +219,57 @@ export default function BoardPage() {
|
||||
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) => {
|
||||
acc[p.block_id] = p
|
||||
return acc
|
||||
@@ -202,14 +277,7 @@ export default function BoardPage() {
|
||||
|
||||
const activeFieldValues = fieldValues[groupBy] ?? []
|
||||
|
||||
// Columns: defined values + a trailing "None" bucket for unassigned cards
|
||||
const noneLabelByField: Record<GroupBy, string> = {
|
||||
status: 'Backlog',
|
||||
type: 'No Type',
|
||||
priority: 'No Priority',
|
||||
sprint: 'No Sprint',
|
||||
}
|
||||
const columns: ColDef[] = fieldValuesToCols(activeFieldValues, noneLabelByField[groupBy])
|
||||
const columns: ColDef[] = fieldValuesToCols(activeFieldValues)
|
||||
|
||||
const blocksByGroup = columns.reduce<Record<string, Block[]>>((acc, col) => {
|
||||
acc[col.value] = blocks.filter((b) => getCardGroupValue(b, groupBy, propsByBlockId[b.id]) === col.value)
|
||||
@@ -223,6 +291,8 @@ export default function BoardPage() {
|
||||
projectId={projectId ?? ''}
|
||||
parentItems={[{ label: 'Boards', to: `/projects/${projectId}/boards` }]}
|
||||
currentLabel={board?.title ?? 'Board'}
|
||||
fillHeight
|
||||
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
|
||||
contextToolbar={
|
||||
<ContextToolbar
|
||||
kind="Board"
|
||||
@@ -320,6 +390,34 @@ export default function BoardPage() {
|
||||
}
|
||||
>
|
||||
<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 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{board.description}
|
||||
@@ -349,24 +447,26 @@ export default function BoardPage() {
|
||||
|
||||
{/* Kanban columns */}
|
||||
{view === 'board' ? (
|
||||
<BoardKanbanView
|
||||
boardId={boardId ?? ''}
|
||||
columns={columns}
|
||||
blocksByGroup={blocksByGroup}
|
||||
propsByBlockId={propsByBlockId}
|
||||
fieldValues={fieldValues}
|
||||
groupBy={groupBy}
|
||||
addingInCol={addingInCol}
|
||||
newCardTitle={newCardTitle}
|
||||
addingCard={addingCard}
|
||||
onStartAddCard={startAddCard}
|
||||
onCancelAddCard={cancelAddCard}
|
||||
onCommitAddCard={commitAddCard}
|
||||
onNewCardTitleChange={setNewCardTitle}
|
||||
onCardClick={setSelectedCard}
|
||||
onBlocksChanged={() => { loadBlocks(); loadAllProperties() }}
|
||||
onColumnsReordered={() => loadFieldValues(groupBy)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<BoardKanbanView
|
||||
boardId={boardId ?? ''}
|
||||
columns={columns}
|
||||
blocksByGroup={blocksByGroup}
|
||||
propsByBlockId={propsByBlockId}
|
||||
fieldValues={fieldValues}
|
||||
groupBy={groupBy}
|
||||
addingInCol={addingInCol}
|
||||
newCardTitle={newCardTitle}
|
||||
addingCard={addingCard}
|
||||
onStartAddCard={startAddCard}
|
||||
onCancelAddCard={cancelAddCard}
|
||||
onCommitAddCard={commitAddCard}
|
||||
onNewCardTitleChange={setNewCardTitle}
|
||||
onCardClick={setSelectedCard}
|
||||
onBlocksChanged={() => { loadBlocks(); loadAllProperties() }}
|
||||
onColumnsReordered={() => loadFieldValues(groupBy)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Board edit dialog */}
|
||||
|
||||
Reference in New Issue
Block a user