From dcc5f2b318adb1d4e8a08ac0220085b9ac344c5d Mon Sep 17 00:00:00 2001 From: Zoltan Takacs Date: Wed, 17 Feb 2021 14:54:44 +0100 Subject: [PATCH] Advanced search (#1180) * Advanced search * Localization & search query builder & unit tests * Fix build * type fix * review fixes * Search improvements --- apps/sensenet/package.json | 1 + .../src/components/dialogs/content-picker.tsx | 13 +- .../components/dialogs/date-range-picker.tsx | 130 +++++++++ .../components/dialogs/dialog-provider.tsx | 2 + .../src/components/dialogs/dialogs.tsx | 3 + apps/sensenet/src/components/dialogs/index.ts | 1 + .../src/components/dialogs/restore.tsx | 5 +- .../src/components/dialogs/save-query.tsx | 9 +- .../components/search/filters/date-filter.tsx | 166 +++++++++++ .../src/components/search/filters/index.tsx | 32 +++ .../search/filters/more-filters.tsx | 29 ++ .../components/search/filters/path-filter.tsx | 45 +++ .../search/filters/reference-filter.tsx | 86 ++++++ .../components/search/filters/type-filter.tsx | 151 ++++++++++ apps/sensenet/src/components/search/index.tsx | 263 +++++------------- .../src/components/search/saved-queries.tsx | 2 +- .../src/components/search/search-bar.tsx | 95 +++++++ .../src/components/search/search-results.tsx | 79 ++++++ apps/sensenet/src/context/search.tsx | 132 +++++++++ apps/sensenet/src/localization/default.ts | 54 +++- .../src/services/search-query-builder.ts | 60 ++++ .../src/components/mainpanel.tsx | 2 +- .../src/DefaultContentTypes.ts | 2 + .../components/tree-picker/tree-picker.tsx | 2 +- packages/sn-query/src/QueryExpression.ts | 34 ++- packages/sn-query/src/QuerySegment.ts | 11 +- packages/sn-query/test/index.test.ts | 42 ++- yarn.lock | 7 + 28 files changed, 1228 insertions(+), 230 deletions(-) create mode 100644 apps/sensenet/src/components/dialogs/date-range-picker.tsx create mode 100644 apps/sensenet/src/components/search/filters/date-filter.tsx create mode 100644 apps/sensenet/src/components/search/filters/index.tsx create mode 100644 apps/sensenet/src/components/search/filters/more-filters.tsx create mode 100644 apps/sensenet/src/components/search/filters/path-filter.tsx create mode 100644 apps/sensenet/src/components/search/filters/reference-filter.tsx create mode 100644 apps/sensenet/src/components/search/filters/type-filter.tsx create mode 100644 apps/sensenet/src/components/search/search-bar.tsx create mode 100644 apps/sensenet/src/components/search/search-results.tsx create mode 100644 apps/sensenet/src/context/search.tsx create mode 100644 apps/sensenet/src/services/search-query-builder.ts diff --git a/apps/sensenet/package.json b/apps/sensenet/package.json index dc8ea66af..e832a7997 100644 --- a/apps/sensenet/package.json +++ b/apps/sensenet/package.json @@ -101,6 +101,7 @@ "monaco-editor": "0.21.3", "react": "^16.13.0", "react-autosuggest": "^10.1.0", + "react-day-picker": "^7.4.8", "react-dom": "^16.13.0", "react-markdown": "^5.0.3", "react-monaco-editor": "0.41.2", diff --git a/apps/sensenet/src/components/dialogs/content-picker.tsx b/apps/sensenet/src/components/dialogs/content-picker.tsx index bff5a3e95..90b0bea39 100644 --- a/apps/sensenet/src/components/dialogs/content-picker.tsx +++ b/apps/sensenet/src/components/dialogs/content-picker.tsx @@ -12,10 +12,12 @@ import { Icon } from '../Icon' import { DialogTitle, useDialog } from '.' export interface ContentPickerDialogProps { + defaultValue?: GenericContentWithIsParent currentPath: string selectionRoot?: string - content: GenericContent - handleSubmit?: (path: string) => void + required?: boolean + content?: GenericContent + handleSubmit?: (content: GenericContentWithIsParent) => void } export const ContentPickerDialog: React.FunctionComponent = (props) => { @@ -32,7 +34,7 @@ export const ContentPickerDialog: React.FunctionComponent ({ filter: '' }), []) const handleSubmit = async (selection: GenericContentWithIsParent[]) => { - props.handleSubmit?.(selection[0].Path) + props.handleSubmit?.(selection[0]) closeLastDialog() } @@ -40,13 +42,14 @@ export const ContentPickerDialog: React.FunctionComponent
- + {props.content && } {localization.title}
diff --git a/apps/sensenet/src/components/dialogs/date-range-picker.tsx b/apps/sensenet/src/components/dialogs/date-range-picker.tsx new file mode 100644 index 000000000..65cf722a3 --- /dev/null +++ b/apps/sensenet/src/components/dialogs/date-range-picker.tsx @@ -0,0 +1,130 @@ +import { Button, createStyles, DialogActions, DialogContent, makeStyles, Theme } from '@material-ui/core' +import React, { useState } from 'react' +import DayPicker, { DateUtils, Modifier } from 'react-day-picker' +import MomentLocaleUtils from 'react-day-picker/moment' +import { useGlobalStyles } from '../../globalStyles' +import { useLocalization, usePersonalSettings } from '../../hooks' +import { useDialog } from '.' + +import 'react-day-picker/lib/style.css' + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + root: { + '& .DayPicker-wrapper, & .DayPicker-NavButton': { + outline: 0, + }, + '& .DayPicker-Day': { + borderRadius: '0 !important', + }, + '& .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside)': { + backgroundColor: theme.palette.primary.main, + }, + }, + }) +}) + +export interface DateRangePickerProps { + defaultValue?: { + from: Date + to: Date + } + handleSubmit: (range: { from?: Date; to?: Date }) => void +} + +export const DateRangePicker: React.FunctionComponent = (props) => { + const { closeLastDialog } = useDialog() + const globalClasses = useGlobalStyles() + const classes = useStyles() + const localization = useLocalization().dateRangePicker + const personalSettings = usePersonalSettings() + const langCode = personalSettings.language === 'hungarian' ? 'hu' : 'en' + + const [from, setFrom] = useState(props.defaultValue?.from) + const [to, setTo] = useState(props.defaultValue?.to) + const [enteredTo, setEnteredTo] = useState(props.defaultValue?.to) + + const isSelectingFirstDay = (day: Date) => { + const isBeforeFirstDay = from && DateUtils.isDayBefore(day, from) + const isRangeSelected = from && to + return !from || isBeforeFirstDay || isRangeSelected + } + + const handleDayClick = (day: Date) => { + if (from && to && day >= from && day <= to) { + handleResetClick() + return + } + if (isSelectingFirstDay(day)) { + setFrom(day) + setTo(undefined) + setEnteredTo(undefined) + } else { + setTo(day) + setEnteredTo(day) + } + } + + const handleDayMouseEnter = (day: Date) => { + if (!isSelectingFirstDay(day)) { + setEnteredTo(day) + } + } + + const handleResetClick = () => { + setFrom(undefined) + setTo(undefined) + setEnteredTo(undefined) + } + + const modifiers = { start: from, end: enteredTo } + const disabledDays: Modifier = from ? { before: from } : undefined + const selectedDays: Modifier[] = from && enteredTo ? [from, { from, to: enteredTo }] : from ? [from] : [] + + return ( + <> + + + + + + + + + + ) +} + +export default DateRangePicker diff --git a/apps/sensenet/src/components/dialogs/dialog-provider.tsx b/apps/sensenet/src/components/dialogs/dialog-provider.tsx index 1f72a4090..4d5f299f9 100644 --- a/apps/sensenet/src/components/dialogs/dialog-provider.tsx +++ b/apps/sensenet/src/components/dialogs/dialog-provider.tsx @@ -9,6 +9,7 @@ import { ContentPickerDialogProps, CopyMoveDialogProps, CustomActionResultDialogProps, + DateRangePickerProps, DeleteContentDialogProps, ExecuteActionDialogProps, PermissionEditorDialogProps, @@ -37,6 +38,7 @@ export type DialogWithProps = ( | { name: 'content-picker'; props: ContentPickerDialogProps } | { name: 'feedback' } | { name: 'change-password' } + | { name: 'date-range-picker'; props: DateRangePickerProps } ) & { dialogProps?: Partial } type Action = { type: 'PUSH_DIALOG'; dialog: DialogWithProps } | { type: 'POP_DIALOG' } | { type: 'CLOSE_ALL_DIALOGS' } diff --git a/apps/sensenet/src/components/dialogs/dialogs.tsx b/apps/sensenet/src/components/dialogs/dialogs.tsx index e4a7ff64b..e00c05a7b 100644 --- a/apps/sensenet/src/components/dialogs/dialogs.tsx +++ b/apps/sensenet/src/components/dialogs/dialogs.tsx @@ -21,6 +21,7 @@ const Restore = React.lazy(() => import('./restore')) const ContentPicker = React.lazy(() => import('./content-picker')) const Feedback = React.lazy(() => import('./feedback')) const ChangePasswordDialog = React.lazy(() => import('./change-password')) +const DateRangePicker = React.lazy(() => import('./date-range-picker')) function dialogRenderer(dialog: DialogWithProps) { switch (dialog.name) { @@ -60,6 +61,8 @@ function dialogRenderer(dialog: DialogWithProps) { return case 'change-password': return + case 'date-range-picker': + return default: return null } diff --git a/apps/sensenet/src/components/dialogs/index.ts b/apps/sensenet/src/components/dialogs/index.ts index 0660abc2b..fd1b9bae9 100644 --- a/apps/sensenet/src/components/dialogs/index.ts +++ b/apps/sensenet/src/components/dialogs/index.ts @@ -5,6 +5,7 @@ export * from './check-in' export * from './content-picker' export * from './copy-move' export * from './custom-action-result' +export * from './date-range-picker' export * from './delete' export * from './dialog-provider' export * from './dialog-title' diff --git a/apps/sensenet/src/components/dialogs/restore.tsx b/apps/sensenet/src/components/dialogs/restore.tsx index 8ecad2261..43a433c95 100644 --- a/apps/sensenet/src/components/dialogs/restore.tsx +++ b/apps/sensenet/src/components/dialogs/restore.tsx @@ -1,5 +1,5 @@ import { PathHelper } from '@sensenet/client-utils' -import { TrashBag } from '@sensenet/default-content-types' +import { GenericContent, TrashBag } from '@sensenet/default-content-types' import { useLogger, useRepository } from '@sensenet/hooks-react' import { Button, DialogActions, DialogContent, InputAdornment, TextField } from '@material-ui/core' import RestoreIcon from '@material-ui/icons/RestoreFromTrash' @@ -95,7 +95,8 @@ export function Restore(props: RestoreProps) { content: props.content, currentPath: props.content.OriginalPath || '/Root', selectionRoot: rootPath, - handleSubmit: (path: string) => setDestination(path), + handleSubmit: (content: GenericContent) => setDestination(content.Path), + required: true, }, dialogProps: { disableBackdropClick: true, open: true, classes: { paper: globalClasses.pickerDialog } }, }) diff --git a/apps/sensenet/src/components/dialogs/save-query.tsx b/apps/sensenet/src/components/dialogs/save-query.tsx index 245d9367d..42909ae76 100644 --- a/apps/sensenet/src/components/dialogs/save-query.tsx +++ b/apps/sensenet/src/components/dialogs/save-query.tsx @@ -5,11 +5,13 @@ import { Button, DialogActions, DialogContent, TextField } from '@material-ui/co import React, { useState } from 'react' import { useGlobalStyles } from '../../globalStyles' import { useLocalization } from '../../hooks' +import { createSearchQuery } from '../../services/search-query-builder' +import { SearchFilters } from '../search' import { DialogTitle, useDialog } from '.' export type SaveQueryProps = { saveName?: string - query: string + filters: SearchFilters } export function SaveQuery(props: SaveQueryProps) { @@ -30,9 +32,10 @@ export function SaveQuery(props: SaveQueryProps) { select: ['DisplayName', 'Query'], }, body: { - query: props.query, + query: createSearchQuery(props.filters).toString(), displayName: saveName, queryType: 'Public', + uiFilters: JSON.stringify(props.filters), }, }) logger.information({ @@ -52,7 +55,7 @@ export function SaveQuery(props: SaveQueryProps) { setSaveName(ev.currentTarget.value)} /> diff --git a/apps/sensenet/src/components/search/filters/date-filter.tsx b/apps/sensenet/src/components/search/filters/date-filter.tsx new file mode 100644 index 000000000..28ff17ef0 --- /dev/null +++ b/apps/sensenet/src/components/search/filters/date-filter.tsx @@ -0,0 +1,166 @@ +import { Button, Menu, MenuItem } from '@material-ui/core' +import AccessTime from '@material-ui/icons/AccessTime' +import ExpandMore from '@material-ui/icons/ExpandMore' +import React, { useState } from 'react' +import { useSearch } from '../../../context/search' +import { useLocalization } from '../../../hooks' +import { useDialog } from '../../dialogs' + +export type Filter = typeof options[number] + +const options = [ + { + name: 'anytime', + }, + { + name: 'created', + disabled: true, + }, + { + name: 'createdLastHour', + query: { + field: 'CreationDate', + value: '@@CurrentTime-1hours@@', + }, + }, + { + name: 'createdToday', + query: { + field: 'CreationDate', + value: '@@Today@@', + }, + }, + { + name: 'createdThisWeek', + query: { + field: 'CreationDate', + value: '@@CurrentWeek@@', + }, + }, + { + name: 'createdCustomRange', + query: { + field: 'CreationDate', + from: '', + to: '', + }, + }, + { + name: 'modified', + disabled: true, + }, + { + name: 'modifiedLastHour', + query: { + field: 'ModificationDate', + value: '@@CurrentTime-1hours@@', + }, + }, + { + name: 'modifiedToday', + query: { + field: 'ModificationDate', + value: '@@Today@@', + }, + }, + { + name: 'modifiedThisWeek', + query: { + field: 'ModificationDate', + value: '@@CurrentWeek@@', + }, + }, + { + name: 'modifiedCustomRange', + query: { + field: 'ModificationDate', + from: '', + to: '', + }, + }, +] + +export const defaultDateFilter = options[0] + +export const DateFilter = () => { + const [anchorEl, setAnchorEl] = useState(null) + const { openDialog, closeLastDialog } = useDialog() + const searchState = useSearch() + const localization = useLocalization().search.filters.date + + const handleCustomRangePick = (filter: any) => ({ from, to }: { from: Date; to: Date }) => { + const newFilter = { ...filter } + + if (from && to) { + newFilter.query.from = from.toISOString() + newFilter.query.to = to.toISOString() + + searchState.setFilters((filters) => ({ ...filters, date: newFilter })) + } else if (!from && !to) { + searchState.setFilters((filters) => ({ ...filters, date: defaultDateFilter })) + } else { + newFilter.query.value = from.toISOString() + searchState.setFilters((filters) => ({ ...filters, date: newFilter })) + } + + closeLastDialog() + } + + return ( + <> + + setAnchorEl(null)} + getContentAnchorEl={null} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }}> + {options.map((filter) => ( + { + setAnchorEl(null) + if (filter.name.includes('CustomRange')) { + const defaultValue = + searchState.filters.date.query?.from && searchState.filters.date.query?.to + ? { + from: new Date(searchState.filters.date.query.from), + to: new Date(searchState.filters.date.query.to), + } + : undefined + + return openDialog({ + name: 'date-range-picker', + props: { handleSubmit: handleCustomRangePick(filter), defaultValue }, + dialogProps: { fullWidth: false }, + }) + } + searchState.setFilters((filters) => ({ ...filters, date: filter })) + }}> + {localization[filter.name as keyof typeof localization]} + + ))} + + + ) +} diff --git a/apps/sensenet/src/components/search/filters/index.tsx b/apps/sensenet/src/components/search/filters/index.tsx new file mode 100644 index 000000000..e7674d4f6 --- /dev/null +++ b/apps/sensenet/src/components/search/filters/index.tsx @@ -0,0 +1,32 @@ +import { Button } from '@material-ui/core' +import FilterList from '@material-ui/icons/FilterList' +import React, { useState } from 'react' +import { useLocalization } from '../../../hooks' +import { MoreFilters } from './more-filters' +import { TypeFilter } from './type-filter' + +interface FiltersProps { + defaultFilterVisibility?: boolean +} + +export const Filters: React.FunctionComponent = ({ defaultFilterVisibility }) => { + const [showMoreFilters, setShowMoreFilters] = useState(defaultFilterVisibility ?? false) + const localization = useLocalization().search.filters + + return ( + <> +
+ + + +
+ + {showMoreFilters && } + + ) +} diff --git a/apps/sensenet/src/components/search/filters/more-filters.tsx b/apps/sensenet/src/components/search/filters/more-filters.tsx new file mode 100644 index 000000000..a4d4fa48d --- /dev/null +++ b/apps/sensenet/src/components/search/filters/more-filters.tsx @@ -0,0 +1,29 @@ +import { createStyles, makeStyles } from '@material-ui/core' +import React from 'react' +import { DateFilter } from './date-filter' +import { PathFilter } from './path-filter' +import { ReferenceFilter } from './reference-filter' + +const useStyles = makeStyles(() => { + return createStyles({ + root: { + margin: '5px 15px', + + '& > *': { + marginRight: '1.5rem', + }, + }, + }) +}) + +export const MoreFilters = () => { + const classes = useStyles() + + return ( +
+ + + +
+ ) +} diff --git a/apps/sensenet/src/components/search/filters/path-filter.tsx b/apps/sensenet/src/components/search/filters/path-filter.tsx new file mode 100644 index 000000000..7ae348302 --- /dev/null +++ b/apps/sensenet/src/components/search/filters/path-filter.tsx @@ -0,0 +1,45 @@ +import { ConstantContent } from '@sensenet/client-core' +import { GenericContent } from '@sensenet/default-content-types' +import { Button, Tooltip } from '@material-ui/core' +import ExpandMore from '@material-ui/icons/ExpandMore' +import Person from '@material-ui/icons/Person' +import React from 'react' +import { useSearch } from '../../../context/search' +import { useGlobalStyles } from '../../../globalStyles' +import { useLocalization } from '../../../hooks' +import { useDialog } from '../../dialogs' + +export const PathFilter = () => { + const searchState = useSearch() + const { openDialog } = useDialog() + const localization = useLocalization().search.filters.path + const globalClasses = useGlobalStyles() + + return ( + + + + ) +} diff --git a/apps/sensenet/src/components/search/filters/reference-filter.tsx b/apps/sensenet/src/components/search/filters/reference-filter.tsx new file mode 100644 index 000000000..2b7e5c13d --- /dev/null +++ b/apps/sensenet/src/components/search/filters/reference-filter.tsx @@ -0,0 +1,86 @@ +import { Button, Menu, MenuItem } from '@material-ui/core' +import ExpandMore from '@material-ui/icons/ExpandMore' +import Person from '@material-ui/icons/Person' +import React, { useState } from 'react' +import { useSearch } from '../../../context/search' +import { useLocalization } from '../../../hooks' + +export type Filter = typeof options[number] + +export const options = [ + { + name: 'anybody', + }, + { + name: 'createdByMe', + query: 'CreatedBy', + }, + { + name: 'modifiedByMe', + query: 'ModifiedBy', + }, + { + name: 'sharedWithMe', + query: 'SharedWith', + }, + { + name: 'assignedToMe', + query: 'AssignedTo', + }, + { + name: 'ownedByMe', + query: 'Owner', + }, +] + +export const defaultReferenceFilter = options[0] + +export const ReferenceFilter = () => { + const [anchorEl, setAnchorEl] = useState(null) + const localization = useLocalization().search.filters.reference + const searchState = useSearch() + + return ( + <> + + setAnchorEl(null)} + getContentAnchorEl={null} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }}> + {options.map((filter) => ( + { + setAnchorEl(null) + searchState.setFilters((filters) => + filters.reference.name === filter.name ? filters : { ...filters, reference: filter }, + ) + }}> + {localization[filter.name as keyof typeof localization]} + + ))} + + + ) +} diff --git a/apps/sensenet/src/components/search/filters/type-filter.tsx b/apps/sensenet/src/components/search/filters/type-filter.tsx new file mode 100644 index 000000000..33fac9a10 --- /dev/null +++ b/apps/sensenet/src/components/search/filters/type-filter.tsx @@ -0,0 +1,151 @@ +import { Button, createStyles, makeStyles, Menu, MenuItem } from '@material-ui/core' +import AccessTime from '@material-ui/icons/AccessTime' +import ExpandMore from '@material-ui/icons/ExpandMore' +import Image from '@material-ui/icons/Image' +import InsertDriveFile from '@material-ui/icons/InsertDriveFile' +import Person from '@material-ui/icons/Person' +import Search from '@material-ui/icons/Search' +import React, { useState } from 'react' +import { useSearch } from '../../../context/search' +import { useLocalization } from '../../../hooks' + +export type Filter = typeof options[number] | typeof moreOptions[number] + +const options = [ + { + name: 'all', + icon: , + }, + { + name: 'documents', + icon: , + type: 'File', + }, + { + name: 'images', + icon: , + type: 'Image', + }, + { + name: 'event', + icon: , + type: 'CalendarEvent', + }, + { + name: 'user', + icon: , + type: 'User', + }, +] + +const moreOptions = [ + { + name: 'article', + type: 'Article', + }, + { + name: 'workspace', + type: 'Workspace', + }, + { + name: 'folder', + type: 'Folder', + }, + { + name: 'task', + type: 'Task', + }, + { + name: 'memo', + type: 'Memo', + }, + { + name: 'group', + type: 'Group', + }, +] + +export const defaultTypeFilter = options[0] + +const useStyles = makeStyles(() => { + return createStyles({ + filter: { + marginRight: '1rem', + }, + }) +}) + +export const TypeFilter = () => { + const classes = useStyles() + const localization = useLocalization().search.filters.type + const [anchorEl, setAnchorEl] = useState(null) + + const searchState = useSearch() + + const [[activeFromMore], othersFromMore] = moreOptions.reduce( + ([pass, fail], filter) => { + return filter.name === searchState.filters.type.name ? [[...pass, filter], fail] : [pass, [...fail, filter]] + }, + [[], [] as Filter[]], + ) + + return ( +
+ {options.map((filter) => ( + + ))} + + + setAnchorEl(null)} + getContentAnchorEl={null} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }}> + {(othersFromMore as Filter[]).map((filter) => ( + { + setAnchorEl(null) + searchState.setFilters((filters) => + filters.type.name === filter.name ? filters : { ...filters, type: filter }, + ) + }}> + {localization[filter.name as keyof typeof localization]} + + ))} + +
+ ) +} diff --git a/apps/sensenet/src/components/search/index.tsx b/apps/sensenet/src/components/search/index.tsx index 5a1550656..f97982143 100644 --- a/apps/sensenet/src/components/search/index.tsx +++ b/apps/sensenet/src/components/search/index.tsx @@ -1,226 +1,111 @@ -import { ConstantContent, ODataFieldParameter } from '@sensenet/client-core' -import { debounce } from '@sensenet/client-utils' -import { GenericContent } from '@sensenet/default-content-types' -import { - CurrentAncestorsContext, - CurrentChildrenContext, - CurrentContentContext, - LoadSettingsContext, - useLogger, - useRepository, -} from '@sensenet/hooks-react' -import { createStyles, IconButton, InputAdornment, makeStyles, TextField, Theme, Typography } from '@material-ui/core' -import Bookmark from '@material-ui/icons/Bookmark' -import Cancel from '@material-ui/icons/Cancel' +import { Query } from '@sensenet/default-content-types' +import { useLogger, useRepository } from '@sensenet/hooks-react' +import { createStyles, makeStyles } from '@material-ui/core' import clsx from 'clsx' -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { PATHS } from '../../application-paths' -import { ResponsivePersonalSettings } from '../../context' +import { Filters as FiltersInterface, SearchProvider } from '../../context/search' import { useGlobalStyles } from '../../globalStyles' -import { useLocalization, useSelectionService, useSnRoute } from '../../hooks' +import { useLocalization } from '../../hooks' import { useQuery } from '../../hooks/use-query' -import { getPrimaryActionUrl, pathWithQueryParams } from '../../services' -import { ContentList } from '../content-list' -import { useDialog } from '../dialogs' +import { createSearchQuery } from '../../services/search-query-builder' +import { FullScreenLoader } from '../full-screen-loader' +import { Filters } from './filters' +import { defaultDateFilter } from './filters/date-filter' +import { defaultReferenceFilter } from './filters/reference-filter' +import { SearchBar } from './search-bar' +import { SearchResults } from './search-results' -const searchDebounceTime = 400 +export interface SearchFilters { + term: string + filters: FiltersInterface +} -const useStyles = makeStyles((theme: Theme) => { +const useStyles = makeStyles(() => { return createStyles({ contentWrapper: { paddingRight: 30, }, - inputButton: { - color: theme.palette.type === 'light' ? theme.palette.common.black : theme.palette.common.white, - }, - searchBar: { - display: 'flex', - width: '100%', - marginLeft: '1em', - marginBottom: '1rem', - }, }) }) export const Search = () => { - const repository = useRepository() - const termFromQuery = useQuery().get('term') + const queryFromUrl = useQuery().get('query') ?? undefined + const termFromUrl = useQuery().get('term') ?? undefined + const logger = useLogger('search') const history = useHistory() - const { location } = history - const { openDialog } = useDialog() - const logger = useLogger('Search') - const [query, setQuery] = useState(termFromQuery || undefined) - const selectionService = useSelectionService() const localization = useLocalization().search const classes = useStyles() const globalClasses = useGlobalStyles() - const [result, setResult] = useState() - const [resultCount, setResultCount] = useState(0) - const [error, setError] = useState() - const loadSettingsContext = useContext(LoadSettingsContext) - const uiSettings = useContext(ResponsivePersonalSettings) - const searchInputRef = useRef() - const snRoute = useSnRoute() - - const debouncedQuery = useCallback( - debounce((a: string) => setQuery(a), searchDebounceTime), - [], - ) - - useEffect(() => { - if (!termFromQuery) { - if (searchInputRef.current) { - setResult([]) - searchInputRef.current.value = '' - searchInputRef.current.focus() - } - return - } - - if (searchInputRef.current) { - searchInputRef.current.value = termFromQuery - } - setQuery((currentState) => (currentState !== termFromQuery ? termFromQuery : currentState)) - }, [termFromQuery]) + const repository = useRepository() + const [searchFilters, setSearchFilters] = useState>() useEffect(() => { - const ac = new AbortController() - const fetchResult = async () => { - if (!query) { - history.push(PATHS.search.appPath) - setResult([]) - setResultCount(0) + ;(async () => { + if (!queryFromUrl) { return } - try { - history.push(pathWithQueryParams({ path: PATHS.search.appPath, newParams: { term: query } })) - const extendedQuery = `${query.trim()}* .AUTOFILTERS:OFF` - const r = await repository.loadCollection({ - path: ConstantContent.PORTAL_ROOT.Path, + try { + const response = await repository.load({ + idOrPath: queryFromUrl, oDataOptions: { - ...loadSettingsContext.loadChildrenSettings, - select: Array.isArray(repository.configuration.requiredSelect) - ? [ - 'DisplayName', - 'Path', - ...(repository.configuration.requiredSelect as string[]).map((field) => `ModifiedBy/${field}`), - ] - : repository.configuration.requiredSelect, - expand: ['ModifiedBy'], - query: extendedQuery, + select: ['Query', 'UiFilters'] as any, }, - requestInit: { signal: ac.signal }, }) - setError('') - setResult(r.d.results) - setResultCount(r.d.__count) - } catch (e) { - if (!ac.signal.aborted) { - setError(e.message) - setResult([]) - setResultCount(0) + + if (response.d.UiFilters) { + const filters = JSON.parse(response.d.UiFilters) + + if (response.d.Query !== createSearchQuery(filters).toString()) { + throw new Error(localization.invalidSavedQuery) + } + setSearchFilters(filters) + } else { + setSearchFilters({ term: undefined, filters: undefined }) } + } catch (error) { + history.push(PATHS.search.appPath) + logger.error({ + message: error.message || localization.errorGetQuery, + data: { + error, + }, + }) + return false } - } + })() + }, [repository, queryFromUrl, history, logger, localization.errorGetQuery, localization.invalidSavedQuery]) - fetchResult() - return () => ac.abort() - }, [history, loadSettingsContext.loadChildrenSettings, logger, query, repository]) + if (queryFromUrl && !searchFilters) { + return + } return ( -
-
- {localization.title} -
-
-
- { - debouncedQuery(ev.target.value) - }} - InputProps={{ - endAdornment: ( - - {query && ( - null}> - setQuery(undefined)} /> - - )} - { - // We don't want to save empty queries - if (!query) { - return - } - openDialog({ - name: 'save-query', - props: { query, saveName: `Search results for '${query}'` }, - }) - }}> - - - - ), - }} - /> + +
+
+ {localization.title}
+ + + + + +
- {error ? ( - - {error} - - ) : null} - About {resultCount} results - - - - { - history.push(getPrimaryActionUrl({ content: p, repository, uiSettings, location, snRoute })) - }} - onActivateItem={async (item) => { - const expandedItem = await repository.load({ - idOrPath: item.Id, - oDataOptions: { - select: Array.isArray(repository.configuration.requiredSelect) - ? ([ - ...repository.configuration.requiredSelect, - 'Actions/Name', - ] as ODataFieldParameter) - : repository.configuration.requiredSelect, - expand: ['Actions'] as ODataFieldParameter, - }, - }) - history.push( - getPrimaryActionUrl({ content: expandedItem.d, repository, uiSettings, location, snRoute }), - ) - }} - onActiveItemChange={(item) => selectionService.activeContent.setValue(item)} - /> - - - -
+ ) } diff --git a/apps/sensenet/src/components/search/saved-queries.tsx b/apps/sensenet/src/components/search/saved-queries.tsx index a2e1a700f..774bee2de 100644 --- a/apps/sensenet/src/components/search/saved-queries.tsx +++ b/apps/sensenet/src/components/search/saved-queries.tsx @@ -121,7 +121,7 @@ export default function SavedQueries() { }} onActivateItem={(p) => { history.push( - pathWithQueryParams({ path: PATHS.search.appPath, newParams: { term: p.Query } }), + pathWithQueryParams({ path: PATHS.search.appPath, newParams: { query: p.Id.toString() } }), ) }} onActiveItemChange={(item) => selectionService.activeContent.setValue(item)} diff --git a/apps/sensenet/src/components/search/search-bar.tsx b/apps/sensenet/src/components/search/search-bar.tsx new file mode 100644 index 000000000..fed2bdcbe --- /dev/null +++ b/apps/sensenet/src/components/search/search-bar.tsx @@ -0,0 +1,95 @@ +import { debounce } from '@sensenet/client-utils' +import { createStyles, IconButton, InputAdornment, makeStyles, TextField, Theme } from '@material-ui/core' +import Bookmark from '@material-ui/icons/Bookmark' +import Cancel from '@material-ui/icons/Cancel' +import React, { useCallback, useRef } from 'react' +import { useSearch } from '../../context/search' +import { useGlobalStyles } from '../../globalStyles' +import { useLocalization } from '../../hooks' +import { useDialog } from '../dialogs' + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + root: { + display: 'flex', + width: '100%', + marginLeft: '1em', + marginBottom: '1rem', + }, + inputButton: { + color: theme.palette.type === 'light' ? theme.palette.common.black : theme.palette.common.white, + }, + }) +}) + +const searchDebounceTime = 400 + +export const SearchBar = () => { + const classes = useStyles() + const globalClasses = useGlobalStyles() + const localization = useLocalization().search + const { openDialog } = useDialog() + + const searchInputRef = useRef() + const searchState = useSearch() + + const debouncedQuery = useCallback( + debounce((a: string) => searchState.setTerm(a), searchDebounceTime), + [searchState.setTerm], + ) + + return ( +
+
+ { + debouncedQuery(ev.target.value) + }} + InputProps={{ + endAdornment: ( + + {searchState.term && ( + null}> + { + if (searchInputRef.current) { + searchInputRef.current.value = '' + } + searchState.setTerm('') + }} + /> + + )} + { + openDialog({ + name: 'save-query', + props: { + saveName: `Search results for '${searchState.term}'`, + filters: { term: searchState.term, filters: searchState.filters }, + }, + }) + }}> + + + + ), + }} + /> +
+
+ ) +} diff --git a/apps/sensenet/src/components/search/search-results.tsx b/apps/sensenet/src/components/search/search-results.tsx new file mode 100644 index 000000000..4eedd80e0 --- /dev/null +++ b/apps/sensenet/src/components/search/search-results.tsx @@ -0,0 +1,79 @@ +import { ConstantContent, ODataFieldParameter } from '@sensenet/client-core' +import { GenericContent } from '@sensenet/default-content-types' +import { + CurrentAncestorsContext, + CurrentChildrenContext, + CurrentContentContext, + useRepository, +} from '@sensenet/hooks-react' +import { LinearProgress, Typography } from '@material-ui/core' +import React, { useContext } from 'react' +import { useHistory } from 'react-router-dom' +import { ResponsivePersonalSettings } from '../../context' +import { useSearch } from '../../context/search' +import { useLocalization, useSelectionService, useSnRoute } from '../../hooks' +import { getPrimaryActionUrl } from '../../services' +import { ContentList } from '../content-list' + +export const SearchResults = () => { + const repository = useRepository() + const localization = useLocalization().search + const history = useHistory() + const { location } = history + const selectionService = useSelectionService() + const uiSettings = useContext(ResponsivePersonalSettings) + const snRoute = useSnRoute() + + const searchState = useSearch() + + return ( + <> + {searchState.error ? ( + + {searchState.error} + + ) : null} + + {searchState.isLoading && } + + {localization.resultCount(searchState.resultCount)} + + + + + { + history.push(getPrimaryActionUrl({ content: p, repository, uiSettings, location, snRoute })) + }} + onActivateItem={async (item) => { + const expandedItem = await repository.load({ + idOrPath: item.Id, + oDataOptions: { + select: Array.isArray(repository.configuration.requiredSelect) + ? ([ + ...repository.configuration.requiredSelect, + 'Actions/Name', + ] as ODataFieldParameter) + : repository.configuration.requiredSelect, + expand: ['Actions'] as ODataFieldParameter, + }, + }) + history.push( + getPrimaryActionUrl({ content: expandedItem.d, repository, uiSettings, location, snRoute }), + ) + }} + onActiveItemChange={(item) => selectionService.activeContent.setValue(item)} + /> + + + + + ) +} diff --git a/apps/sensenet/src/context/search.tsx b/apps/sensenet/src/context/search.tsx new file mode 100644 index 000000000..f00b05e97 --- /dev/null +++ b/apps/sensenet/src/context/search.tsx @@ -0,0 +1,132 @@ +import { ConstantContent } from '@sensenet/client-core' +import { AllFieldNames, GenericContent } from '@sensenet/default-content-types' +import { useRepository } from '@sensenet/hooks-react' +import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { useHistory } from 'react-router' +import { PATHS } from '../application-paths' +import { Filter as DateFilterInterface, defaultDateFilter } from '../components/search/filters/date-filter' +import { + defaultReferenceFilter, + Filter as ReferenceFilterInterface, +} from '../components/search/filters/reference-filter' +import { defaultTypeFilter, Filter as TypeFilterInterface } from '../components/search/filters/type-filter' +import { pathWithQueryParams } from '../services' +import { createSearchQuery } from '../services/search-query-builder' + +export interface Filters { + type: TypeFilterInterface + path?: GenericContent + date: DateFilterInterface + reference: ReferenceFilterInterface +} + +const SearchContext = createContext<{ + term: string + setTerm: React.Dispatch> + filters: Filters + setFilters: React.Dispatch> + result: GenericContent[] + resultCount: number + error: string + isLoading: boolean +}>({ + term: '', + setTerm: () => null, + filters: { + type: defaultTypeFilter, + path: undefined, + date: defaultDateFilter, + reference: defaultReferenceFilter, + }, + setFilters: () => null, + result: [], + resultCount: 0, + error: '', + isLoading: false, +}) + +export function SearchProvider({ + defaultTerm, + defaultFilters, + children, +}: PropsWithChildren<{ defaultTerm?: string; defaultFilters?: Filters }>) { + const repository = useRepository() + const history = useHistory() + + const [term, setTerm] = useState(defaultTerm ?? '') + const [result, setResult] = useState([]) + const [resultCount, setResultCount] = useState(0) + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const [filters, setFilters] = useState( + defaultFilters ?? { + type: defaultTypeFilter, + path: undefined, + date: defaultDateFilter, + reference: defaultReferenceFilter, + }, + ) + + useEffect(() => { + const ac = new AbortController() + const fetchResult = async () => { + if (!term) { + setResult([]) + setResultCount(0) + history.push(PATHS.search.appPath) + return + } + + try { + setIsLoading(true) + + const response = await repository.loadCollection({ + path: ConstantContent.PORTAL_ROOT.Path, + oDataOptions: { + query: createSearchQuery({ term, filters }).toString(), + select: Array.isArray(repository.configuration.requiredSelect) + ? ([ + 'DisplayName', + 'Path', + ...(repository.configuration.requiredSelect as AllFieldNames[]).map((field) => `ModifiedBy/${field}`), + ] as Array) + : repository.configuration.requiredSelect, + expand: ['ModifiedBy'], + }, + requestInit: { signal: ac.signal }, + }) + setError('') + setResult(response.d.results) + setResultCount(response.d.__count) + history.push(pathWithQueryParams({ path: PATHS.search.appPath, newParams: { term } })) + } catch (e) { + if (!ac.signal.aborted) { + setError(e.message) + setResult([]) + setResultCount(0) + } + } finally { + setIsLoading(false) + } + } + + fetchResult() + return () => ac.abort() + }, [term, repository, history, filters]) + + return ( + + {children} + + ) +} + +export function useSearch() { + const context = useContext(SearchContext) + + if (!context) { + throw new Error('useSearchFilter must be used within a SearchFilterProvider') + } + return context +} diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index 98f0b31a8..9086f4a8a 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -350,9 +350,9 @@ const values = { onlyPublic: 'Show public queries only', saveInputPlaceholder: (term: string) => `Search results for '${term}'`, savedQueries: 'Saved queries', - queryHelperText: 'Enter a keyword or a content query expression', - autoFilters: 'AutoFilters', - clearTerm: 'Clear term input', + queryHelperText: 'Enter a keyword', + clearTerm: 'Clear input', + resultCount: (count: number) => `${count} results`, openInSearchTitle: (term: string) => `See all results for '${term}'`, openInSearchDescription: 'Opens the query expression in the Search view', saveQuery: 'Save Query', @@ -361,6 +361,49 @@ const values = { cancel: 'Cancel', public: 'Public', confirmDeleteQuery: `Are you sure that you want to delete the query '{0}'?`, + errorGetQuery: 'Loading query content has failed.', + invalidSavedQuery: 'Invalid saved query', + filters: { + date: { + anytime: 'anytime', + created: 'created', + createdLastHour: 'in the last hour', + createdToday: 'today', + createdThisWeek: 'this week', + createdCustomRange: 'custom range', + modified: 'modified', + modifiedLastHour: 'in the last hour', + modifiedToday: 'today', + modifiedThisWeek: 'this week', + modifiedCustomRange: 'custom range', + }, + path: { + anywhere: 'anywhere', + }, + reference: { + anybody: 'anybody', + createdByMe: 'created by me', + modifiedByMe: 'modified by me', + sharedWithMe: 'shared with me', + assignedToMe: 'assigned to me', + ownedByMe: 'owned by me', + }, + type: { + all: 'all', + documents: 'documents', + images: 'images', + event: 'event', + user: 'user', + article: 'article', + workspace: 'workspace', + folder: 'folder', + task: 'task', + memo: 'memo', + group: 'group', + more: 'more', + }, + more: 'more filters', + }, }, settings: { edit: 'Edit', @@ -467,6 +510,11 @@ const values = { cancelButton: 'Cancel', selectButton: 'Select', }, + dateRangePicker: { + cancelButton: 'Cancel', + resetButton: 'Reset', + submitButton: 'Submit', + }, feedback: { title: 'Give us feedback or suggest new idea', feedbackText1: (link: string) => `We are using a public ${link} to collect customer feedback and ideas.`, diff --git a/apps/sensenet/src/services/search-query-builder.ts b/apps/sensenet/src/services/search-query-builder.ts new file mode 100644 index 000000000..a025fc1f9 --- /dev/null +++ b/apps/sensenet/src/services/search-query-builder.ts @@ -0,0 +1,60 @@ +import * as DefaultContentTypes from '@sensenet/default-content-types' +import { Query, QueryExpression, QueryOperators } from '@sensenet/query' +import { Filters } from '../context/search' + +interface CreateSearchQuery { + term: string + filters: Filters +} + +export const createSearchQuery = ({ term, filters }: CreateSearchQuery) => { + const query = new Query((q) => q.query((q2) => q2.equals('Name', `*${term}*`).or.equals('DisplayName', `*${term}*`))) + + if (filters.type.type) { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).typeIs((DefaultContentTypes as any)[filters.type.type!]) + return q2 + }) + } + + if (filters.reference.query) { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).equals(filters.reference.query!, '@@CurrentUser@@') + return q2 + }) + } + + if (filters.date.query) { + if (filters.date.name.includes('CustomRange')) { + if (filters.date.query.from && filters.date.query.to) { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).between( + filters.date.query!.field, + filters.date.query!.from, + filters.date.query!.to, + ) + return q2 + }) + } else { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).equals(filters.date.query!.field, filters.date.query!.value) + return q2 + }) + } + } else { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).greaterThan(filters.date.query!.field, filters.date.query!.value, true) + return q2 + }) + } + } + + if (filters.path) { + new QueryOperators(query).and.query((q2) => { + new QueryExpression(q2.queryRef).inTree(filters.path!.Path) + return q2 + }) + } + + return query +} diff --git a/examples/sn-react-calendar/src/components/mainpanel.tsx b/examples/sn-react-calendar/src/components/mainpanel.tsx index 58515d7c3..29defbed3 100644 --- a/examples/sn-react-calendar/src/components/mainpanel.tsx +++ b/examples/sn-react-calendar/src/components/mainpanel.tsx @@ -103,7 +103,7 @@ const MainPanel: React.FunctionComponent = () => { 'OwnerEmail', ] as any, query: new Query((q) => - q.greatherThan('StartDate', '2019-01-01').and.lessThan('StartDate', '2019.12.31'), + q.greaterThan('StartDate', '2019-01-01').and.lessThan('StartDate', '2019.12.31'), ).toString(), orderby: [['StartDate', 'asc']], expand: ['CreatedBy', 'ModifiedBy'], diff --git a/packages/sn-default-content-types/src/DefaultContentTypes.ts b/packages/sn-default-content-types/src/DefaultContentTypes.ts index 4baa1eec2..d4ed07896 100644 --- a/packages/sn-default-content-types/src/DefaultContentTypes.ts +++ b/packages/sn-default-content-types/src/DefaultContentTypes.ts @@ -643,6 +643,8 @@ export class Query extends GenericContent { public Query?: string /* Public queries are stored under the workspace, private queries are stored under the user profile. */ public QueryType?: Enums.QueryType[] + /* Filters object used by search query builder */ + public UiFilters?: string } /** diff --git a/packages/sn-pickers-react/src/components/tree-picker/tree-picker.tsx b/packages/sn-pickers-react/src/components/tree-picker/tree-picker.tsx index 94351c344..51914776a 100644 --- a/packages/sn-pickers-react/src/components/tree-picker/tree-picker.tsx +++ b/packages/sn-pickers-react/src/components/tree-picker/tree-picker.tsx @@ -41,7 +41,7 @@ export function TreePicker { - if (node.IsFolder) { + if (node.IsFolder || node.isParent) { navigateTo(node) props.onTreeNavigation?.(node.Path) } diff --git a/packages/sn-query/src/QueryExpression.ts b/packages/sn-query/src/QueryExpression.ts index 72b1faf86..491ddd10a 100644 --- a/packages/sn-query/src/QueryExpression.ts +++ b/packages/sn-query/src/QueryExpression.ts @@ -28,8 +28,7 @@ export class QueryExpression extends QuerySegment { * @returns { QueryOperator } The Next query operator (fluent) */ public inTree(path: string) { - const pathValue = this.escapeValue(path) - this.stringValue = `InTree:"${pathValue}"` + this.stringValue = `InTree:"${path}"` this.segmentType = 'inTree' return this.finialize() } @@ -40,8 +39,7 @@ export class QueryExpression extends QuerySegment { * @returns { QueryOperator } The Next query operator (fluent) */ public inFolder(path: string) { - const pathValue = this.escapeValue(path) - this.stringValue = `InFolder:"${pathValue}"` + this.stringValue = `InFolder:"${path}"` this.segmentType = 'inFolder' return this.finialize() } @@ -79,7 +77,9 @@ export class QueryExpression extends QuerySegment { fieldName: K | '_Text', value: KValue, ) { - this.stringValue = `${fieldName}:'${this.escapeValue(value.toString())}'` + this.stringValue = this.isTemplateValue(value.toString()) + ? `${fieldName}:${value.toString()}` + : `${fieldName}:'${value.toString()}'` this.segmentType = 'equals' return this.finialize() } @@ -95,7 +95,9 @@ export class QueryExpression extends QuerySegment { fieldName: K, value: KValue, ) { - this.stringValue = `NOT(${fieldName}:'${this.escapeValue(value.toString())}')` + this.stringValue = this.isTemplateValue(value.toString()) + ? `NOT(${fieldName}:${value.toString()})` + : `NOT(${fieldName}:'${value.toString()}')` this.segmentType = 'notEquals' return this.finialize() } @@ -142,26 +144,28 @@ export class QueryExpression extends QuerySegment { minimumInclusive = false, maximumInclusive = false, ) { - this.stringValue = `${fieldName}:${minimumInclusive ? '[' : '{'}'${this.escapeValue( - minValue.toString(), - )}' TO '${this.escapeValue(maxValue.toString())}'${maximumInclusive ? ']' : '}'}` + this.stringValue = `${fieldName}:${ + minimumInclusive ? '[' : '{' + }'${minValue.toString()}' TO '${maxValue.toString()}'${maximumInclusive ? ']' : '}'}` this.segmentType = 'between' return this.finialize() } /** - * Greather than query expression (+FieldName:>'value') + * Greater than query expression (+FieldName:>'value') * @param { K } fieldName he name of the Field to be checked * @param { TReturns[K] } minValue The minimum allowed value * @param { boolean } minimumInclusive Lower limit will be inclusive / exclusive */ - public greatherThan string }>( + public greaterThan string }>( fieldName: K, minValue: KValue, minimumInclusive = false, ) { - this.stringValue = `${fieldName}:>${minimumInclusive ? '=' : ''}'${this.escapeValue(minValue.toString())}'` - this.segmentType = 'greatherThan' + this.stringValue = this.isTemplateValue(minValue.toString()) + ? `${fieldName}:>${minimumInclusive ? '=' : ''}${minValue.toString()}` + : `${fieldName}:>${minimumInclusive ? '=' : ''}'${minValue.toString()}'` + this.segmentType = 'greaterThan' return this.finialize() } @@ -176,7 +180,9 @@ export class QueryExpression extends QuerySegment { maxValue: KValue, maximumInclusive = false, ) { - this.stringValue = `${fieldName}:<${maximumInclusive ? '=' : ''}'${this.escapeValue(maxValue.toString())}'` + this.stringValue = this.isTemplateValue(maxValue.toString()) + ? `${fieldName}:<${maximumInclusive ? '=' : ''}${maxValue.toString()}` + : `${fieldName}:<${maximumInclusive ? '=' : ''}'${maxValue.toString()}'` this.segmentType = 'lessThan' return this.finialize() } diff --git a/packages/sn-query/src/QuerySegment.ts b/packages/sn-query/src/QuerySegment.ts index d3bf208b7..d99a411d6 100644 --- a/packages/sn-query/src/QuerySegment.ts +++ b/packages/sn-query/src/QuerySegment.ts @@ -10,14 +10,13 @@ export class QuerySegment { public segmentType?: string /** - * Escapes a String value (except '?' and '*' characters for wildcards) - * @param {string} value The String value to be escaped - * @returns {string} The escaped value + * Check if value is a template string + * @param {string} value The String value to be checked + * @returns {boolean} whether the value is a template string */ - protected escapeValue(value: string): string { - return value.replace(/([!+&|()[\]{}^~:"])/g, '\\$1') + protected isTemplateValue(value: string): boolean { + return new RegExp('^@@.*@@$').test(value) } - /** * The String value of the current Query expression */ diff --git a/packages/sn-query/test/index.test.ts b/packages/sn-query/test/index.test.ts index fc891f40a..3636e6df0 100644 --- a/packages/sn-query/test/index.test.ts +++ b/packages/sn-query/test/index.test.ts @@ -34,7 +34,7 @@ describe('Query', () => { ) expect(query.toString()).toBe( - "TypeIs:Task AND DisplayName:'Unicorn' AND ModificationDate:{'2017-01-01T00\\:00\\:00' TO '2017-02-01T00\\:00\\:00'} OR (NOT(Approvable:'true') AND NOT(Description:'*alma*')) .SORT:DisplayName .TOP:5 .SKIP:10", + "TypeIs:Task AND DisplayName:'Unicorn' AND ModificationDate:{'2017-01-01T00:00:00' TO '2017-02-01T00:00:00'} OR (NOT(Approvable:'true') AND NOT(Description:'*alma*')) .SORT:DisplayName .TOP:5 .SKIP:10", ) }) @@ -68,10 +68,22 @@ describe('Query', () => { const queryInstance = new Query((q) => q.equals('DisplayName', 'test')) expect(queryInstance.toString()).toBe("DisplayName:'test'") }) + + it('Equals with template string', () => { + const queryInstance = new Query((q) => q.equals('ModifiedBy', '@@CurrentUser@@')) + expect(queryInstance.toString()).toBe('ModifiedBy:@@CurrentUser@@') + }) + it('NotEquals', () => { const queryInstance = new Query((q) => q.notEquals('DisplayName', 'test')) expect(queryInstance.toString()).toBe("NOT(DisplayName:'test')") }) + + it('NotEquals with template string', () => { + const queryInstance = new Query((q) => q.notEquals('ModifiedBy', '@@CurrentUser@@')) + expect(queryInstance.toString()).toBe('NOT(ModifiedBy:@@CurrentUser@@)') + }) + it('EqualsNested', () => { const queryInstance = new Query((q) => q.equalsNested('Owner', 'DisplayName', 'test')) expect(queryInstance.toString()).toBe('Owner:{{DisplayName:test}}') @@ -90,26 +102,46 @@ describe('Query', () => { expect(queryInstance.toString()).toBe("Index:['10' TO '50']") }) - it('GreatherThan exclusive', () => { - const queryInstance = new Query((q) => q.greatherThan('Index', 10)) + it('GreaterThan exclusive', () => { + const queryInstance = new Query((q) => q.greaterThan('Index', 10)) expect(queryInstance.toString()).toBe("Index:>'10'") }) - it('GreatherThan inclusive', () => { - const queryInstance = new Query((q) => q.greatherThan('Index', 10, true)) + it('GreaterThan exclusive with template string', () => { + const queryInstance = new Query((q) => q.greaterThan('CreationDate', '@@Today@@')) + expect(queryInstance.toString()).toBe('CreationDate:>@@Today@@') + }) + + it('GreaterThan inclusive', () => { + const queryInstance = new Query((q) => q.greaterThan('Index', 10, true)) expect(queryInstance.toString()).toBe("Index:>='10'") }) + it('GreaterThan inclusive with template string', () => { + const queryInstance = new Query((q) => q.greaterThan('CreationDate', '@@Today@@', true)) + expect(queryInstance.toString()).toBe('CreationDate:>=@@Today@@') + }) + it('LessThan exclusive', () => { const queryInstance = new Query((q) => q.lessThan('Index', 10)) expect(queryInstance.toString()).toBe("Index:<'10'") }) + it('LessThan exclusive with template string', () => { + const queryInstance = new Query((q) => q.lessThan('CreationDate', '@@Today@@')) + expect(queryInstance.toString()).toBe('CreationDate:<@@Today@@') + }) + it('LessThan inclusive', () => { const queryInstance = new Query((q) => q.lessThan('Index', 10, true)) expect(queryInstance.toString()).toBe("Index:<='10'") }) + it('LessThan inclusive with template string', () => { + const queryInstance = new Query((q) => q.lessThan('CreationDate', '@@Today@@', true)) + expect(queryInstance.toString()).toBe('CreationDate:<=@@Today@@') + }) + it('AND syntax', () => { const queryInstance = new Query((q) => q.equals('Index', 1).and.equals('DisplayName', 'Test')) expect(queryInstance.toString()).toBe("Index:'1' AND DisplayName:'Test'") diff --git a/yarn.lock b/yarn.lock index a1b5c278b..b084c1600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16610,6 +16610,13 @@ react-custom-scrollbars@^4.2.1: prop-types "^15.5.10" raf "^3.1.0" +react-day-picker@^7.4.8: + version "7.4.8" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-7.4.8.tgz#675625240d3fae1b41c0a9d5177c968c8517c1d4" + integrity sha512-pp0hnxFVoRuBQcRdR1Hofw4CQtOCGVmzCNrscyvS0Q8NEc+UiYLEDqE5dk37bf0leSnBW4lheIt0CKKhuKzDVw== + dependencies: + prop-types "^15.6.2" + react-dev-utils@^10.0.0: version "10.2.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-10.2.1.tgz#f6de325ae25fa4d546d09df4bb1befdc6dd19c19"