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}`
|
return `Every ${dayNames} at ${timeStr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepeatOnSections = ({
|
export const RepeatOnSections = ({
|
||||||
frequencyType,
|
frequencyType,
|
||||||
frequency,
|
frequency,
|
||||||
onFrequencyUpdate,
|
onFrequencyUpdate,
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { Add, EditNotifications } from '@mui/icons-material'
|
import { Add } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Typography } from '@mui/joy'
|
||||||
Box,
|
import { useMediaQuery } from '@mui/material'
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { FormControl } from '@mui/material'
|
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@@ -30,8 +21,15 @@ import {
|
|||||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
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 LearnMoreButton from './LearnMore'
|
||||||
|
import NotificationPickerField from './NotificationPickerField'
|
||||||
|
import PriorityPickerField from './PriorityPickerField'
|
||||||
|
import RepeatPickerField from './RepeatPickerField'
|
||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
import SubTasks from './SubTask'
|
import SubTasks from './SubTask'
|
||||||
const getDefaultNotification = () => {
|
const getDefaultNotification = () => {
|
||||||
@@ -54,6 +52,8 @@ const getDefaultNotification = () => {
|
|||||||
|
|
||||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
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: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||||
useCircleMembers()
|
useCircleMembers()
|
||||||
@@ -98,7 +98,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||||
const [hasDescription, setHasDescription] = useState(false)
|
const [hasDescription, setHasDescription] = useState(false)
|
||||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||||
const [hasNotifications, setHasNotifications] = useState(false)
|
|
||||||
const [hasDeadline, setHasDeadline] = useState(false)
|
const [hasDeadline, setHasDeadline] = useState(false)
|
||||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||||
@@ -106,6 +105,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
const [projectId, setProjectId] = useState(getInitialProject())
|
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:
|
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,20 +279,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
resolvedHighlights.push(current)
|
resolvedHighlights.push(current)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No overlap, add the current highlight
|
|
||||||
resolvedHighlights.push(current)
|
resolvedHighlights.push(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const highlight of resolvedHighlights) {
|
for (const highlight of resolvedHighlights) {
|
||||||
// Add the text before the highlight
|
|
||||||
if (highlight.start > lastIndex) {
|
if (highlight.start > lastIndex) {
|
||||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||||
parts.push(textBefore)
|
parts.push(textBefore)
|
||||||
plainText += textBefore
|
plainText += textBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the class name based on the highlight type
|
|
||||||
let className = ''
|
let className = ''
|
||||||
switch (highlight.type) {
|
switch (highlight.type) {
|
||||||
case 'repeat':
|
case 'repeat':
|
||||||
@@ -300,7 +314,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the highlighted span
|
|
||||||
const highlightedText = sentence.substring(
|
const highlightedText = sentence.substring(
|
||||||
highlight.start,
|
highlight.start,
|
||||||
highlight.end,
|
highlight.end,
|
||||||
@@ -310,9 +323,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
key={highlight.start}
|
key={highlight.start}
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
// text underline:
|
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
// textDecorationColor: 'red',
|
|
||||||
textDecorationThickness: '2px',
|
textDecorationThickness: '2px',
|
||||||
textDecorationStyle: 'dashed',
|
textDecorationStyle: 'dashed',
|
||||||
}}
|
}}
|
||||||
@@ -321,11 +332,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</span>,
|
</span>,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update the last index to the end of the current highlight
|
|
||||||
lastIndex = highlight.end
|
lastIndex = highlight.end
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last highlight
|
|
||||||
if (lastIndex < sentence.length) {
|
if (lastIndex < sentence.length) {
|
||||||
const remainingText = sentence.substring(lastIndex)
|
const remainingText = sentence.substring(lastIndex)
|
||||||
parts.push(remainingText)
|
parts.push(remainingText)
|
||||||
@@ -342,12 +351,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
const processText = useCallback(
|
const processText = useCallback(
|
||||||
sentence => {
|
sentence => {
|
||||||
// Parse everything from the original sentence to get correct highlight positions
|
|
||||||
const priority = parsePriority(sentence)
|
const priority = parsePriority(sentence)
|
||||||
const pointsParsed = parsePoints(sentence)
|
const pointsParsed = parsePoints(sentence)
|
||||||
const labels = parseLabels(sentence, userLabels || [])
|
const labels = parseLabels(sentence, userLabels || [])
|
||||||
|
|
||||||
// Parse assignees using circle members
|
|
||||||
const circleMembersList = circleMembers?.res || []
|
const circleMembersList = circleMembers?.res || []
|
||||||
const assigneesForParsing = circleMembersList.map(member => ({
|
const assigneesForParsing = circleMembersList.map(member => ({
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
@@ -364,9 +371,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||||
|
|
||||||
// Set all the parsed values
|
// 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 (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) {
|
if (assigneesResult.isAnyone) {
|
||||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
// @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'),
|
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
setUseCustomTime(false)
|
||||||
|
setDueTime(null)
|
||||||
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
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
|
const timeValue = e.target.value
|
||||||
setDueTime(timeValue)
|
setDueTime(timeValue)
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
if (timeValue) {
|
||||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
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)
|
setUseCustomTime(checked)
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const defaultTime = dueTime || '18:00'
|
const defaultTime = dueTime || '18:00'
|
||||||
setDueTime(defaultTime)
|
if (!dueTime) {
|
||||||
|
setDueTime(defaultTime)
|
||||||
|
}
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
setDueDate(
|
||||||
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setDueTime(null)
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
setDueDate(
|
||||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||||
@@ -605,6 +631,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
notificationMetadata: {},
|
notificationMetadata: {},
|
||||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||||
projectId: projectId === 'default' ? null : projectId,
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
|
attachments: attachments.length > 0 ? attachments : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frequency) {
|
if (frequency) {
|
||||||
@@ -793,6 +820,93 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
{/* <Box>
|
||||||
<Typography level='body-sm'>Title:</Typography>
|
<Typography level='body-sm'>Title:</Typography>
|
||||||
<Input
|
<Input
|
||||||
@@ -802,12 +916,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
/>
|
/>
|
||||||
</Box> */}
|
</Box> */}
|
||||||
|
|
||||||
<Box>
|
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||||
{!hasDescription && (
|
{!hasDescription && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='outlined'
|
||||||
size='sm'
|
color='neutral'
|
||||||
|
size='md'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHasDescription(true)
|
setHasDescription(true)
|
||||||
// Focus will be handled by the useEffect hook
|
// Focus will be handled by the useEffect hook
|
||||||
@@ -823,8 +938,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
{!hasSubTasks && (
|
{!hasSubTasks && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='outlined'
|
||||||
size='sm'
|
color='neutral'
|
||||||
|
size='md'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHasSubTasks(true)
|
setHasSubTasks(true)
|
||||||
}}
|
}}
|
||||||
@@ -835,39 +951,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
Subtasks
|
Subtasks
|
||||||
</Button>
|
</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 && (
|
{/* {!hasDeadline && dueDate && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
@@ -907,61 +991,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</Box>
|
</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 && (
|
{/* {projects.length >= 1 && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography level='body-sm'>Project</Typography>
|
<Typography level='body-sm'>Project</Typography>
|
||||||
@@ -1082,33 +1111,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</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>
|
</Box>
|
||||||
</ResponsiveModal>
|
</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