Skip to content

#290 filter components #313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions data-browser/src/components/SearchFilter.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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 (
<div>
<ResourceSelector
setSubject={setClass}
value={klass}
classType={urls.classes.class}
/>
{allProps?.map(propertySubject => (
<PropertyFilter
key={propertySubject}
subject={propertySubject}
filters={filters}
setFilters={setFilters}
/>
))}
</div>
);
}

function PropertyFilter({ filters, setFilters, subject }): JSX.Element {
const prop = useProperty(subject);

function handleChange(e) {
setFilters({
...filters,
[prop.shortname]: e.target.value,
});
}

return (
<div>
<label>{prop.shortname}</label>
<input
type='text'
value={filters[prop.shortname]}
onChange={handleChange}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion data-browser/src/components/forms/ResourceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<ArrayError | undefined>>;
setError?: Dispatch<SetStateAction<ArrayError | undefined>>;
disabled?: boolean;
autoFocus?: boolean;
/** Is used when a new item is created using the ResourceSelector */
Expand Down
64 changes: 52 additions & 12 deletions data-browser/src/routes/SearchRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -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';
Expand All @@ -79,19 +95,45 @@ export function Search(): JSX.Element {
message = 'Loading results...';
}

if (results.length > 0) {
message = undefined;
}

return (
<ContainerNarrow>
{error ? (
<ErrorLook>{error.message}</ErrorLook>
) : query?.length !== 0 && results.length !== 0 ? (
) : (
<>
<Heading>
<FaSearch />
<span>
{results.length} {results.length > 1 ? 'Results' : 'Result'} for{' '}
<QueryText>{query}</QueryText>
</span>
</Heading>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Heading>
<FaSearch />
<span>
{message ? (
message
) : (
<>
{results.length} {results.length > 1 ? 'Results' : 'Result'}{' '}
for <QueryText>{query}</QueryText>
</>
)}
</span>
</Heading>
{enableFilter && (
<Button onClick={() => setShowFilter(!showFilter)}>
<FaFilter />
Filter
</Button>
)}
</div>
{showFilter && (
<ClassFilter setFilters={setFilters} filters={filters} />
)}
<div ref={resultsDiv}>
{results.map((subject, index) => (
<ResourceCard
Expand All @@ -104,8 +146,6 @@ export function Search(): JSX.Element {
))}
</div>
</>
) : (
<>{message}</>
)}
</ContainerNarrow>
);
Expand Down
21 changes: 11 additions & 10 deletions data-browser/tests/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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
Expand All @@ -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"]');
Expand Down
1 change: 1 addition & 0 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 14 additions & 0 deletions lib/src/search.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
78 changes: 78 additions & 0 deletions lib/src/search.ts
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 1 addition & 1 deletion lib/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading