Compare commits
2 Commits
b029179d1d
...
6a09f6a6a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a09f6a6a2 | |||
| 61441eaf7c |
@@ -11,6 +11,10 @@ import "codit/internal/models"
|
|||||||
func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
var comments []models.BlockComment
|
var comments []models.BlockComment
|
||||||
var err error
|
var err error
|
||||||
|
var user models.User
|
||||||
|
var ok bool
|
||||||
|
var role string
|
||||||
|
var i int
|
||||||
|
|
||||||
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
|
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
|
||||||
return
|
return
|
||||||
@@ -23,6 +27,16 @@ func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params
|
|||||||
if comments == nil {
|
if comments == nil {
|
||||||
comments = []models.BlockComment{}
|
comments = []models.BlockComment{}
|
||||||
}
|
}
|
||||||
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
|
if ok {
|
||||||
|
role, err = api.boardRoleForUser(r, params["boardId"], user)
|
||||||
|
if err != nil {
|
||||||
|
role = ""
|
||||||
|
}
|
||||||
|
for i = 0; i < len(comments); i++ {
|
||||||
|
comments[i].CanDelete = user.IsAdmin || comments[i].CreatedBy == user.ID || boardRoleAllows(role, "admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
WriteJSON(w, http.StatusOK, comments)
|
WriteJSON(w, http.StatusOK, comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +77,51 @@ func (api *API) CreateBlockComment(w http.ResponseWriter, r *http.Request, param
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) DeleteBlockComment(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
func (api *API) DeleteBlockComment(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
|
var err error
|
||||||
|
var user models.User
|
||||||
|
var ok bool
|
||||||
|
var role string
|
||||||
|
var comments []models.BlockComment
|
||||||
|
var i int
|
||||||
|
var found bool
|
||||||
|
var canDelete bool
|
||||||
|
|
||||||
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comments, err = api.store(r).ListBlockComments(params["boardId"], params["blockId"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i = 0; i < len(comments); i++ {
|
||||||
|
if comments[i].ID == params["commentId"] {
|
||||||
|
found = true
|
||||||
|
canDelete = comments[i].CreatedBy == user.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "comment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canDelete {
|
||||||
|
role, err = api.boardRoleForUser(r, params["boardId"], user)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "comment deletion requires the comment author or board admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canDelete = user.IsAdmin || boardRoleAllows(role, "admin")
|
||||||
|
}
|
||||||
|
if !canDelete {
|
||||||
|
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "comment deletion requires the comment author or board admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
err = api.store(r).DeleteBlockComment(params["boardId"], params["blockId"], params["commentId"])
|
err = api.store(r).DeleteBlockComment(params["boardId"], params["blockId"], params["commentId"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrCommentNotFound) {
|
if errors.Is(err, db.ErrCommentNotFound) {
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ func boardRoleAllows(actual, required string) bool {
|
|||||||
return boardRoleRank(actual) >= boardRoleRank(required)
|
return boardRoleRank(actual) >= boardRoleRank(required)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) boardRoleForUser(r *http.Request, boardID string, user models.User) (string, error) {
|
||||||
|
var role string
|
||||||
|
var boardRole string
|
||||||
|
var boardErr error
|
||||||
|
var projectID string
|
||||||
|
var projRole string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if user.IsAdmin {
|
||||||
|
return "admin", nil
|
||||||
|
}
|
||||||
|
projectID, err = api.store(r).GetBoardProjectID(boardID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
|
||||||
|
if err == nil {
|
||||||
|
role = projectRoleToBoardRole(projRole)
|
||||||
|
} else if err != sql.ErrNoRows {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID)
|
||||||
|
if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) {
|
||||||
|
role = boardRole
|
||||||
|
} else if boardErr != nil && boardErr != sql.ErrNoRows {
|
||||||
|
return "", boardErr
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
func projectRoleToBoardRole(projectRole string) string {
|
func projectRoleToBoardRole(projectRole string) string {
|
||||||
switch projectRole {
|
switch projectRole {
|
||||||
case "admin":
|
case "admin":
|
||||||
@@ -58,35 +88,17 @@ func (api *API) requireBoardRole(w http.ResponseWriter, r *http.Request, boardID
|
|||||||
var principal models.ServicePrincipal
|
var principal models.ServicePrincipal
|
||||||
var ok bool
|
var ok bool
|
||||||
var role string
|
var role string
|
||||||
var boardRole string
|
var err error
|
||||||
var boardErr error
|
|
||||||
var projectID string
|
var projectID string
|
||||||
var projRole string
|
var projRole string
|
||||||
var err error
|
|
||||||
|
|
||||||
user, ok = middleware.UserFromContext(r.Context())
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
if ok && user.IsAdmin {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if ok {
|
if ok {
|
||||||
// Always resolve the project role so an explicit board membership
|
role, err = api.boardRoleForUser(r, boardID, user)
|
||||||
// can never downgrade higher project-level access.
|
|
||||||
projectID, err = api.store(r).GetBoardProjectID(boardID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
|
|
||||||
if err == nil {
|
|
||||||
role = projectRoleToBoardRole(projRole)
|
|
||||||
}
|
|
||||||
boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID)
|
|
||||||
if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) {
|
|
||||||
role = boardRole
|
|
||||||
} else if boardErr != nil && boardErr != sql.ErrNoRows {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if role == "" {
|
if role == "" {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ type BlockComment struct {
|
|||||||
CreatedByName string `json:"created_by_name"`
|
CreatedByName string `json:"created_by_name"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CanDelete bool `json:"can_delete"`
|
||||||
DeleteAt int64 `json:"delete_at,omitempty"`
|
DeleteAt int64 `json:"delete_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1451,6 +1451,7 @@ export interface BlockComment {
|
|||||||
created_by_name: string
|
created_by_name: string
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
can_delete?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockProperties {
|
export interface BlockProperties {
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import { api, Block, BlockComment, BlockProperties, BlockPropertiesPayload, BoardFieldValue } from '../api'
|
import { api, Block, BlockComment, BlockProperties, BlockPropertiesPayload, BoardFieldValue } from '../api'
|
||||||
import ModalDialog from './ModalDialog'
|
import ModalDialog from './ModalDialog'
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
const [loadingProps, setLoadingProps] = useState(false)
|
const [loadingProps, setLoadingProps] = useState(false)
|
||||||
const [savingProps, setSavingProps] = useState(false)
|
const [savingProps, setSavingProps] = useState(false)
|
||||||
const [propError, setPropError] = useState<string | null>(null)
|
const [propError, setPropError] = useState<string | null>(null)
|
||||||
|
const [descriptionMode, setDescriptionMode] = useState<'write' | 'preview'>('write')
|
||||||
|
|
||||||
const [comments, setComments] = useState<BlockComment[]>([])
|
const [comments, setComments] = useState<BlockComment[]>([])
|
||||||
const [loadingComments, setLoadingComments] = useState(false)
|
const [loadingComments, setLoadingComments] = useState(false)
|
||||||
@@ -99,6 +104,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
setConfirmDelete(false)
|
setConfirmDelete(false)
|
||||||
setNewComment('')
|
setNewComment('')
|
||||||
setCompletedAt(block.completed_at || 0)
|
setCompletedAt(block.completed_at || 0)
|
||||||
|
setDescriptionMode('write')
|
||||||
|
|
||||||
setLoadingProps(true)
|
setLoadingProps(true)
|
||||||
api.getBlockProperties(boardId, block.id)
|
api.getBlockProperties(boardId, block.id)
|
||||||
@@ -263,7 +269,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
const isCompleted: boolean = completedAt > 0
|
const isCompleted: boolean = completedAt > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<ModalDialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||||
<DialogTitle sx={{ pb: 0 }}>
|
<DialogTitle sx={{ pb: 0 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
{currentTypeColor ? (
|
{currentTypeColor ? (
|
||||||
@@ -344,16 +350,58 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
{/* Left: description + comments */}
|
{/* Left: description + comments */}
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Description
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={descriptionMode}
|
||||||
|
onChange={(_, value: 'write' | 'preview' | null) => {
|
||||||
|
if (value) setDescriptionMode(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="write">Write</ToggleButton>
|
||||||
|
<ToggleButton value="preview">Preview</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
{descriptionMode === 'write' ? (
|
||||||
<TextField
|
<TextField
|
||||||
label="Description"
|
label="Description (Markdown)"
|
||||||
value={props_.description}
|
value={props_.description}
|
||||||
onChange={(e) => setProps({ ...props_, description: e.target.value })}
|
onChange={(e) => setProps({ ...props_, description: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }}
|
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={4}
|
minRows={8}
|
||||||
disabled={savingProps}
|
disabled={savingProps}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: 236,
|
||||||
|
p: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
overflow: 'auto',
|
||||||
|
'& > :first-of-type': { mt: 0 },
|
||||||
|
'& > :last-child': { mb: 0 },
|
||||||
|
'& code': { fontFamily: 'monospace', fontSize: '0.9em' },
|
||||||
|
'& pre': { overflow: 'auto' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props_.description.trim() ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{props_.description}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
No description.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{descriptionDirty ? (
|
{descriptionDirty ? (
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||||
<Button size="small" variant="contained" onClick={saveDescription} disabled={savingProps}>
|
<Button size="small" variant="contained" onClick={saveDescription} disabled={savingProps}>
|
||||||
@@ -388,6 +436,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{relativeTime(c.created_at)}
|
{relativeTime(c.created_at)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{c.can_delete ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
@@ -396,6 +445,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Typography>
|
</Typography>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
|
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
|
||||||
{c.content}
|
{c.content}
|
||||||
@@ -557,6 +607,10 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
|
|
||||||
<Divider sx={{ mt: 'auto' }} />
|
<Divider sx={{ mt: 'auto' }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="error" sx={{ fontWeight: 700 }}>
|
||||||
|
Danger zone
|
||||||
|
</Typography>
|
||||||
{confirmDelete ? (
|
{confirmDelete ? (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="error">Delete this card?</Typography>
|
<Typography variant="caption" color="error">Delete this card?</Typography>
|
||||||
@@ -569,12 +623,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Button size="small" color="error" onClick={() => setConfirmDelete(true)} sx={{ alignSelf: 'flex-start' }}>
|
<Button size="small" color="error" variant="outlined" onClick={() => setConfirmDelete(true)}>
|
||||||
Delete card
|
Delete card
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import BarChartIcon from '@mui/icons-material/BarChart'
|
import BarChartIcon from '@mui/icons-material/BarChart'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import TableRowsIcon from '@mui/icons-material/TableRows'
|
import TableRowsIcon from '@mui/icons-material/TableRows'
|
||||||
import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
|
import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
IconButton,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
|
Tooltip,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { MouseEvent, useEffect, 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'
|
||||||
@@ -89,6 +94,7 @@ export default function BoardPage() {
|
|||||||
const [fieldValues, setFieldValues] = useState<FieldValuesMap>({})
|
const [fieldValues, setFieldValues] = useState<FieldValuesMap>({})
|
||||||
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 [selectedCard, setSelectedCard] = useState<Block | null>(null)
|
const [selectedCard, setSelectedCard] = useState<Block | null>(null)
|
||||||
|
|
||||||
@@ -221,18 +227,6 @@ export default function BoardPage() {
|
|||||||
<ContextToolbar
|
<ContextToolbar
|
||||||
kind="Board"
|
kind="Board"
|
||||||
label={board?.title ?? 'Board'}
|
label={board?.title ?? 'Board'}
|
||||||
nav={
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={view}
|
|
||||||
exclusive
|
|
||||||
size="small"
|
|
||||||
onChange={(_, v) => { if (v) setView(v) }}
|
|
||||||
>
|
|
||||||
<ToggleButton value="board"><ViewKanbanIcon fontSize="small" sx={{ mr: 0.5 }} />Board</ToggleButton>
|
|
||||||
<ToggleButton value="table"><TableRowsIcon fontSize="small" sx={{ mr: 0.5 }} />Table</ToggleButton>
|
|
||||||
<ToggleButton value="chart"><BarChartIcon fontSize="small" sx={{ mr: 0.5 }} />Charts</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
@@ -250,10 +244,20 @@ export default function BoardPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Box>
|
</Box>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={view}
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
onChange={(_, v) => { if (v) setView(v) }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="board"><ViewKanbanIcon fontSize="small" sx={{ mr: 0.5 }} />Board</ToggleButton>
|
||||||
|
<ToggleButton value="table"><TableRowsIcon fontSize="small" sx={{ mr: 0.5 }} />Table</ToggleButton>
|
||||||
|
<ToggleButton value="chart"><BarChartIcon fontSize="small" sx={{ mr: 0.5 }} />Charts</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
<ListRowActions>
|
<ListRowActions>
|
||||||
<ListRowActionButton
|
<ListRowActionButton
|
||||||
startIcon={<SettingsIcon />}
|
startIcon={<SettingsIcon />}
|
||||||
onClick={(e) => setFieldsMenuAnchor(e.currentTarget)}
|
onClick={(e: MouseEvent<HTMLElement>) => setFieldsMenuAnchor(e.currentTarget)}
|
||||||
>
|
>
|
||||||
Fields
|
Fields
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
@@ -268,12 +272,47 @@ export default function BoardPage() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
<ListRowActionButton onClick={() => { setEditBoardOpen(true); setEditBoardError(null) }}>
|
<Tooltip title="Board actions">
|
||||||
Edit
|
<IconButton
|
||||||
</ListRowActionButton>
|
size="small"
|
||||||
<ListRowActionButton color="error" onClick={() => { setDeleteBoardOpen(true); setDeleteBoardError(null); setDeleteBoardConfirmValue('') }}>
|
aria-label="Board actions"
|
||||||
Delete
|
onClick={(e: MouseEvent<HTMLElement>) => setBoardActionsAnchor(e.currentTarget)}
|
||||||
</ListRowActionButton>
|
>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={boardActionsAnchor}
|
||||||
|
open={!!boardActionsAnchor}
|
||||||
|
onClose={() => setBoardActionsAnchor(null)}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setBoardActionsAnchor(null)
|
||||||
|
setEditBoardOpen(true)
|
||||||
|
setEditBoardError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
Edit board
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
onClick={() => {
|
||||||
|
setBoardActionsAnchor(null)
|
||||||
|
setDeleteBoardOpen(true)
|
||||||
|
setDeleteBoardError(null)
|
||||||
|
setDeleteBoardConfirmValue('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
Delete board
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user