From 8b819ef6c7a6cd4ca8922ee75271f915c179b03e Mon Sep 17 00:00:00 2001 From: Dinika Saxena Date: Fri, 8 Sep 2023 11:20:25 +0200 Subject: [PATCH] 4083 // Run predicates in backend Signed-off-by: Dinika Saxena --- package.json | 7 +- .../handlers/DataExplorer/handlers.ts | 27 +- .../dataExplorer/DataExplorer.spec.tsx | 91 +++++- src/subapps/dataExplorer/DataExplorer.tsx | 27 +- .../dataExplorer/DataExplorerUtils.tsx | 97 +++++- .../dataExplorer/PredicateSelector.tsx | 304 +++++++++++++----- src/subapps/dataExplorer/styles.less | 14 + 7 files changed, 453 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index 64820344a..a94597307 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "NODE_ENV=development DEBUG=* webpack --mode development --config-name server && node dist/server.js", "build": "NODE_ENV=production NODE_OPTIONS=--max_old_space_size=8192 webpack --mode production", "test": "jest", - "test:watch": "jest --watch", + "test:watch": "jest --watch --maxWorkers=4", "cy:open": "cypress open", "cy:run": "cypress run", "cy:ci": "echo | sudo docker exec --user=$UID -t cypress cypress run --config-file cypress.config.ts --browser chrome", @@ -212,7 +212,10 @@ ], "globals": { "FUSION_VERSION": "1.0.0", - "COMMIT_HASH": "9013fa343" + "COMMIT_HASH": "9013fa343", + "ts-jest": { + "isolatedModules": true + } }, "watchPathIgnorePatterns": [ "node_modules" diff --git a/src/__mocks__/handlers/DataExplorer/handlers.ts b/src/__mocks__/handlers/DataExplorer/handlers.ts index d3d9e55b8..6264b6079 100644 --- a/src/__mocks__/handlers/DataExplorer/handlers.ts +++ b/src/__mocks__/handlers/DataExplorer/handlers.ts @@ -60,12 +60,12 @@ export const graphAnalyticsTypeHandler = () => { _name: 'propertyAlwaysThere', }, { - '@id': 'https://neuroshapes.org/nr__number_stems', + '@id': 'https://neuroshapes.org/createdBy', _count: 30, _name: '_createdBy', }, { - '@id': 'https://neuroshapes.org/nr__number_stems', + '@id': 'https://neuroshapes.org/edition', _count: 30, _name: 'edition', }, @@ -147,6 +147,29 @@ export const filterByProjectHandler = ( }); }; +export const elasticSearchQueryHandler = (ids: string[]) => { + return rest.post( + deltaPath('/graph-analytics/:org/:project/_search'), + (req, res, ctx) => { + const esResponse = { + hits: { + hits: ids.map(id => ({ + _id: id, + _source: { + '@id': id, + }, + })), + max_score: 0, + total: { + value: 479, + }, + }, + }; + return res(ctx.status(200), ctx.json(esResponse)); + } + ); +}; + const mockAggregationsResult = ( bucketForTypes: AggregatedBucket[] = defaultBucketForTypes ): AggregationsResult => { diff --git a/src/subapps/dataExplorer/DataExplorer.spec.tsx b/src/subapps/dataExplorer/DataExplorer.spec.tsx index fe105ab31..e3da2e493 100644 --- a/src/subapps/dataExplorer/DataExplorer.spec.tsx +++ b/src/subapps/dataExplorer/DataExplorer.spec.tsx @@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { dataExplorerPageHandler, + elasticSearchQueryHandler, filterByProjectHandler, getCompleteResources, getMockResource, @@ -24,6 +25,7 @@ import { DOES_NOT_CONTAIN, DOES_NOT_EXIST, EXISTS, + FRONTEND_PREDICATE_WARNING, getAllPaths, } from './PredicateSelector'; import { createMemoryHistory } from 'history'; @@ -64,7 +66,12 @@ describe('DataExplorer', () => { fetch, uri: deltaPath(), }); - const store = configureStore(history, { nexus }, {}); + + const store = configureStore( + history, + { nexus }, + { config: { apiEndpoint: 'https://localhost:3000' } } + ); dataExplorerPage = ( @@ -109,9 +116,6 @@ describe('DataExplorer', () => { const expectRowCountToBe = async (expectedRowsCount: number) => { return await waitFor(() => { const rows = visibleTableRows(); - rows.forEach(row => { - // console.log('MY ROW', row.innerHTML) - }); expect(rows.length).toEqual(expectedRowsCount); return rows; }); @@ -141,6 +145,7 @@ describe('DataExplorer', () => { selector: 'th .ant-table-column-title', exact: false, }); + expect(header).toBeInTheDocument(); return header; }; @@ -355,6 +360,19 @@ describe('DataExplorer', () => { server.use(filterByProjectHandler(mockResourcesForPage2)); }; + const mockElasticSearchHits = ( + path: string, + predicate: typeof EXISTS | typeof DOES_NOT_EXIST, + resources: Resource[] + ) => { + const matchingResources = resources.filter(res => + predicate === EXISTS ? res[path] : !res[path] + ); + server.use( + elasticSearchQueryHandler(matchingResources.map(res => res['@id'])) + ); + }; + const getResetProjectButton = async () => { return await screen.getByTestId('reset-project-button'); }; @@ -673,12 +691,14 @@ describe('DataExplorer', () => { it('shows resources that have path missing', async () => { await updateResourcesShownInTable(mockResourcesForPage2); + mockElasticSearchHits('author', DOES_NOT_EXIST, mockResourcesForPage2); await selectPath('author'); await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); await expectRowCountToBe(1); await resetPredicate(); + mockElasticSearchHits('edition', DOES_NOT_EXIST, mockResourcesForPage2); await selectPath('edition'); await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); await expectRowCountToBe(2); @@ -718,11 +738,8 @@ describe('DataExplorer', () => { }); it('shows resources that have a path when user selects exists predicate', async () => { - await updateResourcesShownInTable([ - getMockResource('self1', { author: 'piggy', edition: 1 }), - getMockResource('self2', { author: ['iggy', 'twinky'] }), - getMockResource('self3', { year: 2013 }), - ]); + await updateResourcesShownInTable(mockResourcesForPage2); + mockElasticSearchHits('author', EXISTS, mockResourcesForPage2); await selectPath('author'); await userEvent.click(container); @@ -798,20 +815,61 @@ describe('DataExplorer', () => { expect(totalFromFrontend).toEqual(null); }); - it('shows total filtered count if predicate is selected', async () => { + it('does not show total filtered count if backend predicate is selected', async () => { await expectRowCountToBe(10); await updateResourcesShownInTable(mockResourcesForPage2); + mockElasticSearchHits('author', EXISTS, mockResourcesForPage2); await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, EXISTS); await expectRowCountToBe(2); - const totalFromFrontendAfter = await getFilteredResultsCount(2); - expect(totalFromFrontendAfter).toBeVisible(); + expect(await getFilteredResultsCount()).toBeFalsy(); + + mockElasticSearchHits('author', DOES_NOT_EXIST, mockResourcesForPage2); + await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); + await expectRowCountToBe(1); + expect(await getFilteredResultsCount()).toBeFalsy(); + }); + + it('shows filtered count if frontend predicate is selected', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + + await selectPath('author'); + await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); + const valueInput = await screen.getByPlaceholderText('Search for...'); + await userEvent.type(valueInput, 'twinky'); + await expectRowCountToBe(1); + + expect(await getFilteredResultsCount(1)).toBeVisible(); + }); + + it('shows predicate warning if frontend predicate is selected', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + + await selectPath('author'); + await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); + + expect(await screen.getByText(FRONTEND_PREDICATE_WARNING)).toBeVisible(); + + await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_CONTAIN); + expect(await screen.getByText(FRONTEND_PREDICATE_WARNING)).toBeVisible(); + }); + + it('does not show predicate warning if backend predicate is selected', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + + await selectPath('author'); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + + expect(await screen.queryByText(FRONTEND_PREDICATE_WARNING)).toBeFalsy(); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + expect(await screen.queryByText(FRONTEND_PREDICATE_WARNING)).toBeFalsy(); }); it('shows column for metadata path even if toggle for show metadata is off', async () => { const metadataProperty = '_createdBy'; + mockElasticSearchHits(metadataProperty, EXISTS, mockResourcesOnPage1); await expectRowCountToBe(10); await expectColumHeaderToNotExist(metadataProperty); @@ -821,6 +879,8 @@ describe('DataExplorer', () => { await selectPath(metadataProperty); await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + await expectRowCountToBe(10); + await expectColumHeaderToExist(metadataProperty); expect(getTotalColumns().length).toEqual(originalColumns + 1); @@ -830,6 +890,7 @@ describe('DataExplorer', () => { it('resets predicate fields when reset predicate clicked', async () => { await updateResourcesShownInTable(mockResourcesForPage2); + mockElasticSearchHits('author', EXISTS, mockResourcesForPage2); await selectPath('author'); await selectPredicate(EXISTS); @@ -985,17 +1046,17 @@ describe('DataExplorer', () => { expect((valueInputAfter as HTMLInputElement).value).not.toEqual('iggy'); }); - it('does not show predicate selector if multiple types are selected', async () => { + it('disables predicate selector if multiple types are selected', async () => { await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); await selectOptionFromMenu(TypeMenuLabel, 'file', TypeOptionSelector); - expect(await getInputForLabel(PathMenuLabel)).toBeVisible(); + expect(await getInputForLabel(PathMenuLabel)).toBeEnabled(); await selectOptionFromMenu( TypeMenuLabel, 'StudioDashboard', TypeOptionSelector ); - expect(getInputForLabel(PathMenuLabel)).rejects.toThrow(); + expect(await getInputForLabel(PathMenuLabel)).toBeDisabled(); }); }); diff --git a/src/subapps/dataExplorer/DataExplorer.tsx b/src/subapps/dataExplorer/DataExplorer.tsx index 0f521d492..ee216b8af 100644 --- a/src/subapps/dataExplorer/DataExplorer.tsx +++ b/src/subapps/dataExplorer/DataExplorer.tsx @@ -40,7 +40,8 @@ export interface DataExplorerConfiguration { offset: number; orgAndProject?: [string, string]; types?: TType[]; - predicate: ((resource: Resource) => boolean) | null; + frontendPredicate: ((resource: Resource) => boolean) | null; + backendPredicateQuery: Object | null; selectedPath: string | null; deprecated: boolean; columns: TColumn[]; @@ -137,7 +138,11 @@ const DataExplorer: React.FC<{}> = () => { pageSize, offset, orgAndProject, - predicate, + // NOTE: Right now, the `EXISTS` and `DOES_NOT_EXIST` predicates run on the backend and update the `backendPredicateQuery` parameter. + // `CONTAINS` and `DOES_NOT_CONTAIN` predicates on the other hand, only run on frontend and update `frontendPredicate` parameter. + // When we implement running all the predicates on backend, we should discard `frontendPredicate` parameter completely. + frontendPredicate, + backendPredicateQuery, types, selectedPath, deprecated, @@ -158,7 +163,8 @@ const DataExplorer: React.FC<{}> = () => { offset: 0, orgAndProject: undefined, types: [], - predicate: null, + frontendPredicate: null, + backendPredicateQuery: null, selectedPath: null, deprecated: false, columns: [], @@ -173,12 +179,13 @@ const DataExplorer: React.FC<{}> = () => { deprecated, typeOperator, types: types?.map(t => t.value), + predicateQuery: backendPredicateQuery, }); const currentPageDataSource: Resource[] = resources?._results || []; - const displayedDataSource = predicate - ? currentPageDataSource.filter(predicate) + const displayedDataSource = frontendPredicate + ? currentPageDataSource.filter(frontendPredicate) : currentPageDataSource; const buildColumns = useMemo(() => { @@ -224,6 +231,7 @@ const DataExplorer: React.FC<{}> = () => { updateTableConfiguration({ columns: newColumns }); updateSelectedColumnsCached(newColumns); }; + useEffect(() => { const selectedFilters = getSelectedFiltersCached(); if (selectedFilters) { @@ -286,8 +294,7 @@ const DataExplorer: React.FC<{}> = () => { return () => unlisten(); }, []); - const shouldShowPredicateSelector = - orgAndProject?.length === 2 && types?.length === 1; + const shouldShowPredicateSelector = orgAndProject?.length === 2; return (
@@ -359,7 +366,7 @@ const DataExplorer: React.FC<{}> = () => { onResetCallback={onResetPredicateCallback} org={orgAndProject![0]} project={orgAndProject![1]} - types={types!.map(type => type.value)} + types={types} /> ) : null}
@@ -380,7 +387,9 @@ const DataExplorer: React.FC<{}> = () => { types={types} nexusTotal={resources?._total ?? 0} totalOnPage={resources?._results?.length ?? 0} - totalFiltered={predicate ? displayedDataSource.length : undefined} + totalFiltered={ + frontendPredicate ? displayedDataSource.length : undefined + } />
{ const nexus = useNexusContext(); + const { apiEndpoint } = useSelector((state: RootState) => state.config); return useQuery({ queryKey: [ 'data-explorer', @@ -24,6 +30,7 @@ export const usePaginatedExpandedResources = ({ pageSize, offset, orgAndProject, + predicateQuery, ...(types?.length ? { types, @@ -34,6 +41,18 @@ export const usePaginatedExpandedResources = ({ ], retry: false, queryFn: async () => { + if (predicateQuery && orgAndProject) { + return getResultsForPredicateQuery( + nexus, + apiEndpoint, + orgAndProject[0], + orgAndProject[1], + predicateQuery, + pageSize, + offset + ); + } + const resultWithPartialResources = await nexus.Resource.list( orgAndProject?.[0], orgAndProject?.[1], @@ -129,6 +148,7 @@ export const useAggregations = ( }; export type GraphAnalyticsProperty = { + '@id'?: string; // TODO Make necessory _name: string; _count?: number; _properties: GraphAnalyticsProperty[]; @@ -156,11 +176,59 @@ export const useGraphAnalyticsPath = ( )) as GraphAnalyticsResponse; }, select: data => { - return getUniquePathsForProperties(data._properties); + return getPathsForProperties(data._properties); }, }); }; +const getResultsForPredicateQuery = async ( + nexus: ReturnType, + apiEndpoint: string, + org: string, + project: string, + query: Object, + pageSize: number, + offset: number +) => { + const searchResults: SearchResponse<{ '@id': string }> = await nexus.httpPost( + { + path: `${apiEndpoint}/graph-analytics/${org}/${project}/_search`, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + from: offset, + size: pageSize, + ...query, + }), + } + ); + + const { results: matchingResources } = await PromisePool.withConcurrency(4) + .for(searchResults.hits.hits) + .handleError((err, item) => { + console.error( + `There was an error retrieving matching resource ${item._source['@id']} for query.`, + query, + err + ); + }) + .process(async matchingHit => { + return (await nexus.Resource.get( + org, + project, + encodeURIComponent(matchingHit._source['@id']), + { annotate: true } + )) as Resource; + }); + + return { + _results: matchingResources, + _total: searchResults.hits.total.value, + } as PaginatedList; +}; + export const getUniquePathsForProperties = ( properties: GraphAnalyticsProperty[], paths: string[] = [], @@ -175,6 +243,30 @@ export const getUniquePathsForProperties = ( return Array.from(new Set(paths)); }; +export type PropertyPath = { + label: string; + value: string; +}; + +export const getPathsForProperties = ( + properties: GraphAnalyticsProperty[], + paths: PropertyPath[] = [], + pathSoFar?: string, + valueSoFar?: string +): PropertyPath[] => { + properties?.forEach(property => { + const label = pathSoFar ? `${pathSoFar}.${property._name}` : property._name; + const value = valueSoFar + ? `${valueSoFar} / ${property['@id']!}` + : property['@id']!; + paths.push({ label, value }); + getPathsForProperties(property._properties ?? [], paths, label, value); + }); + + const uniquePaths = new Set(paths.map(path => path.value)); + return paths.filter(path => uniquePaths.has(path.value)); +}; + export const sortColumns = (a: string, b: string) => { // Sorts paths alphabetically. Additionally all paths starting with an underscore are sorted at the end of the list (because they represent metadata). const columnA = columnFromPath(a); @@ -250,6 +342,7 @@ interface PaginatedResourcesParams { deprecated: boolean; types?: string[]; typeOperator: TTypeOperator; + predicateQuery: Object | null; } export const useTimeoutMessage = ({ diff --git a/src/subapps/dataExplorer/PredicateSelector.tsx b/src/subapps/dataExplorer/PredicateSelector.tsx index d0ac5d4b2..c4c0e3157 100644 --- a/src/subapps/dataExplorer/PredicateSelector.tsx +++ b/src/subapps/dataExplorer/PredicateSelector.tsx @@ -1,19 +1,21 @@ import { UndoOutlined } from '@ant-design/icons'; import { Resource } from '@bbp/nexus-sdk'; -import { Button, Form, Input, Select } from 'antd'; +import { Button, Form, Input, Select, Tooltip } from 'antd'; import { FormInstance } from 'antd/es/form'; import { DefaultOptionType } from 'antd/lib/cascader'; -import React, { useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { TType } from 'shared/molecules/TypeSelector/types'; import { normalizeString } from '../../utils/stringUtils'; +import { TColumn } from './ColumnsSelector'; import { DataExplorerConfiguration } from './DataExplorer'; import { + PropertyPath, columnFromPath, isObject, isUserColumn, sortColumns, useGraphAnalyticsPath, } from './DataExplorerUtils'; -import { TColumn } from './ColumnsSelector'; import './styles.less'; interface Props { @@ -22,7 +24,7 @@ interface Props { onResetCallback: (column: string, checked: boolean) => void; org: string; project: string; - types: string[]; + types: TType[] | undefined; } export const PredicateSelector: React.FC = ({ @@ -38,6 +40,7 @@ export const PredicateSelector: React.FC = ({ path: '', selected: false, }); + const predicateFilterOptions: PredicateFilterOptions[] = [ { value: EXISTS }, { value: DOES_NOT_EXIST }, @@ -48,62 +51,91 @@ export const PredicateSelector: React.FC = ({ const { data: paths, isLoading: arePathsLoading } = useGraphAnalyticsPath( org, project, - types + types?.map(t => t.value) ?? [] ); + // NOTE: Right now, the `EXISTS` and `DOES_NOT_EXIST` predicates run on the backend and update the `backendPredicateQuery` parameter. + // `CONTAINS` and `DOES_NOT_CONTAIN` predicates on the other hand, only run on frontend and update `frontendPredicate` parameter. + // When we implement running all the predicates on backend, we should discard `frontendPredicate` parameter completely. const predicateSelected = ( - path: string, + path: DefaultOptionType, predicate: PredicateFilterOptions['value'] | null, searchTerm: string | null ) => { if (!path || !predicate) { - onPredicateChange({ predicate: null, selectedPath: null }); + onPredicateChange({ + frontendPredicate: null, + selectedPath: null, + backendPredicateQuery: null, + }); } switch (predicate) { - case EXISTS: + case EXISTS: { onPredicateChange({ - predicate: (resource: Resource) => - checkPathExistence(resource, path, 'exists'), - selectedPath: path, + backendPredicateQuery: getPredicateQuery( + predicate, + types![0].value, + path.value as string + ), + frontendPredicate: null, + selectedPath: path.key, }); break; - case DOES_NOT_EXIST: + } + case DOES_NOT_EXIST: { onPredicateChange({ - predicate: (resource: Resource) => - checkPathExistence(resource, path, 'does-not-exist'), - selectedPath: path, + backendPredicateQuery: getPredicateQuery( + predicate, + types![0].value, + path.value as string + ), + frontendPredicate: null, + selectedPath: path.key, }); break; + } case CONTAINS: if (searchTerm) { onPredicateChange({ - predicate: (resource: Resource) => - doesResourceContain(resource, path, searchTerm, 'contains'), - selectedPath: path, + frontendPredicate: (resource: Resource) => + doesResourceContain(resource, path.key, searchTerm, 'contains'), + selectedPath: path.key, }); } else { - onPredicateChange({ predicate: null, selectedPath: null }); + onPredicateChange({ + frontendPredicate: null, + selectedPath: null, + backendPredicateQuery: null, + }); } break; case DOES_NOT_CONTAIN: if (searchTerm) { onPredicateChange({ - predicate: (resource: Resource) => + frontendPredicate: (resource: Resource) => doesResourceContain( resource, - path, + path.key, searchTerm, 'does-not-contain' ), - selectedPath: path, + selectedPath: path.key, }); } else { - onPredicateChange({ predicate: null, selectedPath: null }); + onPredicateChange({ + frontendPredicate: null, + selectedPath: null, + backendPredicateQuery: null, + }); } break; default: { - onPredicateChange({ predicate: null, selectedPath: null }); + onPredicateChange({ + frontendPredicate: null, + selectedPath: null, + backendPredicateQuery: null, + }); break; } } @@ -113,7 +145,10 @@ export const PredicateSelector: React.FC = ({ return formRef.current?.getFieldValue(fieldName) ?? ''; }; - const setFormField = (fieldName: string, fieldValue: string) => { + const setFormField = ( + fieldName: string, + fieldValue: string | DefaultOptionType + ) => { if (formRef.current) { formRef.current.setFieldValue(fieldName, fieldValue); } @@ -126,76 +161,104 @@ export const PredicateSelector: React.FC = ({ form.resetFields(); } pathRef.current = { path: '', selected: false }; - onPredicateChange({ predicate: null, selectedPath: null }); + onPredicateChange({ + frontendPredicate: null, + backendPredicateQuery: null, + selectedPath: null, + }); }; + useEffect(() => { + onReset(); + }, [types]); + const shouldShowValueInput = getFormFieldValue(PREDICATE_FIELD) === CONTAINS || getFormFieldValue(PREDICATE_FIELD) === DOES_NOT_CONTAIN; + const disablePredicateSelection = types?.length !== 1; + const isFrontendPredicateSelected = + getFormFieldValue(PREDICATE_FIELD) === CONTAINS || + getFormFieldValue(PREDICATE_FIELD) === DOES_NOT_CONTAIN; + return (
with - { + setFormField(PATH_FIELD, pathLabel); + predicateSelected( + pathLabel, + getFormFieldValue(PREDICATE_FIELD), + getFormFieldValue(SEARCH_TERM_FIELD) + ); + }} + disabled={disablePredicateSelection} + loading={arePathsLoading} + allowClear={true} + onClear={() => { + onReset(); + }} + virtual={true} + className="select-menu" + popupClassName="search-menu" + optionLabelProp="label" + aria-label="path-selector" + style={{ width: 200, minWidth: 'max-content' }} + dropdownMatchSelectWidth={false} // This ensures that the items in the dropdown list are always fully legible (ie they are not truncated) just because the input of select is too short. + /> + {getFormFieldValue(PATH_FIELD) && ( <> = - - { + setFormField(PREDICATE_FIELD, predicateLabel); + setFormField(SEARCH_TERM_FIELD, ''); + const selectedPath = getFormFieldValue(PATH_FIELD); + pathRef.current = { + path: selectedPath.key, + selected: + columns.find(column => column.value === selectedPath) + ?.selected ?? false, + }; + + predicateSelected(selectedPath, predicateLabel, ''); + }} + aria-label="predicate-selector" + className="select-menu reduced-width" + popupClassName="search-menu" + autoFocus={true} + allowClear={true} + onClear={() => { + predicateSelected(getFormFieldValue(PATH_FIELD), null, ''); + }} + /> + + {isFrontendPredicateSelected && ( + + {FRONTEND_PREDICATE_WARNING} + + )} +
)} @@ -237,6 +300,9 @@ export const EXISTS = 'Exists'; export const CONTAINS = 'Contains'; export const DOES_NOT_CONTAIN = 'Does not contain'; +export const FRONTEND_PREDICATE_WARNING = + 'This predicate will only run on the resources loaded in the current page.'; + const PATH_FIELD = 'path'; const PREDICATE_FIELD = 'predicate'; const SEARCH_TERM_FIELD = 'searchTerm'; @@ -252,23 +318,93 @@ type PredicateFilterOptions = { value: Exclude; }; +const getPredicateQuery = ( + predicateVerb: typeof EXISTS | typeof DOES_NOT_EXIST, + type: string, + path: string +) => { + if (predicateVerb === EXISTS) { + return { + query: { + bool: { + filter: [ + { + terms: { + '@type': [type], + }, + }, + { + term: { + _deprecated: false, + }, + }, + { + nested: { + path: 'properties', + query: { + term: { 'properties.path': path }, + }, + }, + }, + ], + }, + }, + }; + } + if (predicateVerb === DOES_NOT_EXIST) { + return { + query: { + bool: { + filter: [ + { + terms: { + '@type': [type], + }, + }, + { + term: { + _deprecated: false, + }, + }, + { + bool: { + must_not: { + nested: { + path: 'properties', + query: { + term: { 'properties.path': path }, + }, + }, + }, + }, + }, + ], + }, + }, + }; + } + + return null; +}; + // Creates