From 79d92ca8c988d1ab42f949ffbf40d3bc29a7b0e7 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 16 Aug 2026 01:36:20 -0400 Subject: [PATCH] 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 --- public/locales/en/common.json | 3 + src/search/GlobalSearchPalette.jsx | 99 +++++++++++++++++++---- src/search/searchProviders.js | 10 +-- src/utils/Helpers.jsx | 13 +++ src/views/Chores/hooks/useChoreFilters.js | 11 ++- src/views/Filters/FilterView.jsx | 44 ++++++---- src/views/Labels/LabelView.jsx | 59 +++++++++----- src/views/Projects/ProjectView.jsx | 47 +++++++---- 8 files changed, 210 insertions(+), 76 deletions(-) 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) {