diff options
author | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
---|---|---|
committer | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
commit | 2657469964e24ffbeb905024532120395f6e797c (patch) | |
tree | 2fe9db8a4ecfa92d854ca94f7586d81163c8bd25 /src/views/ChoreEdit | |
download | donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.gz donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.bz2 donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.zip |
move to Donetick Org, First commit frontend
Diffstat (limited to 'src/views/ChoreEdit')
-rw-r--r-- | src/views/ChoreEdit/ChoreEdit.jsx | 744 | ||||
-rw-r--r-- | src/views/ChoreEdit/RepeatSection.jsx | 496 | ||||
-rw-r--r-- | src/views/ChoreEdit/ThingTriggerSection.jsx | 230 |
3 files changed, 1470 insertions, 0 deletions
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx new file mode 100644 index 0000000..e8eb17d --- /dev/null +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -0,0 +1,744 @@ +import { + Box, + Button, + Card, + Checkbox, + Chip, + Container, + Divider, + FormControl, + FormHelperText, + Input, + List, + ListItem, + Option, + Radio, + RadioGroup, + Select, + Sheet, + Typography, +} from '@mui/joy' +import moment from 'moment' +import { useContext, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { UserContext } from '../../contexts/UserContext' +import { + CreateChore, + DeleteChore, + GetAllCircleMembers, + GetChoreByID, + GetChoreHistory, + GetThings, + SaveChore, +} from '../../utils/Fetcher' +import { isPlusAccount } from '../../utils/Helpers' +import FreeSoloCreateOption from '../components/AutocompleteSelect' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' +import RepeatSection from './RepeatSection' +const ASSIGN_STRATEGIES = [ + 'random', + 'least_assigned', + 'least_completed', + 'keep_last_assigned', +] +const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month'] + +const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once'] +const NO_DUE_DATE_ALLOWED_TYPE = ['trigger'] +const ChoreEdit = () => { + const { userProfile, setUserProfile } = useContext(UserContext) + const [chore, setChore] = useState([]) + const [choresHistory, setChoresHistory] = useState([]) + const [userHistory, setUserHistory] = useState({}) + const { choreId } = useParams() + const [name, setName] = useState('') + const [confirmModelConfig, setConfirmModelConfig] = useState({}) + const [assignees, setAssignees] = useState([]) + const [performers, setPerformers] = useState([]) + const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2]) + const [dueDate, setDueDate] = useState(null) + const [completed, setCompleted] = useState(false) + const [completedDate, setCompletedDate] = useState('') + const [assignedTo, setAssignedTo] = useState(-1) + const [frequencyType, setFrequencyType] = useState('once') + const [frequency, setFrequency] = useState(1) + const [frequencyMetadata, setFrequencyMetadata] = useState({}) + const [labels, setLabels] = useState([]) + const [allUserThings, setAllUserThings] = useState([]) + const [thingTrigger, setThingTrigger] = useState({}) + const [isThingValid, setIsThingValid] = useState(false) + + const [notificationMetadata, setNotificationMetadata] = useState({}) + + const [isRolling, setIsRolling] = useState(false) + const [isNotificable, setIsNotificable] = useState(false) + const [isActive, setIsActive] = useState(true) + const [updatedBy, setUpdatedBy] = useState(0) + const [createdBy, setCreatedBy] = useState(0) + const [errors, setErrors] = useState({}) + const [attemptToSave, setAttemptToSave] = useState(false) + + const Navigate = useNavigate() + + const HandleValidateChore = () => { + const errors = {} + + if (name.trim() === '') { + errors.name = 'Name is required' + } + if (assignees.length === 0) { + errors.assignees = 'At least 1 assignees is required' + } + if (assignedTo < 0) { + errors.assignedTo = 'Assigned to is required' + } + if (frequencyType === 'interval' && frequency < 1) { + errors.frequency = 'Frequency is required' + } + if ( + frequencyType === 'days_of_the_week' && + frequencyMetadata['days']?.length === 0 + ) { + errors.frequency = 'At least 1 day is required' + } + if ( + frequencyType === 'day_of_the_month' && + frequencyMetadata['months']?.length === 0 + ) { + errors.frequency = 'At least 1 month is required' + } + if ( + dueDate === null && + !NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && + !NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType) + ) { + if (REPEAT_ON_TYPE.includes(frequencyType)) { + errors.dueDate = 'Start date is required' + } else { + errors.dueDate = 'Due date is required' + } + } + if (frequencyType === 'trigger') { + if (!isThingValid) { + errors.thingTrigger = 'Thing trigger is invalid' + } + } + + // if there is any error then return false: + setErrors(errors) + if (Object.keys(errors).length > 0) { + return false + } + return true + } + + const HandleSaveChore = () => { + setAttemptToSave(true) + if (!HandleValidateChore()) { + console.log('validation failed') + console.log(errors) + return + } + const chore = { + id: Number(choreId), + name: name, + assignees: assignees, + dueDate: dueDate ? new Date(dueDate).toISOString() : null, + frequencyType: frequencyType, + frequency: Number(frequency), + frequencyMetadata: frequencyMetadata, + assignedTo: assignedTo, + assignStrategy: assignStrategy, + isRolling: isRolling, + isActive: isActive, + notification: isNotificable, + labels: labels, + notificationMetadata: notificationMetadata, + thingTrigger: thingTrigger, + } + let SaveFunction = CreateChore + if (choreId > 0) { + SaveFunction = SaveChore + } + + SaveFunction(chore).then(response => { + if (response.status === 200) { + Navigate(`/my/chores`) + } else { + alert('Failed to save chore') + } + }) + } + useEffect(() => { + //fetch performers: + GetAllCircleMembers() + .then(response => response.json()) + .then(data => { + setPerformers(data.res) + }) + GetThings().then(response => { + response.json().then(data => { + setAllUserThings(data.res) + }) + }) + // fetch chores: + if (choreId > 0) { + GetChoreByID(choreId) + .then(response => { + if (response.status !== 200) { + alert('You are not authorized to view this chore.') + Navigate('/my/chores') + return null + } else { + return response.json() + } + }) + .then(data => { + setChore(data.res) + setName(data.res.name ? data.res.name : '') + setAssignees(data.res.assignees ? data.res.assignees : []) + setAssignedTo(data.res.assignedTo) + setFrequencyType( + data.res.frequencyType ? data.res.frequencyType : 'once', + ) + + setFrequencyMetadata(JSON.parse(data.res.frequencyMetadata)) + setFrequency(data.res.frequency) + + setNotificationMetadata(JSON.parse(data.res.notificationMetadata)) + setLabels(data.res.labels ? data.res.labels.split(',') : []) + + setAssignStrategy( + data.res.assignStrategy + ? data.res.assignStrategy + : ASSIGN_STRATEGIES[2], + ) + setIsRolling(data.res.isRolling) + setIsActive(data.res.isActive) + // parse the due date to a string from this format "2021-10-10T00:00:00.000Z" + // use moment.js or date-fns to format the date for to be usable in the input field: + setDueDate( + data.res.nextDueDate + ? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:ss') + : null, + ) + setUpdatedBy(data.res.updatedBy) + setCreatedBy(data.res.createdBy) + setIsNotificable(data.res.notification) + setThingTrigger(data.res.thingChore) + // setDueDate(data.res.dueDate) + // setCompleted(data.res.completed) + // setCompletedDate(data.res.completedDate) + }) + + // fetch chores history: + GetChoreHistory(choreId) + .then(response => response.json()) + .then(data => { + setChoresHistory(data.res) + const newUserChoreHistory = {} + data.res.forEach(choreHistory => { + if (newUserChoreHistory[choreHistory.completedBy]) { + newUserChoreHistory[choreHistory.completedBy] += 1 + } else { + newUserChoreHistory[choreHistory.completedBy] = 1 + } + }) + + setUserHistory(newUserChoreHistory) + }) + } + // set focus on the first input field: + else { + // new task/ chore set focus on the first input field: + document.querySelector('input').focus() + } + }, []) + + 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) { + setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00')) + } + if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) { + setDueDate(null) + } + }, [frequencyType]) + + useEffect(() => { + if (assignees.length === 1) { + setAssignedTo(assignees[0].userId) + } + }, [assignees]) + + useEffect(() => { + if (performers.length > 0 && assignees.length === 0) { + setAssignees([ + { + userId: userProfile.id, + }, + ]) + } + }, [performers]) + + // if user resolve the error trigger validation to remove the error message from the respective field + useEffect(() => { + if (attemptToSave) { + HandleValidateChore() + } + }, [assignees, name, frequencyMetadata, attemptToSave, dueDate]) + + const handleDelete = () => { + setConfirmModelConfig({ + isOpen: true, + title: 'Delete Chore', + confirmText: 'Delete', + cancelText: 'Cancel', + message: 'Are you sure you want to delete this chore?', + onClose: isConfirmed => { + if (isConfirmed === true) { + DeleteChore(choreId).then(response => { + if (response.status === 200) { + Navigate('/my/chores') + } else { + alert('Failed to delete chore') + } + }) + } + setConfirmModelConfig({}) + }, + }) + } + return ( + <Container maxWidth='md'> + {/* <Typography level='h3' mb={1.5}> + Edit Chore + </Typography> */} + <Box> + <FormControl error={errors.name}> + <Typography level='h4'>Descritpion :</Typography> + <Typography level='h5'>What is this chore about?</Typography> + <Input value={name} onChange={e => setName(e.target.value)} /> + <FormHelperText error>{errors.name}</FormHelperText> + </FormControl> + </Box> + <Box mt={2}> + <Typography level='h4'>Assignees :</Typography> + <Typography level='h5'>Who can do this chore?</Typography> + <Card> + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + }} + > + {performers?.map((item, index) => ( + <ListItem key={item.id}> + <Checkbox + // disabled={index === 0} + checked={assignees.find(a => a.userId == item.id) != null} + onClick={() => { + if (assignees.find(a => a.userId == item.id)) { + setAssignees(assignees.filter(i => i.userId !== item.id)) + } else { + setAssignees([...assignees, { userId: item.id }]) + } + }} + overlay + disableIcon + variant='soft' + label={item.displayName} + /> + </ListItem> + ))} + </List> + </Card> + <FormControl error={Boolean(errors.assignee)}> + <FormHelperText error>{Boolean(errors.assignee)}</FormHelperText> + </FormControl> + </Box> + {assignees.length > 1 && ( + // this wrap the details that needed if we have more than one assingee + // we need to pick the next assignedTo and also the strategy to pick the next assignee. + // if we have only one then no need to display this section + <> + <Box mt={2}> + <Typography level='h4'>Assigned :</Typography> + <Typography level='h5'> + Who is assigned the next due chore? + </Typography> + + <Select + placeholder={ + assignees.length === 0 + ? 'No Assignees yet can perform this chore' + : 'Select an assignee for this chore' + } + disabled={assignees.length === 0} + value={assignedTo > -1 ? assignedTo : null} + > + {performers + ?.filter(p => assignees.find(a => a.userId == p.userId)) + .map((item, index) => ( + <Option + value={item.id} + key={item.displayName} + onClick={() => { + setAssignedTo(item.id) + }} + > + {item.displayName} + {/* <Chip size='sm' color='neutral' variant='soft'> + </Chip> */} + </Option> + ))} + </Select> + </Box> + <Box mt={2}> + <Typography level='h4'>Picking Mode :</Typography> + <Typography level='h5'> + How to pick the next assignee for the following chore? + </Typography> + + <Card> + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + }} + > + {ASSIGN_STRATEGIES.map((item, idx) => ( + <ListItem key={item}> + <Checkbox + // disabled={index === 0} + checked={assignStrategy === item} + onClick={() => setAssignStrategy(item)} + overlay + disableIcon + variant='soft' + label={item + .split('_') + .map(x => x.charAt(0).toUpperCase() + x.slice(1)) + .join(' ')} + /> + </ListItem> + ))} + </List> + </Card> + </Box> + </> + )} + <RepeatSection + frequency={frequency} + onFrequencyUpdate={setFrequency} + frequencyType={frequencyType} + onFrequencyTypeUpdate={setFrequencyType} + frequencyMetadata={frequencyMetadata} + onFrequencyMetadataUpdate={setFrequencyMetadata} + frequencyError={errors?.frequency} + allUserThings={allUserThings} + onTriggerUpdate={thingUpdate => { + if (thingUpdate === null) { + setThingTrigger(null) + return + } + setThingTrigger({ + triggerState: thingUpdate.triggerState, + condition: thingUpdate.condition, + thingID: thingUpdate.thing.id, + }) + }} + OnTriggerValidate={setIsThingValid} + isAttemptToSave={attemptToSave} + selectedThing={thingTrigger} + /> + + <Box mt={2}> + <Typography level='h4'> + {REPEAT_ON_TYPE.includes(frequencyType) ? 'Start date' : 'Due date'} : + </Typography> + {frequencyType === 'trigger' && !dueDate && ( + <Typography level='body-sm'> + Due Date will be set when the trigger of the thing is met + </Typography> + )} + + {NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && ( + <FormControl sx={{ mt: 1 }}> + <Checkbox + onChange={e => { + if (e.target.checked) { + setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00')) + } else { + setDueDate(null) + } + }} + defaultChecked={dueDate !== null} + checked={dueDate !== null} + value={dueDate !== null} + overlay + label='Give this task a due date' + /> + <FormHelperText> + task needs to be completed by a specific time. + </FormHelperText> + </FormControl> + )} + {dueDate && ( + <FormControl error={Boolean(errors.dueDate)}> + <Typography level='h5'> + {REPEAT_ON_TYPE.includes(frequencyType) + ? 'When does this chore start?' + : 'When is the next first time this chore is due?'} + </Typography> + <Input + type='datetime-local' + value={dueDate} + onChange={e => { + setDueDate(e.target.value) + }} + /> + <FormHelperText>{errors.dueDate}</FormHelperText> + </FormControl> + )} + </Box> + {!['once', 'no_repeat'].includes(frequencyType) && ( + <Box mt={2}> + <Typography level='h4'>Scheduling Preferences: </Typography> + <Typography level='h5'> + How to reschedule the next due date? + </Typography> + + <RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}> + <FormControl> + <Radio + overlay + checked={!isRolling} + onClick={() => setIsRolling(false)} + label='Reschedule from due date' + /> + <FormHelperText> + the next task will be scheduled from the original due date, even + if the previous task was completed late + </FormHelperText> + </FormControl> + <FormControl> + <Radio + overlay + checked={isRolling} + onClick={() => setIsRolling(true)} + label='Reschedule from completion date' + /> + <FormHelperText> + the next task will be scheduled from the actual completion date + of the previous task + </FormHelperText> + </FormControl> + </RadioGroup> + </Box> + )} + <Box mt={2}> + <Typography level='h4'>Notifications : </Typography> + <Typography level='h5'> + Get Reminders when this task is due or completed + {!isPlusAccount(userProfile) && ( + <Chip variant='soft' color='warning'> + Not available in Basic Plan + </Chip> + )} + </Typography> + + <FormControl sx={{ mt: 1 }}> + <Checkbox + onChange={e => { + setIsNotificable(e.target.checked) + }} + defaultChecked={isNotificable} + checked={isNotificable} + value={isNotificable} + disabled={!isPlusAccount(userProfile)} + overlay + label='Notify for this task' + /> + <FormHelperText + sx={{ + opacity: !isPlusAccount(userProfile) ? 0.5 : 1, + }} + > + Receive notifications for this task + </FormHelperText> + </FormControl> + </Box> + {isNotificable && ( + <Box + ml={4} + sx={{ + display: 'flex', + flexDirection: 'column', + gap: 2, + + '& > div': { p: 2, borderRadius: 'md', display: 'flex' }, + }} + > + <Card variant='outlined'> + <Typography level='h5'> + What things should trigger the notification? + </Typography> + {[ + { + title: 'Due Date/Time', + description: 'A simple reminder that a task is due', + id: 'dueDate', + }, + // { + // title: 'Upon Completion', + // description: 'A notification when a task is completed', + // id: 'completion', + // }, + { + title: 'Predued', + description: 'before a task is due in few hours', + id: 'predue', + }, + { + title: 'Overdue', + description: 'A notification when a task is overdue', + }, + { + title: 'Nagging', + description: 'Daily reminders until the task is completed', + id: 'nagging', + }, + ].map(item => ( + <FormControl sx={{ mb: 1 }} key={item.id}> + <Checkbox + overlay + onClick={() => { + setNotificationMetadata({ + ...notificationMetadata, + [item.id]: !notificationMetadata[item.id], + }) + }} + checked={ + notificationMetadata ? notificationMetadata[item.id] : false + } + label={item.title} + key={item.title} + /> + <FormHelperText>{item.description}</FormHelperText> + </FormControl> + ))} + </Card> + </Box> + )} + <Box mt={2}> + <Typography level='h4'>Labels :</Typography> + <Typography level='h5'> + Things to remember about this chore or to tag it + </Typography> + <FreeSoloCreateOption + options={labels} + onSelectChange={changes => { + const newLabels = [] + changes.map(change => { + // if type is string : + if (typeof change === 'string') { + // add the change to the labels array: + newLabels.push(change) + } else { + newLabels.push(change.inputValue) + } + }) + setLabels(newLabels) + }} + /> + </Box> + {choreId > 0 && ( + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> + <Sheet + sx={{ + p: 2, + borderRadius: 'md', + boxShadow: 'sm', + }} + > + <Typography level='body1'> + Created by{' '} + <Chip variant='solid'> + {performers.find(f => f.id === createdBy)?.displayName} + </Chip>{' '} + {moment(chore.createdAt).fromNow()} + </Typography> + {(chore.updatedAt && updatedBy > 0 && ( + <> + <Divider sx={{ my: 1 }} /> + + <Typography level='body1'> + Updated by{' '} + <Chip variant='solid'> + {performers.find(f => f.id === updatedBy)?.displayName} + </Chip>{' '} + {moment(chore.updatedAt).fromNow()} + </Typography> + </> + )) || <></>} + </Sheet> + </Box> + )} + <Divider sx={{ mb: 9 }} /> + + {/* <Box mt={2} alignSelf={'flex-start'} display='flex' gap={2}> + <Button onClick={SaveChore}>Save</Button> + </Box> */} + <Sheet + variant='outlined' + sx={{ + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + p: 2, // padding + display: 'flex', + justifyContent: 'flex-end', + gap: 2, + 'z-index': 1000, + bgcolor: 'background.body', + boxShadow: 'md', // Add a subtle shadow + }} + > + {choreId > 0 && ( + <Button + color='danger' + variant='solid' + onClick={() => { + // confirm before deleting: + handleDelete() + }} + > + Delete + </Button> + )} + <Button + color='neutral' + variant='outlined' + onClick={() => { + window.history.back() + }} + > + Cancel + </Button> + <Button color='primary' variant='solid' onClick={HandleSaveChore}> + {choreId > 0 ? 'Save' : 'Create'} + </Button> + </Sheet> + <ConfirmationModal config={confirmModelConfig} /> + {/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */} + </Container> + ) +} + +export default ChoreEdit diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx new file mode 100644 index 0000000..cb680eb --- /dev/null +++ b/src/views/ChoreEdit/RepeatSection.jsx @@ -0,0 +1,496 @@ +import { + Box, + Card, + Checkbox, + Chip, + FormControl, + FormHelperText, + Grid, + Input, + List, + ListItem, + Option, + Radio, + RadioGroup, + Select, + Typography, +} from '@mui/joy' +import { useContext, useState } from 'react' +import { UserContext } from '../../contexts/UserContext' +import { isPlusAccount } from '../../utils/Helpers' +import ThingTriggerSection from './ThingTriggerSection' + +const FREQUANCY_TYPES_RADIOS = [ + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'adaptive', + 'custom', +] + +const FREQUENCY_TYPE_MESSAGE = { + adaptive: + 'This chore will be scheduled dynamically based on previous completion dates.', + custom: 'This chore will be scheduled based on a custom frequency.', +} +const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month'] +const FREQUANCY_TYPES = [ + 'once', + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'adaptive', + ...REPEAT_ON_TYPE, +] +const MONTH_WITH_NO_31_DAYS = [ + // TODO: Handle these months if day is 31 + 'february', + 'april', + 'june', + 'september', + 'november', +] +const RepeatOnSections = ({ + frequencyType, + frequency, + onFrequencyUpdate, + onFrequencyTypeUpdate, + frequencyMetadata, + onFrequencyMetadataUpdate, + things, +}) => { + const [months, setMonths] = useState({}) + // const [dayOftheMonth, setDayOftheMonth] = useState(1) + const [daysOfTheWeek, setDaysOfTheWeek] = useState({}) + const [monthsOfTheYear, setMonthsOfTheYear] = useState({}) + const [intervalUnit, setIntervalUnit] = useState('days') + + switch (frequencyType) { + case 'interval': + return ( + <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}> + <Typography level='h5'>Every: </Typography> + <Input + type='number' + value={frequency} + onChange={e => { + if (e.target.value < 1) { + e.target.value = 1 + } + onFrequencyUpdate(e.target.value) + }} + /> + <Select placeholder='Unit' value={intervalUnit}> + {['hours', 'days', 'weeks', 'months', 'years'].map(item => ( + <Option + key={item} + value={item} + onClick={() => { + setIntervalUnit(item) + onFrequencyMetadataUpdate({ + unit: item, + }) + }} + > + {item.charAt(0).toUpperCase() + item.slice(1)} + </Option> + ))} + </Select> + </Grid> + ) + case 'days_of_the_week': + return ( + <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}> + <Card> + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + }} + > + {[ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ].map(item => ( + <ListItem key={item}> + <Checkbox + // disabled={index === 0} + + checked={frequencyMetadata?.days?.includes(item) || false} + onClick={() => { + const newDaysOfTheWeek = frequencyMetadata['days'] || [] + if (newDaysOfTheWeek.includes(item)) { + newDaysOfTheWeek.splice( + newDaysOfTheWeek.indexOf(item), + 1, + ) + } else { + newDaysOfTheWeek.push(item) + } + + onFrequencyMetadataUpdate({ + days: newDaysOfTheWeek.sort(), + }) + }} + overlay + disableIcon + variant='soft' + label={item.charAt(0).toUpperCase() + item.slice(1)} + /> + </ListItem> + ))} + </List> + </Card> + </Grid> + ) + case 'day_of_the_month': + return ( + <Grid + item + sm={12} + sx={{ + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'space-between', + }} + > + <Box + sx={{ + display: 'flex', + alignItems: 'center', + mb: 1.5, + }} + > + <Typography>on the </Typography> + <Input + sx={{ width: '80px' }} + type='number' + value={frequency} + onChange={e => { + if (e.target.value < 1) { + e.target.value = 1 + } else if (e.target.value > 31) { + e.target.value = 31 + } + // setDayOftheMonth(e.target.value) + + onFrequencyUpdate(e.target.value) + }} + /> + <Typography>of the following month/s: </Typography> + </Box> + <Card> + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + }} + > + {[ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', + ].map(item => ( + <ListItem key={item}> + <Checkbox + // disabled={index === 0} + checked={frequencyMetadata?.months?.includes(item)} + // checked={months[item] || false} + // onClick={() => { + // const newMonthsOfTheYear = { + // ...monthsOfTheYear, + // } + // newMonthsOfTheYear[item] = !newMonthsOfTheYear[item] + // onFrequencyMetadataUpdate({ + // months: newMonthsOfTheYear, + // }) + // setMonthsOfTheYear(newMonthsOfTheYear) + // }} + onClick={() => { + const newMonthsOfTheYear = + frequencyMetadata['months'] || [] + if (newMonthsOfTheYear.includes(item)) { + newMonthsOfTheYear.splice( + newMonthsOfTheYear.indexOf(item), + 1, + ) + } else { + newMonthsOfTheYear.push(item) + } + + onFrequencyMetadataUpdate({ + months: newMonthsOfTheYear.sort(), + }) + console.log('newMonthsOfTheYear', newMonthsOfTheYear) + // setDaysOfTheWeek(newDaysOfTheWeek) + }} + overlay + disableIcon + variant='soft' + label={item.charAt(0).toUpperCase() + item.slice(1)} + /> + </ListItem> + ))} + </List> + </Card> + </Grid> + ) + + default: + return <></> + } +} + +const RepeatSection = ({ + frequencyType, + frequency, + onFrequencyUpdate, + onFrequencyTypeUpdate, + frequencyMetadata, + onFrequencyMetadataUpdate, + frequencyError, + allUserThings, + onTriggerUpdate, + OnTriggerValidate, + isAttemptToSave, + selectedThing, +}) => { + const [repeatOn, setRepeatOn] = useState('interval') + const { userProfile, setUserProfile } = useContext(UserContext) + return ( + <Box mt={2}> + <Typography level='h4'>Repeat :</Typography> + <FormControl sx={{ mt: 1 }}> + <Checkbox + onChange={e => { + onFrequencyTypeUpdate(e.target.checked ? 'daily' : 'once') + if (e.target.checked) { + onTriggerUpdate(null) + } + }} + defaultChecked={!['once', 'trigger'].includes(frequencyType)} + checked={!['once', 'trigger'].includes(frequencyType)} + value={!['once', 'trigger'].includes(frequencyType)} + overlay + label='Repeat this task' + /> + <FormHelperText> + Is this something needed to be done regularly? + </FormHelperText> + </FormControl> + {!['once', 'trigger'].includes(frequencyType) && ( + <> + <Card sx={{ mt: 1 }}> + <Typography level='h5'>How often should it be repeated?</Typography> + + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + }} + > + {FREQUANCY_TYPES_RADIOS.map((item, index) => ( + <ListItem key={item}> + <Checkbox + // disabled={index === 0} + checked={ + item === frequencyType || + (item === 'custom' && + REPEAT_ON_TYPE.includes(frequencyType)) + } + // defaultChecked={item === frequencyType} + onClick={() => { + if (item === 'custom') { + onFrequencyTypeUpdate(REPEAT_ON_TYPE[0]) + onFrequencyUpdate(1) + onFrequencyMetadataUpdate({ + unit: 'days', + }) + return + } + onFrequencyTypeUpdate(item) + }} + overlay + disableIcon + variant='soft' + label={ + item.charAt(0).toUpperCase() + + item.slice(1).replace('_', ' ') + } + /> + </ListItem> + ))} + </List> + <Typography>{FREQUENCY_TYPE_MESSAGE[frequencyType]}</Typography> + {frequencyType === 'custom' || + (REPEAT_ON_TYPE.includes(frequencyType) && ( + <> + <Grid container spacing={1} mt={2}> + <Grid item> + <Typography>Repeat on:</Typography> + <Box + sx={{ display: 'flex', alignItems: 'center', gap: 2 }} + > + <RadioGroup + orientation='horizontal' + aria-labelledby='segmented-controls-example' + name='justify' + // value={justify} + // onChange={event => setJustify(event.target.value)} + sx={{ + minHeight: 48, + padding: '4px', + borderRadius: '12px', + bgcolor: 'neutral.softBg', + '--RadioGroup-gap': '4px', + '--Radio-actionRadius': '8px', + mb: 1, + }} + > + {REPEAT_ON_TYPE.map(item => ( + <Radio + key={item} + color='neutral' + checked={item === frequencyType} + onClick={() => { + if ( + item === 'day_of_the_month' || + item === 'interval' + ) { + onFrequencyUpdate(1) + } + onFrequencyTypeUpdate(item) + if (item === 'days_of_the_week') { + onFrequencyMetadataUpdate({ days: [] }) + } else if (item === 'day_of_the_month') { + onFrequencyMetadataUpdate({ months: [] }) + } else if (item === 'interval') { + onFrequencyMetadataUpdate({ unit: 'days' }) + } + // setRepeatOn(item) + }} + value={item} + disableIcon + label={item + .split('_') + .map((i, idx) => { + // first or last word + if ( + idx === 0 || + idx === item.split('_').length - 1 + ) { + return ( + i.charAt(0).toUpperCase() + i.slice(1) + ) + } + return i + }) + .join(' ')} + variant='plain' + sx={{ + px: 2, + alignItems: 'center', + }} + slotProps={{ + action: ({ checked }) => ({ + sx: { + ...(checked && { + bgcolor: 'background.surface', + boxShadow: 'sm', + '&:hover': { + bgcolor: 'background.surface', + }, + }), + }, + }), + }} + /> + ))} + </RadioGroup> + </Box> + </Grid> + + <RepeatOnSections + frequency={frequency} + onFrequencyUpdate={onFrequencyUpdate} + frequencyType={frequencyType} + onFrequencyTypeUpdate={onFrequencyTypeUpdate} + frequencyMetadata={frequencyMetadata || {}} + onFrequencyMetadataUpdate={onFrequencyMetadataUpdate} + things={allUserThings} + /> + </Grid> + </> + ))} + <FormControl error={Boolean(frequencyError)}> + <FormHelperText error>{frequencyError}</FormHelperText> + </FormControl> + </Card> + </> + )} + <FormControl sx={{ mt: 1 }}> + <Checkbox + onChange={e => { + onFrequencyTypeUpdate(e.target.checked ? 'trigger' : 'once') + // if unchecked, set selectedThing to null: + if (!e.target.checked) { + onTriggerUpdate(null) + } + }} + defaultChecked={frequencyType === 'trigger'} + checked={frequencyType === 'trigger'} + value={frequencyType === 'trigger'} + disabled={!isPlusAccount(userProfile)} + overlay + label='Trigger this task based on a thing state' + /> + <FormHelperText + sx={{ + opacity: !isPlusAccount(userProfile) ? 0.5 : 1, + }} + > + Is this something that should be done when a thing state changes?{' '} + {!isPlusAccount(userProfile) && ( + <Chip variant='soft' color='warning'> + Not available in Basic Plan + </Chip> + )} + </FormHelperText> + </FormControl> + {frequencyType === 'trigger' && ( + <ThingTriggerSection + things={allUserThings} + onTriggerUpdate={onTriggerUpdate} + onValidate={OnTriggerValidate} + isAttemptToSave={isAttemptToSave} + selected={selectedThing} + /> + )} + </Box> + ) +} + +export default RepeatSection diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx new file mode 100644 index 0000000..7a040ad --- /dev/null +++ b/src/views/ChoreEdit/ThingTriggerSection.jsx @@ -0,0 +1,230 @@ +import { Widgets } from '@mui/icons-material' +import { + Autocomplete, + Box, + Button, + Card, + Chip, + FormControl, + FormLabel, + Input, + ListItem, + ListItemContent, + ListItemDecorator, + Option, + Select, + TextField, + Typography, +} from '@mui/joy' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +const isValidTrigger = (thing, condition, triggerState) => { + const newErrors = {} + if (!thing || !triggerState) { + newErrors.thing = 'Please select a thing and trigger state' + return false + } + if (thing.type === 'boolean') { + if (['true', 'false'].includes(triggerState)) { + return true + } else { + newErrors.type = 'Boolean type does not require a condition' + return false + } + } + if (thing.type === 'number') { + if (isNaN(triggerState)) { + newErrors.triggerState = 'Trigger state must be a number' + return false + } + if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(condition)) { + return true + } + } + if (thing.type === 'text') { + if (typeof triggerState === 'string') { + return true + } + } + newErrors.triggerState = 'Trigger state must be a number' + + return false +} + +const ThingTriggerSection = ({ + things, + onTriggerUpdate, + onValidate, + selected, + isAttepmtingToSave, +}) => { + const [selectedThing, setSelectedThing] = useState(null) + const [condition, setCondition] = useState(null) + const [triggerState, setTriggerState] = useState(null) + const navigate = useNavigate() + + useEffect(() => { + if (selected) { + setSelectedThing(things?.find(t => t.id === selected.thingId)) + setCondition(selected.condition) + setTriggerState(selected.triggerState) + } + }, [things]) + + useEffect(() => { + if (selectedThing && triggerState) { + onTriggerUpdate({ + thing: selectedThing, + condition: condition, + triggerState: triggerState, + }) + } + if (isValidTrigger(selectedThing, condition, triggerState)) { + onValidate(true) + } else { + onValidate(false) + } + }, [selectedThing, condition, triggerState]) + + return ( + <Card sx={{ mt: 1 }}> + <Typography level='h5'> + Trigger a task when a thing state changes to a desired state + </Typography> + {things.length !== 0 && ( + <Typography level='body-sm'> + it's look like you don't have any things yet, create a thing to + trigger a task when the state changes. + <Button + startDecorator={<Widgets />} + size='sm' + onClick={() => { + navigate('/things') + }} + > + Go to Things + </Button>{' '} + to create a thing + </Typography> + )} + <FormControl error={isAttepmtingToSave && !selectedThing}> + <Autocomplete + options={things} + value={selectedThing} + onChange={(e, newValue) => setSelectedThing(newValue)} + getOptionLabel={option => option.name} + renderOption={(props, option) => ( + <ListItem {...props}> + <Box + sx={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + alignItems: 'center', + p: 1, + }} + > + <ListItemDecorator sx={{ alignSelf: 'flex-start' }}> + <Typography level='body-lg' textColor='primary'> + {option.name} + </Typography> + </ListItemDecorator> + <ListItemContent> + <Typography level='body2' textColor='text.secondary'> + <Chip>type: {option.type}</Chip>{' '} + <Chip>state: {option.state}</Chip> + </Typography> + </ListItemContent> + </Box> + </ListItem> + )} + renderInput={params => ( + <TextField {...params} label='Select a thing' /> + )} + /> + </FormControl> + <Typography level='body-sm'> + Create a condition to trigger a task when the thing state changes to + desired state + </Typography> + {selectedThing?.type == 'boolean' && ( + <Box> + <Typography level='body-sm'> + When the state of {selectedThing.name} changes as specified below, + the task will become due. + </Typography> + <Select + value={triggerState} + onChange={e => { + if (e?.target.value === 'true' || e?.target.value === 'false') + setTriggerState(e.target.value) + else setTriggerState('false') + }} + > + {['true', 'false'].map(state => ( + <Option + key={state} + value={state} + onClick={() => setTriggerState(state)} + > + {state.charAt(0).toUpperCase() + state.slice(1)} + </Option> + ))} + </Select> + </Box> + )} + {selectedThing?.type == 'number' && ( + <Box> + <Typography level='body-sm'> + When the state of {selectedThing.name} changes as specified below, + the task will become due. + </Typography> + + <Box sx={{ display: 'flex', gap: 1, direction: 'row' }}> + <Typography level='body-sm'>State is</Typography> + <Select value={condition} sx={{ width: '50%' }}> + {[ + { name: 'Equal', value: 'eq' }, + { name: 'Not equal', value: 'neq' }, + { name: 'Greater than', value: 'gt' }, + { name: 'Greater than or equal', value: 'gte' }, + { name: 'Less than', value: 'lt' }, + { name: 'Less than or equal', value: 'lte' }, + ].map(condition => ( + <Option + key={condition.value} + value={condition.value} + onClick={() => setCondition(condition.value)} + > + {condition.name} + </Option> + ))} + </Select> + <Input + type='number' + value={triggerState} + onChange={e => setTriggerState(e.target.value)} + sx={{ width: '50%' }} + /> + </Box> + </Box> + )} + {selectedThing?.type == 'text' && ( + <Box> + <Typography level='body-sm'> + When the state of {selectedThing.name} changes as specified below, + the task will become due. + </Typography> + + <Input + value={triggerState} + onChange={e => setTriggerState(e.target.value)} + label='Enter the text to trigger the task' + /> + </Box> + )} + </Card> + ) +} + +export default ThingTriggerSection |