diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfe5e47..47a54c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,27 @@ This changelog covers all three packages, as they are (for now) updated as a who ## UNRELEASED -- Add `Store.parseMetaTags` to load JSON-AD objects stored in the DOM. Speeds up initial page load by allowing server to set JSON-AD objects in the initial HTML response. +### @tomic/browser + - Move static assets around, align build with server and fix PWA #292 -- `store.createSubject` allows creating nested paths - Add `useChildren` hook and `Store.getChildren` method -- Add `Store.postToServer` method, add `endpoints`, `importJsonAdString` - Add new file preview UI for images, audio, text and PDF files. - Add new file preview types to the folder grid view. -- Add `store.preloadClassesAndProperties` and remove `urls.properties.getAll` and `urls.classes.getAll`. This enables using `atomic-data-browser` without relying on `atomicdata.dev` being available. - Fix Dialogue form #308 +- Refactor search, escape query strings for Tantivy + +### @tomic/react + +- Add more options to `useSearch` + +### @tomic/lib + +- Add `Store.parseMetaTags` to load JSON-AD objects stored in the DOM. Speeds up initial page load by allowing server to set JSON-AD objects in the initial HTML response. +- `store.createSubject` allows creating nested paths +- Add `Store.postToServer` method, add `endpoints`, `importJsonAdString` +- Add `store.preloadClassesAndProperties` and remove `urls.properties.getAll` and `urls.classes.getAll`. This enables using `atomic-data-browser` without relying on `atomicdata.dev` being available. - Fix Race condition of `store.getResourceAsync` #309 +- Add `buildSearchSubject` in `search.ts` which allows you to build full text search queries to send to Atomic-Server. ## v0.35.0 diff --git a/data-browser/src/components/SearchFilter.tsx b/data-browser/src/components/SearchFilter.tsx new file mode 100644 index 00000000..1b687230 --- /dev/null +++ b/data-browser/src/components/SearchFilter.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import { urls, useArray, useProperty, useResource } from '@tomic/react'; +import { ResourceSelector } from '../components/forms/ResourceSelector'; + +/** + * Shows a Class selector to the user. + * If a Class is selected, the filters for the required and recommended properties + * of that Class are shown. + */ +export function ClassFilter({ filters, setFilters }): JSX.Element { + const [klass, setClass] = useState(undefined); + const resource = useResource(klass); + const [requiredProps] = useArray(resource, urls.properties.requires); + const [recommendedProps] = useArray(resource, urls.properties.recommends); + const allProps = [...requiredProps, ...recommendedProps]; + + useEffect(() => { + // Set the filters to the default values of the properties + setFilters({ + ...filters, + [urls.properties.isA]: klass, + }); + }, [klass, JSON.stringify(filters)]); + + return ( +
+ + {allProps?.map(propertySubject => ( + + ))} +
+ ); +} + +function PropertyFilter({ filters, setFilters, subject }): JSX.Element { + const prop = useProperty(subject); + + function handleChange(e) { + setFilters({ + ...filters, + [prop.shortname]: e.target.value, + }); + } + + return ( +
+ + +
+ ); +} diff --git a/data-browser/src/components/forms/ResourceSelector.tsx b/data-browser/src/components/forms/ResourceSelector.tsx index 36598282..5339ac67 100644 --- a/data-browser/src/components/forms/ResourceSelector.tsx +++ b/data-browser/src/components/forms/ResourceSelector.tsx @@ -48,7 +48,7 @@ interface ResourceSelectorProps { * Set an ArrayError. A special type, because the parent needs to know where * in the Array the error occurred */ - setError: Dispatch>; + setError?: Dispatch>; disabled?: boolean; autoFocus?: boolean; /** Is used when a new item is created using the ResourceSelector */ diff --git a/data-browser/src/routes/SearchRoute.tsx b/data-browser/src/routes/SearchRoute.tsx index 0e1faf1f..75958222 100644 --- a/data-browser/src/routes/SearchRoute.tsx +++ b/data-browser/src/routes/SearchRoute.tsx @@ -7,20 +7,36 @@ import ResourceCard from '../views/Card/ResourceCard'; import { useServerSearch } from '@tomic/react'; import { ErrorLook } from '../components/ErrorLook'; import styled from 'styled-components'; -import { FaSearch } from 'react-icons/fa'; +import { FaFilter, FaSearch } from 'react-icons/fa'; import { useQueryScopeHandler } from '../hooks/useQueryScope'; import { useSettings } from '../helpers/AppSettings'; +import { ClassFilter } from '../components/SearchFilter'; +import { Button } from '../components/Button'; /** Full text search route */ export function Search(): JSX.Element { const [query] = useSearchQuery(); const { drive } = useSettings(); const { scope } = useQueryScopeHandler(); + const [filters, setFilters] = useState({}); + const [enableFilter, setEnableFilter] = useState(false); + + useHotkeys( + 'f12', + e => { + e.preventDefault(); + setEnableFilter(!enableFilter); + }, + [enableFilter], + ); + + const [showFilter, setShowFilter] = useState(false); const [selectedIndex, setSelected] = useState(0); const { results, loading, error } = useServerSearch(query, { debounce: 0, scope: scope || drive, + filters, }); const navigate = useNavigate(); const resultsDiv = useRef(null); @@ -69,7 +85,7 @@ export function Search(): JSX.Element { { enableOnTags: ['INPUT'] }, ); - let message = 'No hits'; + let message: string | undefined = 'No hits'; if (query?.length === 0) { message = 'Enter a search query'; @@ -79,19 +95,45 @@ export function Search(): JSX.Element { message = 'Loading results...'; } + if (results.length > 0) { + message = undefined; + } + return ( {error ? ( {error.message} - ) : query?.length !== 0 && results.length !== 0 ? ( + ) : ( <> - - - - {results.length} {results.length > 1 ? 'Results' : 'Result'} for{' '} - {query} - - +
+ + + + {message ? ( + message + ) : ( + <> + {results.length} {results.length > 1 ? 'Results' : 'Result'}{' '} + for {query} + + )} + + + {enableFilter && ( + + )} +
+ {showFilter && ( + + )}
{results.map((subject, index) => ( - ) : ( - <>{message} )} ); diff --git a/data-browser/tests/e2e.spec.ts b/data-browser/tests/e2e.spec.ts index 7063d682..9f5dffdf 100644 --- a/data-browser/tests/e2e.spec.ts +++ b/data-browser/tests/e2e.spec.ts @@ -128,10 +128,10 @@ test.describe('data-browser', async () => { await signIn(page); await newDrive(page); - // Create folder called 'Not This folder' + // Create folder called 1 await page.locator('[data-test="sidebar-new-resource"]').click(); await page.locator('button:has-text("folder")').click(); - await setTitle(page, 'Not This Folder'); + await setTitle(page, 'Salad folder'); // Create document called 'Avocado Salad' await page.locator('button:has-text("New Resource")').click(); @@ -143,9 +143,9 @@ test.describe('data-browser', async () => { await page.locator('[data-test="sidebar-new-resource"]').click(); - // Create folder called 'This folder' + // Create folder called 'Cake folder' await page.locator('button:has-text("folder")').click(); - await setTitle(page, 'This Folder'); + await setTitle(page, 'Cake Folder'); // Create document called 'Avocado Salad' await page.locator('button:has-text("New Resource")').click(); @@ -155,15 +155,16 @@ test.describe('data-browser', async () => { await page.waitForResponse(`${serverUrl}/commit`); await editTitle('Avocado Cake', page); - await clickSidebarItem('This Folder', page); - - // Set search scope to 'This folder' + await clickSidebarItem('Cake Folder', page); + // Set search scope to 'Cake folder' await page.waitForTimeout(REBUILD_INDEX_TIME); - await page.locator('button[title="Search in This Folder"]').click(); + await page.locator('button[title="Search in Cake Folder"]').click(); // Search for 'Avocado' await page.locator('[data-test="address-bar"]').type('Avocado'); - await expect(page.locator('h2:text("Avocado Cake")')).toBeVisible(); + // I don't like the `.first` here, but for some reason there is one frame where + // Multiple hits render, which fails the tests. + await expect(page.locator('h2:text("Avocado Cake")').first()).toBeVisible(); await expect(page.locator('h2:text("Avocado Salad")')).not.toBeVisible(); // Remove scope @@ -183,7 +184,7 @@ test.describe('data-browser', async () => { '[data-test="sort-https://atomicdata.dev/properties/description"]', ); // These values can change as new Properties are added to atomicdata.dev - const firstPageText = 'text=A base64'; + const firstPageText = 'text=A base64 serialized JSON object'; const secondPageText = 'text=include-nested'; await expect(page.locator(firstPageText)).toBeVisible(); await page.click('[data-test="next-page"]'); diff --git a/lib/src/index.ts b/lib/src/index.ts index b80890d1..49da91a4 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -38,6 +38,7 @@ export * from './error.js'; export * from './endpoints.js'; export * from './datatypes.js'; export * from './parse.js'; +export * from './search.js'; export * from './resource.js'; export * from './store.js'; export * from './value.js'; diff --git a/lib/src/search.test.ts b/lib/src/search.test.ts new file mode 100644 index 00000000..fde9b625 --- /dev/null +++ b/lib/src/search.test.ts @@ -0,0 +1,14 @@ +import { escapeTantivyKey } from './search.js'; + +const testTuples = [ + ['https://test', 'https\\://test'], + ['https://test.com', 'https\\://test\\.com'], +]; + +describe('search.ts', () => { + it('Handles resources without an ID', () => { + for (const [input, output] of testTuples) { + expect(escapeTantivyKey(input)).toBe(output); + } + }); +}); diff --git a/lib/src/search.ts b/lib/src/search.ts new file mode 100644 index 00000000..8d616eb1 --- /dev/null +++ b/lib/src/search.ts @@ -0,0 +1,78 @@ +import { Store } from './index.js'; + +export interface SearchOpts { + /** Fetch full resources instead of subjects */ + include?: boolean; + /** Max of how many results to return */ + limit?: number; + /** Subject of resource to scope the search to. This should be a parent of the resources you're looking for. */ + scope?: string; + /** Property-Value pair of set filters. For now, use the `shortname` of the property as the key. */ + filters?: { + [propertyShortname: string]: string; + }; +} + +// https://github.com/quickwit-oss/tantivy/blob/064518156f570ee2aa03cf63be6d5605a96d6285/query-grammar/src/query_grammar.rs#L19 +const specialCharsTantivy = [ + '+', + '^', + '`', + ':', + '{', + '}', + '"', + '[', + ']', + '(', + ')', + '!', + '\\', + '*', + ' ', + // The dot is escaped, even though it's not in Tantivy's list. + '.', +]; + +/** escape the key conform to Tantivy syntax, escaping all specialCharsTantivy */ +export function escapeTantivyKey(key: string) { + return key.replace( + new RegExp(`([${specialCharsTantivy.join('\\')}])`, 'g'), + '\\$1', + ); +} + +/** Uses Tantivy query syntax */ +function buildFilterString(filters: { [key: string]: string }): string { + return Object.entries(filters) + .map(([key, value]) => { + return value && value.length > 0 && `${escapeTantivyKey(key)}:"${value}"`; + }) + .join(' AND '); +} + +/** Returns the URL of the search query. Fetch that and you get your results! */ +export function buildSearchSubject( + store: Store, + query: string, + opts: SearchOpts = {}, +) { + const { include = false, limit = 30, scope, filters } = opts; + const url = new URL(store.getServerUrl()); + url.pathname = 'search'; + query && url.searchParams.set('q', query); + include && url.searchParams.set('include', include.toString()); + limit && url.searchParams.set('limit', limit.toString()); + // Only add filters if there are any keys, and if any key is defined + const hasFilters = + filters && + Object.keys(filters).length > 0 && + Object.values(filters).filter(v => v && v.length > 0).length > 0; + hasFilters && url.searchParams.set('filters', buildFilterString(filters)); + + if (scope) { + url.searchParams.set('parent', scope); + } + + return url.toString(); +} diff --git a/lib/src/store.test.ts b/lib/src/store.test.ts index 0ac56f43..09247651 100644 --- a/lib/src/store.test.ts +++ b/lib/src/store.test.ts @@ -32,7 +32,7 @@ describe('Store', () => { }); it('accepts a custom fetch implementation', async () => { - const testResourceSubject = 'https://example.com/test'; + const testResourceSubject = 'https://atomicdata.dev'; const customFetch = jest.fn( async (url: RequestInfo | URL, options: RequestInit | undefined) => { diff --git a/react/src/useServerSearch.tsx b/react/src/useServerSearch.tsx index da4911ca..03e51595 100644 --- a/react/src/useServerSearch.tsx +++ b/react/src/useServerSearch.tsx @@ -1,4 +1,4 @@ -import { urls } from '@tomic/lib'; +import { buildSearchSubject, SearchOpts, urls } from '@tomic/lib'; import { useEffect, useMemo, useState } from 'react'; import { useArray, useDebounce, useResource, useStore } from './index.js'; @@ -9,47 +9,32 @@ interface SearchResults { error?: Error; } -interface SearchOpts { +interface SearchOptsHook extends SearchOpts { /** * Debouncing makes queries slower, but prevents sending many request. Number * respresents milliseconds. */ debounce?: number; - /** Fetch full resources instead of subjects */ - include?: boolean; - /** Max of how many results to return */ - limit?: number; - /** Subject of resource to scope the search to */ - scope?: string; } /** Pass a query to search the current server */ export function useServerSearch( query: string | undefined, - opts: SearchOpts = {}, + opts: SearchOptsHook = {}, ): SearchResults { - const { debounce = 50, include = false, limit = 30, scope } = opts; + const { debounce = 50 } = opts; const [results, setResults] = useState([]); const store = useStore(); // Calculating the query takes a while, so we debounce it const debouncedQuery = useDebounce(query, debounce) ?? ''; - const urlString: string = useMemo(() => { - const url = new URL(store.getServerUrl()); - url.pathname = 'search'; - url.searchParams.set('q', debouncedQuery); - url.searchParams.set('include', include.toString()); - url.searchParams.set('limit', limit.toString()); + const searchSubjectURL: string = useMemo( + () => buildSearchSubject(store, debouncedQuery, opts), + [debouncedQuery, opts], + ); - if (scope) { - url.searchParams.set('parent', scope); - } - - return url.toString(); - }, [debouncedQuery, scope, include, limit]); - - const resource = useResource(urlString, { + const resource = useResource(searchSubjectURL, { noWebSocket: true, }); const [resultsIn] = useArray(resource, urls.properties.endpoint.results);