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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user