diff --git a/package.json b/package.json index 7df9091c1..e1a1e38df 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 --maxWorkers=4", + "test:watch": "jest --watch", "cy:open": "cypress open", "cy:run": "cypress run", "test-ui": "API_ENDPOINT=http://test start-server-and-test start http://localhost:8000 cy:run", @@ -211,10 +211,7 @@ ], "globals": { "FUSION_VERSION": "1.0.0", - "COMMIT_HASH": "9013fa343", - "ts-jest": { - "isolatedModules": true - } + "COMMIT_HASH": "9013fa343" }, "watchPathIgnorePatterns": [ "node_modules" diff --git a/src/__mocks__/handlers/DataTableContainer/handlers.ts b/src/__mocks__/handlers/DataTableContainer/handlers.ts index 0b289d9b6..d1700302d 100644 --- a/src/__mocks__/handlers/DataTableContainer/handlers.ts +++ b/src/__mocks__/handlers/DataTableContainer/handlers.ts @@ -129,7 +129,11 @@ export const ORIGINAL_6_SORTED_5 = 'sterling'; export const MOCK_VAR = 'Given Name'; export const sparqlViewSingleResult = rest.post( - deltaPath('/views/bbp/agents/graph/sparql'), + deltaPath( + `/views/bbp/agents/${encodeURIComponent( + 'https://bluebrain.github.io/nexus/vocabulary/defaultSparqlIndex' + )}/sparql` + ), (req, res, ctx) => { const mockResponse = { head: { @@ -287,7 +291,9 @@ export const sparqlViewResultHandler = ( studioRows: ReturnType[] ) => { return rest.post( - deltaPath('/views/bbp/agents/graph/sparql'), + deltaPath( + '/views/bbp/agents/https%3A%2F%2Fbluebrain.github.io%2Fnexus%2Fvocabulary%2FdefaultSparqlIndex/sparql' + ), (req, res, ctx) => { const mockResponse = { head: { diff --git a/src/__mocks__/handlers/Settings/ViewsSubViewHandlers.ts b/src/__mocks__/handlers/Settings/ViewsSubViewHandlers.ts new file mode 100644 index 000000000..2a0e03239 --- /dev/null +++ b/src/__mocks__/handlers/Settings/ViewsSubViewHandlers.ts @@ -0,0 +1,208 @@ +import { rest } from 'msw'; +import { deltaPath } from '__mocks__/handlers/handlers'; + +export const identitiesHandler = () => { + return rest.get(deltaPath(`/identities`), (req, res, ctx) => { + const mockResponse = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + 'https://bluebrain.github.io/nexus/contexts/identities.json', + ], + identities: [ + { + '@id': + 'https://dev.nise.bbp.epfl.ch/nexus/v1/realms/local/authenticated', + '@type': 'Authenticated', + realm: 'test', + }, + ], + }; + return res(ctx.status(200), ctx.json(mockResponse)); + }); +}; + +export const aclsHandler = (orgLabel: string, projectLabel: string) => { + return rest.get( + deltaPath(`/acls/${orgLabel}/${projectLabel}`), + (req, res, ctx) => { + const mockResponse = { + '@context': ['https://bluebrain.github.io/nexus/contexts/acls.json'], + _total: 1, + _results: [ + { + '@id': 'https://dev.nise.bbp.epfl.ch/nexus/v1/acls', + '@type': 'AccessControlList', + acl: [ + { + identity: { + '@id': + 'https://dev.nise.bbp.epfl.ch/nexus/v1/realms/local/authenticated', + '@type': 'Authenticated', + realm: 'test', + }, + permissions: [ + 'realms/write', + 'files/write', + 'events/read', + 'organizations/write', + 'projects/delete', + 'projects/write', + 'views/write', + ], + }, + { + identity: { + '@id': 'https://dev.nise.bbp.epfl.ch/nexus/v1/anonymous', + '@type': 'Anonymous', + }, + permissions: [ + 'realms/read', + 'permissions/read', + 'version/read', + ], + }, + ], + _self: 'https://dev.nise.bbp.epfl.ch/nexus/v1/acls', + }, + ], + }; + return res(ctx.status(200), ctx.json(mockResponse)); + } + ); +}; + +export const viewWithIndexingErrors = + 'https://bluebrain.github.io/nexus/vocabulary/defaultSparqlIndex'; + +export const viewWithNoIndexingErrors = + 'https://bluebrain.github.io/nexus/vocabulary/defaultElasticSearchIndex'; + +export const viewsHandler = (orgLabel: string, projectLabel: string) => { + const baseViewObject = baseView(orgLabel, projectLabel); + + return rest.get( + deltaPath(`/views/${orgLabel}/${projectLabel}`), + (req, res, ctx) => { + const mockResponse = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/search.json', + 'https://bluebrain.github.io/nexus/contexts/search-metadata.json', + ], + _total: 3, + _results: [ + { + '@id': viewWithNoIndexingErrors, + '@type': ['ElasticSearchView', 'View'], + name: 'Default Elasticsearch view', + ...baseViewObject, + }, + { + '@id': 'https://bluebrain.github.io/nexus/vocabulary/searchView', + '@type': ['View', 'CompositeView'], + ...baseViewObject, + }, + { + '@id': viewWithIndexingErrors, + '@type': ['View', 'SparqlView'], + name: 'Default Sparql view', + ...baseViewObject, + }, + ], + }; + return res(ctx.status(200), ctx.json(mockResponse)); + } + ); +}; + +export const viewErrorsHandler = (orgLabel: string, projectLabel: string) => { + return rest.get( + deltaPath(`/views/${orgLabel}/${projectLabel}/:viewId/failures`), + (req, res, ctx) => { + const { viewId } = req.params; + const decodedId = decodeURIComponent(viewId as string); + + const mockResponse = { + '@context': ['https://bluebrain.github.io/nexus/contexts/error.json'], + _total: decodedId === viewWithIndexingErrors ? 2 : 0, + _results: + decodedId === viewWithIndexingErrors + ? [ + { + ...baseIndexingError( + orgLabel, + projectLabel, + `${decodedId}-1` + ), + message: 'Mock Error 1', + }, + { + ...baseIndexingError( + orgLabel, + projectLabel, + `${decodedId}-2` + ), + message: 'Mock Error 2', + }, + ] + : [], + }; + return res(ctx.status(200), ctx.json(mockResponse)); + } + ); +}; + +export const viewStatsHandler = (orgLabel: string, projectLabel: string) => { + return rest.get( + deltaPath(`/views/${orgLabel}/${projectLabel}/:viewId/statistics`), + (req, res, ctx) => { + const mockResponse = { + '@context': + 'https://bluebrain.github.io/nexus/contexts/statistics.json', + '@type': 'ViewStatistics', + delayInSeconds: 0, + discardedEvents: 0, + evaluatedEvents: 10489, + failedEvents: 0, + lastEventDateTime: '2023-08-24T13:53:01.884Z', + lastProcessedEventDateTime: '2023-08-24T13:53:01.884Z', + processedEvents: 10489, + remainingEvents: 0, + totalEvents: 10489, + }; + return res(ctx.status(200), ctx.json(mockResponse)); + } + ); +}; + +const baseIndexingError = ( + orgLabel: string, + projectLabel: string, + id: string +) => ({ + id, + errorType: 'epfl.indexing.ElasticSearchSink.BulkUpdateException', + message: 'Super dramatic error', + offset: { + '@type': 'At', + value: 264934, + }, + project: `${orgLabel}/${projectLabel}`, + _rev: 1, +}); + +const baseView = (orgLabel: string, projectLabel: string) => ({ + _constrainedBy: 'https://bluebrain.github.io/nexus/schemas/views.json', + _createdAt: '2022-04-01T08:27:55.583Z', + _createdBy: + 'https://test.nise.bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-nexus-sa', + _deprecated: false, + _incoming: `https://test.nise.bbp.epfl.ch/nexus/v1/views/${orgLabel}/${projectLabel}/graph/incoming`, + _indexingRev: 1, + _outgoing: `https://test.nise.bbp.epfl.ch/nexus/v1/views/${orgLabel}/${projectLabel}/graph/outgoing`, + _project: `https://test.nise.bbp.epfl.ch/nexus/v1/projects/${orgLabel}/${projectLabel}`, + _rev: 1, + _self: `https://test.nise.bbp.epfl.ch/nexus/v1/views/${orgLabel}/${projectLabel}/graph`, + _updatedAt: '2022-04-01T08:27:55.583Z', + _updatedBy: `https://test.nise.bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-nexus-sa`, + _uuid: 'b48c6970-2e20-4a39-9180-574b5c656965', +}); diff --git a/src/__mocks__/handlers/handlers.ts b/src/__mocks__/handlers/handlers.ts index cb04a9e45..42f1ca88a 100644 --- a/src/__mocks__/handlers/handlers.ts +++ b/src/__mocks__/handlers/handlers.ts @@ -588,133 +588,149 @@ export const handlers = [ ); } ), - rest.post(deltaPath('/views/org/project/graph/sparql'), (req, res, ctx) => { - const mockResponse = { - head: { vars: ['self', 'p', 'o'] }, - results: { - bindings: [ - { - o: { type: 'bnode', value: 't78' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien1'), - }, - }, - { - o: { type: 'bnode', value: 't87' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien2'), - }, - }, - { - o: { type: 'bnode', value: 't91' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien3'), - }, - }, - { - o: { type: 'bnode', value: 't97' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien4'), - }, - }, - { - o: { type: 'bnode', value: 't98' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien5'), - }, - }, - { - o: { type: 'bnode', value: 't100' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', - }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien6'), + rest.post( + deltaPath( + `/views/org/project/${encodeURIComponent( + 'https://bluebrain.github.io/nexus/vocabulary/defaultSparqlIndex' + )}/sparql` + ), + (req, res, ctx) => { + const mockResponse = { + head: { vars: ['self', 'p', 'o'] }, + results: { + bindings: [ + { + o: { type: 'bnode', value: 't78' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien1'), + }, }, - }, - { - o: { type: 'bnode', value: 't114' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + { + o: { type: 'bnode', value: 't87' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien2'), + }, }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien7'), + { + o: { type: 'bnode', value: 't91' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien3'), + }, }, - }, - { - o: { type: 'bnode', value: 't120' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + { + o: { type: 'bnode', value: 't97' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien4'), + }, }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien8'), + { + o: { type: 'bnode', value: 't98' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien5'), + }, }, - }, - { - o: { type: 'bnode', value: 't127' }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + { + o: { type: 'bnode', value: 't100' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien6'), + }, }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien9'), + { + o: { type: 'bnode', value: 't114' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien7'), + }, }, - }, - { - o: { - type: 'uri', - value: 'https://bbp.epfl.ch/neurosciencegraph/data/', + { + o: { type: 'bnode', value: 't120' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien8'), + }, }, - p: { - type: 'uri', - value: 'https://bluebrain.github.io/nexus/vocabulary/base', + { + o: { type: 'bnode', value: 't127' }, + p: { + type: 'uri', + value: + 'https://bluebrain.github.io/nexus/vocabulary/apiMappings', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien9'), + }, }, - self: { - type: 'uri', - value: deltaPath('/nexus/v1/projects/nise/kerrien10'), + { + o: { + type: 'uri', + value: 'https://bbp.epfl.ch/neurosciencegraph/data/', + }, + p: { + type: 'uri', + value: 'https://bluebrain.github.io/nexus/vocabulary/base', + }, + self: { + type: 'uri', + value: deltaPath('/nexus/v1/projects/nise/kerrien10'), + }, }, - }, - ], - }, - }; - return res( - // Respond with a 200 status code - ctx.status(200), - ctx.json(mockResponse) - ); - }), + ], + }, + }; + return res( + // Respond with a 200 status code + ctx.status(200), + ctx.json(mockResponse) + ); + } + ), rest.get(deltaPath('/views/org/project'), (req, res, ctx) => { const mockResponse = { '@context': [ diff --git a/src/shared/canvas/MyData/types.ts b/src/shared/canvas/MyData/types.ts index 94b620c6b..c86c56f3a 100644 --- a/src/shared/canvas/MyData/types.ts +++ b/src/shared/canvas/MyData/types.ts @@ -35,7 +35,7 @@ export type TTitleProps = { }; export type THeaderFilterProps = Pick< THeaderProps, - 'types' | 'dateField' | 'setFilterOptions' | 'typeOperator' + 'types' | 'dateField' | 'setFilterOptions' | 'typeOperator' | 'issuer' >; export type THeaderTitleProps = Pick< THeaderProps, diff --git a/src/shared/containers/DataTableContainer.spec.tsx b/src/shared/containers/DataTableContainer.spec.tsx index 2bb11dff5..d6ae7b3f9 100644 --- a/src/shared/containers/DataTableContainer.spec.tsx +++ b/src/shared/containers/DataTableContainer.spec.tsx @@ -454,7 +454,11 @@ const invalidSparqlQueryResponse = { }; export const invalidSparqlHandler = rest.post( - deltaPath('/views/bbp/agents/graph/sparql'), + deltaPath( + `/views/bbp/agents/${encodeURIComponent( + 'https://bluebrain.github.io/nexus/vocabulary/defaultSparqlIndex' + )}/sparql` + ), (req, res, ctx) => { return res(ctx.status(400), ctx.json(invalidSparqlQueryResponse)); } diff --git a/src/shared/hooks/useJIRA.ts b/src/shared/hooks/useJIRA.ts index e9dca4dc1..5172810bb 100644 --- a/src/shared/hooks/useJIRA.ts +++ b/src/shared/hooks/useJIRA.ts @@ -52,9 +52,11 @@ export function useJiraPlugin() { } }; - isInaccessibleBecauseNotOnVPN().then(value => { - setJiraInaccessibleBecauseOfVPN(value); - }); + React.useEffect(() => { + isInaccessibleBecauseNotOnVPN().then(value => { + setJiraInaccessibleBecauseOfVPN(value); + }); + }, []); return { isUserInSupportedJiraRealm, jiraInaccessibleBecauseOfVPN }; } @@ -464,9 +466,14 @@ function useJIRA({ getRequestToken(); return; } - fetchProjects(); - fetchLinkedIssues(); - }, [isJiraConnected, projectSelf]); + }, [isJiraConnected]); + + React.useEffect(() => { + if (projectSelf && isJiraConnected) { + fetchProjects(); + fetchLinkedIssues(); + } + }, [projectSelf, isJiraConnected]); return { isLoading, diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx index b63d28888..a5a23b00a 100644 --- a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx @@ -39,6 +39,7 @@ const MyDataHeaderFilters = ({ typeOperator, dateField, setFilterOptions, + issuer, }: THeaderFilterProps) => { return (
@@ -48,6 +49,7 @@ const MyDataHeaderFilters = ({ { return await nexus.Resource.list(orgLabel, projectLabel, { aggregations: true, + ...(issuer ? { [issuer]: issuerUri } : {}), }); }; @@ -47,17 +55,34 @@ const useTypesAggregation = ({ org, project, selectCallback, + issuer, + issuerUri, }: { nexus: NexusClient; org?: string; project?: string; selectCallback: (data: any) => TType[]; + issuer?: TIssuer; + issuerUri?: string; }) => { return useQuery({ refetchOnWindowFocus: false, - queryKey: ['types-aggregation-results', { org, project }], + queryKey: [ + 'types-aggregation-results', + { + org, + project, + ...(issuer ? { issuer, issuerUri } : {}), + }, + ], queryFn: () => - getTypesByAggregation({ nexus, orgLabel: org, projectLabel: project }), + getTypesByAggregation({ + nexus, + issuer, + issuerUri, + orgLabel: org, + projectLabel: project, + }), select: selectCallback, }); }; @@ -127,11 +152,18 @@ const TypeSelector = ({ typeOperator = 'OR', popupContainer, onVisibilityChange, + issuer, }: TTypeSelectorProps) => { const nexus = useNexusContext(); const originTypes = useRef([]); const [typeSearchValue, updateSearchType] = useState(''); const [typesOptionsArray, setTypesOptionsArray] = useState([]); + const identities = useSelector( + (state: RootState) => state.auth.identities?.data?.identities + ); + const issuerUri = identities?.find((item: any) => item['@type'] === 'User')?.[ + '@id' + ]; const selectCallback = useCallback((data: TTypeAggregationsResult) => { console.log('@@selectCallback', data); @@ -147,6 +179,8 @@ const TypeSelector = ({ org, project, selectCallback, + issuer, + issuerUri, }); const onChangeTypeChange = ({ diff --git a/src/shared/molecules/TypeSelector/types.ts b/src/shared/molecules/TypeSelector/types.ts index c683c2af3..6dbd9f5ae 100644 --- a/src/shared/molecules/TypeSelector/types.ts +++ b/src/shared/molecules/TypeSelector/types.ts @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; +import { TIssuer } from '../../../shared/canvas/MyData/types'; export type TType = { key: string; @@ -45,4 +46,5 @@ export type TTypeSelectorProps = { afterUpdate?(typeOperator: TTypeOperator, types?: TType[]): void; popupContainer(): HTMLElement; onVisibilityChange?(open: boolean): void; + issuer?: TIssuer; }; diff --git a/src/shared/utils/querySparqlView.ts b/src/shared/utils/querySparqlView.ts index 9fb134bd9..7ab43b39f 100644 --- a/src/shared/utils/querySparqlView.ts +++ b/src/shared/utils/querySparqlView.ts @@ -31,19 +31,18 @@ export const sparqlQueryExecutor = async ( const { org: orgLabel, project: projectLabel, id: viewId } = parseURL( view._self ); - const result: SparqlViewQueryResponse = hasProjection ? await nexus.View.compositeSparqlQuery( orgLabel, projectLabel, - encodeURIComponent(viewId), + encodeURIComponent(view['@id'] ?? viewId), encodeURIComponent(projectionId || '_'), dataQuery ) : await nexus.View.sparqlQuery( orgLabel, projectLabel, - encodeURIComponent(viewId), + encodeURIComponent(view['@id'] ?? viewId), dataQuery ); const data: SelectQueryResponse = result as SelectQueryResponse; diff --git a/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx b/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx new file mode 100644 index 000000000..9f341f5b6 --- /dev/null +++ b/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx @@ -0,0 +1,77 @@ +import { NexusClient } from '@bbp/nexus-sdk'; +import { Alert, Collapse, List } from 'antd'; +import React from 'react'; +import ReactJson from 'react-json-view'; +import './styles.less'; + +interface Props { + indexingErrors: IndexingErrorResults; +} + +export const ViewIndexingErrors: React.FC = ({ + indexingErrors, +}: Props) => { + return ( +
+ {

{indexingErrors._total} Total errors

} + {indexingErrors._total && ( + + )} + ( + + + + + + )} + /> +
+ ); +}; + +export const fetchIndexingErrors = async ({ + nexus, + apiEndpoint, + orgLabel, + projectLabel, + viewId, +}: { + nexus: NexusClient; + apiEndpoint: string; + orgLabel: string; + projectLabel: string; + viewId: string; +}): Promise => { + const indexingErrors = await nexus.httpGet({ + path: `${apiEndpoint}/views/${orgLabel}/${projectLabel}/${encodeURIComponent( + viewId + )}/failures`, + headers: { Accept: 'application/json' }, + }); + return indexingErrors; +}; + +export interface IndexingErrorResults { + '@context': string[] | string; + _next: string; + _results: IndexingError[]; + _total: number; +} + +interface IndexingError { + id: string; + message: string; + errorType: string; +} diff --git a/src/subapps/admin/components/Settings/ViewsSubView.spec.tsx b/src/subapps/admin/components/Settings/ViewsSubView.spec.tsx new file mode 100644 index 000000000..8bd833b92 --- /dev/null +++ b/src/subapps/admin/components/Settings/ViewsSubView.spec.tsx @@ -0,0 +1,152 @@ +import { setupServer } from 'msw/node'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import configureStore from '../../../../shared/store'; +import { createNexusClient } from '@bbp/nexus-sdk'; +import { createMemoryHistory } from 'history'; +import { Provider } from 'react-redux'; +import { Route, Router } from 'react-router-dom'; +import { NexusProvider } from '@bbp/react-nexus'; +import ViewsSubView from './ViewsSubView'; +import { render, screen, waitFor } from '../../../../utils/testUtil'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent from '@testing-library/user-event'; +import { + aclsHandler, + identitiesHandler, + viewErrorsHandler, + viewStatsHandler, + viewWithIndexingErrors, + viewWithNoIndexingErrors, + viewsHandler, +} from '__mocks__/handlers/Settings/ViewsSubViewHandlers'; + +describe('ViewsSubView', () => { + const mockOrganisation = 'copies'; + const mockProject = 'hippocampus'; + + const server = setupServer( + identitiesHandler(), + viewsHandler(mockOrganisation, mockProject), + aclsHandler(mockOrganisation, mockProject), + viewErrorsHandler(mockOrganisation, mockProject), + viewStatsHandler(mockOrganisation, mockProject) + ); + + const history = createMemoryHistory({ + initialEntries: [`/orgs/${mockOrganisation}/${mockProject}/settings`], + }); + + let user: UserEvent; + let container: HTMLElement; + let viewsSubViewComponent: JSX.Element; + + beforeEach(async () => { + server.listen(); + const queryClient = new QueryClient(); + const nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + const store = configureStore( + history, + { nexus }, + { config: { apiEndpoint: 'https://localhost:3000' } } + ); + + viewsSubViewComponent = ( + + + + + + + + + + + + ); + + const component = render(viewsSubViewComponent); + + container = component.container; + user = userEvent.setup(); + await expectRowCountToBe(3); + }); + + it('shows a badge for views that have errors', async () => { + expect(getErrorBadgeContent(viewWithIndexingErrors)).toEqual('2'); + }); + + it('does not show error badge for views that dont have errors', async () => { + expect(getErrorBadgeContent(viewWithNoIndexingErrors)).toBeUndefined(); + }); + + it('shows indexing errors when view row is expanded', async () => { + await expandRow(viewWithIndexingErrors); + + await screen.getByText(/2 Total errors/i, { selector: 'h3' }); + + const indexingErrorRows = await getErrorRows(); + expect(indexingErrorRows.length).toEqual(2); + + const errorRow1 = await getErrorRow('Mock Error 1'); + expect(errorRow1).toBeTruthy(); + const errorRow2 = await getErrorRow('Mock Error 2'); + expect(errorRow2).toBeTruthy(); + }); + + it('shows detailed error when error row is expanded', async () => { + await expandRow(viewWithIndexingErrors); + + const errorRow1 = await getErrorRow('Mock Error 1'); + await user.click(errorRow1); + + const detailedErrorContainer = container.querySelector('.react-json-view'); + expect(detailedErrorContainer).toBeTruthy(); + }); + + const getErrorRow = async (errorMessage: string) => { + const row = await screen.getByText(new RegExp(errorMessage, 'i'), { + selector: '.ant-collapse-header-text', + }); + return row; + }; + + const getErrorRows = () => { + const rowContainer = container.querySelector( + 'div[data-testid="indexing-error-list"]' + )!; + return Array.from(rowContainer.querySelector('ul')!.children); + }; + + const expandRow = async (rowId: string) => { + const row = getViewRowById(rowId); + const expandButton = row.querySelector( + 'span[data-testid="Expand indexing errors"]' + )!; + await user.click(expandButton); + }; + + const getViewRowById = (rowId: string) => { + return container.querySelector(`tr[data-row-key="${rowId}"`)!; + }; + + const expectRowCountToBe = async (expectedRowsCount: number) => { + return await waitFor(() => { + const rows = visibleTableRows(); + expect(rows.length).toEqual(expectedRowsCount); + return rows; + }); + }; + + const visibleTableRows = () => { + return container.querySelectorAll('table tbody tr.view-item-row'); + }; + + const getErrorBadgeContent = (rowId: string) => { + const row = getViewRowById(rowId); + return row.querySelector('sup')?.textContent; + }; +}); diff --git a/src/subapps/admin/components/Settings/ViewsSubView.tsx b/src/subapps/admin/components/Settings/ViewsSubView.tsx index c130911d3..d59b2b4f0 100644 --- a/src/subapps/admin/components/Settings/ViewsSubView.tsx +++ b/src/subapps/admin/components/Settings/ViewsSubView.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { useHistory, useRouteMatch } from 'react-router'; import { AccessControl, useNexusContext } from '@bbp/react-nexus'; import { useMutation, useQuery } from 'react-query'; -import { Table, Button, Row, Col, notification, Tooltip } from 'antd'; +import { Table, Button, Row, Col, notification, Tooltip, Badge } from 'antd'; import { isArray, isString, orderBy } from 'lodash'; import { ColumnsType } from 'antd/es/table'; -import { NexusClient } from '@bbp/nexus-sdk'; +import { NexusClient, View } from '@bbp/nexus-sdk'; import { PromisePool } from '@supercharge/promise-pool'; import { useSelector } from 'react-redux'; import * as Sentry from '@sentry/browser'; @@ -13,6 +13,16 @@ import { getOrgAndProjectFromProjectId } from '../../../../shared/utils'; import { RootState } from '../../../../shared/store/reducers'; import HasNoPermission from '../../../../shared/components/Icons/HasNoPermission'; import './styles.less'; +import { + IndexingErrorResults, + ViewIndexingErrors, + fetchIndexingErrors, +} from './ViewIndexingErrors'; +import { + MinusCircleTwoTone, + PlusCircleTwoTone, + WarningOutlined, +} from '@ant-design/icons'; type TViewType = { key: string; @@ -23,7 +33,9 @@ type TViewType = { orgLabel: string; projectLabel: string; isAggregateView: boolean; + indexingErrors: IndexingErrorResults; }; + const AggregateViews = ['AggregateElasticSearchView', 'AggregateSparqlView']; const aggregateFilterPredicate = (type?: string | string[]) => { if (type) { @@ -39,31 +51,43 @@ const fetchViewsList = async ({ nexus, orgLabel, projectLabel, + apiEndpoint, }: { nexus: NexusClient; orgLabel: string; projectLabel: string; + apiEndpoint: string; }) => { try { const views = await nexus.View.list(orgLabel, projectLabel, {}); - const result: TViewType[] = views._results.map(item => { - const { orgLabel, projectLabel } = getOrgAndProjectFromProjectId( - item._project - )!; - return { - orgLabel, - projectLabel, - id: item['@id'], - key: item['@id'] as string, - name: (item['@id'] as string).split('/').pop() as string, - type: item['@type'], - isAggregateView: aggregateFilterPredicate(item['@type']), - status: '100%', - }; - }); + const result: Omit[] = views._results.map( + item => { + const { orgLabel, projectLabel } = getOrgAndProjectFromProjectId( + item._project + )!; + return { + orgLabel, + projectLabel, + id: item['@id'], + key: item['@id'] as string, + name: (item['@id'] as string).split('/').pop() as string, + type: item['@type'], + isAggregateView: aggregateFilterPredicate(item['@type']), + status: '100%', + }; + } + ); const { results, errors } = await PromisePool.withConcurrency(4) .for(result!) .process(async view => { + const indexingErrors = await fetchIndexingErrors({ + nexus, + apiEndpoint, + orgLabel, + projectLabel, + viewId: view.id, + }); + if (!view.isAggregateView) { const iViewStats = await nexus.View.statistics( orgLabel, @@ -78,14 +102,18 @@ const fetchViewsList = async ({ : 0; return { ...view, + indexingErrors, status: percentage ? `${(percentage * 100).toFixed(0)}%` : '0%', }; } + return { ...view, + indexingErrors, status: 'N/A', }; }); + return { errors, results: orderBy(results, ['isAggregateView', 'name'], ['asc', 'asc']), @@ -177,18 +205,22 @@ const ViewsSubView = () => { projectLabel: string; viewId?: string; }>(); + const { params: { orgLabel, projectLabel }, } = match; + const createNewViewHandler = () => { const queryURI = `/orgs/${orgLabel}/${projectLabel}/create`; history.push(queryURI); }; const { data: views, status } = useQuery({ queryKey: [`views-${orgLabel}-${projectLabel}`], - queryFn: () => fetchViewsList({ nexus, orgLabel, projectLabel }), + queryFn: () => + fetchViewsList({ nexus, orgLabel, projectLabel, apiEndpoint }), refetchInterval: 30 * 1000, // 30s }); + const { mutateAsync: handleReindexingOneView } = useMutation( restartIndexOneView ); @@ -370,6 +402,35 @@ const ViewsSubView = () => { size="middle" pagination={false} rowKey={r => r.key} + expandIcon={({ expanded, onExpand, record }) => + expanded ? ( + onExpand(record, e)} + /> + ) : ( + + onExpand(record, e)} + style={{ fontSize: '16px' }} + /> + + ) + } + expandedRowRender={(r: TViewType) => { + return ( + + ); + }} />
diff --git a/src/subapps/admin/components/Settings/styles.less b/src/subapps/admin/components/Settings/styles.less index 8a6ea8cda..69ba8280f 100644 --- a/src/subapps/admin/components/Settings/styles.less +++ b/src/subapps/admin/components/Settings/styles.less @@ -215,3 +215,11 @@ align-items: center; justify-content: center; } + +.indexing-error-header { + .ant-collapse-header-text { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } +} diff --git a/src/subapps/dataExplorer/DataExplorer.spec.tsx b/src/subapps/dataExplorer/DataExplorer.spec.tsx index 40096960f..799e23eb7 100644 --- a/src/subapps/dataExplorer/DataExplorer.spec.tsx +++ b/src/subapps/dataExplorer/DataExplorer.spec.tsx @@ -1,14 +1,7 @@ import { Resource, createNexusClient } from '@bbp/nexus-sdk'; import { NexusProvider } from '@bbp/react-nexus'; import '@testing-library/jest-dom'; -import { - RenderResult, - act, - fireEvent, - queryByRole, - waitForElementToBeRemoved, - within, -} from '@testing-library/react'; +import { RenderResult, act, fireEvent, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { diff --git a/yarn.lock b/yarn.lock index 2e4929556..4006f6ac3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19470,4 +19470,4 @@ yn@3.1.1: yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== \ No newline at end of file