add quick actions for creating labels, projects, and filters in GlobalSearchPalette

refactor: move stripHtml function to Helpers utility

fix: update chore filters to include raw description for better search indexing
enhance: implement search parameter handling for modal openings in ProjectView and LabelView
This commit is contained in:
Mo Tarbin
2026-08-16 01:36:20 -04:00
parent 2480049fd1
commit 79d92ca8c9
8 changed files with 210 additions and 76 deletions

View File

@@ -56,6 +56,9 @@
"quickAction": "Quick action",
"navigation": "Navigation",
"createTask": "Create a task",
"createLabel": "Create a label",
"createProject": "Create a project",
"createFilter": "Create a filter",
"viewAllTasks": "View all tasks",
"viewArchivedTasks": "View archived tasks",
"openSettings": "Open settings",

View File

@@ -56,13 +56,41 @@ const buildQuickActions = t => [
provider: 'actions',
title: t('search.actions.createTask'),
subtitle: t('search.actions.quickAction'),
route: '/chores/create',
keywords: 'new task chore add create',
// Reuses the widget deep-link param so this lands on the task list with the
// quick-add modal open, instead of the full create page.
route: '/chores?add_task=1',
},
{
id: 'action:create-label',
provider: 'actions',
title: t('search.actions.createLabel'),
subtitle: t('search.actions.quickAction'),
keywords: 'new label tag add create',
route: '/labels?create=1',
},
{
id: 'action:create-project',
provider: 'actions',
title: t('search.actions.createProject'),
subtitle: t('search.actions.quickAction'),
keywords: 'new project folder add create',
route: '/projects?create=1',
},
{
id: 'action:create-filter',
provider: 'actions',
title: t('search.actions.createFilter'),
subtitle: t('search.actions.quickAction'),
keywords: 'new filter view saved search add create',
route: '/filters?create=1',
},
{
id: 'action:tasks',
provider: 'actions',
title: t('search.actions.viewAllTasks'),
subtitle: t('search.actions.navigation'),
keywords: 'tasks chores list open',
route: '/chores',
},
{
@@ -70,6 +98,7 @@ const buildQuickActions = t => [
provider: 'actions',
title: t('search.actions.viewArchivedTasks'),
subtitle: t('search.actions.navigation'),
keywords: 'archive archived tasks open',
route: '/archived',
},
{
@@ -77,6 +106,7 @@ const buildQuickActions = t => [
provider: 'actions',
title: t('search.actions.openSettings'),
subtitle: t('search.actions.navigation'),
keywords: 'settings preferences configuration open',
route: '/settings',
},
]
@@ -192,6 +222,22 @@ const GlobalSearchPalette = ({
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
const quickActions = useMemo(() => buildQuickActions(t), [t])
const quickActionIndex = useMemo(
() =>
new Fuse(quickActions, {
threshold: 0.38,
distance: 120,
ignoreLocation: true,
includeScore: true,
keys: [
{ name: 'title', weight: 0.7 },
{ name: 'keywords', weight: 0.3 },
],
}),
[quickActions],
)
const searchIndexes = useMemo(
() =>
new Map(
@@ -227,7 +273,7 @@ const GlobalSearchPalette = ({
const recentResults = recents
.map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id))
return [...recentResults, ...buildQuickActions(t)]
return [...recentResults, ...quickActions]
}
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
@@ -250,15 +296,40 @@ const GlobalSearchPalette = ({
})
.sort((a, b) => a.score - b.score),
)
grouped.push({
id: 'action:filter-tasks',
provider: 'actions',
title: t('search.actions.filterTasks', { query: query.trim() }),
subtitle: t('search.actions.filterTasksSubtitle'),
route: `/chores?search=${encodeURIComponent(query.trim())}`,
})
return grouped
}, [documents, query, recents, searchIndexes, t])
const actionMatches = (
quickActionIndex.search(normalized, { limit: 4 }) || []
)
.map(match => ({ ...match.item, score: match.score ?? 1 }))
.sort((a, b) => a.score - b.score)
// An action whose title the query starts spelling out ("create la…") is
// what the person is after, so it leads. Anything matched only through its
// keywords stays below the real content it shares words with.
const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
const trailingActions = actionMatches.filter(
action => !leadingActions.includes(action),
)
return [
...leadingActions,
...grouped,
...trailingActions,
{
id: 'action:filter-tasks',
provider: 'actions',
title: t('search.actions.filterTasks', { query: query.trim() }),
subtitle: t('search.actions.filterTasksSubtitle'),
route: `/chores?search=${encodeURIComponent(query.trim())}`,
},
]
}, [documents, query, quickActionIndex, recents, searchIndexes, t])
// Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter(
result => result.id !== 'action:filter-tasks',
).length
useEffect(() => {
selectedResultRef.current?.scrollIntoView({
@@ -339,7 +410,7 @@ const GlobalSearchPalette = ({
pb: 'var(--safe-area-inset-bottom, 0px)',
}}
>
{!isLoading && query.trim() && results.length === 1 && (
{!isLoading && query.trim() && matchCount === 0 && (
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
<InboxOutlined
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
@@ -434,9 +505,7 @@ const GlobalSearchPalette = ({
<Typography level='body-xs'> {t('search.footer.open')}</Typography>
<Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim()
? t('search.footer.results', {
count: Math.max(0, results.length - 1),
})
? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')}
</Typography>
</Box>

View File

@@ -1,13 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
import { stripHtml } from '../utils/Helpers'
const HISTORY_STATUS = {
0: 'in progress',

View File

@@ -1,10 +1,22 @@
import moment from 'moment'
import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
}
// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
// can be indexed or matched by search.
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const resolvePhotoURL = url => {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('https')) {
@@ -83,4 +95,5 @@ export {
isPlusAccount,
isSignedUrlExpired,
resolvePhotoURL,
stripHtml,
}

View File

@@ -1,11 +1,13 @@
import Fuse from 'fuse.js'
import { useCallback, useMemo, useState } from 'react'
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
import { stripHtml } from '../../../utils/Helpers'
export const useChoreFilters = ({
chores,
selectedProject,
impersonatedUser,
selectedProject,
userProfile,
}) => {
const [searchTerm, setSearchTerm] = useState('')
@@ -30,9 +32,14 @@ export const useChoreFilters = ({
const searchableChores = chores.map(chore => ({
...chore,
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
raw_description: stripHtml(chore.description),
}))
return new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
keys: [
{ name: 'name', weight: 0.6 },
{ name: 'raw_label', weight: 0.25 },
{ name: 'raw_description', weight: 0.15 },
],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,

View File

@@ -1,11 +1,20 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Add,
FilterAlt,
MoreVert,
Star,
StarBorder,
Task,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -19,22 +28,13 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Add,
FilterAlt,
MoreVert,
Star,
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'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -49,9 +49,9 @@ import {
const FilterCardContent = ({
filter,
taskCount = 0,
overdueCount = 0,
onToggleActions,
overdueCount = 0,
taskCount = 0,
}) => {
// Get condition labels for display
const getConditionSummary = () => {
@@ -246,6 +246,7 @@ const FilterCardContent = ({
const FilterView = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false)
const { data: labels = [] } = useLabels()
@@ -321,6 +322,21 @@ const FilterView = () => {
setShowAdvancedFilterBuilder(true)
}
// ?create=1 lets other surfaces (global search quick actions) land here with
// the filter builder already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setEditingFilter(null)
setShowAdvancedFilterBuilder(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
const handleEditFilter = filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)

View File

@@ -1,3 +1,20 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import {
Add,
Close,
MoreVert,
Search,
SearchOff,
Style,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -11,38 +28,22 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Add,
Close,
MoreVert,
Search,
SearchOff,
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'
import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries'
const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
const LabelCardContent = ({ currentUserId, label, onToggleActions }) => {
const { t } = useTranslation('labels')
// Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId
@@ -162,9 +163,10 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
const LabelView = () => {
const { t } = useTranslation('labels')
const { data: labels, isLabelsLoading, isError } = useLabels()
const { data: labels, isError, isLabelsLoading } = useLabels()
const { data: userProfile } = useUserProfile()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [userLabels, setUserLabels] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -255,6 +257,21 @@ const LabelView = () => {
}
}, [labels])
// ?create=1 lets other surfaces (global search quick actions) land here with
// the create modal already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setCurrentLabel(null)
setModalOpen(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
if (isLabelsLoading) {
return (
<Box

View File

@@ -1,3 +1,13 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import { Add, MoreVert, Task } from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
@@ -10,21 +20,11 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { Add, MoreVert, Task } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query'
import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
@@ -33,13 +33,14 @@ import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import { useProjects } from './ProjectQueries'
const ProjectCardContent = ({
project,
currentUserId,
taskCounts = {},
onCardClick,
onToggleActions,
project,
taskCounts = {},
}) => {
const { t } = useTranslation('projects')
// Check if current user owns this project
@@ -221,7 +222,7 @@ const ProjectCardContent = ({
const ProjectView = () => {
const { t } = useTranslation('projects')
const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: projects, isError, isProjectsLoading } = useProjects()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
@@ -230,6 +231,7 @@ const ProjectView = () => {
!projectsLoading,
)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -302,6 +304,21 @@ const ProjectView = () => {
}
}, [projects])
// ?create=1 lets other surfaces (global search quick actions) land here with
// the create modal already open.
useEffect(() => {
if (searchParams.get('create') !== '1') return
setCurrentProject(null)
setModalOpen(true)
setSearchParams(
params => {
params.delete('create')
return params
},
{ replace: true },
)
}, [searchParams, setSearchParams])
// Calculate real task counts from chores data
useEffect(() => {
if (chores && chores.res) {