Add Support for LabelV2, Add LabelModal and LabelView.

Add React Query
This commit is contained in:
Mo Tarbin 2024-11-23 20:23:59 -05:00
parent 5e590bfe9f
commit 42182371ff
18 changed files with 839 additions and 71 deletions

View file

@ -1,3 +1,4 @@
import { Add } from '@mui/icons-material'
import {
Box,
Button,
@ -11,6 +12,7 @@ import {
Input,
List,
ListItem,
MenuItem,
Option,
Radio,
RadioGroup,
@ -34,8 +36,10 @@ import {
SaveChore,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import FreeSoloCreateOption from '../components/AutocompleteSelect'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import RepeatSection from './RepeatSection'
const ASSIGN_STRATEGIES = [
'random',
@ -66,6 +70,7 @@ const ChoreEdit = () => {
const [frequency, setFrequency] = useState(1)
const [frequencyMetadata, setFrequencyMetadata] = useState({})
const [labels, setLabels] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [allUserThings, setAllUserThings] = useState([])
const [thingTrigger, setThingTrigger] = useState(null)
const [isThingValid, setIsThingValid] = useState(false)
@ -82,6 +87,9 @@ const ChoreEdit = () => {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarColor, setSnackbarColor] = useState('warning')
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const { data: userLabels, isLoading: isUserLabelsLoading } = useLabels()
const Navigate = useNavigate()
const HandleValidateChore = () => {
@ -172,7 +180,8 @@ const ChoreEdit = () => {
isRolling: isRolling,
isActive: isActive,
notification: isNotificable,
labels: labels,
labels: labels.map(l => l.name),
labelsV2: labelsV2,
notificationMetadata: notificationMetadata,
thingTrigger: thingTrigger,
}
@ -226,8 +235,9 @@ const ChoreEdit = () => {
setFrequency(data.res.frequency)
setNotificationMetadata(JSON.parse(data.res.notificationMetadata))
setLabels(data.res.labels ? data.res.labels.split(',') : [])
// setLabels(data.res.labels ? data.res.labels.split(',') : [])
setLabelsV2(data.res.labelsV2)
setAssignStrategy(
data.res.assignStrategy
? data.res.assignStrategy
@ -275,6 +285,14 @@ const ChoreEdit = () => {
}
}, [])
// useEffect(() => {
// if (userLabels && userLabels.length == 0 && labelsV2.length == 0) {
// return
// }
// const labelIds = labelsV2.map(l => l.id)
// setLabelsV2(userLabels.filter(l => labelIds.indexOf(l.id) > -1))
// }, [userLabels, labelsV2])
useEffect(() => {
// if frequancy type change to somthing need a due date then set it to the current date:
if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
@ -329,6 +347,7 @@ const ChoreEdit = () => {
},
})
}
return (
<Container maxWidth='md'>
{/* <Typography level='h3' mb={1.5}>
@ -726,8 +745,9 @@ const ChoreEdit = () => {
<Typography level='h5'>
Things to remember about this chore or to tag it
</Typography>
<FreeSoloCreateOption
options={labels}
{/* <FreeSoloCreateOption
options={[...labels, 'test']}
selected={labels}
onSelectChange={changes => {
const newLabels = []
changes.map(change => {
@ -741,7 +761,99 @@ const ChoreEdit = () => {
})
setLabels(newLabels)
}}
/>
/> */}
<Select
multiple
onChange={(event, newValue) => {
setLabelsV2(userLabels.filter(l => newValue.indexOf(l.name) > -1))
}}
value={labelsV2.map(l => l.name)}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: '0.25rem' }}>
{labelsV2.map(selectedOption => {
return (
<Chip
variant='soft'
color='primary'
key={selectedOption.id}
size='lg'
sx={{
background: selectedOption.color,
color: getTextColorFromBackgroundColor(
selectedOption.color,
),
}}
>
{selectedOption.name}
</Chip>
)
})}
</Box>
)}
sx={{ minWidth: '15rem' }}
slotProps={{
listbox: {
sx: {
width: '100%',
},
},
}}
>
{userLabels &&
userLabels
// .map(l => l.name)
.map(label => (
<Option key={label.id + label.name} value={label.name}>
<div
style={{
width: '20 px',
height: '20 px',
borderRadius: '50%',
background: label.color,
}}
/>
{label.name}
</Option>
))}
<MenuItem
key={'addNewLabel'}
value={' New Label'}
onClick={() => {
setAddLabelModalOpen(true)
}}
>
<Add />
Add New Label
</MenuItem>
</Select>
{/* <Card>
<List
orientation='horizontal'
wrap
sx={{
'--List-gap': '8px',
'--ListItem-radius': '20px',
}}
>
{labels?.map((label, index) => (
<ListItem key={label}>
<Chip
onClick={() => {
setLabels(labels.filter(l => l !== label))
}}
checked={true}
overlay
variant='soft'
color='primary'
size='lg'
endDecorator={<Cancel />}
>
{label}
</Chip>
</ListItem>
))}
</List>
</Card> */}
</Box>
{choreId > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
@ -822,6 +934,16 @@ const ChoreEdit = () => {
</Button>
</Sheet>
<ConfirmationModal config={confirmModelConfig} />
{addLabelModalOpen && (
<LabelModal
isOpen={addLabelModalOpen}
onSave={label => {
setLabels([...labels, label])
setAddLabelModalOpen(false)
}}
onClose={() => setAddLabelModalOpen(false)}
/>
)}
{/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
<Snackbar
open={isSnackbarOpen}

View file

@ -43,6 +43,7 @@ import {
SkipChore,
UpdateChorePriority,
} from '../../utils/Fetcher'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const IconCard = styled('div')({
@ -264,6 +265,22 @@ const ChoreView = () => {
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
: 'N/A'}
</Chip>
{/* show each label : */}
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
>
{label?.name}
</Chip>
))}
</Box>
<Box>
<Sheet

View file

@ -47,6 +47,7 @@ import {
SkipChore,
UpdateChoreAssignee,
} from '../../utils/Fetcher'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import { Fetch } from '../../utils/TokenManager'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
@ -58,6 +59,7 @@ const ChoreCard = ({
performers,
onChoreUpdate,
onChoreRemove,
userLabels,
sx,
viewOnly,
}) => {
@ -407,7 +409,7 @@ const ChoreCard = ({
}
return (
<>
<Box key={chore.id + '-box'}>
<Chip
variant='soft'
sx={{
@ -455,6 +457,7 @@ const ChoreCard = ({
// backgroundColor: 'white',
boxShadow: 'sm',
borderRadius: 20,
key: `${chore.id}-card`,
// mb: 2,
}}
@ -485,7 +488,7 @@ const ChoreCard = ({
</Chip>
</Typography>
)}
<Box>
<Box key={`${chore.id}-labels`}>
{chore.priority > 0 && (
<Chip
sx={{
@ -505,22 +508,27 @@ const ChoreCard = ({
P{chore.priority}
</Chip>
)}
{chore.labels?.split(',').map((label, index) => (
<Chip
variant='solid'
key={label}
color='primary'
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
}}
startDecorator={getIconForLabel(label)}
>
{label}
</Chip>
))}
{chore.labelsV2?.map((l, index) => {
return (
<Chip
variant='solid'
key={l.id}
color='primary'
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: l?.color,
color: getTextColorFromBackgroundColor(l?.color),
}}
// startDecorator={getIconForLabel(label)}
>
{l?.name}
</Chip>
)
})}
</Box>
</Box>
</Box>
@ -757,7 +765,7 @@ const ChoreCard = ({
</Typography>
</Snackbar>
</Card>
</>
</Box>
)
}

View file

@ -21,8 +21,10 @@ import Fuse from 'fuse.js'
import { useContext, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { GetAllUsers, GetChores, GetUserProfile } from '../../utils/Fetcher'
import { useChores } from '../../queries/ChoreQueries'
import { GetAllUsers, GetUserProfile } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ChoreCard from './ChoreCard'
const MyChores = () => {
@ -38,6 +40,8 @@ const MyChores = () => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
const Navigate = useNavigate()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: choresData, isLoading: choresLoading } = useChores()
const choreSorter = (a, b) => {
// 1. Handle null due dates (always last):
if (!a.nextDueDate && !b.nextDueDate) return 0 // Both null, no order
@ -74,14 +78,6 @@ const MyChores = () => {
setUserProfile(data.res)
})
}
GetChores()
.then(response => response.json())
.then(data => {
data.res.sort(choreSorter)
setChores(data.res)
setFilteredChores(data.res)
})
GetAllUsers()
.then(response => response.json())
@ -94,6 +90,15 @@ const MyChores = () => {
setActiveUserId(currentUser.id)
}
}, [])
useEffect(() => {
if (choresData) {
const sortedChores = choresData.res.sort(choreSorter)
setChores(sortedChores)
setFilteredChores(sortedChores)
}
}, [choresData, choresLoading])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
@ -160,12 +165,21 @@ const MyChores = () => {
const searchOptions = {
// keys to search in
keys: ['name', 'labels'],
keys: ['name', 'raw_label'],
includeScore: true, // Optional: if you want to see how well each result matched the search term
isCaseSensitive: false,
findAllMatches: true,
}
const fuse = new Fuse(chores, searchOptions)
const fuse = new Fuse(
chores.map(c => ({
...c,
raw_label: c.labelsV2
.map(l => userLabels.find(x => (x.id = l.id)).name)
.join(' '),
})),
searchOptions,
)
const handleSearchChange = e => {
const search = e.target.value
@ -180,7 +194,12 @@ const MyChores = () => {
setFilteredChores(fuse.search(term).map(result => result.item))
}
if (userProfile === null) {
if (
userProfile === null ||
userLabelsLoading ||
performers.length === 0 ||
choresLoading
) {
return <LoadingComponent />
}
@ -326,6 +345,7 @@ const MyChores = () => {
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
/>
))}

View file

@ -0,0 +1,8 @@
import { useQuery } from 'react-query'
import { GetLabels } from '../../utils/Fetcher'
export const useLabels = () => {
return useQuery('labels', GetLabels, {
initialData: [],
})
}

View file

@ -0,0 +1,178 @@
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Box,
CircularProgress,
Container,
IconButton,
Table,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries'
// import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Add } from '@mui/icons-material'
import { useQueryClient } from 'react-query'
import { DeleteLabel } from '../../utils/Fetcher'
const LabelView = () => {
const { data: labels, isLabelsLoading, isError } = useLabels()
const [userLabels, setUserLabels] = useState([labels])
const [modalOpen, setModalOpen] = useState(false)
const [currentLabel, setCurrentLabel] = useState(null) // Label being edited or null for new label
const queryClient = useQueryClient()
const handleAddLabel = () => {
setCurrentLabel(null) // Adding a new label
setModalOpen(true)
}
const handleEditLabel = label => {
setCurrentLabel(label) // Editing an existing label
setModalOpen(true)
}
const handleDeleteLabel = id => {
DeleteLabel(id).then(res => {
// Invalidate and refetch labels after deleting a label
const updatedLabels = userLabels.filter(label => label.id !== id)
setUserLabels(updatedLabels)
queryClient.invalidateQueries('labels')
})
// Implement deletion logic here
}
const handleSaveLabel = newOrUpdatedLabel => {
queryClient.invalidateQueries('labels')
setModalOpen(false)
const updatedLabels = userLabels.map(label =>
label.id === newOrUpdatedLabel.id ? newOrUpdatedLabel : label,
)
setUserLabels(updatedLabels)
}
useEffect(() => {
if (labels) {
setUserLabels(labels)
}
}, [labels])
if (isLabelsLoading) {
return (
<Box
display='flex'
justifyContent='center'
alignItems='center'
height='100vh'
>
<CircularProgress />
</Box>
)
}
if (isError) {
return (
<Typography color='danger' textAlign='center'>
Failed to load labels. Please try again.
</Typography>
)
}
return (
<Container maxWidth='md'>
<Table aria-label='Manage Labels' stickyHeader hoverRow>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Label</th>
<th style={{ textAlign: 'center' }}>Color</th>
<th style={{ textAlign: 'center' }}>Actions</th>
</tr>
</thead>
<tbody>
{userLabels.map(label => (
<tr key={label.id}>
<td>{label.name}</td>
<td
style={{
// center without display flex:
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
width={20}
height={20}
borderRadius='50%'
sx={{
backgroundColor: label.color,
}}
/>
</td>
<td
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<IconButton onClick={() => handleEditLabel(label)}>
<EditIcon />
</IconButton>
<IconButton
onClick={() => handleDeleteLabel(label.id)}
color='danger'
>
<DeleteIcon />
</IconButton>
</td>
</tr>
))}
</tbody>
</Table>
{userLabels.length === 0 && (
<Typography textAlign='center' mt={2}>
No labels available. Add a new label to get started.
</Typography>
)}
{modalOpen && (
<LabelModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveLabel}
label={currentLabel}
/>
)}
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<IconButton
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={handleAddLabel}
>
<Add />
</IconButton>
</Box>
</Container>
)
}
export default LabelView

View file

View file

@ -0,0 +1,175 @@
import {
Box,
Button,
FormControl,
Input,
Modal,
ModalDialog,
Option,
Select,
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
import { useQueryClient } from 'react-query'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import LABEL_COLORS from '../../../utils/LabelColors'
import { useLabels } from '../../Labels/LabelQueries'
function LabelModal({ isOpen, onClose, onSave, label }) {
const [labelName, setLabelName] = React.useState('')
const [color, setColor] = React.useState('')
const [error, setError] = React.useState('')
const { data: userLabels, isLoadingLabels } = useLabels()
const queryClient = useQueryClient()
// Populate the form fields when editing
useEffect(() => {
if (label) {
setLabelName(label.name)
setColor(label.color)
} else {
setLabelName('')
setColor('')
}
setError('')
}, [label])
const validateLabel = () => {
if (!labelName || labelName.trim() === '') {
setError('Name cannot be empty')
return false
} else if (
!label ||
userLabels.some(
userLabel => userLabel.name === labelName && userLabel.id !== label.id,
)
) {
setError('Label with this name already exists')
return false
} else if (color === '') {
setError('Please select a color')
return false
}
return true
}
const handleSave = () => {
if (!validateLabel()) {
return
}
const saveAction = label
? UpdateLabel({ id: label.id, name: labelName, color })
: CreateLabel({ name: labelName, color })
saveAction.then(res => {
if (res.error) {
console.log(res.error)
setError('Failed to save label. Please try again.')
return
}
queryClient.invalidateQueries('labels').then(() => {
onSave({ id: label?.id, name: labelName, color })
onClose()
})
})
}
return (
<Modal open={isOpen} onClose={onClose}>
<ModalDialog>
<Typography level='title-md' mb={1}>
{label ? 'Edit Label' : 'Add Label'}
</Typography>
<FormControl>
<Typography level='body-sm' alignSelf={'start'}>
Name
</Typography>
<Input
margin='normal'
required
fullWidth
name='labelName'
type='text'
id='labelName'
value={labelName}
onChange={e => setLabelName(e.target.value)}
/>
</FormControl>
{/* Color Selection */}
<FormControl>
<Typography level='body-sm' alignSelf={'start'}>
Color:
</Typography>
<Select
label='Color'
value={color}
renderValue={selected => (
<Typography
key={selected.value}
startDecorator={
<Box
className='h-4 w-4'
borderRadius={10}
sx={{
background: selected.value,
shadow: { xs: 1 },
}}
/>
}
>
{selected.label}
</Typography>
)}
onChange={(e, value) => {
value && setColor(value)
}}
>
{LABEL_COLORS.map(val => (
<Option key={val.value} value={val.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{
background: val.value,
}}
/>
<Typography
sx={{
ml: 1,
color: 'text.secondary',
}}
variant='caption'
>
{val.name}
</Typography>
</Box>
</Option>
))}
</Select>
{error && (
<Typography color='warning' level='body-sm'>
{error}
</Typography>
)}
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{label ? 'Save Changes' : 'Add Label'}
</Button>
<Button onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
)
}
export default LabelModal

View file

@ -7,14 +7,18 @@ import * as React from 'react'
const filter = createFilterOptions()
export default function FreeSoloCreateOption({ options, onSelectChange }) {
export default function FreeSoloCreateOption({
options,
onSelectChange,
selected,
}) {
React.useEffect(() => {
setValue(options)
}, [options])
const [value, setValue] = React.useState([])
const [value, setValue] = React.useState([selected])
const [selectOptions, setSelectOptions] = React.useState(
options ? options : [],
selected ? selected : [],
)
return (
<FormControl id='free-solo-with-text-demo'>
@ -38,26 +42,27 @@ export default function FreeSoloCreateOption({ options, onSelectChange }) {
}
onSelectChange(newValue)
}}
filterOptions={(options, params) => {
const filtered = filter(options, params)
filterOptions={(selected, params) => {
const filtered = filter(selected, params)
const { inputValue } = params
// Suggest the creation of a new value
const isExisting = options.some(option => inputValue === option.title)
const isExisting = selected.some(
option => inputValue === option.title,
)
if (inputValue !== '' && !isExisting) {
filtered.push({
inputValue,
title: `Add "${inputValue}"`,
})
}
return filtered
}}
selectOnFocus
clearOnBlur
handleHomeEndKeys
// freeSolo
options={selectOptions}
options={options}
getOptionLabel={option => {
// Value selected with enter, right from the input
if (typeof option === 'string') {

View file

@ -2,7 +2,7 @@ import Logo from '@/assets/logo.svg'
import {
AccountBox,
HomeOutlined,
ListAltRounded,
ListAlt,
Logout,
MenuRounded,
Message,
@ -30,6 +30,7 @@ const links = [
label: 'Home',
icon: <HomeOutlined />,
},
// {
// to: '/chores',
// label: 'Desktop View',
@ -40,6 +41,11 @@ const links = [
label: 'Things',
icon: <Widgets />,
},
{
to: 'labels',
label: 'Labels',
icon: <ListAlt />,
},
{
to: '/settings#sharing',
label: 'Sharing',