Compare commits

..

2 Commits

Author SHA1 Message Date
hyung-hwan a59eb48b74 renamed the repos table's source column to Access.
enahnced the table's Status column output
2026-06-15 22:32:31 +09:00
hyung-hwan 3c57ccf0fd switch to use icon-only buttons for rows actions in repospage and boardspage 2026-06-15 22:13:44 +09:00
3 changed files with 121 additions and 58 deletions
@@ -1,6 +1,9 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import { ButtonProps } from '@mui/material/Button' import { ButtonProps } from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import { IconButtonProps } from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { ReactNode } from 'react' import { ReactNode } from 'react'
type ListRowActionsProps = { type ListRowActionsProps = {
@@ -8,6 +11,9 @@ type ListRowActionsProps = {
} }
type ListRowActionButtonProps = Omit<ButtonProps, 'size' | 'variant'> type ListRowActionButtonProps = Omit<ButtonProps, 'size' | 'variant'>
type ListRowIconActionProps = Omit<IconButtonProps, 'size'> & {
title: string
}
export function ListRowActions(props: ListRowActionsProps) { export function ListRowActions(props: ListRowActionsProps) {
return ( return (
@@ -31,3 +37,15 @@ export function ListRowActions(props: ListRowActionsProps) {
export function ListRowActionButton(props: ListRowActionButtonProps) { export function ListRowActionButton(props: ListRowActionButtonProps) {
return <Button variant="outlined" size="small" {...props} /> return <Button variant="outlined" size="small" {...props} />
} }
export function ListRowIconAction({ title, children, ...buttonProps }: ListRowIconActionProps) {
return (
<Tooltip title={title}>
<span>
<IconButton size="small" aria-label={title} {...buttonProps}>
{children}
</IconButton>
</span>
</Tooltip>
)
}
+12 -24
View File
@@ -1,10 +1,8 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { import {
Box, Box,
Button,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -13,7 +11,7 @@ import { api, Board } from '../api'
import BoardFormDialog from '../components/BoardFormDialog' import BoardFormDialog from '../components/BoardFormDialog'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
import PaginationControls from '../components/PaginationControls' import PaginationControls from '../components/PaginationControls'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
@@ -122,7 +120,6 @@ export default function ProjectBoardsPage() {
display: 'block', display: 'block',
textDecoration: 'none', textDecoration: 'none',
color: 'primary.main', color: 'primary.main',
fontWeight: 600,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -147,35 +144,26 @@ export default function ProjectBoardsPage() {
{ {
key: 'actions', key: 'actions',
label: 'Actions', label: 'Actions',
defaultWidth: 300, defaultWidth: 96,
minWidth: 300, minWidth: 96,
sortable: false, sortable: false,
resizable: false, resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' }, cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (board: Board) => ( renderCell: (board: Board) => (
<ListRowActions> <ListRowActions>
<Button <ListRowIconAction
component={Link} title="Edit board"
to={`/projects/${projectId}/boards/${board.id}`}
variant="outlined"
size="small"
startIcon={<OpenInNewIcon />}
>
Open
</Button>
<ListRowActionButton
startIcon={<EditIcon />}
onClick={() => { setEditBoard(board); setEditError(null) }} onClick={() => { setEditBoard(board); setEditError(null) }}
> >
Edit <EditIcon fontSize="small" />
</ListRowActionButton> </ListRowIconAction>
<ListRowActionButton <ListRowIconAction
startIcon={<DeleteIcon />} title="Delete board"
color="error" color="error"
onClick={() => { setDeleteBoard(board); setDeleteError(null); setDeleteConfirmValue('') }} onClick={() => { setDeleteBoard(board); setDeleteError(null); setDeleteConfirmValue('') }}
> >
Delete <DeleteIcon fontSize="small" />
</ListRowActionButton> </ListRowIconAction>
</ListRowActions> </ListRowActions>
), ),
}, },
@@ -188,7 +176,7 @@ export default function ProjectBoardsPage() {
title="Boards" title="Boards"
actions={ actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New Board New
</HeaderActionButton> </HeaderActionButton>
} }
> >
+90 -33
View File
@@ -1,4 +1,5 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import LinkOffIcon from '@mui/icons-material/LinkOff' import LinkOffIcon from '@mui/icons-material/LinkOff'
@@ -9,23 +10,31 @@ import {
Box, Box,
Chip, Chip,
Checkbox, Checkbox,
IconButton,
MenuItem, MenuItem,
TextField, TextField,
Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, AvailableRepo, Project, Repo, RepoStats } from '../api' import { api, AvailableRepo, Project, Repo, RepoStats } from '../api'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
import PaginationControls from '../components/PaginationControls' import PaginationControls from '../components/PaginationControls'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable' import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import RepositoryFormDialog from '../components/RepositoryFormDialog' import RepositoryFormDialog from '../components/RepositoryFormDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
type RepoAccessInfo = {
label: string
displayValue: string
copyValue: string
}
export default function ReposPage() { export default function ReposPage() {
const { projectId } = useParams() const { projectId } = useParams()
const [repos, setRepos] = useState<Repo[]>([]) const [repos, setRepos] = useState<Repo[]>([])
@@ -337,10 +346,32 @@ export default function ReposPage() {
} }
} }
function repoSource(repo: Repo): string { function repoAccess(repo: Repo): RepoAccessInfo {
if (repo.type === 'rpm') return repo.rpm_url || '' let value: string
if (repo.type === 'docker') return repo.docker_url || '' if (repo.type === 'rpm') {
return repo.clone_url || '' value = repo.rpm_url || ''
return { label: 'dnf/yum', displayValue: value, copyValue: fullAccessURL(value) }
}
if (repo.type === 'docker') {
value = repo.docker_url || ''
return { label: 'docker pull', displayValue: value, copyValue: fullAccessURL(value) }
}
value = repo.clone_url || ''
return { label: 'git clone', displayValue: value, copyValue: fullAccessURL(value) }
}
function fullAccessURL(value: string): string {
const trimmed: string = value.trim()
if (!trimmed) return ''
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
if (trimmed.startsWith('//')) return `${window.location.protocol}${trimmed}`
if (trimmed.startsWith('/')) return `${window.location.origin}${trimmed}`
return `${window.location.origin}/${trimmed}`
}
function copyRepoAccess(value: string) {
if (!value || !navigator.clipboard || !navigator.clipboard.writeText) return
void navigator.clipboard.writeText(value)
} }
const columns: RSTColumn<Repo>[] = [ const columns: RSTColumn<Repo>[] = [
@@ -363,7 +394,7 @@ export default function ReposPage() {
{ {
key: 'name', key: 'name',
label: 'Name', label: 'Name',
defaultWidth: 200, defaultWidth: 150,
minWidth: 100, minWidth: 100,
getValue: (repo: Repo) => repo.name, getValue: (repo: Repo) => repo.name,
renderCell: (repo: Repo) => ( renderCell: (repo: Repo) => (
@@ -375,7 +406,6 @@ export default function ReposPage() {
display: 'block', display: 'block',
textDecoration: 'none', textDecoration: 'none',
color: 'primary.main', color: 'primary.main',
fontWeight: 600,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -397,7 +427,7 @@ export default function ReposPage() {
{ {
key: 'description', key: 'description',
label: 'Description', label: 'Description',
defaultWidth: 300, defaultWidth: 250,
minWidth: 160, minWidth: 160,
getValue: (repo: Repo) => repo.description || '', getValue: (repo: Repo) => repo.description || '',
renderCell: (repo: Repo) => ( renderCell: (repo: Repo) => (
@@ -407,16 +437,40 @@ export default function ReposPage() {
), ),
}, },
{ {
key: 'source', key: 'access',
label: 'Source', label: 'Access',
defaultWidth: 200, defaultWidth: 280,
minWidth: 150, minWidth: 180,
getValue: (repo: Repo) => repoSource(repo), getValue: (repo: Repo) => {
renderCell: (repo: Repo) => ( const access: RepoAccessInfo = repoAccess(repo)
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> return `${access.label} ${access.displayValue}`
{repoSource(repo) || '-'} },
renderCell: (repo: Repo) => {
const access: RepoAccessInfo = repoAccess(repo)
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
<Chip label={access.label} size="small" variant="outlined" sx={{ flexShrink: 0 }} />
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{access.displayValue || '-'}
</Typography> </Typography>
), {access.copyValue ? (
<Tooltip title="Copy access URL">
<IconButton
size="small"
aria-label="Copy access URL"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
copyRepoAccess(access.copyValue)
}}
sx={{ flexShrink: 0 }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
</Box>
)
},
}, },
{ {
key: 'status', key: 'status',
@@ -425,35 +479,38 @@ export default function ReposPage() {
minWidth: 50, minWidth: 50,
getValue: (repo: Repo) => `${repo.is_foreign ? 'foreign' : 'local'} ${repo.owner_slug || ''}`, getValue: (repo: Repo) => `${repo.is_foreign ? 'foreign' : 'local'} ${repo.owner_slug || ''}`,
renderCell: (repo: Repo) => ( renderCell: (repo: Repo) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}> repo.is_foreign ? (
{repo.is_foreign ? <Chip label="foreign" size="small" color="warning" variant="outlined" /> : <Chip label="local" size="small" variant="outlined" />} <Tooltip title={repo.owner_slug ? `Owned by ${repo.owner_slug}` : 'Foreign repository'}>
{repo.is_foreign && repo.owner_slug ? <Chip label={repo.owner_slug} size="small" variant="outlined" /> : null} <Chip label="foreign" size="small" color="warning" variant="outlined" />
</Box> </Tooltip>
) : (
<Chip label="local" size="small" variant="outlined" />
)
), ),
}, },
{ {
key: 'actions', key: 'actions',
label: 'Actions', label: 'Actions',
defaultWidth: 210, defaultWidth: 96,
minWidth: 210, minWidth: 96,
sortable: false, sortable: false,
resizable: false, resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' }, cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (repo: Repo) => ( renderCell: (repo: Repo) => (
!repo.is_foreign ? ( !repo.is_foreign ? (
<ListRowActions> <ListRowActions>
<ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(repo)}> <ListRowIconAction title="Edit repository" onClick={() => openEdit(repo)}>
Edit <EditIcon fontSize="small" />
</ListRowActionButton> </ListRowIconAction>
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(repo)}> <ListRowIconAction title="Delete repository" color="error" onClick={() => openDelete(repo)}>
Delete <DeleteIcon fontSize="small" />
</ListRowActionButton> </ListRowIconAction>
</ListRowActions> </ListRowActions>
) : ( ) : (
<ListRowActions> <ListRowActions>
<ListRowActionButton startIcon={<LinkOffIcon />} onClick={() => openDetachForeign(repo)}> <ListRowIconAction title="Detach repository" onClick={() => openDetachForeign(repo)}>
Detach <LinkOffIcon fontSize="small" />
</ListRowActionButton> </ListRowIconAction>
</ListRowActions> </ListRowActions>
) )
), ),