feat: add DueDatePickerPreview component for managing due dates and times
feat: implement LabelsPickerField component for selecting multiple labels feat: create NotificationPickerField component for managing notification reminders feat: add PriorityPickerField component for selecting task priority levels feat: implement ProjectPickerField component for selecting projects feat: create RepeatPickerField component for setting up recurring tasks feat: add RepeatPickerPreview component for displaying and selecting repeat options
This commit is contained in:
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Add, EditNotifications } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl } from '@mui/material'
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -30,8 +21,15 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import SubTasks from './SubTask'
|
||||
const getDefaultNotification = () => {
|
||||
@@ -54,6 +52,8 @@ const getDefaultNotification = () => {
|
||||
|
||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
@@ -98,7 +98,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [hasNotifications, setHasNotifications] = useState(false)
|
||||
const [hasDeadline, setHasDeadline] = useState(false)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
@@ -106,6 +105,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
@@ -262,20 +279,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
} else {
|
||||
// No overlap, add the current highlight
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlight of resolvedHighlights) {
|
||||
// Add the text before the highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
parts.push(textBefore)
|
||||
plainText += textBefore
|
||||
}
|
||||
|
||||
// Determine the class name based on the highlight type
|
||||
let className = ''
|
||||
switch (highlight.type) {
|
||||
case 'repeat':
|
||||
@@ -300,7 +314,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
break
|
||||
}
|
||||
|
||||
// Add the highlighted span
|
||||
const highlightedText = sentence.substring(
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
@@ -310,9 +323,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
style={{
|
||||
// text underline:
|
||||
textDecoration: 'underline',
|
||||
// textDecorationColor: 'red',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
@@ -321,11 +332,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</span>,
|
||||
)
|
||||
|
||||
// Update the last index to the end of the current highlight
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add any remaining text after the last highlight
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
parts.push(remainingText)
|
||||
@@ -342,12 +351,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
// Parse everything from the original sentence to get correct highlight positions
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
// Parse assignees using circle members
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
userId: member.userId,
|
||||
@@ -364,9 +371,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||
|
||||
// Set all the parsed values
|
||||
if (priority.result) setPriority(priority.result)
|
||||
if (priority.result) setPriority(parseInt(priority.result, 10))
|
||||
if (pointsParsed.result) setPoints(pointsParsed.result)
|
||||
if (labels.result) setLabelsV2(labels.result)
|
||||
if (labels.result) {
|
||||
// parseLabels returns array of label objects, extract their IDs
|
||||
const labelIds = labels.result
|
||||
.filter(label => label.id) // Only labels with IDs (existing labels)
|
||||
.map(label => label.id)
|
||||
setLabelsV2(labelIds)
|
||||
}
|
||||
|
||||
if (assigneesResult.isAnyone) {
|
||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||
@@ -500,6 +513,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
}
|
||||
}
|
||||
@@ -508,9 +523,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const timeValue = e.target.value
|
||||
setDueTime(timeValue)
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
if (timeValue) {
|
||||
setUseCustomTime(true)
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,13 +541,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setUseCustomTime(checked)
|
||||
if (checked) {
|
||||
const defaultTime = dueTime || '18:00'
|
||||
setDueTime(defaultTime)
|
||||
if (!dueTime) {
|
||||
setDueTime(defaultTime)
|
||||
}
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setDueTime(null)
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
@@ -605,6 +631,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
attachments: attachments.length > 0 ? attachments : null,
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
@@ -793,6 +820,93 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
|
||||
// scrollable horizontally but hide the scrollbar:
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
// emptyDisplay='icon-text'
|
||||
/>
|
||||
{/* <ProjectPickerField
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
// onClear={() => setProjectId(getInitialProject())} there should be no option to unselect a project, so we don't need an onClear handler
|
||||
projects={projects || []}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/> */}
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
{/* <Box>
|
||||
<Typography level='body-sm'>Title:</Typography>
|
||||
<Input
|
||||
@@ -802,12 +916,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
/>
|
||||
</Box> */}
|
||||
|
||||
<Box>
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasDescription(true)
|
||||
// Focus will be handled by the useEffect hook
|
||||
@@ -823,8 +938,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasSubTasks(true)
|
||||
}}
|
||||
@@ -835,39 +951,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
{!dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const tomorrow = moment().add(1, 'day')
|
||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
|
||||
}
|
||||
>
|
||||
Due Date
|
||||
</Button>
|
||||
)}
|
||||
{!hasNotifications && dueDate && (
|
||||
<Button
|
||||
startDecorator={<EditNotifications />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasNotifications(true)
|
||||
setFrequencyHumanReadable('Once')
|
||||
setFrequency(null)
|
||||
}}
|
||||
>
|
||||
Edit Notifications
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* {!hasDeadline && dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
@@ -907,61 +991,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{priority > 0 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Priority</Typography>
|
||||
<Select
|
||||
defaultValue={0}
|
||||
value={priority}
|
||||
onChange={(e, value) => setPriority(value)}
|
||||
>
|
||||
<Option value='0'>No Priority</Option>
|
||||
<Option value='1'>P1</Option>
|
||||
<Option value='2'>P2</Option>
|
||||
<Option value='3'>P3</Option>
|
||||
<Option value='4'>P4</Option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{dueDate && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Due Date</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={handleDueDateChange}
|
||||
/>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={useCustomTime}
|
||||
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
||||
label='Set a specific time'
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{useCustomTime
|
||||
? 'Task will be due at the specified time'
|
||||
: 'Task will be due at the end of the day (11:59 PM)'}
|
||||
</FormHelperText>
|
||||
{useCustomTime && (
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || '18:00'}
|
||||
onChange={handleDueTimeChange}
|
||||
sx={{ maxWidth: 200, mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
{/* {projects.length >= 1 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Project</Typography>
|
||||
@@ -1082,33 +1111,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
)} */}
|
||||
{hasNotifications && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Notification Schedule</Typography>
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<NotificationTemplate
|
||||
onChange={metadata => {
|
||||
if (
|
||||
metadata.notifications !== notificationMetadata.templates
|
||||
) {
|
||||
const newNotificationMetadata = {
|
||||
...notificationMetadata,
|
||||
templates: metadata.notifications,
|
||||
}
|
||||
setNotificationMetadata(newNotificationMetadata)
|
||||
}
|
||||
}}
|
||||
value={notificationMetadata}
|
||||
showTimeline={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
43
src/views/components/AssigneePickerField.jsx
Normal file
43
src/views/components/AssigneePickerField.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
getItemValue={item => item.userId}
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssigneePickerField
|
||||
40
src/views/components/AssigneePickerPreview.jsx
Normal file
40
src/views/components/AssigneePickerPreview.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const AssigneePickerPreview = ({
|
||||
value = null,
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
getItemValue={item => item.userId}
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssigneePickerPreview
|
||||
258
src/views/components/AttachmentPickerField.jsx
Normal file
258
src/views/components/AttachmentPickerField.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEscape = e => {
|
||||
if (e.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
if (url) {
|
||||
onChange([...attachments, { url, name: file.name }])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
const updated = attachments.filter((_, i) => i !== index)
|
||||
onChange(updated)
|
||||
if (updated.length === 0) setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isEmpty = attachments.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size='sm'
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: '20px' }} />
|
||||
)}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isEmpty
|
||||
? 'Attachments'
|
||||
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 0.5,
|
||||
borderRadius: 'sm',
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.level2',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attachment.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<DeleteOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentPickerField
|
||||
253
src/views/components/BaseOptionPicker.jsx
Normal file
253
src/views/components/BaseOptionPicker.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
value = null,
|
||||
values = [],
|
||||
multiple = false,
|
||||
onChange,
|
||||
onValuesChange,
|
||||
emptyDisplay = 'icon',
|
||||
emptyLabel = 'Select',
|
||||
placement = 'top-start',
|
||||
menuMinWidth = 180,
|
||||
menuMaxHeight = 280,
|
||||
getItemValue = item => item.id,
|
||||
getItemLabel = item => item.label,
|
||||
renderItemStart,
|
||||
renderTriggerIcon,
|
||||
getItemColor,
|
||||
getTriggerText,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
return items.filter(item => selectedSet.has(getItemValue(item)))
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return []
|
||||
return items.filter(item => getItemValue(item) === value)
|
||||
}, [items, multiple, value, values, getItemValue])
|
||||
|
||||
const isEmpty = selectedItems.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
const triggerText = getTriggerText
|
||||
? getTriggerText({ selectedItems, isEmpty })
|
||||
: isEmpty
|
||||
? emptyLabel
|
||||
: getItemLabel(selectedItems[0])
|
||||
|
||||
const triggerColor = isEmpty
|
||||
? undefined
|
||||
: getItemColor
|
||||
? getItemColor(selectedItems[0])
|
||||
: undefined
|
||||
|
||||
const handleSelect = selectedValue => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
if (selectedSet.has(selectedValue)) {
|
||||
selectedSet.delete(selectedValue)
|
||||
} else {
|
||||
selectedSet.add(selectedValue)
|
||||
}
|
||||
onValuesChange?.(Array.from(selectedSet))
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(selectedValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isSelected = item => {
|
||||
const optionValue = getItemValue(item)
|
||||
if (multiple) {
|
||||
return values.includes(optionValue)
|
||||
}
|
||||
return value === optionValue
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={'sm'}
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
color: triggerColor || undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderTriggerIcon?.({ selectedItems, isEmpty })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{triggerText}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement={placement}
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: menuMinWidth,
|
||||
maxHeight: menuMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const optionValue = getItemValue(item)
|
||||
const selected = isSelected(item)
|
||||
const itemColor = getItemColor ? getItemColor(item) : undefined
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optionValue ?? index}
|
||||
variant={selected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
mb: index === items.length - 1 ? 0 : 0.5,
|
||||
color: selected
|
||||
? itemColor || 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{renderItemStart?.({ item, selected })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getItemLabel(item)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseOptionPicker
|
||||
304
src/views/components/DueDatePickerField.jsx
Normal file
304
src/views/components/DueDatePickerField.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Close,
|
||||
NextWeek,
|
||||
Today,
|
||||
WbSunny,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onDueDateChange,
|
||||
onDueTimeChange,
|
||||
onUseCustomTimeChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
onDueDateChange?.({ target: { value: dateStr } })
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const hasDueDate = Boolean(dueDateOnly)
|
||||
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
|
||||
|
||||
const dueDateLabel = useMemo(() => {
|
||||
if (!dueDateOnly) {
|
||||
return 'Due'
|
||||
}
|
||||
|
||||
const formattedDate = moment(dueDateOnly).format('MMM D')
|
||||
if (useCustomTime && dueTime) {
|
||||
return `${formattedDate}, ${dueTime}`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}, [dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<CalendarMonth sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 260,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Due Date
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 0.5, mb: 1.25, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Today sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handleQuickSchedule('today')}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<WbSunny sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handleQuickSchedule('tomorrow')}
|
||||
>
|
||||
Tomorrow
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Weekend sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handleQuickSchedule('weekend')}
|
||||
>
|
||||
Weekend
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<NextWeek sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handleQuickSchedule('next-week')}
|
||||
>
|
||||
Next week
|
||||
</Button>
|
||||
</Box>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={onDueDateChange}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.5, color: 'text.tertiary' }}
|
||||
>
|
||||
Due time (optional)
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || ''}
|
||||
disabled={!dueDateOnly}
|
||||
onChange={e => {
|
||||
if (!useCustomTime) {
|
||||
onUseCustomTimeChange?.(true)
|
||||
}
|
||||
onDueTimeChange?.(e)
|
||||
}}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear due date
|
||||
</Button>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerField
|
||||
221
src/views/components/DueDatePickerPreview.jsx
Normal file
221
src/views/components/DueDatePickerPreview.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { CalendarMonth, Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const DueDatePickerPreview = ({
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onDueDateChange,
|
||||
onDueTimeChange,
|
||||
onUseCustomTimeChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const hasDueDate = Boolean(dueDateOnly)
|
||||
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
|
||||
|
||||
const dueDateLabel = useMemo(() => {
|
||||
if (!dueDateOnly) {
|
||||
return 'Due'
|
||||
}
|
||||
|
||||
const formattedDate = moment(dueDateOnly).format('MMM D')
|
||||
if (useCustomTime && dueTime) {
|
||||
return `${formattedDate}, ${dueTime}`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}, [dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<CalendarMonth sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 260,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Due Date
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={onDueDateChange}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.5, color: 'text.tertiary' }}
|
||||
>
|
||||
Due time (optional)
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || ''}
|
||||
disabled={!dueDateOnly}
|
||||
onChange={e => {
|
||||
if (!useCustomTime) {
|
||||
onUseCustomTimeChange?.(true)
|
||||
}
|
||||
onDueTimeChange?.(e)
|
||||
}}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear due date
|
||||
</Button>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerPreview
|
||||
48
src/views/components/LabelsPickerField.jsx
Normal file
48
src/views/components/LabelsPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Label } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const LabelsPickerField = ({
|
||||
values = [],
|
||||
onChange,
|
||||
onClear,
|
||||
labels = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
}))
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabelsPickerField
|
||||
160
src/views/components/NotificationPickerField.jsx
Normal file
160
src/views/components/NotificationPickerField.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
const count = templates.length
|
||||
if (count === 1) {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
}
|
||||
return `${count} reminders`
|
||||
}
|
||||
|
||||
const NotificationPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
latestTemplatesRef.current = value?.templates || []
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
const templates = value?.templates || []
|
||||
const hasNotifications = templates.length > 0
|
||||
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
|
||||
const displayLabel = getDisplayLabel(templates)
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ ...value, templates: latestTemplatesRef.current })
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasNotifications ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<NotificationsNone sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasNotifications && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={value}
|
||||
onChange={({ notifications }) => {
|
||||
latestTemplatesRef.current = notifications
|
||||
}}
|
||||
showTimeline
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationPickerField
|
||||
74
src/views/components/PriorityPickerField.jsx
Normal file
74
src/views/components/PriorityPickerField.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Flag } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const defaultPriorityColors = {
|
||||
0: '#9CA3AF',
|
||||
1: '#EF4444',
|
||||
2: '#F97316',
|
||||
3: '#FBBF24',
|
||||
4: '#3B82F6',
|
||||
}
|
||||
|
||||
const defaultPriorityLabels = {
|
||||
0: 'No Priority',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
const PriorityPickerField = ({
|
||||
value = 0,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
priorityColors = defaultPriorityColors,
|
||||
priorityLabels = defaultPriorityLabels,
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const options = [1, 2, 3, 4].map(priorityOption => ({
|
||||
id: priorityOption,
|
||||
label: priorityLabels[priorityOption],
|
||||
color: priorityColors[priorityOption],
|
||||
}))
|
||||
|
||||
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
size={size}
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.label}
|
||||
getItemColor={item => item.color}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
// For priority 0 (no priority), show empty string (icon only)
|
||||
if (value === 0 || isEmpty) return 'Priority'
|
||||
return selectedItems[0]?.label || ''
|
||||
}}
|
||||
renderTriggerIcon={({ selectedItems, isEmpty }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
|
||||
fontSize: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderItemStart={({ item }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: item.color,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
menuMinWidth={180}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriorityPickerField
|
||||
48
src/views/components/ProjectPickerField.jsx
Normal file
48
src/views/components/ProjectPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FolderOpen } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ProjectPickerField = ({
|
||||
value = 'default',
|
||||
onChange,
|
||||
onClear,
|
||||
projects = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = [
|
||||
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
|
||||
...projects.map(project => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Project'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<FolderOpen
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Project' : selectedItems[0].name
|
||||
}
|
||||
menuMinWidth={240}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPickerField
|
||||
639
src/views/components/RepeatPickerField.jsx
Normal file
639
src/views/components/RepeatPickerField.jsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { Close, Repeat } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const FREQUENCY_TYPES = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'adaptive',
|
||||
'custom',
|
||||
]
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
const DAYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
|
||||
const MONTHS = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
]
|
||||
|
||||
const OCCURRENCE_OPTIONS = [
|
||||
{ value: 1, label: '1st' },
|
||||
{ value: 2, label: '2nd' },
|
||||
{ value: 3, label: '3rd' },
|
||||
{ value: 4, label: '4th' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const defaultMetadata = () => ({
|
||||
unit: 'days',
|
||||
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
const initLocalState = value => {
|
||||
if (!value) {
|
||||
return {
|
||||
frequencyType: 'daily',
|
||||
frequency: 1,
|
||||
frequencyMetadata: defaultMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
let { frequencyType, frequency, frequencyMetadata } = value
|
||||
|
||||
// Normalize parser output: interval/1/days → daily, etc.
|
||||
if (frequencyType === 'interval' && frequency === 1) {
|
||||
const unitTypeMap = {
|
||||
days: 'daily',
|
||||
weeks: 'weekly',
|
||||
months: 'monthly',
|
||||
years: 'yearly',
|
||||
}
|
||||
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
|
||||
}
|
||||
|
||||
return {
|
||||
frequencyType,
|
||||
frequency: frequency ?? 1,
|
||||
frequencyMetadata: {
|
||||
...defaultMetadata(),
|
||||
...frequencyMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayType = frequencyType =>
|
||||
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
|
||||
|
||||
// Shared section label
|
||||
const SectionLabel = ({ children }) => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 0.75,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
// Shared time-of-day picker
|
||||
const TimeRow = ({ metadata, onUpdate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Time of day</SectionLabel>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={moment(metadata?.time).format('HH:mm')}
|
||||
onChange={e =>
|
||||
onUpdate({
|
||||
...metadata,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
time: moment(
|
||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
||||
).format(),
|
||||
})
|
||||
}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
// Interval section
|
||||
const IntervalSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => (
|
||||
<Box>
|
||||
<SectionLabel>Repeat every</SectionLabel>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e =>
|
||||
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||
}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 999 } }}
|
||||
/>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{['days', 'weeks', 'months', 'years'].map(unit => (
|
||||
<ListItem key={unit}>
|
||||
<Checkbox
|
||||
checked={frequencyMetadata?.unit === unit}
|
||||
onClick={() =>
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
||||
}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Days of week section
|
||||
const DaysOfWeekSection = ({
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedDays = frequencyMetadata?.days || []
|
||||
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
||||
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
||||
|
||||
const toggleDay = day => {
|
||||
const next = selectedDays.includes(day)
|
||||
? selectedDays.filter(d => d !== day)
|
||||
: [...selectedDays, day]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
|
||||
}
|
||||
|
||||
const toggleOccurrence = val => {
|
||||
const next = selectedOccurrences.includes(val)
|
||||
? selectedOccurrences.filter(v => v !== val)
|
||||
: [...selectedOccurrences, val]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Days</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{DAYS.map(day => (
|
||||
<ListItem key={day}>
|
||||
<Checkbox
|
||||
checked={selectedDays.includes(day)}
|
||||
onClick={() => toggleDay(day)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionLabel>Pattern</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={weekPattern}
|
||||
onChange={e =>
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: e.target.value,
|
||||
occurrences:
|
||||
e.target.value === 'every_week' ? [] : selectedOccurrences,
|
||||
})
|
||||
}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ value: 'every_week', label: 'Every week' },
|
||||
{ value: 'week_of_month', label: 'Specific weeks' },
|
||||
].map(opt => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={opt.label}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
{weekPattern === 'week_of_month' && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<SectionLabel>Occurrences</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{OCCURRENCE_OPTIONS.map(opt => (
|
||||
<ListItem key={opt.value}>
|
||||
<Checkbox
|
||||
checked={selectedOccurrences.includes(opt.value)}
|
||||
onClick={() => toggleOccurrence(opt.value)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={opt.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Day of month section
|
||||
const DayOfMonthSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedMonths = frequencyMetadata?.months || []
|
||||
|
||||
const toggleMonth = month => {
|
||||
const next = selectedMonths.includes(month)
|
||||
? selectedMonths.filter(m => m !== month)
|
||||
: [...selectedMonths, month]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Months</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{MONTHS.map(month => (
|
||||
<ListItem key={month}>
|
||||
<Checkbox
|
||||
checked={selectedMonths.includes(month)}
|
||||
onClick={() => toggleMonth(month)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Day of month</SectionLabel>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e => {
|
||||
const v = Math.min(
|
||||
31,
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
onFrequencyUpdate(v)
|
||||
}}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 31 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const RepeatPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
|
||||
const [localFrequency, setLocalFrequency] = useState(1)
|
||||
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
||||
useState(defaultMetadata)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const init = initLocalState(value)
|
||||
setLocalFrequencyType(init.frequencyType)
|
||||
setLocalFrequency(init.frequency)
|
||||
setLocalFrequencyMetadata(init.frequencyMetadata)
|
||||
}, [isOpen, value])
|
||||
|
||||
const hasRepeat = Boolean(value)
|
||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
||||
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
|
||||
const displayType = getDisplayType(localFrequencyType)
|
||||
|
||||
const handleTypeSelect = type => {
|
||||
if (type === 'custom') {
|
||||
setLocalFrequencyType('interval')
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
|
||||
} else {
|
||||
setLocalFrequencyType(type)
|
||||
setLocalFrequency(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubTypeSelect = newType => {
|
||||
setLocalFrequencyType(newType)
|
||||
if (newType === 'interval') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
|
||||
} else if (newType === 'days_of_the_week') {
|
||||
setLocalFrequencyMetadata(prev => ({
|
||||
...prev,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
}))
|
||||
} else if (newType === 'day_of_the_month') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({
|
||||
frequencyType: localFrequencyType,
|
||||
frequency: localFrequency,
|
||||
frequencyMetadata: localFrequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Repeat sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Box>
|
||||
<SectionLabel>Frequency</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{FREQUENCY_TYPES.map(type => (
|
||||
<ListItem key={type}>
|
||||
<Checkbox
|
||||
checked={displayType === type}
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Custom sub-type + detail panel */}
|
||||
{displayType === 'custom' && (
|
||||
<>
|
||||
<Box>
|
||||
<SectionLabel>Schedule type</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={localFrequencyType}
|
||||
onChange={e => handleSubTypeSelect(e.target.value)}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{REPEAT_ON_TYPE.map(type => (
|
||||
<Radio
|
||||
key={type}
|
||||
value={type}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={type
|
||||
.split('_')
|
||||
.map((w, i, arr) =>
|
||||
i === 0 || i === arr.length - 1
|
||||
? w.charAt(0).toUpperCase() + w.slice(1)
|
||||
: w,
|
||||
)
|
||||
.join(' ')}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{localFrequencyType === 'interval' && (
|
||||
<IntervalSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'days_of_the_week' && (
|
||||
<DaysOfWeekSection
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'day_of_the_month' && (
|
||||
<DayOfMonthSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerField
|
||||
211
src/views/components/RepeatPickerPreview.jsx
Normal file
211
src/views/components/RepeatPickerPreview.jsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Close, Repeat } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
|
||||
const REPEAT_PRESETS = [
|
||||
{
|
||||
id: 'daily',
|
||||
label: 'Daily',
|
||||
frequencyType: 'interval',
|
||||
frequency: 1,
|
||||
frequencyMetadata: { unit: 'days' },
|
||||
},
|
||||
{
|
||||
id: 'weekly',
|
||||
label: 'Weekly',
|
||||
frequencyType: 'interval',
|
||||
frequency: 1,
|
||||
frequencyMetadata: { unit: 'weeks' },
|
||||
},
|
||||
{
|
||||
id: 'monthly',
|
||||
label: 'Monthly',
|
||||
frequencyType: 'interval',
|
||||
frequency: 1,
|
||||
frequencyMetadata: { unit: 'months' },
|
||||
},
|
||||
{
|
||||
id: 'yearly',
|
||||
label: 'Yearly',
|
||||
frequencyType: 'interval',
|
||||
frequency: 1,
|
||||
frequencyMetadata: { unit: 'years' },
|
||||
},
|
||||
]
|
||||
|
||||
const matchPreset = value => {
|
||||
if (!value) return null
|
||||
return (
|
||||
REPEAT_PRESETS.find(
|
||||
p =>
|
||||
p.frequencyType === value.frequencyType &&
|
||||
p.frequency === value.frequency &&
|
||||
p.frequencyMetadata?.unit === value.frequencyMetadata?.unit,
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
const RepeatPickerPreview = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const hasRepeat = Boolean(value)
|
||||
const selectedPreset = matchPreset(value)
|
||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
||||
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Repeat sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{REPEAT_PRESETS.map((preset, index) => {
|
||||
const isSelected = selectedPreset?.id === preset.id
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
variant={isSelected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onChange({
|
||||
frequencyType: preset.frequencyType,
|
||||
frequency: preset.frequency,
|
||||
frequencyMetadata: preset.frequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
mb: index === REPEAT_PRESETS.length - 1 ? 0 : 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{getRecurrentChipText(preset)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ width: '100%', mt: 0.5, justifyContent: 'flex-start' }}
|
||||
>
|
||||
No repeat
|
||||
</Button>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerPreview
|
||||
Reference in New Issue
Block a user