diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index af1f291..4317f1a 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -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",
diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx
index dd7125b..a7963f2 100644
--- a/src/search/GlobalSearchPalette.jsx
+++ b/src/search/GlobalSearchPalette.jsx
@@ -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 && (
↵ {t('search.footer.open')}
{query.trim()
- ? t('search.footer.results', {
- count: Math.max(0, results.length - 1),
- })
+ ? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')}
diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js
index 6b98a4f..27b5552 100644
--- a/src/search/searchProviders.js
+++ b/src/search/searchProviders.js
@@ -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',
diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx
index abf17de..5589ddc 100644
--- a/src/utils/Helpers.jsx
+++ b/src/utils/Helpers.jsx
@@ -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,
}
diff --git a/src/views/Chores/hooks/useChoreFilters.js b/src/views/Chores/hooks/useChoreFilters.js
index a8903a6..4361aa8 100644
--- a/src/views/Chores/hooks/useChoreFilters.js
+++ b/src/views/Chores/hooks/useChoreFilters.js
@@ -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,
diff --git a/src/views/Filters/FilterView.jsx b/src/views/Filters/FilterView.jsx
index be37bd8..a340ea7 100644
--- a/src/views/Filters/FilterView.jsx
+++ b/src/views/Filters/FilterView.jsx
@@ -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)
diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx
index ce09edd..e3144a0 100644
--- a/src/views/Labels/LabelView.jsx
+++ b/src/views/Labels/LabelView.jsx
@@ -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 (
{
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) {