From dc88fe97045e3bd53cdc72e9954f0c47c22399a8 Mon Sep 17 00:00:00 2001 From: Mohamad Tarbin Date: Sun, 2 Aug 2026 23:16:32 -0400 Subject: [PATCH] unifying the empty state across components (#184) --- src/components/common/EmptyState.jsx | 231 ++++++++++++++++++ src/views/Chores/ArchivedTasks.jsx | 72 +++--- src/views/Chores/MyChores.jsx | 215 +++++++++------- src/views/Chores/TasksByAssigneeCard.jsx | 12 +- src/views/Filters/FilterView.jsx | 33 +-- src/views/History/ChoreHistory.jsx | 69 ++---- src/views/Labels/LabelView.jsx | 25 +- .../Modals/Inputs/AttachmentBrowserModal.jsx | 13 +- src/views/Things/ThingsHistory.jsx | 49 ++-- src/views/Things/ThingsView.jsx | 32 ++- src/views/Timer/TimerDetails.jsx | 10 +- src/views/User/UserActivities.jsx | 66 ++--- src/views/components/NotFound.jsx | 70 ++---- 13 files changed, 517 insertions(+), 380 deletions(-) create mode 100644 src/components/common/EmptyState.jsx diff --git a/src/components/common/EmptyState.jsx b/src/components/common/EmptyState.jsx new file mode 100644 index 0000000..95250fd --- /dev/null +++ b/src/components/common/EmptyState.jsx @@ -0,0 +1,231 @@ +import { Box, Button, Typography } from '@mui/joy' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' + +/** + * The single empty/error surface for the app. + * + * variant drives the tone, the icon tile color and the a11y role: + * - 'empty' nothing exists yet. Teach the feature, offer the way in. + * - 'no-results' something exists, the current search/filter hides it. + * - 'error' we failed to load. Say what happened, offer a retry. + * + * Actions are objects instead of nodes so every call site gets the same + * button vocabulary (solid primary lead, plain neutral follow). + */ + +const TONES = { + empty: { + tileBg: 'primary.softHoverBg', + halo: 'primary.softBg', + iconColor: 'primary.softColor', + role: 'status', + }, + 'no-results': { + tileBg: 'neutral.softHoverBg', + halo: 'neutral.softBg', + iconColor: 'neutral.softColor', + role: 'status', + }, + error: { + tileBg: 'danger.softHoverBg', + halo: 'danger.softBg', + iconColor: 'danger.softColor', + role: 'alert', + }, +} + +const SIZES = { + sm: { + tile: 48, + icon: '1.375rem', + halo: 6, + py: 5, + title: 'title-sm', + titleSize: '1rem', + }, + md: { + tile: 68, + icon: '1.875rem', + halo: 10, + py: 8, + title: 'title-md', + titleSize: '1.25rem', + }, +} + +const ActionButton = ({ action, ...buttonProps }) => { + const { label, to, onClick, ...rest } = action + return ( + + ) +} + +ActionButton.propTypes = { + action: PropTypes.object.isRequired, +} + +const EmptyState = ({ + variant = 'empty', + icon, + title, + description, + primaryAction, + secondaryAction, + size = 'md', + fullHeight = false, + sx, + ...rest +}) => { + const tone = TONES[variant] || TONES.empty + const dimensions = SIZES[size] || SIZES.md + const buttonSize = size === 'sm' ? 'sm' : 'md' + + return ( + + {icon && ( + + )} + + + {title} + + + {description && ( + + {description} + + )} + + {(primaryAction || secondaryAction) && ( + + {primaryAction && ( + + )} + {secondaryAction && ( + + )} + + )} + + ) +} + +EmptyState.propTypes = { + variant: PropTypes.oneOf(['empty', 'no-results', 'error']), + icon: PropTypes.node, + title: PropTypes.node.isRequired, + description: PropTypes.node, + primaryAction: PropTypes.shape({ + label: PropTypes.node.isRequired, + onClick: PropTypes.func, + to: PropTypes.string, + startDecorator: PropTypes.node, + }), + secondaryAction: PropTypes.shape({ + label: PropTypes.node.isRequired, + onClick: PropTypes.func, + to: PropTypes.string, + startDecorator: PropTypes.node, + }), + size: PropTypes.oneOf(['sm', 'md']), + fullHeight: PropTypes.bool, + sx: PropTypes.object, +} + +export default EmptyState diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index dc79a35..c14a952 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -7,6 +7,7 @@ import { Label, Person, PriorityHigh, + SearchOff, SelectAll, Unarchive, ViewAgenda, @@ -27,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' +import EmptyState from '../../components/common/EmptyState' import FilterBar from '../../components/common/FilterBar' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' @@ -1004,45 +1006,37 @@ const ArchivedTasks = () => { {/* Content */} {finalChores.length === 0 ? ( - - - - {searchTerm || hasActiveFilters - ? 'No archived tasks found' - : 'No archived tasks'} - - - {searchTerm || hasActiveFilters - ? 'Try adjusting your search or filters' - : 'Archived tasks will appear here when you archive them from the main task list'} - - {(searchTerm || hasActiveFilters) && ( - - {searchTerm && ( - - )} - {hasActiveFilters && ( - - )} - - )} - + searchTerm || hasActiveFilters ? ( + } + title='No archived tasks match' + description={ + searchTerm + ? `Nothing in the archive matches "${searchTerm}".` + : 'There are archived tasks, but none fit the filters that are currently on.' + } + primaryAction={ + searchTerm + ? { label: 'Clear search', onClick: handleSearchClose } + : { label: 'Clear filters', onClick: clearAll } + } + secondaryAction={ + searchTerm && hasActiveFilters + ? { label: 'Clear filters', onClick: clearAll } + : undefined + } + /> + ) : ( + } + title='Nothing archived' + description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.' + primaryAction={{ label: 'Back to tasks', to: '/chores' }} + /> + ) ) : ( diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index fd6afac..2e133a1 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -2,18 +2,18 @@ import { Add, Bolt, CalendarMonth, + CloudOff, EditCalendar, + SearchOff, ExpandCircleDown, PriorityHigh, Style, } from '@mui/icons-material' -import Logo from '../../Logo' import { Accordion, AccordionDetails, AccordionGroup, Box, - Button, Chip, Container, Divider, @@ -33,6 +33,7 @@ import IconButtonWithMenu from './IconButtonWithMenu' import { useMediaQuery } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' +import EmptyState from '../../components/common/EmptyState' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useFilter } from '../../hooks/useFilter' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' @@ -217,8 +218,7 @@ const MyChores = () => { ) case 'Due Later': return ( - d !== null && - d > new Date(now.getTime() + 24 * 60 * 60 * 1000) + d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000) ) case 'No Due Date': return item.nextDueDate === null @@ -637,7 +637,8 @@ const MyChores = () => { selectedChores, addTaskModalOpen, searchTerm, - searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All', + searchFilter: + hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All', filteredChores: getFilteredChores, choreSections, openChoreSections, @@ -826,10 +827,12 @@ const MyChores = () => { } const toggleViewMode = value => { - const newMode = value ?? (() => { - const modes = ['default', 'compact', 'calendar'] - return modes[(modes.indexOf(viewMode) + 1) % modes.length] - })() + const newMode = + value ?? + (() => { + const modes = ['default', 'compact', 'calendar'] + return modes[(modes.indexOf(viewMode) + 1) % modes.length] + })() setViewMode(newMode) localStorage.setItem('choreCardViewMode', newMode) if (newMode !== 'calendar') { @@ -905,9 +908,28 @@ const MyChores = () => { [getFilteredChores], ) + + // "Narrowed" means the user actively cut the list down (search, quick + // filters, a saved filter). Picking a project is not narrowing: an empty + // project is an empty place, not a filtered-away result. + const isNarrowed = Boolean( + searchTerm?.length > 0 || hasQuickFilters || activeFilterId, + ) + const isCustomProjectSelected = Boolean( + selectedProject && selectedProject.id !== 'default', + ) + + const clearNarrowing = () => { + clearQuickFilters() + setSearchTerm('') + clearActiveFilter() + updateFilterUrl(null, null) + } + const appendChore = (prev, newChore) => { let newChores = [...prev, newChore] + if (impersonatedUser) { newChores = newChores.filter( chore => chore.assignedTo === impersonatedUser.userId, @@ -930,40 +952,23 @@ const MyChores = () => { if (choresError || membersError) { return ( - - - - - - Unable to communicate with server - - - {choresErrorDetails?.message || - 'The server is currently unavailable. Please check your connection and try again.'} - - - + }, + }} + /> ) } @@ -1118,50 +1123,79 @@ const MyChores = () => { } /> - {/* Show "Nothing scheduled" when appropriate based on current view mode */} - {(searchTerm?.length > 0 || hasQuickFilters || activeFilterId + {/* Empty state. Three different situations, three different messages: + nothing created yet, nothing left after narrowing, or an empty + project. Only the middle one is about filters. */} + {(isNarrowed ? getFilteredChores.length === 0 : projectFilteredChores.length === 0) && // only if not in calendar view: - viewMode !== 'calendar' && ( - } + title='No tasks yet' + description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.' + primaryAction={{ + label: 'Create a task', + startDecorator: , + onClick: () => setAddTaskModalOpen(true), }} - > - - - Nothing scheduled - - {chores.length > 0 && ( - <> - - - )} - - )} + secondaryAction={{ + label: 'More options', + onClick: () => Navigate('/chores/create'), + }} + /> + ) : isNarrowed ? ( + } + title='No tasks match this view' + description={ + searchTerm?.length > 0 + ? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.` + : 'You have tasks, but none of them fit the filters that are currently on.' + } + primaryAction={{ + label: + searchTerm?.length > 0 ? 'Clear search' : 'Clear filters', + onClick: clearNarrowing, + }} + /> + ) : isCustomProjectSelected ? ( + } + title={`Nothing in ${selectedProject.name} yet`} + description='Tasks you add to this project show up here. Your other tasks are still where you left them.' + primaryAction={{ + label: 'Add a task here', + startDecorator: , + onClick: () => setAddTaskModalOpen(true), + }} + secondaryAction={{ + label: 'See tasks outside projects', + onClick: () => setSelectedProjectWithCache(null), + }} + /> + ) : ( + } + title='No tasks here yet' + description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.' + primaryAction={{ + label: 'Create a task', + startDecorator: , + onClick: () => setAddTaskModalOpen(true), + }} + /> + ))} {searchTerm?.length > 0 && viewMode !== 'calendar' && ( { }} > {getChoresForDate(selectedCalendarDate).length === 0 ? ( - } + title='Nothing scheduled' + description='This day is free. Add a task if you want something to land here.' + primaryAction={{ + label: 'Add task', + startDecorator: , + onClick: () => setAddTaskModalOpen(true), }} - > - No tasks scheduled for this date - + /> ) : ( { mb: 1, }} > - - - No assigned tasks found - + } + title='No one has tasks yet' + description='Assign a task to someone in your circle and their workload shows up here.' + /> ) } diff --git a/src/views/Filters/FilterView.jsx b/src/views/Filters/FilterView.jsx index c097057..b7706f3 100644 --- a/src/views/Filters/FilterView.jsx +++ b/src/views/Filters/FilterView.jsx @@ -29,6 +29,7 @@ import { StarBorder, Task, } from '@mui/icons-material' +import EmptyState from '../../components/common/EmptyState' import { useChores } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine' @@ -417,29 +418,17 @@ const FilterView = () => { }} > {savedFilters.length === 0 ? ( - } + title='No saved filters yet' + description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.' + primaryAction={{ + label: 'Create a filter', + startDecorator: , + onClick: handleAddFilter, }} - > - - - No saved filters yet - - - Create custom filters to quickly access your most used chore - - + /> ) : ( {savedFilters.map(filter => { diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 3298408..cf5906c 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -28,10 +28,11 @@ import { } from '@mui/icons-material' import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' -import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy' +import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy' import moment from 'moment' import { useEffect, useMemo, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' +import EmptyState from '../../components/common/EmptyState' import FilterBar from '../../components/common/FilterBar' import { useLocalization } from '../../contexts/LocalizationContext' import useConfirmationModal from '../../hooks/useConfirmationModal' @@ -303,36 +304,14 @@ const ChoreHistory = () => { } if (!choreHistory.length) { return ( - - + } + title='No history yet' + description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.' + primaryAction={{ label: 'Back to tasks', to: '/chores' }} /> - - - No History Yet - - - You haven't completed any tasks. Once you start finishing tasks, - they'll show up here. - - ) } @@ -441,27 +420,13 @@ const ChoreHistory = () => { /> {sortedHistory.length === 0 && activeFilterCount > 0 && ( - - - - No results match your filters - - - Try adjusting or clearing the active filters. - - - + } + title='No history matches these filters' + description='There is history here, but none of it fits the filters that are currently on.' + primaryAction={{ label: 'Clear filters', onClick: clearAll }} + /> )} {sortedHistory.length > 0 && ( diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 6191207..1f05020 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -21,7 +21,8 @@ import { TrailingActions, } from '@meauxt/react-swipeable-list' import '@meauxt/react-swipeable-list/dist/styles.css' -import { Add, MoreVert } from '@mui/icons-material' +import { Add, MoreVert, Style } from '@mui/icons-material' +import EmptyState from '../../components/common/EmptyState' import { useQueryClient } from '@tanstack/react-query' import { useUserProfile } from '../../queries/UserQueries' import { getTextColorFromBackgroundColor } from '../../utils/Colors' @@ -258,19 +259,17 @@ const LabelView = () => { }} > {userLabels.length === 0 && ( - } + title='No labels yet' + description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.' + primaryAction={{ + label: 'Create a label', + startDecorator: , + onClick: handleAddLabel, }} - > - - No labels available. Add a new label to get started. - - + /> )} {userLabels.map(label => ( diff --git a/src/views/Modals/Inputs/AttachmentBrowserModal.jsx b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx index 8165a17..76401bf 100644 --- a/src/views/Modals/Inputs/AttachmentBrowserModal.jsx +++ b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx @@ -8,6 +8,7 @@ import { Typography, } from '@mui/joy' import { useEffect, useState } from 'react' +import EmptyState from '../../../components/common/EmptyState' import ModalActions from '../../../components/common/ModalActions' import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { GetChoreAttachments } from '../../../utils/Fetcher' @@ -95,12 +96,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) { ) : attachments.length === 0 ? ( - - No attachments found. - + } + title='No attachments' + description='Photos and files added to this task will show up here.' + /> ) : ( {attachments.map((attachment, index) => ( diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx index 854355f..0b53c8e 100644 --- a/src/views/Things/ThingsHistory.jsx +++ b/src/views/Things/ThingsHistory.jsx @@ -2,6 +2,7 @@ import { Analytics, BarChart, CallReceived, + CloudOff, EventBusy, Schedule, Speed, @@ -25,7 +26,7 @@ import { } from '@mui/joy' import { useTheme } from '@mui/joy/styles' import moment from 'moment' -import { Link, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import { useLocalization } from '../../contexts/LocalizationContext' import { Line, @@ -35,6 +36,7 @@ import { XAxis, YAxis, } from 'recharts' +import EmptyState from '../../components/common/EmptyState' import { useThingHistory } from '../../queries/ThingQueries' import LoadingComponent from '../components/Loading' @@ -49,6 +51,7 @@ const ThingsHistory = () => { fetchNextPage, hasNextPage, isFetchingNextPage, + refetch, } = useThingHistory(id) // Flatten all pages of history data @@ -152,35 +155,23 @@ const ThingsHistory = () => { if (error || !thingsHistory || thingsHistory.length === 0) { return ( - - + : } + title={error ? "Couldn't load this history" : 'No history yet'} + description={ + error + ? 'We could not reach the server. Check your connection and try again.' + : "Each time this thing's value changes, the change is recorded here." + } + primaryAction={ + error + ? { label: 'Try again', onClick: () => refetch() } + : { label: 'Back to things', to: '/things' } + } /> - - - No history found - - - It looks like there is no history for this thing yet. - - ) } diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index 3ab615a..a8ae371 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -28,6 +28,7 @@ import { } from '@mui/joy' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' +import EmptyState from '../../components/common/EmptyState' import { useNotification } from '../../service/NotificationProvider' import { CreateThing, @@ -405,25 +406,20 @@ const ThingsView = () => { }} > {things.length === 0 && ( - } + title='No things yet' + description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.' + primaryAction={{ + label: 'Create a thing', + startDecorator: , + onClick: () => { + setCreateModalThing(null) + setIsShowCreateThingModal(true) + }, }} - > - - - No things has been created/found - - + /> )} {things.map(thing => ( diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index e41aac7..7c2997a 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -38,6 +38,7 @@ import { import moment from 'moment' import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' +import EmptyState from '../../components/common/EmptyState' import { useLocalization } from '../../contexts/LocalizationContext' import { useChoreTimer, @@ -1204,9 +1205,12 @@ const TimerDetails = () => { )} {(!timerData.pauseLog || timerData.pauseLog.length === 0) && ( - - No work sessions found for this timer. - + } + title='No work sessions yet' + description='Start the timer on this task and each session lands here.' + /> )} ) : ( diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index 663aed8..60f47bf 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -20,17 +20,16 @@ import { import { Avatar, Box, - Button, Card, Chip, Container, Divider, Grid, - Link, Stack, Typography, } from '@mui/joy' import React, { useEffect, useMemo, useState } from 'react' +import EmptyState from '../../components/common/EmptyState' import FilterBar from '../../components/common/FilterBar' import { useFilter } from '../../hooks/useFilter' @@ -992,54 +991,21 @@ const UserActivites = () => { {/* Conditional Content Based on Data Availability */} {!choresData.res?.length > 0 || !choresHistory?.length > 0 ? ( - - - - - No activities found - - - No activities found for{' '} - - {selectedUser === undefined || selectedUser === 'all' - ? 'All Users' - : circleUsers.find(user => user.userId === selectedUser) - ?.displayName || 'Unknown User'} - {' '} - in the{' '} - - {tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`} - - . - - - Try selecting a different time period or user filter above. - - - + } + title='No activity in this range' + description={`Nothing was completed by ${ + selectedUser === undefined || selectedUser === 'all' + ? 'anyone in your circle' + : circleUsers.find(user => user.userId === selectedUser) + ?.displayName || 'this member' + } ${ + tabValue === 365 ? 'so far' : `in the last ${tabValue} days` + }. Try a wider time range or a different member.`} + primaryAction={{ label: 'Back to tasks', to: '/chores' }} + /> ) : ( <> {/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */} diff --git a/src/views/components/NotFound.jsx b/src/views/components/NotFound.jsx index 8bc1a2e..f89d13b 100644 --- a/src/views/components/NotFound.jsx +++ b/src/views/components/NotFound.jsx @@ -1,61 +1,23 @@ -import { HomeRounded, Login } from '@mui/icons-material' -import { Box, Button, CircularProgress, Container } from '@mui/joy' -import { Typography } from '@mui/material' -import { Link } from 'react-router-dom' // Assuming you are using React Router -import Logo from '../../Logo' +import { Explore, HomeRounded } from '@mui/icons-material' +import { Container } from '@mui/joy' +import EmptyState from '../../components/common/EmptyState' const NotFound = () => { return ( - - + } + title='Page not found' + description='This link does not lead anywhere. It may have moved, or the address has a typo in it.' + primaryAction={{ + label: 'Go to my tasks', + to: '/chores', + startDecorator: , }} - > - - - - - Page Not Found - - - Sorry, I could be wrong but I think you are lost. - - - - + secondaryAction={{ label: 'Log in', to: '/login' }} + /> ) }