refactor: move stripHtml function to Helpers utility fix: update chore filters to include raw description for better search indexing enhance: implement search parameter handling for modal openings in ProjectView and LabelView
537 lines
15 KiB
JavaScript
537 lines
15 KiB
JavaScript
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
|
|
import {
|
|
SwipeableList,
|
|
SwipeableListItem,
|
|
SwipeAction,
|
|
TrailingActions,
|
|
Type as ListType,
|
|
} from '@meauxt/react-swipeable-list'
|
|
import { Add, MoreVert, Task } from '@mui/icons-material'
|
|
import DeleteIcon from '@mui/icons-material/Delete'
|
|
import EditIcon from '@mui/icons-material/Edit'
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
IconButton,
|
|
Stack,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
|
import { useChores } from '../../queries/ChoreQueries'
|
|
import { useUserProfile } from '../../queries/UserQueries'
|
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
|
import { DeleteProject } from '../../utils/Fetcher'
|
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
|
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
|
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
|
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
|
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
|
import { useProjects } from './ProjectQueries'
|
|
const ProjectCardContent = ({
|
|
currentUserId,
|
|
onCardClick,
|
|
onToggleActions,
|
|
project,
|
|
taskCounts = {},
|
|
}) => {
|
|
const { t } = useTranslation('projects')
|
|
// Check if current user owns this project
|
|
const isOwnedByCurrentUser = project.created_by === currentUserId
|
|
const isDefaultProject = project.id === 'default'
|
|
const taskCount = taskCounts[project.id] || 0
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
minHeight: 64,
|
|
width: '100%',
|
|
px: 2,
|
|
py: 1.5,
|
|
bgcolor: 'background.body',
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={onCardClick}
|
|
>
|
|
{/* Project Avatar */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
mr: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Avatar
|
|
size='sm'
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
bgcolor: project.color || 'primary.500',
|
|
border: '2px solid',
|
|
borderColor: isDefaultProject
|
|
? 'primary.300'
|
|
: isOwnedByCurrentUser
|
|
? 'background.surface'
|
|
: 'warning.300',
|
|
boxShadow: isDefaultProject
|
|
? '0 0 0 1px var(--joy-palette-primary-300)'
|
|
: isOwnedByCurrentUser
|
|
? 'sm'
|
|
: '0 0 0 1px var(--joy-palette-warning-300)',
|
|
}}
|
|
>
|
|
{project.icon ? (
|
|
(() => {
|
|
const IconComponent = getIconComponent(project.icon)
|
|
return (
|
|
<IconComponent
|
|
sx={{
|
|
fontSize: 16,
|
|
color: getTextColorFromBackgroundColor(
|
|
project.color || '#1976d2',
|
|
),
|
|
}}
|
|
/>
|
|
)
|
|
})()
|
|
) : (
|
|
<></>
|
|
)}
|
|
</Avatar>
|
|
</Box>
|
|
|
|
{/* Content - Center */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
{/* Project Name */}
|
|
<Typography
|
|
level='title-sm'
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
mb: 0.25,
|
|
}}
|
|
>
|
|
{project.name}
|
|
{isDefaultProject && (
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
color='primary'
|
|
sx={{
|
|
fontSize: 9,
|
|
height: 16,
|
|
px: 0.5,
|
|
ml: 1,
|
|
fontWeight: 'md',
|
|
}}
|
|
>
|
|
{t('defaultChip')}
|
|
</Chip>
|
|
)}
|
|
</Typography>
|
|
|
|
{/* Project Info */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
{project.description && (
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{
|
|
color: 'text.tertiary',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '200px',
|
|
}}
|
|
>
|
|
{project.description}
|
|
</Typography>
|
|
)}
|
|
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
startDecorator={<Task />}
|
|
sx={{
|
|
fontSize: 10,
|
|
height: 18,
|
|
px: 0.75,
|
|
bgcolor: 'primary.softBg',
|
|
color: 'primary.500',
|
|
}}
|
|
>
|
|
{t('tasks', { count: taskCount })}
|
|
</Chip>
|
|
|
|
{!isOwnedByCurrentUser && !isDefaultProject && (
|
|
<Chip
|
|
size='sm'
|
|
variant='soft'
|
|
color='warning'
|
|
sx={{
|
|
fontSize: 9,
|
|
height: 16,
|
|
px: 0.5,
|
|
fontWeight: 'md',
|
|
}}
|
|
>
|
|
{t('shared')}
|
|
</Chip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
{onToggleActions && (
|
|
<IconButton
|
|
color='neutral'
|
|
variant='plain'
|
|
size='sm'
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
onToggleActions()
|
|
}}
|
|
>
|
|
<MoreVert sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const ProjectView = () => {
|
|
const { t } = useTranslation('projects')
|
|
const { data: projects, isError, isProjectsLoading } = useProjects()
|
|
const { data: userProfile } = useUserProfile()
|
|
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
|
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
|
const { setSelectedProjectWithCache } = useProjectFilter(
|
|
projectsData,
|
|
!projectsLoading,
|
|
)
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
|
|
const [userProjects, setUserProjects] = useState([])
|
|
const [modalOpen, setModalOpen] = useState(false)
|
|
const [currentProject, setCurrentProject] = useState(null)
|
|
const [taskCounts, setTaskCounts] = useState({})
|
|
const queryClient = useQueryClient()
|
|
const [confirmationModel, setConfirmationModel] = useState({})
|
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
|
|
|
const handleAddProject = () => {
|
|
setCurrentProject(null)
|
|
setModalOpen(true)
|
|
}
|
|
|
|
const handleEditProject = project => {
|
|
setCurrentProject(project)
|
|
setModalOpen(true)
|
|
}
|
|
|
|
const handleDeleteClicked = id => {
|
|
const project = userProjects.find(p => p.id === id)
|
|
setConfirmationModel({
|
|
isOpen: true,
|
|
title: t('delete.title'),
|
|
message: t('delete.message', { name: project?.name }),
|
|
confirmText: t('common:delete'),
|
|
color: 'danger',
|
|
cancelText: t('common:cancel'),
|
|
onClose: confirmed => {
|
|
if (confirmed === true) {
|
|
handleDeleteProject(id)
|
|
}
|
|
setConfirmationModel({})
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleDeleteProject = id => {
|
|
DeleteProject(id).then(() => {
|
|
const updatedProjects = userProjects.filter(project => project.id !== id)
|
|
setUserProjects(updatedProjects)
|
|
queryClient.invalidateQueries('projects')
|
|
|
|
// If the deleted project was the active project, clear it
|
|
const saved = localStorage.getItem('selectedProject')
|
|
if (saved) {
|
|
const savedProject = JSON.parse(saved)
|
|
if (savedProject && savedProject.id === id) {
|
|
setSelectedProjectWithCache(null)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleSaveProject = () => {
|
|
setModalOpen(false)
|
|
}
|
|
|
|
const handleCardClick = project => {
|
|
// Always navigate to MyChores with project filter when clicking on the card
|
|
// For default project, use 'default', for others use project ID
|
|
const projectIdentifier = project.id === 'default' ? 'default' : project.id
|
|
setSelectedProjectWithCache(project)
|
|
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (projects) {
|
|
setUserProjects(projects)
|
|
}
|
|
}, [projects])
|
|
|
|
// ?create=1 lets other surfaces (global search quick actions) land here with
|
|
// the create modal already open.
|
|
useEffect(() => {
|
|
if (searchParams.get('create') !== '1') return
|
|
setCurrentProject(null)
|
|
setModalOpen(true)
|
|
setSearchParams(
|
|
params => {
|
|
params.delete('create')
|
|
return params
|
|
},
|
|
{ replace: true },
|
|
)
|
|
}, [searchParams, setSearchParams])
|
|
|
|
// Calculate real task counts from chores data
|
|
useEffect(() => {
|
|
if (chores && chores.res) {
|
|
const choresList = chores.res
|
|
const realCounts = {}
|
|
|
|
// First, count tasks for the default project (tasks without a projectId)
|
|
const defaultProjectCount = choresList.filter(chore => {
|
|
const choreProjectId = chore.projectId || chore.project_id
|
|
return (
|
|
!choreProjectId ||
|
|
choreProjectId === '' ||
|
|
choreProjectId === 'default' ||
|
|
choreProjectId === null
|
|
)
|
|
}).length
|
|
realCounts['default'] = defaultProjectCount
|
|
|
|
// Then count tasks for each user project
|
|
userProjects.forEach(project => {
|
|
const choreCount = choresList.filter(chore => {
|
|
const choreProjectId = chore.projectId || chore.project_id
|
|
return choreProjectId === project.id
|
|
}).length
|
|
|
|
realCounts[project.id] = choreCount
|
|
})
|
|
|
|
setTaskCounts(realCounts)
|
|
}
|
|
}, [chores, userProjects])
|
|
|
|
if (isProjectsLoading) {
|
|
return (
|
|
<Box
|
|
display='flex'
|
|
justifyContent='center'
|
|
alignItems='center'
|
|
height='100vh'
|
|
>
|
|
<CircularProgress />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<Typography color='danger' textAlign='center'>
|
|
{t('loadError')}
|
|
</Typography>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth='md' sx={{ px: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
|
|
<Stack sx={{ flex: 1 }}>
|
|
<Typography
|
|
level='h3'
|
|
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
|
>
|
|
{t('common:navigation.projects')}
|
|
</Typography>
|
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
|
{t('blurb')}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Default project - not swipeable */}
|
|
<ProjectCardContent
|
|
project={{
|
|
id: 'default',
|
|
name: t('chores:toolbar.defaultProject'),
|
|
description: t('defaultDescription'),
|
|
icon: 'FolderOpen',
|
|
color: '#1976d2',
|
|
created_by: userProfile?.id,
|
|
}}
|
|
currentUserId={userProfile?.id}
|
|
taskCounts={{ default: taskCounts.default || 0 }}
|
|
onCardClick={() =>
|
|
handleCardClick({
|
|
id: 'default',
|
|
name: t('chores:toolbar.defaultProject'),
|
|
icon: 'FolderOpen',
|
|
color: '#1976d2',
|
|
})
|
|
}
|
|
/>
|
|
|
|
{/* User projects - swipeable */}
|
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
|
{userProjects.map(project => (
|
|
<SwipeableListItem
|
|
onClick={() => handleCardClick(project)}
|
|
key={project.id}
|
|
swipeActionOpen={
|
|
showMoreInfoId === project.id ? 'trailing' : null
|
|
}
|
|
trailingActions={
|
|
<TrailingActions>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
|
zIndex: 0,
|
|
}}
|
|
>
|
|
<SwipeAction onClick={() => handleEditProject(project)}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'neutral.softBg',
|
|
color: 'neutral.700',
|
|
px: 3,
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<EditIcon sx={{ fontSize: 20 }} />
|
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
|
{t('common:edit')}
|
|
</Typography>
|
|
</Box>
|
|
</SwipeAction>
|
|
<SwipeAction
|
|
onClick={() => handleDeleteClicked(project.id)}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'danger.softBg',
|
|
color: 'danger.700',
|
|
px: 3,
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<DeleteIcon sx={{ fontSize: 20 }} />
|
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
|
{t('common:delete')}
|
|
</Typography>
|
|
</Box>
|
|
</SwipeAction>
|
|
</Box>
|
|
</TrailingActions>
|
|
}
|
|
>
|
|
<ProjectCardContent
|
|
project={project}
|
|
currentUserId={userProfile?.id}
|
|
taskCounts={taskCounts}
|
|
onToggleActions={() => {
|
|
if (showMoreInfoId === project.id) {
|
|
setShowMoreInfoId(null)
|
|
} else {
|
|
setShowMoreInfoId(project.id)
|
|
}
|
|
}}
|
|
/>
|
|
</SwipeableListItem>
|
|
))}
|
|
</SwipeableList>
|
|
</Box>
|
|
|
|
{modalOpen && (
|
|
<ProjectModal
|
|
isOpen={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
onSave={handleSaveProject}
|
|
project={currentProject}
|
|
/>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
|
|
left: 10,
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: 2,
|
|
'z-index': 1000,
|
|
}}
|
|
>
|
|
<IconButton
|
|
data-testid='open-add-project-modal'
|
|
color='primary'
|
|
variant='solid'
|
|
sx={{
|
|
borderRadius: '50%',
|
|
width: 50,
|
|
height: 50,
|
|
}}
|
|
onClick={handleAddProject}
|
|
>
|
|
<Add />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<ConfirmationModal config={confirmationModel} />
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
export default ProjectView
|