diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..37d38bbbb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a6fb5838..8d55aef27 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ dist/ node_modules/ storybook-static/ coverage/ -.vscode yarn-error.log cypress/screenshots/ cypress/videos/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..aa9a29c8b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "christian-kohler.npm-intellisense" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..2751ea45b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Attach SPA debugging (frontend-only)", + "url": "http://localhost:8000", + "webRoot": "${workspaceFolder}" + }, + { + "type": "node", + "request": "launch", + "name": "SRR debugging (full-stack)", + "runtimeExecutable": "node", + "runtimeArgs": ["--inspect-brk", "${workspaceFolder}/dist/server.js"], + "env": { + "NODE_ENV": "development", + "DEBUG": "*" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "openOnSessionStart" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..144a7ec7a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "files.eol": "\n" +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 000000000..96133bd1d Binary files /dev/null and b/bun.lockb differ diff --git a/cypress/e2e/ResourceContainer.cy.ts b/cypress/e2e/ResourceContainer.cy.ts new file mode 100644 index 000000000..ffd29bdea --- /dev/null +++ b/cypress/e2e/ResourceContainer.cy.ts @@ -0,0 +1,135 @@ +import { Resource } from '@bbp/nexus-sdk'; + +describe('Resource with id that contains URL encoded characters', () => { + const resourceIdWithEncodedCharacters = + 'https://hello.lol/https%3A%2F%2Fencoded.url%2Fwow'; + const displayName = 'https%3A%2F%2Fencoded.url%2Fwow'; + + before(() => { + if ( + !Cypress.env('use_existing_delta_instance') || + Cypress.env('use_existing_delta_instance') === false + ) { + cy.task('auth:createRealmsAndUsers', Cypress.env('users')); + } + + cy.login( + `${Cypress.env('users').morty.username}-studio`, + Cypress.env('users').morty.realm, + Cypress.env('users').morty.username, + Cypress.env('users').morty.password + ).then(() => { + cy.window().then(win => { + const authToken = win.localStorage.getItem('nexus__token'); + cy.wrap(authToken).as('nexusToken'); + + const orgLabel = Cypress.env('ORG_LABEL'); + const projectLabelBase = Cypress.env('PROJECT_LABEL_BASE'); + + cy.task('project:setup', { + nexusApiUrl: Cypress.env('NEXUS_API_URL'), + authToken, + orgLabel, + projectLabelBase, + }).then(({ projectLabel }: { projectLabel: string }) => { + cy.wrap(projectLabel).as('projectLabel'); + cy.fixture('ResourceWithEncodedCharactersId.json').then( + resourcePayload => { + cy.task('resource:create', { + nexusApiUrl: Cypress.env('NEXUS_API_URL'), + authToken, + orgLabel, + projectLabel, + resourcePayload, + }).then((resource: Resource) => { + cy.wrap(resource['@id']).as('fullResourceId'); + }); + } + ); + }); + }); + }); + }); + + beforeEach(() => { + cy.login( + `${Cypress.env('users').morty.username}-report-plugin`, + Cypress.env('users').morty.realm, + Cypress.env('users').morty.username, + Cypress.env('users').morty.password + ); + }); + + after(function() { + cy.task('project:teardown', { + nexusApiUrl: Cypress.env('NEXUS_API_URL'), + authToken: this.nexusToken, + orgLabel: Cypress.env('ORG_LABEL'), + projectLabel: this.projectLabel, + }); + }); + + function testResourceDataInJsonViewer() { + cy.findByText('Advanced View').click(); + + cy.contains(`"@id"`); + cy.contains(resourceIdWithEncodedCharacters); + cy.contains('type'); + cy.contains('[]'); + } + + it('resource opens when user clicks on resource row in MyData table', function() { + cy.visit(`/`); + + cy.findAllByText(new RegExp(displayName)) + .first() + .click(); + + cy.findByTestId('resource-details').within(() => { + testResourceDataInJsonViewer(); + }); + }); + + it('resource opens when user directly navigates to resource page', function() { + const resourcePage = `/${Cypress.env('ORG_LABEL')}/${ + this.projectLabel + }/resources/${encodeURIComponent(resourceIdWithEncodedCharacters)}`; + + cy.visit(`${resourcePage}`); + + cy.findByTestId('resource-details').within(() => { + testResourceDataInJsonViewer(); + }); + }); + + it('resource opens with id resolution page', function() { + const resolvePage = `/resolve/${encodeURIComponent( + resourceIdWithEncodedCharacters + )}`; + const resourcePage = `/${Cypress.env('ORG_LABEL')}/${ + this.projectLabel + }/resources/${encodeURIComponent(resourceIdWithEncodedCharacters)}`; + + cy.visit(resolvePage); + + cy.intercept(`${Cypress.env('NEXUS_API_URL')}/${resolvePage}`).as( + 'idResolution' + ); + + // If many e2e tests ran together there may be many resources with same id. + // In this case the id resolution page will look different. Test accordingly. + cy.wait('@idResolution').then(interception => { + const resolvedResources = interception.response.body._results; + + if (resolvedResources.length === 1) { + testResourceDataInJsonViewer(); + } else { + // Multiple resources with same id found. + cy.findByText('Open Resource', { + selector: `a[href="${resourcePage}"]`, + }).click(); + testResourceDataInJsonViewer(); + } + }); + }); +}); diff --git a/cypress/fixtures/ResourceWithEncodedCharactersId.json b/cypress/fixtures/ResourceWithEncodedCharactersId.json new file mode 100644 index 000000000..6b9d74260 --- /dev/null +++ b/cypress/fixtures/ResourceWithEncodedCharactersId.json @@ -0,0 +1,4 @@ +{ + "@id": "https://hello.lol/https%3A%2F%2Fencoded.url%2Fwow", + "@type": [] +} diff --git a/package.json b/package.json index f5438fc8c..aa53dd6d9 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "express-prom-bundle": "^5.0.2", "git-revision-webpack-plugin": "^3.0.6", "handlebars": "^4.7.7", - "history": "^4.7.2", + "history": "4.5.1", "http-proxy-middleware": "^2.0.1", "json2csv": "^5.0.5", "jwt-decode": "^2.2.0", @@ -240,5 +240,8 @@ "webpack-hot-middleware": "^2.24.3", "webpack-node-externals": "^1.7.2" }, + "resolutions": { + "history": "4.5.1" + }, "url": "https://github.com/BlueBrain/nexus/issues/new?labels=fusion" } diff --git a/src/__mocks__/handlers/ResourceListContainerHandlers.ts b/src/__mocks__/handlers/ResourceListContainerHandlers.ts new file mode 100644 index 000000000..276a90ae3 --- /dev/null +++ b/src/__mocks__/handlers/ResourceListContainerHandlers.ts @@ -0,0 +1,102 @@ +import { rest } from 'msw'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import { Resource } from '@bbp/nexus-sdk'; + +export const resourcesHandler = rest.get( + deltaPath(`resources/bbp/agents`), + (_, res, ctx) => { + const mockResponse = { + '@context': ['https://bluebrain.github.io/nexus/contexts/metadata.json'], + _total: 3, + _results: [ + getMockResource('1'), + getMockResource('2'), + getMockResource('3'), + ], + }; + + return res(ctx.status(200), ctx.json(mockResponse)); + } +); + +export const searchHitsHandler = rest.post( + deltaPath( + '/views/bbp/agents/https%3A%2F%2Fbluebrain.github.io%2Fnexus%2Fvocabulary%2FdefaultElasticSearchIndex/_search' + ), + (_, res, ctx) => { + const filteredByDeprecation = { + buckets: [getMockBucket('1'), getMockBucket('2'), getMockBucket('3')], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }; + const mockResponse = { + aggregations: { + schemas: { + doc_count: 3, + filteredByDeprecation: { ...filteredByDeprecation }, + }, + types: { + doc_count: 8, + filteredByDeprecation: { ...filteredByDeprecation }, + }, + }, + hits: { + hits: [ + getMockSearchHit('1'), + getMockSearchHit('2'), + getMockSearchHit('3'), + ], + max_score: 123, + total: { relation: 'eq', value: 11 }, + }, + timed_out: false, + took: 0, + _shards: { failed: 0, skipped: 0, successful: 1, total: 1 }, + }; + + return res( + // Respond with a 200 status code + ctx.status(200), + ctx.json(mockResponse) + ); + } +); + +const getMockResource = (id: string, extra?: Partial) => ({ + '@id': id, + '@type': ['View'], + description: 'Test description', + name: 'Test name', + _constrainedBy: 'https://bluebrain.github.io/nexus/schemas/views.json', + _createdAt: '2024-01-19T11:40:24.804553Z', + _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/mockuser', + _deprecated: false, + _incoming: 'test', + _outgoing: 'test', + _project: 'https://dev.nise.bbp.epfl.ch/nexus/v1/projects/bbp/agents', + _rev: 1, + _self: id, + _updatedAt: '2024-01-19T11:40:24.804553Z', + _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/mockuser', + _uuid: id, + ...extra, +}); + +const getMockSearchHit = (id: string, extra?: Partial) => { + const resource = getMockResource(id, extra); + return { + _id: id, + _index: `delta_${id}`, + _score: 123, + _source: { + ...resource, + _original_source: JSON.stringify(resource), + }, + _type: '_doc', + }; +}; + +const getMockBucket = (key: string, docCount: number = 1) => ({ + key, + doc_count: docCount, +}); diff --git a/src/shared/components/ResourceActions/index.tsx b/src/shared/components/ResourceActions/index.tsx index 96b0431ca..c73a26940 100644 --- a/src/shared/components/ResourceActions/index.tsx +++ b/src/shared/components/ResourceActions/index.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { Tooltip, Button, Popconfirm } from 'antd'; import { Resource } from '@bbp/nexus-sdk'; +import useNotification from '../../hooks/useNotification'; import './ResourceActions.less'; -import useNotification from '../../hooks/useNotification'; export type ActionType = { name: string; // A unique name for your action type diff --git a/src/shared/components/ResourceEditor/customLinter.spec.ts b/src/shared/components/ResourceEditor/customLinter.spec.ts index 929854406..c55b17a6a 100644 --- a/src/shared/components/ResourceEditor/customLinter.spec.ts +++ b/src/shared/components/ResourceEditor/customLinter.spec.ts @@ -17,7 +17,7 @@ describe('customLinter', () => { expect(result).toHaveLength(1); expect(result[0]).toEqual({ message: - 'Fields starting with an underscore are reserved for internal use', + 'Top-level fields starting with an underscore are reserved for internal use', line: 2, }); }); @@ -32,12 +32,12 @@ describe('customLinter', () => { expect(result).toEqual([ { message: - 'Fields starting with an underscore are reserved for internal use', + 'Top-level fields starting with an underscore are reserved for internal use', line: 2, }, { message: - 'Fields starting with an underscore are reserved for internal use', + 'Top-level fields starting with an underscore are reserved for internal use', line: 4, }, ]); @@ -45,17 +45,27 @@ describe('customLinter', () => { it('should detect a field starting with an underscore with spaces', () => { const text = `{ - " _invalidField": "value" + " _invalidField": "value" }`; const result = customLinter(text); expect(result).toHaveLength(1); expect(result[0]).toEqual({ message: - 'Fields starting with an underscore are reserved for internal use', + 'Top-level fields starting with an underscore are reserved for internal use', line: 2, }); }); + it('should ignore a subfield starting with an underscore', () => { + const text = `{ + "validField": { + "_thisShouldStillBeValid": "value" + } + }`; + const result = customLinter(text); + expect(result).toEqual([]); + }); + it('should ignore a field that has an underscore in the middle', () => { const text = `{ "valid_Field": "value" @@ -72,7 +82,7 @@ describe('customLinter', () => { expect(result).toEqual([]); }); - it('should throw an error if the field starts with an underscore followed by a special character', () => { + it('should detect a field starting with an underscore followed by a special character', () => { const text = `{ "_!invalidField": "value" }`; @@ -80,7 +90,7 @@ describe('customLinter', () => { expect(result).toHaveLength(1); expect(result[0]).toEqual({ message: - 'Fields starting with an underscore are reserved for internal use', + 'Top-level fields starting with an underscore are reserved for internal use', line: 2, }); }); diff --git a/src/shared/components/ResourceEditor/customLinter.ts b/src/shared/components/ResourceEditor/customLinter.ts index 7f5a20519..29925ebf6 100644 --- a/src/shared/components/ResourceEditor/customLinter.ts +++ b/src/shared/components/ResourceEditor/customLinter.ts @@ -19,20 +19,47 @@ export type LinterIssue = { */ export const customLinter = (text: string): LinterIssue[] => { const linterErrors: LinterIssue[] = []; - const lines = text.split('\n'); - // Regex to match keys starting with an underscore followed by any character except a space or double quote - const regex = /"\s*_[^"\s]+"\s*:/g; + let json; + try { + json = JSON.parse(text); + } catch (error) { + return linterErrors; + } - lines.forEach((line, index) => { - if (regex.test(line)) { - linterErrors.push({ - message: - 'Fields starting with an underscore are reserved for internal use', - line: index + 1, - }); + // We only iterate through top-level keys of the parsed object + for (const key in json) { + if (Object.prototype.hasOwnProperty.call(json, key)) { + // Identify the actual key starting character by trimming the left side + const actualKeyStart = key.trimLeft()[0]; + + if (actualKeyStart === '_') { + linterErrors.push({ + message: + 'Top-level fields starting with an underscore are reserved for internal use', + line: findLineOfKey(text, key), + }); + } } - }); + } return linterErrors; }; + +/** + * Find the line number of the first occurrence of a key in the given text. + * @param text The text to search through. + * @param key The key whose line number we want to find. + * @return The line number where the key is first found. + */ +function findLineOfKey(text: string, key: string): number { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`"${escapedKey}"\s*:`); + const matches = text.match(regex); + + // Calculate the line number based on the position of the match + if (matches && matches.index !== undefined) { + return text.substring(0, matches.index).split('\n').length; + } + return -1; +} diff --git a/src/shared/containers/ResourceListBoardContainer.spec.tsx b/src/shared/containers/ResourceListBoardContainer.spec.tsx new file mode 100644 index 000000000..b58c71c29 --- /dev/null +++ b/src/shared/containers/ResourceListBoardContainer.spec.tsx @@ -0,0 +1,104 @@ +import { deltaPath } from '__mocks__/handlers/handlers'; +import { setupServer } from 'msw/node'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { cleanup, render, screen, waitFor } from '../../utils/testUtil'; +import { createNexusClient, ResourceListOptions } from '@bbp/nexus-sdk'; +import { NexusProvider } from '@bbp/react-nexus'; +import '@testing-library/jest-dom'; +import { createMemoryHistory } from 'history'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { + resourcesHandler, + searchHitsHandler, +} from '__mocks__/handlers/ResourceListContainerHandlers'; +import ResourceListBoardContainer from './ResourceListBoardContainer'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import configureStore from '../store'; +import userEvent from '@testing-library/user-event'; + +describe('ResourceListBoardContainer', () => { + const queryClient = new QueryClient(); + let user: UserEvent; + let server: ReturnType; + let spy: jasmine.Spy; + + beforeAll(() => { + server = setupServer(resourcesHandler, searchHitsHandler); + server.listen(); + }); + + beforeEach(async () => { + const history = createMemoryHistory({}); + const nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + const store = configureStore(history, { nexus }, {}); + + const resourceListBoardContainer = ( + + + + + + + + + + ); + + spy = spyOn(nexus.Resource, 'list').and.callThrough(); + await waitFor(() => { + renderContainer(resourceListBoardContainer); + }); + await screen.findByText('3 results'); + }); + + const renderContainer = (containerToRender: JSX.Element) => { + render(containerToRender); + user = userEvent.setup(); + }; + + afterEach(() => { + cleanup(); + queryClient.clear(); + localStorage.clear(); + }); + + afterAll(() => { + server.resetHandlers(); + server.close(); + }); + + const querySentToApi = (): ResourceListOptions => + spy.calls.mostRecent().args[2]; + + const defaultSortCriteria = '-_createdAt'; + + it('sends sort without search text by default', async () => { + const defaultQuery = querySentToApi(); + expect(defaultQuery.q).toBeUndefined(); + expect(defaultQuery.sort).toEqual(defaultSortCriteria); + }); + + it('preserves sorting critria in nexus api after search text is cleared', async () => { + const searchInput = screen.getByPlaceholderText('Search...'); + await user.type(searchInput, 'something'); + + const queryWithSearchText = querySentToApi(); + expect(queryWithSearchText.q).toEqual('something'); + expect(queryWithSearchText.sort).toBeUndefined(); + + await user.clear(searchInput); + + const queryWithoutSearchText = querySentToApi(); + expect(queryWithoutSearchText.q).toEqual(''); + expect(queryWithoutSearchText.sort).toEqual('-_createdAt'); + await screen.findByText('3 results'); + }); +}); diff --git a/src/shared/containers/ResourceListContainer.tsx b/src/shared/containers/ResourceListContainer.tsx index 319d9788b..17b914167 100644 --- a/src/shared/containers/ResourceListContainer.tsx +++ b/src/shared/containers/ResourceListContainer.tsx @@ -81,10 +81,6 @@ const ResourceListContainer: React.FunctionComponent<{ size: pageSize, }; - if (searchValue) { - query.sort = undefined; - } - if (searchValue !== list.query.q) { return setList({ ...list, @@ -174,8 +170,13 @@ const ResourceListContainer: React.FunctionComponent<{ size: 10, } ), - nexus.Resource.list(orgLabel, projectLabel, list.query), + nexus.Resource.list( + orgLabel, + projectLabel, + list.query.q ? { ...list.query, sort: undefined } : list.query + ), ]); + if (resourcesByIdOrSelf.status === 'fulfilled') { const resultsLength = resourcesByIdOrSelf.value.hits.hits.length; const hits = resourcesByIdOrSelf.value.hits.hits; diff --git a/src/shared/containers/ResourceViewActionsContainer.tsx b/src/shared/containers/ResourceViewActionsContainer.tsx index 857786669..ab63232d5 100644 --- a/src/shared/containers/ResourceViewActionsContainer.tsx +++ b/src/shared/containers/ResourceViewActionsContainer.tsx @@ -281,7 +281,11 @@ const ResourceViewActionsContainer: React.FC<{ return ( - + - - -
Tag Resource
- - The tag will be applied to revision{' '} - {resource._rev} - - - } - content={ - - autoComplete="off" - name="tag-resource-form" - initialValues={{ tag: '' }} - onFinish={handleTagResource} - style={{ width: 300, padding: '8px 8px O' }} - > - - - - - - - - } - > - -
- {view && ( diff --git a/src/shared/containers/ResourceViewContainer.tsx b/src/shared/containers/ResourceViewContainer.tsx index f520ff2b8..b33417838 100644 --- a/src/shared/containers/ResourceViewContainer.tsx +++ b/src/shared/containers/ResourceViewContainer.tsx @@ -67,14 +67,44 @@ function constructUnDeprecateUrl( latestResource: Resource, orgLabel: string, projectLabel: string -) { - return `${apiEndpoint}/${ - resource!['@type'] === 'File' ? 'files' : 'resources' - }/${orgLabel}/${projectLabel}/${ - resource!['@type'] === 'File' ? '' : '_/' - }${encodeURIComponent(resource!['@id'])}/undeprecate?rev=${ - latestResource!._rev - }`; +): string { + const typePathMapping: { [key: string]: string } = { + File: 'files', + Storage: 'storages', + ElasticSearchView: 'views', + SparqlView: 'views', + CompositeView: 'views', + View: 'views', + Schema: 'schemas', + }; + + const determineResourcePathSegment = ( + inputTypes: string | string[] + ): string => { + const types = Array.isArray(inputTypes) ? inputTypes : [inputTypes]; + + for (const type of types) { + if (type in typePathMapping) { + return typePathMapping[type]; + } + } + + return 'resources'; + }; + + const primaryResourceType = resource['@type'] || ''; + const resourcePathSegment = determineResourcePathSegment(primaryResourceType); + const slashPrefix = Array.isArray(primaryResourceType) + ? primaryResourceType.some(type => type in typePathMapping) + ? '' + : '_/' + : primaryResourceType in typePathMapping + ? '' + : '_/'; + + return `${apiEndpoint}/${resourcePathSegment}/${orgLabel}/${projectLabel}/${slashPrefix}${encodeURIComponent( + resource['@id'] + )}/undeprecate?rev=${latestResource._rev}`; } const ResourceViewContainer: FC<{ @@ -658,7 +688,7 @@ const ResourceViewContainer: FC<{ return ( <> -
+
This resource is deprecated and not modifiable. {// Don't show the undo deprecated button if the resource is - // of any unsupported resource. However, it needs to be shown - // e.g. for custom types of resources. + // of any unsupported resource (e.g. Resolver). However, it needs + // to be shown e.g. for custom types of resources. !resource['@type']?.includes( - 'View' || 'Resolver' || 'Storage' || 'Schema' + 'Resolver' || + 'AggregateElasticSearchView' || + 'AggregateSparqlView' ) ? ( <>
diff --git a/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx b/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx index 79d7b3092..34714b239 100644 --- a/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx +++ b/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx @@ -4,7 +4,7 @@ import { act } from 'react-dom/test-utils'; import { Router } from 'react-router-dom'; import { Store } from 'redux'; -import { createBrowserHistory, History } from 'history'; +import { createMemoryHistory, History } from 'history'; import { ConnectedRouter } from 'connected-react-router'; import { createNexusClient } from '@bbp/nexus-sdk'; import AdvancedModeToggle from './AdvancedMode'; @@ -17,7 +17,7 @@ describe('AdvancedModeToggle', () => { let nexus; beforeEach(() => { - history = createBrowserHistory({ basename: '/' }); + history = createMemoryHistory({}); nexus = createNexusClient({ fetch, uri: 'https://localhost:3000', diff --git a/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx b/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx index 9f341f5b6..43867e60b 100644 --- a/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx +++ b/src/subapps/admin/components/Settings/ViewIndexingErrors.tsx @@ -13,14 +13,17 @@ export const ViewIndexingErrors: React.FC = ({ }: Props) => { return (
- {

{indexingErrors._total} Total errors

} - {indexingErrors._total && ( - + {!!indexingErrors._total && ( + <> +

{indexingErrors._total} Total errors

+ + )} + { const mockOrganisation = 'copies'; @@ -75,26 +74,18 @@ describe('ViewsSubView', () => { 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' }); + await waitFor(() => { + const errorRows = getErrorRows(); + expect(errorRows.length).toEqual(2); + 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(); + const errorRow1 = getErrorRow('Mock Error 1'); + const errorRow2 = getErrorRow('Mock Error 2'); + expect(errorRow2).toBeTruthy(); + }); }); it('shows detailed error when error row is expanded', async () => { @@ -103,12 +94,16 @@ describe('ViewsSubView', () => { const errorRow1 = await getErrorRow('Mock Error 1'); await user.click(errorRow1); - const detailedErrorContainer = container.querySelector('.react-json-view'); - expect(detailedErrorContainer).toBeTruthy(); + await waitFor(() => { + const detailedErrorContainer = container.querySelector( + '.react-json-view' + ); + expect(detailedErrorContainer).toBeTruthy(); + }); }); const getErrorRow = async (errorMessage: string) => { - const row = await screen.getByText(new RegExp(errorMessage, 'i'), { + const row = await screen.findByText(new RegExp(errorMessage, 'i'), { selector: '.ant-collapse-header-text', }); return row; @@ -144,9 +139,4 @@ describe('ViewsSubView', () => { 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 d59b2b4f0..db586d260 100644 --- a/src/subapps/admin/components/Settings/ViewsSubView.tsx +++ b/src/subapps/admin/components/Settings/ViewsSubView.tsx @@ -1,30 +1,26 @@ -import * as React from 'react'; -import { useHistory, useRouteMatch } from 'react-router'; +import { MinusCircleTwoTone, PlusCircleTwoTone } from '@ant-design/icons'; +import { NexusClient } from '@bbp/nexus-sdk'; import { AccessControl, useNexusContext } from '@bbp/react-nexus'; -import { useMutation, useQuery } from 'react-query'; -import { Table, Button, Row, Col, notification, Tooltip, Badge } from 'antd'; -import { isArray, isString, orderBy } from 'lodash'; +import * as Sentry from '@sentry/browser'; +import { PromisePool, PromisePoolError } from '@supercharge/promise-pool'; +import { Button, Col, Row, Table, Tooltip, notification } from 'antd'; import { ColumnsType } from 'antd/es/table'; -import { NexusClient, View } from '@bbp/nexus-sdk'; -import { PromisePool } from '@supercharge/promise-pool'; +import { isArray, isString, orderBy } from 'lodash'; +import { useMutation, useQuery } from 'react-query'; import { useSelector } from 'react-redux'; -import * as Sentry from '@sentry/browser'; -import { getOrgAndProjectFromProjectId } from '../../../../shared/utils'; -import { RootState } from '../../../../shared/store/reducers'; +import { useHistory, useRouteMatch } from 'react-router'; import HasNoPermission from '../../../../shared/components/Icons/HasNoPermission'; -import './styles.less'; +import { RootState } from '../../../../shared/store/reducers'; +import { getOrgAndProjectFromProjectId } from '../../../../shared/utils'; import { IndexingErrorResults, ViewIndexingErrors, fetchIndexingErrors, } from './ViewIndexingErrors'; -import { - MinusCircleTwoTone, - PlusCircleTwoTone, - WarningOutlined, -} from '@ant-design/icons'; +import './styles.less'; +import { useState } from 'react'; -type TViewType = { +type SubView = { key: string; id: string; name: string; @@ -33,7 +29,7 @@ type TViewType = { orgLabel: string; projectLabel: string; isAggregateView: boolean; - indexingErrors: IndexingErrorResults; + indexingErrors: IndexingErrorResults | null; }; const AggregateViews = ['AggregateElasticSearchView', 'AggregateSparqlView']; @@ -51,50 +47,43 @@ const fetchViewsList = async ({ nexus, orgLabel, projectLabel, - apiEndpoint, }: { nexus: NexusClient; orgLabel: string; projectLabel: string; - apiEndpoint: string; -}) => { +}): Promise<{ + errors: PromisePoolError[]; + results: SubView[]; +}> => { try { const views = await nexus.View.list(orgLabel, projectLabel, {}); - 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 result: SubView[] = 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%', + indexingErrors: null, + }; + }); + 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, projectLabel, encodeURIComponent(view.key) ); - // TODO: we should update the type in nexus-sdk! as the response is not the same from delta! + // TODO: We should update the type in nexus-sdk! as the response is not the same from delta! // @ts-ignore const percentage = iViewStats.totalEvents ? // @ts-ignore @@ -102,14 +91,12 @@ const fetchViewsList = async ({ : 0; return { ...view, - indexingErrors, status: percentage ? `${(percentage * 100).toFixed(0)}%` : '0%', }; } return { ...view, - indexingErrors, status: 'N/A', }; }); @@ -131,6 +118,7 @@ const fetchViewsList = async ({ throw new Error('Can not fetch views', { cause: error }); } }; + const restartIndexOneView = async ({ nexus, apiEndpoint, @@ -162,13 +150,14 @@ const restartIndexOneView = async ({ }); } }; + const restartIndexingAllViews = async ({ nexus, apiEndpoint, views, }: { nexus: NexusClient; - views: TViewType[]; + views: SubView[]; apiEndpoint: string; }) => { const { results, errors } = await PromisePool.withConcurrency(4) @@ -182,6 +171,7 @@ const restartIndexingAllViews = async ({ viewId, }); }); + if (errors.length) { Sentry.captureException('Error restarting views', { extra: { @@ -189,7 +179,7 @@ const restartIndexingAllViews = async ({ }, }); // @ts-ignore - throw new Error('Error captured when reindexing the views', { + throw new Error('Error captured when re-indexing the views', { cause: errors, }); } @@ -210,14 +200,17 @@ const ViewsSubView = () => { params: { orgLabel, projectLabel }, } = match; + const [expandedRows, setExpandedRows] = useState<{ + [key: string]: IndexingErrorResults | undefined; + }>({}); + 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, apiEndpoint }), + queryFn: () => fetchViewsList({ nexus, orgLabel, projectLabel }), refetchInterval: 30 * 1000, // 30s }); @@ -228,6 +221,14 @@ const ViewsSubView = () => { restartIndexingAllViews, { onError: error => { + Sentry.captureException(error, { + extra: { + orgLabel, + projectLabel, + error, + }, + }); + notification.error({ message: `Error when restarting indexing the views`, description: '', @@ -236,7 +237,50 @@ const ViewsSubView = () => { } ); - const columns: ColumnsType = [ + const fetchIndexingErrorsOnDemand = async ({ + nexus, + apiEndpoint, + orgLabel, + projectLabel, + viewId, + }: { + nexus: NexusClient; + apiEndpoint: string; + orgLabel: string; + projectLabel: string; + viewId: string; + }) => { + try { + // Fetch indexing errors for the specified view + const indexingErrors = await fetchIndexingErrors({ + nexus, + apiEndpoint, + orgLabel, + projectLabel, + viewId, + }); + + return indexingErrors; + } catch (error) { + notification.error({ + message: `Error fetching indexing errors for the selected view`, + description: '', + }); + + Sentry.captureException(error, { + extra: { + orgLabel, + projectLabel, + viewId, + error, + }, + }); + + return null; + } + }; + + const columns: ColumnsType = [ { key: 'name', dataIndex: 'name', @@ -381,8 +425,7 @@ const ViewsSubView = () => { nexus, apiEndpoint, views: - views?.results.filter(item => !item.isAggregateView) || - [], + views?.results.filter(item => item.isAggregateView) || [], }); }} > @@ -392,7 +435,7 @@ const ViewsSubView = () => { - + loading={status === 'loading'} className="views-table" rowClassName="view-item-row" @@ -402,32 +445,43 @@ const ViewsSubView = () => { size="middle" pagination={false} rowKey={r => r.key} - expandIcon={({ expanded, onExpand, record }) => - expanded ? ( - onExpand(record, e)} + expandIcon={({ expanded, onExpand, record }) => { + if (expanded) { + return onExpand(record, e)} />; + } + + return ( + { + // Fetch errors every time the row is expanded + record.indexingErrors = await fetchIndexingErrorsOnDemand({ + nexus, + apiEndpoint, + orgLabel: record.orgLabel, + projectLabel: record.projectLabel, + viewId: record.id, + }); + setExpandedRows(prevExpandedRows => ({ + ...prevExpandedRows, + [record.key]: record.indexingErrors || undefined, + })); + onExpand(record, e); + }} /> - ) : ( - - onExpand(record, e)} - style={{ fontSize: '16px' }} - /> - - ) - } - expandedRowRender={(r: TViewType) => { + ); + }} + expandedRowRender={record => { + const indexingErrors = expandedRows[record.key]; + if (!indexingErrors) { + // Fallback content in case errors haven't been set yet + return

Loading errors...

; + } return ( ); }} diff --git a/src/subapps/studioLegacy/containers/AddWorkspaceContainer.tsx b/src/subapps/studioLegacy/containers/AddWorkspaceContainer.tsx index b5d739490..84c3cc22c 100644 --- a/src/subapps/studioLegacy/containers/AddWorkspaceContainer.tsx +++ b/src/subapps/studioLegacy/containers/AddWorkspaceContainer.tsx @@ -5,6 +5,7 @@ import { useNexusContext } from '@bbp/react-nexus'; import WorkspaceEditorForm from '../components/WorkspaceEditorForm'; import useNotification, { + NexusError, parseNexusError, } from '../../../shared/hooks/useNotification'; @@ -100,7 +101,7 @@ const AddWorkspaceContainer: React.FC<{ } catch (error) { notification.error({ message: 'An error occurred', - description: parseNexusError(error), + description: parseNexusError(error as NexusError), }); } }; diff --git a/src/subapps/studioLegacy/containers/__tests__/WorkSpaceMenuContainer.spec.tsx b/src/subapps/studioLegacy/containers/__tests__/WorkSpaceMenuContainer.spec.tsx index 789fa6f53..6892e5132 100644 --- a/src/subapps/studioLegacy/containers/__tests__/WorkSpaceMenuContainer.spec.tsx +++ b/src/subapps/studioLegacy/containers/__tests__/WorkSpaceMenuContainer.spec.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { rest } from 'msw'; import { act } from 'react-dom/test-utils'; import { NexusProvider } from '@bbp/react-nexus'; -import { createBrowserHistory } from 'history'; +import { createMemoryHistory } from 'history'; import { createNexusClient } from '@bbp/nexus-sdk'; import { Provider } from 'react-redux'; import fetch from 'node-fetch'; @@ -26,7 +26,7 @@ import { deltaPath } from '__mocks__/handlers/handlers'; import { ButtonHTMLType } from 'antd/lib/button/button'; describe('workSpaceMenu', () => { - const history = createBrowserHistory({ basename: '/' }); + const history = createMemoryHistory({}); const contextValue: StudioContextType = { orgLabel: 'org', projectLabel: 'project', diff --git a/yarn.lock b/yarn.lock index 68d6d4f2e..785971892 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10259,17 +10259,16 @@ highlight.js@~9.13.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz" integrity sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A== -history@^4.7.2, history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== +history@4.5.1, history@^4.9.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.5.1.tgz#44935a51021e3b8e67ebac267a35675732aba569" + integrity sha512-gfHeJeYeMzFtos61gdA1AloO0hGXPF2Yum+2FRdJvlylYQOz51OnT1zuwg9UYst1BRrONhcAh3Nmsg9iblgl6g== dependencies: - "@babel/runtime" "^7.1.2" + invariant "^2.2.1" loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" + resolve-pathname "^2.0.0" + value-equal "^0.2.0" + warning "^3.0.0" hmac-drbg@^1.0.1: version "1.0.1" @@ -10784,9 +10783,9 @@ interpret@^2.0.0, interpret@^2.2.0: resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -invariant@^2.2.3, invariant@^2.2.4: +invariant@^2.2.1, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" - resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== dependencies: loose-envify "^1.0.0" @@ -16682,10 +16681,10 @@ resolve-global@1.0.0, resolve-global@^1.0.0: dependencies: global-dirs "^0.1.1" -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== +resolve-pathname@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + integrity sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg== resolve-protobuf-schema@^2.1.0: version "2.1.0" @@ -19047,10 +19046,10 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +value-equal@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d" + integrity sha512-yRL36Xb2K/HmFT5Fe3M86S7mu4+a12/3l7uytUh6eNPPjP77ldPBvsAvmnWff39sXn55naRMZN8LZWRO8PWaeQ== vary@~1.1.2: version "1.1.2" @@ -19129,6 +19128,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + integrity sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ== + dependencies: + loose-envify "^1.0.0" + warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"