diff --git a/jest.setup.js b/jest.setup.js index 68a36ba63..c77cd3cef 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -23,3 +23,15 @@ jest.mock('resize-observer-polyfill', () => ({ disconnect: jest.fn(), })), })); + +jest.mock('lru-cache', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => { + return ({ + fetch: jest.fn(), + clear: jest.fn(), + }) + }) + } +}); \ No newline at end of file diff --git a/package.json b/package.json index 80c4ce8d3..e1a1e38df 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "jwt-decode": "^2.2.0", "localforage": "^1.9.0", "lodash": "^4.17.21", + "lru-cache": "7.18.3", "moment": "^2.29.4", "morgan": "^1.9.1", "motion": "^10.15.5", diff --git a/src/__mocks__/handlers/DataExplorer/handlers.ts b/src/__mocks__/handlers/DataExplorer/handlers.ts new file mode 100644 index 000000000..523e8b574 --- /dev/null +++ b/src/__mocks__/handlers/DataExplorer/handlers.ts @@ -0,0 +1,247 @@ +import { rest } from 'msw'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import { Resource } from '@bbp/nexus-sdk'; +import { + AggregatedBucket, + AggregationsResult, +} from 'subapps/dataExplorer/DataExplorerUtils'; + +export const getCompleteResources = ( + resources: Resource[] = defaultPartialResources +) => { + return resources.map(res => ({ ...res, ...propertiesOnlyInSource })); +}; + +export const dataExplorerPageHandler = ( + partialResources: Resource[] = defaultPartialResources, + total: number = 300 +) => { + return rest.get(deltaPath(`/resources`), (req, res, ctx) => { + if (req.url.searchParams.has('aggregations')) { + return res(ctx.status(200), ctx.json(mockAggregationsResult())); + } + const passedType = req.url.searchParams.get('type'); + const mockResponse = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + 'https://bluebrain.github.io/nexus/contexts/search.json', + 'https://bluebrain.github.io/nexus/contexts/search-metadata.json', + ], + _total: total, + _results: passedType + ? partialResources.filter(res => res['@type'] === passedType) + : partialResources, + _next: + 'https://bbp.epfl.ch/nexus/v1/resources?size=50&sort=@id&after=%5B1687269183553,%22https://bbp.epfl.ch/neurosciencegraph/data/31e22529-2c36-44f0-9158-193eb50526cd%22%5D', + }; + return res(ctx.status(200), ctx.json(mockResponse)); + }); +}; + +const propertiesOnlyInSource = { userProperty1: { subUserProperty1: 'bar' } }; + +export const sourceResourceHandler = ( + partialResources: Resource[] = defaultPartialResources +) => { + return rest.get( + deltaPath(`/resources/:org/:project/_/:id`), + (req, res, ctx) => { + const { id } = req.params; + const decodedId = decodeURIComponent(id as string); + + const partialResource = partialResources.find( + resource => resource['@id'] === decodedId + ); + if (partialResource) { + return res( + ctx.status(200), + ctx.json({ ...partialResource, ...propertiesOnlyInSource }) + ); + } + + return res( + ctx.status(200), + ctx.json(getMockResource(decodedId, { ...propertiesOnlyInSource })) + ); + } + ); +}; + +export const filterByProjectHandler = ( + mockResources: Resource[] = defaultPartialResources +) => { + return rest.get(deltaPath(`/resources/:org/:project`), (req, res, ctx) => { + if (req.url.searchParams.has('aggregations')) { + return res( + ctx.status(200), + ctx.json( + mockAggregationsResult([ + getMockTypesBucket( + 'https://bluebrain.github.io/nexus/vocabulary/File' + ), + getMockTypesBucket('http://schema.org/StudioDashboard'), + getMockTypesBucket('https://neuroshapes.org/NeuronMorphology'), + ]) + ) + ); + } + + const { project } = req.params; + + const responseBody = project + ? mockResources.filter( + res => + res._project.slice(res._project.lastIndexOf('/') + 1) === project + ) + : mockResources; + const mockResponse = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + 'https://bluebrain.github.io/nexus/contexts/search.json', + 'https://bluebrain.github.io/nexus/contexts/search-metadata.json', + ], + _total: responseBody.length, + _results: responseBody, + _next: + 'https://bbp.epfl.ch/nexus/v1/resources?size=50&sort=@id&after=%5B1687269183553,%22https://bbp.epfl.ch/neurosciencegraph/data/31e22529-2c36-44f0-9158-193eb50526cd%22%5D', + }; + return res(ctx.status(200), ctx.json(mockResponse)); + }); +}; + +const mockAggregationsResult = ( + bucketForTypes: AggregatedBucket[] = defaultBucketForTypes +): AggregationsResult => { + return { + '@context': 'https://bluebrain.github.io/nexus/contexts/aggregations.json', + total: 10, + aggregations: { + projects: { + buckets: [ + getMockProjectBucket('something-brainy', 'bbp'), + getMockProjectBucket('smarty', 'bbp'), + getMockProjectBucket('unhcr', 'un'), + getMockProjectBucket('unicef', 'un'), + getMockProjectBucket('tellytubbies', 'bbc'), + ], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + types: { + buckets: bucketForTypes, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + }, + }; +}; + +export const getAggregationsHandler = () => + rest.get(deltaPath(`/resources?aggregations=true`), (req, res, ctx) => { + const aggregationsResponse: AggregationsResult = { + '@context': + 'https://bluebrain.github.io/nexus/contexts/aggregations.json', + total: 10, + aggregations: { + projects: { + buckets: [ + getMockProjectBucket('something-brainy', 'bbp'), + getMockProjectBucket('smarty', 'bbp'), + getMockProjectBucket('unhcr', 'un'), + getMockProjectBucket('unicef', 'un'), + getMockProjectBucket('tellytubbies', 'bbc'), + ], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + types: { + buckets: [ + getMockProjectBucket('something-brainy', 'bbp'), + getMockProjectBucket('smarty', 'bbp'), + getMockProjectBucket('unhcr', 'un'), + getMockProjectBucket('unicef', 'un'), + getMockProjectBucket('tellytubbies', 'bbc'), + ], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + }, + }; + return res(ctx.status(200), ctx.json(aggregationsResponse)); + }); + +const getMockProjectBucket = (project: string, org: string = 'bbp') => { + return { + key: `https://bbp.epfl.ch/nexus/v1/projects/${org}/${project}`, + doc_count: 10, + }; +}; + +const getMockTypesBucket = (type: string) => { + return { + doc_count: 98, + key: type, + }; +}; + +const defaultBucketForTypes = [ + getMockTypesBucket('https://bluebrain.github.io/nexus/vocabulary/File'), + getMockTypesBucket('http://schema.org/Dataset'), + getMockTypesBucket('https://neuroshapes.org/NeuronMorphology'), + getMockTypesBucket('https://bluebrain.github.io/nexus/vocabulary/View'), + getMockTypesBucket( + 'https://bluebrainnexus.io/studio/vocabulary/StudioDashboard' + ), +]; + +export const getMockResource = ( + selfSuffix: string, + extra: { [key: string]: any }, + project: string = 'hippocampus', + type: string = 'https://bbp.epfl.ch/ontologies/core/bmo/SimulationCampaignConfiguration' +): Resource => ({ + ...extra, + '@id': `https://bbp.epfl.ch/neurosciencegraph/data/${selfSuffix}`, + '@type': type, + _constrainedBy: + 'https://bluebrain.github.io/nexus/schemas/unconstrained.json', + _createdAt: '2023-06-21T09:39:47.217Z', + _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/antonel', + _deprecated: false, + _incoming: `https://bbp.epfl.ch/nexus/v1/resources/bbp/${project}/_/${selfSuffix}/incoming`, + _outgoing: `https://bbp.epfl.ch/nexus/v1/resources/bbp/${project}/_/${selfSuffix}/outgoing`, + _project: `https://bbp.epfl.ch/nexus/v1/projects/bbp/${project}`, + _rev: 2, + _self: `https://bbp.epfl.ch/nexus/v1/resources/bbp/${project}/_/${selfSuffix}`, + _updatedAt: '2023-06-21T09:39:47.844Z', + _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/antonel', +}); + +const defaultPartialResources: Resource[] = [ + getMockResource('self1', {}), + getMockResource( + 'self2', + { specialProperty: 'superSpecialValue' }, + 'unhcr', + 'https://bluebrain.github.io/nexus/vocabulary/File' + ), + getMockResource('self3', { specialProperty: ['superSpecialValue'] }), + getMockResource( + 'self4', + { specialProperty: '' }, + 'https://bluebrain.github.io/nexus/vocabulary/File' + ), + getMockResource('self5', { specialProperty: [] }), + getMockResource('self6', { + specialProperty: ['superSpecialValue', 'so'], + }), + getMockResource('self7', { specialProperty: { foo: 1, bar: 2 } }, 'unhcr'), + getMockResource('self8', { specialProperty: null }), + getMockResource( + 'self9', + { specialProperty: {} }, + undefined, + 'https://bluebrain.github.io/nexus/vocabulary/File' + ), + getMockResource('self10', { specialProperty: undefined }), +]; diff --git a/src/__mocks__/handlers/DataExplorerGraphFlow/handlers.ts b/src/__mocks__/handlers/DataExplorerGraphFlow/handlers.ts new file mode 100644 index 000000000..df9c98f91 --- /dev/null +++ b/src/__mocks__/handlers/DataExplorerGraphFlow/handlers.ts @@ -0,0 +1,748 @@ +import { rest } from 'msw'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import { getMockResource } from '../DataExplorer/handlers'; + +const resource = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + 'https://bbp.neuroshapes.org', + ], + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/bfdd4d1a-8b06-46fe-b663-7d9f8020dcaf', + '@type': ['Entity', 'Dataset', 'NeuronMorphology', 'ReconstructedCell'], + annotation: { + '@type': ['MTypeAnnotation', 'Annotation'], + hasBody: { + '@id': 'ilx:0383236', + '@type': ['MType', 'AnnotationBody'], + label: 'L6_SBC', + }, + name: 'M-type Annotation', + }, + brainLocation: { + '@type': 'BrainLocation', + brainRegion: { + '@id': 'uberon:0008933', + label: 'primary somatosensory cortex', + }, + layer: { + '@id': 'uberon:0005395', + label: 'layer 6', + }, + }, + contribution: [ + { + '@type': 'Contribution', + agent: { + '@id': 'https://orcid.org/0000-0001-9358-1315', + '@type': 'Agent', + }, + hadRole: { + '@id': 'Neuron:ElectrophysiologyRecordingRole', + label: 'neuron electrophysiology recording role', + }, + }, + { + '@type': 'Contribution', + agent: { + '@id': 'https://www.grid.ac/institutes/grid.5333.6', + '@type': 'Agent', + }, + }, + ], + derivation: { + '@type': 'Derivation', + entity: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/350bcafe-9cbb-4c15-bad3-1caed2cbb990', + '@type': ['PatchedCell', 'Entity'], + }, + }, + description: + 'This dataset is about an in vitro-filled neuron morphology from layer 6 with m-type L6_SBC. The distribution contains the neuron morphology in ASC and in SWC file format.', + distribution: [ + { + '@type': 'DataDownload', + atLocation: { + '@type': 'Location', + location: + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/a/a/e/d/8/2/b/5/tkb060126a2_ch3_bc_n_jh_100x_1.asc', + store: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': 'RemoteDiskStorage', + _rev: 1, + }, + }, + contentSize: { + unitCode: 'bytes', + value: 1097726, + }, + contentUrl: + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fbf146eaf-48cf-4b83-b375-bbb92ce7f7c0', + digest: { + algorithm: 'SHA-256', + value: + 'efcf3d6660d9769b3f3066e874c8f13536fbc398b5605ffc5acc223884362ff6', + }, + encodingFormat: 'application/asc', + name: 'tkb060126a2_ch3_bc_n_jh_100x_1.asc', + }, + { + '@type': 'DataDownload', + atLocation: { + '@type': 'Location', + location: + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/6/4/3/8/3/d/0/3/tkb060126a2_ch3_bc_n_jh_100x_1.swc', + store: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': 'RemoteDiskStorage', + _rev: 1, + }, + }, + contentSize: { + unitCode: 'bytes', + value: 891821, + }, + contentUrl: + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F60025362-1ca8-425e-908c-a01e4661c3e7', + digest: { + algorithm: 'SHA-256', + value: + '22bac983b129fe806c80a9ddb4dcf77b79c1a6a28adffd6674290fb1f014a30e', + }, + encodingFormat: 'application/swc', + name: 'tkb060126a2_ch3_bc_n_jh_100x_1.swc', + }, + ], + generation: { + '@type': 'Generation', + activity: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/9ad281da-e352-4275-b1fa-6a3516a654c9', + '@type': ['Activity', 'Reconstruction'], + }, + }, + isPartOf: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/23d3d87e-94fe-4639-b5c8-a26a712587e6', + '@type': 'Entity', + }, + license: { + '@id': 'https://creativecommons.org/licenses/by/4.0/', + '@type': 'License', + }, + name: 'tkb060126a2_ch3_bc_n_jh_100x_1', + objectOfStudy: { + '@id': + 'http://bbp.epfl.ch/neurosciencegraph/taxonomies/objectsofstudy/singlecells', + '@type': 'ObjectOfStudy', + label: 'Single Cell', + }, + sameAs: + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/431a1196-47b5-41a2-931a-3577be9a2dc4', + subject: { + '@type': 'Subject', + species: { + '@id': 'NCBITaxon:10116', + label: 'Rattus norvegicus', + }, + }, + _constrainedBy: 'https://neuroshapes.org/dash/neuronmorphology', + _createdAt: '2021-11-23T11:34:00.952Z', + _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/akkaufma', + _deprecated: false, + _incoming: + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf/incoming', + _outgoing: + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf/outgoing', + _project: 'https://bbp.epfl.ch/nexus/v1/projects/public/sscx', + _rev: 2, + _schemaProject: + 'https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels', + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf', + _updatedAt: '2023-06-23T07:34:56.011Z', + _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/cgonzale', +}; + +const initialResource = getMockResource( + 'neuronmorphologies/bfdd4d1a-8b06-46fe-b663-7d9f8020dcaf', + { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + 'https://bbp.neuroshapes.org', + ], + '@type': ['Entity', 'Dataset', 'NeuronMorphology', 'ReconstructedCell'], + annotation: { + '@type': ['MTypeAnnotation', 'Annotation'], + hasBody: { + '@id': 'ilx:0383236', + '@type': ['MType', 'AnnotationBody'], + label: 'L6_SBC', + }, + name: 'M-type Annotation', + }, + brainLocation: { + '@type': 'BrainLocation', + brainRegion: { + '@id': 'uberon:0008933', + label: 'primary somatosensory cortex', + }, + layer: { + '@id': 'uberon:0005395', + label: 'layer 6', + }, + }, + contribution: [ + { + '@type': 'Contribution', + agent: { + '@id': 'https://orcid.org/0000-0001-9358-1315', + '@type': 'Agent', + }, + hadRole: { + '@id': 'Neuron:ElectrophysiologyRecordingRole', + label: 'neuron electrophysiology recording role', + }, + }, + { + '@type': 'Contribution', + agent: { + '@id': 'https://www.grid.ac/institutes/grid.5333.6', + '@type': 'Agent', + }, + }, + ], + derivation: { + '@type': 'Derivation', + entity: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/350bcafe-9cbb-4c15-bad3-1caed2cbb990', + '@type': ['PatchedCell', 'Entity'], + }, + }, + description: + 'This dataset is about an in vitro-filled neuron morphology from layer 6 with m-type L6_SBC. The distribution contains the neuron morphology in ASC and in SWC file format.', + distribution: [ + { + '@type': 'DataDownload', + atLocation: { + '@type': 'Location', + location: + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/a/a/e/d/8/2/b/5/tkb060126a2_ch3_bc_n_jh_100x_1.asc', + store: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': 'RemoteDiskStorage', + _rev: 1, + }, + }, + contentSize: { + unitCode: 'bytes', + value: 1097726, + }, + contentUrl: + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fbf146eaf-48cf-4b83-b375-bbb92ce7f7c0', + digest: { + algorithm: 'SHA-256', + value: + 'efcf3d6660d9769b3f3066e874c8f13536fbc398b5605ffc5acc223884362ff6', + }, + encodingFormat: 'application/asc', + name: 'tkb060126a2_ch3_bc_n_jh_100x_1.asc', + }, + { + '@type': 'DataDownload', + atLocation: { + '@type': 'Location', + location: + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/6/4/3/8/3/d/0/3/tkb060126a2_ch3_bc_n_jh_100x_1.swc', + store: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': 'RemoteDiskStorage', + _rev: 1, + }, + }, + contentSize: { + unitCode: 'bytes', + value: 891821, + }, + contentUrl: + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F60025362-1ca8-425e-908c-a01e4661c3e7', + digest: { + algorithm: 'SHA-256', + value: + '22bac983b129fe806c80a9ddb4dcf77b79c1a6a28adffd6674290fb1f014a30e', + }, + encodingFormat: 'application/swc', + name: 'tkb060126a2_ch3_bc_n_jh_100x_1.swc', + }, + ], + generation: { + '@type': 'Generation', + activity: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/9ad281da-e352-4275-b1fa-6a3516a654c9', + '@type': ['Activity', 'Reconstruction'], + }, + }, + isPartOf: { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/23d3d87e-94fe-4639-b5c8-a26a712587e6', + '@type': 'Entity', + }, + license: { + '@id': 'https://creativecommons.org/licenses/by/4.0/', + '@type': 'License', + }, + name: 'tkb060126a2_ch3_bc_n_jh_100x_1', + objectOfStudy: { + '@id': + 'http://bbp.epfl.ch/neurosciencegraph/taxonomies/objectsofstudy/singlecells', + '@type': 'ObjectOfStudy', + label: 'Single Cell', + }, + sameAs: + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/431a1196-47b5-41a2-931a-3577be9a2dc4', + subject: { + '@type': 'Subject', + species: { + '@id': 'NCBITaxon:10116', + label: 'Rattus norvegicus', + }, + }, + } +); + +const initialResourceExpanded = { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/bfdd4d1a-8b06-46fe-b663-7d9f8020dcaf', + '@type': [ + 'http://www.w3.org/ns/prov#Entity', + 'http://schema.org/Dataset', + 'https://neuroshapes.org/NeuronMorphology', + 'https://neuroshapes.org/ReconstructedCell', + ], + 'http://schema.org/description': [ + { + '@value': + 'This dataset is about an in vitro-filled neuron morphology from layer 6 with m-type L6_SBC. The distribution contains the neuron morphology in ASC and in SWC file format.', + }, + ], + 'http://schema.org/distribution': [ + { + '@type': ['http://schema.org/DataDownload'], + 'http://schema.org/contentSize': [ + { + 'http://schema.org/unitCode': [ + { + '@value': 'bytes', + }, + ], + 'http://schema.org/value': [ + { + '@value': 1097726, + }, + ], + }, + ], + 'http://schema.org/contentUrl': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fbf146eaf-48cf-4b83-b375-bbb92ce7f7c0', + }, + ], + 'http://schema.org/encodingFormat': [ + { + '@value': 'application/asc', + }, + ], + 'http://schema.org/name': [ + { + '@value': 'tkb060126a2_ch3_bc_n_jh_100x_1.asc', + }, + ], + 'http://www.w3.org/ns/prov#atLocation': [ + { + '@type': ['http://www.w3.org/ns/prov#Location'], + 'https://neuroshapes.org/location': [ + { + '@value': + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/a/a/e/d/8/2/b/5/tkb060126a2_ch3_bc_n_jh_100x_1.asc', + }, + ], + 'https://neuroshapes.org/store': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': [ + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/_/RemoteDiskStorage', + ], + 'https://bluebrain.github.io/nexus/vocabulary/rev': [ + { + '@value': 1, + }, + ], + }, + ], + }, + ], + 'https://neuroshapes.org/digest': [ + { + 'http://schema.org/algorithm': [ + { + '@value': 'SHA-256', + }, + ], + 'http://schema.org/value': [ + { + '@value': + 'efcf3d6660d9769b3f3066e874c8f13536fbc398b5605ffc5acc223884362ff6', + }, + ], + }, + ], + }, + { + '@type': ['http://schema.org/DataDownload'], + 'http://schema.org/contentSize': [ + { + 'http://schema.org/unitCode': [ + { + '@value': 'bytes', + }, + ], + 'http://schema.org/value': [ + { + '@value': 891821, + }, + ], + }, + ], + 'http://schema.org/contentUrl': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/files/public/sscx/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F60025362-1ca8-425e-908c-a01e4661c3e7', + }, + ], + 'http://schema.org/encodingFormat': [ + { + '@value': 'application/swc', + }, + ], + 'http://schema.org/name': [ + { + '@value': 'tkb060126a2_ch3_bc_n_jh_100x_1.swc', + }, + ], + 'http://www.w3.org/ns/prov#atLocation': [ + { + '@type': ['http://www.w3.org/ns/prov#Location'], + 'https://neuroshapes.org/location': [ + { + '@value': + 'file:///gpfs/bbp.cscs.ch/data/project/proj109/nexus/c7d70522-4305-480a-b190-75d757ed9a49/6/4/3/8/3/d/0/3/tkb060126a2_ch3_bc_n_jh_100x_1.swc', + }, + ], + 'https://neuroshapes.org/store': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/4820323e-bee0-48d2-824f-9d9d404dbbee', + '@type': [ + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/_/RemoteDiskStorage', + ], + 'https://bluebrain.github.io/nexus/vocabulary/rev': [ + { + '@value': 1, + }, + ], + }, + ], + }, + ], + 'https://neuroshapes.org/digest': [ + { + 'http://schema.org/algorithm': [ + { + '@value': 'SHA-256', + }, + ], + 'http://schema.org/value': [ + { + '@value': + '22bac983b129fe806c80a9ddb4dcf77b79c1a6a28adffd6674290fb1f014a30e', + }, + ], + }, + ], + }, + ], + 'http://schema.org/isPartOf': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/23d3d87e-94fe-4639-b5c8-a26a712587e6', + '@type': ['http://www.w3.org/ns/prov#Entity'], + }, + ], + 'http://schema.org/license': [ + { + '@id': 'https://creativecommons.org/licenses/by/4.0/', + '@type': ['https://neuroshapes.org/License'], + }, + ], + 'http://schema.org/name': [ + { + '@value': 'tkb060126a2_ch3_bc_n_jh_100x_1', + }, + ], + 'http://schema.org/sameAs': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/431a1196-47b5-41a2-931a-3577be9a2dc4', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/constrainedBy': [ + { + '@id': 'https://neuroshapes.org/dash/neuronmorphology', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/createdAt': [ + { + '@type': 'http://www.w3.org/2001/XMLSchema#dateTime', + '@value': '2021-11-23T11:34:00.952Z', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/createdBy': [ + { + '@id': 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/akkaufma', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/deprecated': [ + { + '@value': false, + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/incoming': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf/incoming', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/outgoing': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf/outgoing', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/project': [ + { + '@id': 'https://bbp.epfl.ch/nexus/v1/projects/public/sscx', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/rev': [ + { + '@value': 2, + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/schemaProject': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/self': [ + { + '@id': + 'https://bbp.epfl.ch/nexus/v1/resources/public/sscx/datashapes:neuronmorphology/neuronmorphologies%2Fbfdd4d1a-8b06-46fe-b663-7d9f8020dcaf', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/updatedAt': [ + { + '@type': 'http://www.w3.org/2001/XMLSchema#dateTime', + '@value': '2023-06-23T07:34:56.011Z', + }, + ], + 'https://bluebrain.github.io/nexus/vocabulary/updatedBy': [ + { + '@id': 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/cgonzale', + }, + ], + 'https://neuroshapes.org/annotation': [ + { + '@type': [ + 'https://neuroshapes.org/MTypeAnnotation', + 'https://neuroshapes.org/Annotation', + ], + 'http://schema.org/name': [ + { + '@value': 'M-type Annotation', + }, + ], + 'https://neuroshapes.org/hasBody': [ + { + '@id': 'http://uri.interlex.org/base/ilx_0383236', + '@type': [ + 'https://neuroshapes.org/MType', + 'https://neuroshapes.org/AnnotationBody', + ], + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'L6_SBC', + }, + ], + }, + ], + }, + ], + 'https://neuroshapes.org/brainLocation': [ + { + '@type': ['https://neuroshapes.org/BrainLocation'], + 'https://neuroshapes.org/brainRegion': [ + { + '@id': 'http://purl.obolibrary.org/obo/UBERON_0008933', + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'primary somatosensory cortex', + }, + ], + }, + ], + 'https://neuroshapes.org/layer': [ + { + '@id': 'http://purl.obolibrary.org/obo/UBERON_0005395', + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'layer 6', + }, + ], + }, + ], + }, + ], + 'https://neuroshapes.org/contribution': [ + { + '@type': ['https://neuroshapes.org/Contribution'], + 'http://www.w3.org/ns/prov#agent': [ + { + '@id': 'https://orcid.org/0000-0001-9358-1315', + '@type': ['http://www.w3.org/ns/prov#Agent'], + }, + ], + 'http://www.w3.org/ns/prov#hadRole': [ + { + '@id': 'https://neuroshapes.org/NeuronElectrophysiologyRecordingRole', + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'neuron electrophysiology recording role', + }, + ], + }, + ], + }, + { + '@type': ['https://neuroshapes.org/Contribution'], + 'http://www.w3.org/ns/prov#agent': [ + { + '@id': 'https://www.grid.ac/institutes/grid.5333.6', + '@type': ['http://www.w3.org/ns/prov#Agent'], + }, + ], + }, + ], + 'https://neuroshapes.org/derivation': [ + { + '@type': ['http://www.w3.org/ns/prov#Derivation'], + 'http://www.w3.org/ns/prov#entity': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/350bcafe-9cbb-4c15-bad3-1caed2cbb990', + '@type': [ + 'https://neuroshapes.org/PatchedCell', + 'http://www.w3.org/ns/prov#Entity', + ], + }, + ], + }, + ], + 'https://neuroshapes.org/generation': [ + { + '@type': ['http://www.w3.org/ns/prov#Generation'], + 'http://www.w3.org/ns/prov#activity': [ + { + '@id': + 'https://bbp.epfl.ch/neurosciencegraph/data/9ad281da-e352-4275-b1fa-6a3516a654c9', + '@type': [ + 'http://www.w3.org/ns/prov#Activity', + 'https://neuroshapes.org/Reconstruction', + ], + }, + ], + }, + ], + 'https://neuroshapes.org/objectOfStudy': [ + { + '@id': + 'http://bbp.epfl.ch/neurosciencegraph/taxonomies/objectsofstudy/singlecells', + '@type': ['https://neuroshapes.org/ObjectOfStudy'], + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'Single Cell', + }, + ], + }, + ], + 'https://neuroshapes.org/subject': [ + { + '@type': ['https://neuroshapes.org/Subject'], + 'https://neuroshapes.org/species': [ + { + '@id': 'http://purl.obolibrary.org/obo/NCBITaxon_10116', + 'http://www.w3.org/2000/01/rdf-schema#label': [ + { + '@value': 'Rattus norvegicus', + }, + ], + }, + ], + }, + ], +}; + +const getDataExplorerGraphFlowResourceObject = rest.get( + deltaPath( + `resources/public/sscx/_/${encodeURIComponent( + initialResource['@id'] + )}?format=expanded` + ), + (req, res, ctx) => { + const format = req.url.searchParams.get('format'); + if (format === 'expanded') { + return res(ctx.status(200), ctx.json([initialResourceExpanded])); + } + return res(ctx.status(200), ctx.json(initialResource)); + } +); +const getDataExplorerGraphFlowResourceObjectTags = rest.get( + deltaPath( + `resources/public/sscx/_/${encodeURIComponent(initialResource['@id'])}/tags` + ), + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + '@context': 'https://bluebrain.github.io/nexus/contexts/tags.json', + tags: [], + }) + ); + } +); + +export { + resource, + initialResource, + getDataExplorerGraphFlowResourceObject, + getDataExplorerGraphFlowResourceObjectTags, +}; diff --git a/src/__mocks__/handlers/ResourceEditor/handlers.ts b/src/__mocks__/handlers/ResourceEditor/handlers.ts index 3cf051d47..e2f3788be 100644 --- a/src/__mocks__/handlers/ResourceEditor/handlers.ts +++ b/src/__mocks__/handlers/ResourceEditor/handlers.ts @@ -483,6 +483,7 @@ const getSearchApiResponseObject = rest.get( export { resourceResolverApi, + resourceResolverApiId, resourceFromSearchApiId, resourceFromSearchApi, getResolverResponseObject, diff --git a/src/pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage.tsx b/src/pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage.tsx index 965a80dfd..af892fe54 100644 --- a/src/pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage.tsx +++ b/src/pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import DataExplorerResolverPage from '../../shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow'; +import DataExplorerGraphFlow from '../../shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow'; const DataExplorerGraphFlowPage = () => { - return ; + return ; }; export default DataExplorerGraphFlowPage; diff --git a/src/pages/DataExplorerPage/DataExplorerPage.tsx b/src/pages/DataExplorerPage/DataExplorerPage.tsx new file mode 100644 index 000000000..8d4993153 --- /dev/null +++ b/src/pages/DataExplorerPage/DataExplorerPage.tsx @@ -0,0 +1,11 @@ +import { DataExplorer } from '../../subapps/dataExplorer/DataExplorer'; + +const DataExplorerPage = () => { + return ( +
+ +
+ ); +}; + +export default DataExplorerPage; diff --git a/src/pages/MyDataPage/MyDataPage.tsx b/src/pages/MyDataPage/MyDataPage.tsx index 20957ed7e..d7defc808 100644 --- a/src/pages/MyDataPage/MyDataPage.tsx +++ b/src/pages/MyDataPage/MyDataPage.tsx @@ -1,11 +1,7 @@ import { MyData } from '../../shared/canvas'; const MyDataPage = () => { - return ( -
- -
- ); + return ; }; export default MyDataPage; diff --git a/src/pages/OrganizationProjectsPage/OrganizationProjectsPage.spec.tsx b/src/pages/OrganizationProjectsPage/OrganizationProjectsPage.spec.tsx index c8a934870..65a55cb4c 100644 --- a/src/pages/OrganizationProjectsPage/OrganizationProjectsPage.spec.tsx +++ b/src/pages/OrganizationProjectsPage/OrganizationProjectsPage.spec.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; import { renderHook } from '@testing-library/react-hooks'; import fetch from 'node-fetch'; import { act } from 'react-dom/test-utils'; -import { NexusProvider, useNexusContext } from '@bbp/react-nexus'; +import { NexusProvider } from '@bbp/react-nexus'; import { ProjectList, ProjectResponseCommon, @@ -13,13 +13,7 @@ import { createBrowserHistory } from 'history'; import { Provider } from 'react-redux'; import { ConnectedRouter } from 'connected-react-router'; -import { - render, - fireEvent, - waitFor, - screen, - server, -} from '../../utils/testUtil'; +import { render, waitFor, screen, server } from '../../utils/testUtil'; import configureStore from '../../shared/store'; import OrganizationProjectsPage, { useInfiniteOrganizationProjectsQuery, diff --git a/src/server/index.tsx b/src/server/index.tsx index a18dbcf7d..b0ff06aae 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -148,10 +148,9 @@ app.get('*', async (req: express.Request, res: express.Response) => { modals: DEFAULT_MODALS_STATE, dataExplorer: { current: null, - links: [], - shrinked: false, - limited: false, - highlightIndex: -1, + leftNodes: { links: [], shrinked: false }, + rightNodes: { links: [], shrinked: false }, + fullscreen: false, }, }; diff --git a/src/shared/App.less b/src/shared/App.less index 98a35ed6e..aeafdfde5 100644 --- a/src/shared/App.less +++ b/src/shared/App.less @@ -9,27 +9,49 @@ margin: 52px auto 0; display: flex; background: #f5f5f5 !important; + &.-unconstrained-width { padding: 2em; max-width: none; } + &.resource-view { - max-width: 60%; + max-width: 1320px; background-color: @primary-card; - background-image: linear-gradient( - 315deg, - @primary-card 0%, - @subtle-white 74% - ); min-height: calc(100vh - 40px); transition: background-image ease-out 1s; + + &.background { + max-width: 60%; + background-image: linear-gradient( + 315deg, + @primary-card 0%, + @subtle-white 74% + ); + + .resource-details { + .highShadow(); + padding: 1em; + width: 100%; + background-color: @background-color-subtle; + } + } + .resource-details { - .highShadow(); - padding: 1em; + background-color: @fusion-main-bg; width: 100%; - background-color: @background-color-subtle; } } + + &.data-explorer-container { + width: fit-content; + min-width: calc(100vw - 1rem); + padding-top: 0; + margin-right: 1rem; + height: 100%; + min-height: calc(100vh - 52px); + margin-top: 0; + } } .graph-wrapper-container { @@ -46,6 +68,7 @@ .ant-alert-warning { margin: 1em 0; } + section.links { width: 48%; } @@ -55,6 +78,7 @@ .identities-list { margin: 0; padding: 0; + .list-item { cursor: auto; } @@ -67,6 +91,7 @@ .ant-pagination-item { margin-right: 2px; } + .ant-list-pagination { text-align: center; } @@ -74,6 +99,7 @@ .ant-input-affix-wrapper .ant-input-suffix { color: rgba(0, 0, 0, 0.2); } + .ant-upload.ant-upload-drag .ant-upload { padding: @default-pad; } @@ -88,6 +114,7 @@ .studio-view { padding: 0 2em; + .workspace { display: flex; width: 100%; @@ -95,6 +122,7 @@ min-width: 800px; min-height: 600px; } + .studio-back-button { margin-bottom: 5px; } @@ -202,3 +230,29 @@ outline: none; border-radius: 0; } + +.full-screen-switch__wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + span { + color: @fusion-blue-8; + } + .full-screen-switch { + border-color: #2e76bf !important; + background: linear-gradient( + 0deg, + rgba(0, 58, 140, 0.3), + rgba(0, 58, 140, 0.3) + ), + linear-gradient(0deg, rgba(46, 118, 191, 0.2), rgba(46, 118, 191, 0.2)); + border: 1px solid #003a8c4d; + .ant-switch-handle { + top: 1px; + &::before { + background: @fusion-daybreak-10; + } + } + } +} diff --git a/src/shared/App.tsx b/src/shared/App.tsx index aeaa1e657..8f366665b 100644 --- a/src/shared/App.tsx +++ b/src/shared/App.tsx @@ -20,7 +20,7 @@ import CreateProject from './modals/CreateProject/CreateProject'; import CreateOrganization from './modals/CreateOrganization/CreateOrganization'; import CreateStudio from './modals/CreateStudio/CreateStudio'; import AppInfo from './modals/AppInfo/AppInfo'; -import ResolvedLinkEditorPopover from './molecules/ResolvedLinkEditorPopover/ResolvedLinkEditorPopover'; + import './App.less'; const App: React.FC = () => { @@ -53,11 +53,10 @@ const App: React.FC = () => { - + {userAuthenticated && ( - diff --git a/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx b/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx new file mode 100644 index 000000000..56c30e4e0 --- /dev/null +++ b/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +const DataExplorerGraphFlowEmpty = () => { + return ( +
+
+ nodes +
No data explorer graph flow
+
+ Please select a node from any resource view editor to start exploring +
+
+
+ ); +}; + +export default DataExplorerGraphFlowEmpty; diff --git a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx new file mode 100644 index 000000000..6a0511cdc --- /dev/null +++ b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx @@ -0,0 +1,165 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { RenderResult, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { NexusClient, createNexusClient } from '@bbp/nexus-sdk'; +import { AnyAction, Store } from 'redux'; +import { NexusProvider } from '@bbp/react-nexus'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { setupServer } from 'msw/node'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import { cleanup, render, screen } from '../../../utils/testUtil'; +import { + DATA_EXPLORER_GRAPH_FLOW_DIGEST, + InitNewVisitDataExplorerGraphView, + TDataExplorerState, +} from '../../../shared/store/reducers/data-explorer'; +import configureStore from '../../store'; +import DateExplorerGraphFlow from './DateExplorerGraphFlow'; +import { + initialResource, + getDataExplorerGraphFlowResourceObject, + getDataExplorerGraphFlowResourceObjectTags, +} from '../../../__mocks__/handlers/DataExplorerGraphFlow/handlers'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent from '@testing-library/user-event'; + +const initialDataExplorerState: TDataExplorerState = { + current: { + isDownloadable: false, + _self: initialResource._self, + title: initialResource.name, + types: initialResource['@type'], + resource: ['public', 'sscx', initialResource['@id'], initialResource._rev], + }, + leftNodes: { links: [], shrinked: false }, + rightNodes: { links: [], shrinked: false }, + fullscreen: false, +}; + +describe('DataExplorerGraphFlow', () => { + let server: ReturnType; + let app: JSX.Element; + let container: HTMLElement; + let rerender: (ui: React.ReactElement) => void; + let store: Store; + let user: UserEvent; + let history: MemoryHistory<{}>; + let nexus: NexusClient; + let component: RenderResult; + + beforeAll(async () => { + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + server = setupServer( + getDataExplorerGraphFlowResourceObject, + getDataExplorerGraphFlowResourceObjectTags + ); + + server.listen(); + history = createMemoryHistory({}); + store = configureStore( + history, + { nexus }, + { + router: { + location: { + pathname: '/', + search: '', + hash: '', + state: {}, + key: 'cvvg7m', + query: {}, + }, + action: 'POP', + }, + } + ); + }); + + afterAll(() => { + server.resetHandlers(); + server.close(); + localStorage.clear(); + cleanup(); + }); + + beforeEach(() => { + history = createMemoryHistory({}); + + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + store = configureStore(history, { nexus }, {}); + app = ( + + + + + + + + ); + component = render(app); + container = component.container; + rerender = component.rerender; + user = userEvent.setup(); + }); + + it('should render the name of the resource', async () => { + store.dispatch( + InitNewVisitDataExplorerGraphView({ + current: initialDataExplorerState.current, + fullscreen: false, + }) + ); + rerender(app); + const resourceTitle = await waitFor(() => + screen.getByText(initialResource.name) + ); + expect(resourceTitle).toBeInTheDocument(); + }); + it('should clean the data explorer state when quit the page', async () => { + store.dispatch( + InitNewVisitDataExplorerGraphView({ + current: initialDataExplorerState.current, + fullscreen: false, + }) + ); + rerender(app); + history.push('/another-page'); + const dataExplorerState = store.getState().dataExplorer; + const sessionStorageItem = sessionStorage.getItem( + DATA_EXPLORER_GRAPH_FLOW_DIGEST + ); + expect(sessionStorageItem).toBeNull(); + expect(dataExplorerState.leftNodes.links.length).toBe(0); + expect(dataExplorerState.rightNodes.links.length).toBe(0); + expect(dataExplorerState.current).toBeNull(); + expect(dataExplorerState.fullscreen).toBe(false); + }); + + it('should the fullscren toggle present in the screen if the user in fullscreen mode', async () => { + store.dispatch( + InitNewVisitDataExplorerGraphView({ + current: initialDataExplorerState.current, + fullscreen: true, + }) + ); + rerender(app); + const fullscreenSwitch = container.querySelector( + 'button[aria-label="fullscreen switch"]' + ); + const fullscreenTitle = container.querySelector( + 'h1[aria-label="fullscreen title"]' + ); + expect(fullscreenSwitch).toBeInTheDocument(); + expect(fullscreenTitle).toBeInTheDocument(); + await user.click(fullscreenSwitch as HTMLButtonElement); + expect(store.getState().dataExplorer.fullscreen).toBe(false); + }); +}); diff --git a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx index 821e02287..d4a5720e9 100644 --- a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx +++ b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx @@ -1,15 +1,118 @@ -import React from 'react'; +import React, { useRef, useEffect, CSSProperties } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useLocation, useHistory } from 'react-router'; +import { clsx } from 'clsx'; +import { RootState } from '../../store/reducers'; +import { + DATA_EXPLORER_GRAPH_FLOW_DIGEST, + DATA_EXPLORER_GRAPH_FLOW_PATH, + PopulateDataExplorerGraphFlow, + ResetDataExplorerGraphFlow, + DataExplorerFlowSliceListener, + DataExplorerMiddlewareMatcher, + calculateDateExplorerGraphFlowDigest, + TDataExplorerState, +} from '../../store/reducers/data-explorer'; +import { + NavigationArrows, + NavigationStack, +} from '../../organisms/DataExplorerGraphFlowNavigationStack'; import DataExplorerContentPage from '../../organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent'; +import useNavigationStackManager from '../../organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack'; +import ResourceResolutionCache from '../../components/ResourceEditor/ResourcesLRUCache'; +import DataExplorerGraphFlowEmpty from './DataExplorerGraphFlowEmpty'; + import './styles.less'; -const DataExplorerResolverPage = () => { - return ( -
+const DataExplorerGraphFlow = () => { + const history = useHistory(); + const location = useLocation(); + const dispatch = useDispatch(); + const digestFirstRender = useRef(false); + const { current, rightNodes, leftNodes } = useSelector( + (state: RootState) => state.dataExplorer + ); + + const { + leftShrinked, + rightShrinked, + leftLinks, + rightLinks, + } = useNavigationStackManager(); + + useEffect(() => { + if (!digestFirstRender.current) { + const state = sessionStorage.getItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST); + if (state) { + dispatch(PopulateDataExplorerGraphFlow(state)); + } + } + digestFirstRender.current = true; + }, [location.search, digestFirstRender.current]); + + useEffect(() => { + const unlisten = history.listen(location => { + if (!location.pathname.startsWith(DATA_EXPLORER_GRAPH_FLOW_PATH)) { + dispatch(ResetDataExplorerGraphFlow({ initialState: null })); + sessionStorage.removeItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST); + } + }); + return () => unlisten(); + }, []); + + useEffect(() => { + return () => { + ResourceResolutionCache.clear(); + }; + }, [ResourceResolutionCache]); + + useEffect(() => { + DataExplorerFlowSliceListener.startListening({ + matcher: DataExplorerMiddlewareMatcher, + effect: (_, api) => { + const state = (api.getState() as RootState).dataExplorer; + calculateDateExplorerGraphFlowDigest(state); + }, + }); + return () => { + DataExplorerFlowSliceListener.clearListeners(); + }; + }, []); + return !current ? ( + + ) : ( +
+ {!!leftLinks.length && ( +
+ +
+ )}
+
+ {!!rightLinks.length && ( +
+ +
+ )}
); }; -export default DataExplorerResolverPage; +export default DataExplorerGraphFlow; diff --git a/src/shared/canvas/DataExplorerGraphFlow/styles.less b/src/shared/canvas/DataExplorerGraphFlow/styles.less index 2a51a48e9..91dcb0816 100644 --- a/src/shared/canvas/DataExplorerGraphFlow/styles.less +++ b/src/shared/canvas/DataExplorerGraphFlow/styles.less @@ -3,10 +3,69 @@ .data-explorer-resolver { width: 100%; max-width: 100%; + display: grid; + grid-auto-columns: 1fr; align-items: flex-start; justify-content: flex-start; background-color: @fusion-main-bg; - display: grid; - grid-auto-columns: 1fr; gap: 10px; + margin-top: 52px; + &.no-links { + grid-template-columns: 1fr; + } + &.with-links { + &.left-existed { + grid-template-columns: calc(calc(var(--left--links-count) * 31px) + 40px) 1fr; + } + &.right-existed { + grid-template-columns: 1fr calc( + calc(var(--right--links-count) * 31px) + 40px + ); + } + &.left-existed.right-existed { + grid-template-columns: calc(calc(var(--left--links-count) * 31px) + 40px) 1fr calc( + calc(var(--right--links-count) * 31px) + 40px + ); + } + } + .degf__navigation-stack { + width: 100%; + } + .degf__content { + padding: 30px 20px 10px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + transition: all 0.4s ease-in-out; + } + &.no-current { + height: 100%; + width: 100%; + padding: 40px 20px; + .empty { + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + box-shadow: 0 2px 12px rgba(#333, 0.12); + padding: 20px; + .empty__title { + font-weight: 700; + font-size: 16px; + line-height: 140%; + color: red; + } + .empty__subtitle { + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #333; + font-style: italic; + } + } + } } diff --git a/src/shared/canvas/MyData/MyData.tsx b/src/shared/canvas/MyData/MyData.tsx index ef4123a1a..5ca77d179 100644 --- a/src/shared/canvas/MyData/MyData.tsx +++ b/src/shared/canvas/MyData/MyData.tsx @@ -1,56 +1,16 @@ import * as React from 'react'; -import * as moment from 'moment'; import { useQuery } from 'react-query'; import { useSelector } from 'react-redux'; import { useNexusContext } from '@bbp/react-nexus'; import { notification } from 'antd'; -import { isObject, isString } from 'lodash'; +import { get, isObject, isString } from 'lodash'; import { MyDataHeader, MyDataTable } from '../../molecules'; import { RootState } from '../../store/reducers'; -import { TDateFilterType, TFilterOptions } from './types'; +import { TFilterOptions } from './types'; +import { makeDatetimePattern } from './utils'; import './styles.less'; -const makeDatetimePattern = ({ - dateFilterType, - singleDate, - dateStart, - dateEnd, -}: { - dateFilterType?: TDateFilterType; - singleDate?: string; - dateStart?: string; - dateEnd?: string; -}) => { - switch (dateFilterType) { - case 'after': { - if (!!singleDate && moment(singleDate).isValid()) { - return `${singleDate}..*`; - } - return undefined; - } - case 'before': { - if (!!singleDate && moment(singleDate).isValid()) { - return `*..${singleDate}`; - } - return undefined; - } - case 'range': { - if ( - !!dateStart && - !!dateEnd && - moment(dateStart).isValid() && - moment(dateEnd).isValid() && - moment(dateStart).isBefore(moment(dateEnd), 'days') - ) { - return `${dateStart}..${dateEnd}`; - } - return undefined; - } - default: - return undefined; - } -}; const HomeMyData: React.FC<{}> = () => { const nexus = useNexusContext(); const identities = useSelector( @@ -59,7 +19,6 @@ const HomeMyData: React.FC<{}> = () => { const issuerUri = identities?.find(item => item['@type'] === 'User')?.['@id']; const [ { - dataType, dateField, query, dateFilterType, @@ -71,6 +30,7 @@ const HomeMyData: React.FC<{}> = () => { sort, locate, issuer, + types, }, setFilterOptions, ] = React.useReducer( @@ -84,13 +44,13 @@ const HomeMyData: React.FC<{}> = () => { singleDate: undefined, dateStart: undefined, dateEnd: undefined, - dataType: [], query: '', offset: 0, size: 50, sort: ['-_createdAt', '@id'], locate: false, issuer: 'createdBy', + types: [], } ); @@ -116,6 +76,7 @@ const HomeMyData: React.FC<{}> = () => { ? `${dateField}-${dateFilterType}-${dateFilterRange}` : undefined; const order = sort.join('-'); + const resourceTypes = types?.map(item => get(item, 'value')); const { data: resources, isLoading } = useQuery({ queryKey: [ 'my-data-resources', @@ -127,6 +88,7 @@ const HomeMyData: React.FC<{}> = () => { issuer, date, order, + types: resourceTypes, }, ], retry: false, @@ -150,7 +112,8 @@ const HomeMyData: React.FC<{}> = () => { [dateField]: dateFilterRange, } : {}), - // type: dataType, + // @ts-ignore + type: resourceTypes, }), onError: error => { notification.error({ @@ -170,10 +133,10 @@ const HomeMyData: React.FC<{}> = () => { }); const total = resources?._total; return ( -
+
& { export type TTitleProps = { text: string; label: string; - total?: string; + total?: number; }; -export type THeaderFilterProps = Omit; +export type THeaderFilterProps = Pick< + THeaderProps, + 'types' | 'dateField' | 'setFilterOptions' +>; +export type THeaderTitleProps = Pick< + THeaderProps, + 'total' | 'query' | 'locate' | 'issuer' | 'setFilterOptions' +>; export type THandleMenuSelect = MenuProps['onClick']; export type TTypeDateItem = { key: string; @@ -45,3 +52,26 @@ export type TDate = { }; export type TDateOptions = 'singleDate' | 'dateStart' | 'dateEnd'; export const DATE_PATTERN = 'DD/MM/YYYY'; +export type TType = { + key: string; + value: string; + label: string; + docCount: number; +}; + +export type TTypeAggregationsResult = { + '@context': string; + total: number; + aggregations: { + projects: TTypesAggregatedProperty; + types: TTypesAggregatedProperty; + }; +}; + +export type TTypesAggregatedProperty = { + buckets: TTypesAggregatedBucket[]; + doc_count_error_upper_bound: number; + sum_other_doc_count: number; +}; + +export type TTypesAggregatedBucket = { key: string; doc_count: number }; diff --git a/src/shared/canvas/MyData/utils.ts b/src/shared/canvas/MyData/utils.ts new file mode 100644 index 000000000..e680da422 --- /dev/null +++ b/src/shared/canvas/MyData/utils.ts @@ -0,0 +1,45 @@ +import * as moment from 'moment'; +import { TDateFilterType } from './types'; + +const makeDatetimePattern = ({ + dateFilterType, + singleDate, + dateStart, + dateEnd, +}: { + dateFilterType?: TDateFilterType; + singleDate?: string; + dateStart?: string; + dateEnd?: string; +}) => { + switch (dateFilterType) { + case 'after': { + if (!!singleDate && moment(singleDate).isValid()) { + return `${singleDate}..*`; + } + return undefined; + } + case 'before': { + if (!!singleDate && moment(singleDate).isValid()) { + return `*..${singleDate}`; + } + return undefined; + } + case 'range': { + if ( + !!dateStart && + !!dateEnd && + moment(dateStart).isValid() && + moment(dateEnd).isValid() && + moment(dateStart).isBefore(moment(dateEnd), 'days') + ) { + return `${dateStart}..${dateEnd}`; + } + return undefined; + } + default: + return undefined; + } +}; + +export { makeDatetimePattern }; diff --git a/src/shared/components/Header/Header.less b/src/shared/components/Header/Header.less index d72259d80..9b6e0dd3e 100644 --- a/src/shared/components/Header/Header.less +++ b/src/shared/components/Header/Header.less @@ -29,7 +29,6 @@ // padding-right: @default-pad; align-items: center; min-width: 120px; - button, a { // margin: 0 4px; diff --git a/src/shared/components/Header/Header.tsx b/src/shared/components/Header/Header.tsx index e1a53153c..ae4eea366 100644 --- a/src/shared/components/Header/Header.tsx +++ b/src/shared/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useState } from 'react'; +import React from 'react'; import { Link } from 'react-router-dom'; import { useLocation } from 'react-router'; import { Menu, Dropdown, MenuItemProps } from 'antd'; @@ -18,6 +18,7 @@ import { UISettingsActionTypes } from '../../store/actions/ui-settings'; import { RootState } from '../../store/reducers'; import { updateAboutModalVisibility } from '../../store/actions/modals'; import { triggerCopy as copyCmd } from '../../utils/copy'; +import { AdvancedModeToggle } from '../../molecules'; import useNotification from '../../hooks/useNotification'; import './Header.less'; @@ -157,6 +158,7 @@ const Header: React.FunctionComponent = ({
{token ? (
+ {name && } {name && showCreationPanel && (
) => { + return ( + + + + ); +}; + +export default CollapseIcon; diff --git a/src/shared/components/Icons/FilterIcon.tsx b/src/shared/components/Icons/FilterIcon.tsx new file mode 100644 index 000000000..c306e5cdb --- /dev/null +++ b/src/shared/components/Icons/FilterIcon.tsx @@ -0,0 +1,20 @@ +export const FilterIcon = () => { + return ( + + + + + ); +}; diff --git a/src/shared/components/ResourceEditor/CodeEditor.tsx b/src/shared/components/ResourceEditor/CodeEditor.tsx index e23481907..7886a79a4 100644 --- a/src/shared/components/ResourceEditor/CodeEditor.tsx +++ b/src/shared/components/ResourceEditor/CodeEditor.tsx @@ -1,7 +1,7 @@ import React, { forwardRef } from 'react'; import codemiror, { EditorConfiguration } from 'codemirror'; import { UnControlled as CodeMirror } from 'react-codemirror2'; -import { INDENT_UNIT } from '.'; +import { INDENT_UNIT } from './editorUtils'; import { clsx } from 'clsx'; import { Spin } from 'antd'; @@ -9,25 +9,24 @@ type TCodeEditor = { busy: boolean; value: string; editable: boolean; + fullscreen: boolean; keyFoldCode(cm: any): void; - loadingResolution: boolean; handleChange(editor: any, data: any, value: any): void; - onLinkClick(_: any, ev: MouseEvent): void; onLinksFound(): void; }; type TEditorConfiguration = EditorConfiguration & { foldCode: boolean; }; + const CodeEditor = forwardRef( ( { busy, value, editable, + fullscreen, keyFoldCode, - loadingResolution, handleChange, - onLinkClick, onLinksFound, }, ref @@ -35,7 +34,7 @@ const CodeEditor = forwardRef( return ( ( } className={clsx( 'code-mirror-editor', - loadingResolution && 'resolution-on-progress' + fullscreen && 'full-screen-mode' )} onChange={handleChange} editorDidMount={editor => { (ref as React.MutableRefObject).current = editor; }} - onMouseDown={onLinkClick} onUpdate={onLinksFound} /> diff --git a/src/shared/components/ResourceEditor/ResourceEditor.less b/src/shared/components/ResourceEditor/ResourceEditor.less index 32b5ab3eb..d78da0b66 100644 --- a/src/shared/components/ResourceEditor/ResourceEditor.less +++ b/src/shared/components/ResourceEditor/ResourceEditor.less @@ -18,12 +18,18 @@ } } +.resource-editor .full-screen-mode { + .CodeMirror { + height: calc(100vh - 200px) !important; + } +} + .sm-string .cm-property { color: @text-color; } .cm-s-base16-light.CodeMirror { - background-color: white; + background-color: @fusion-main-bg !important; } .resource-editor { @@ -56,14 +62,161 @@ } .code-mirror-editor { + .fusion-resource-link { + color: #0974ca !important; + cursor: pointer !important; + background-color: rgba(#0974ca, 0.12); + border-radius: 4px; + padding: 1px; + border: 0.5px solid rgba(#0974ca, 0.14); + + &.wait-for-tooltip { + cursor: progress !important; + } + + &.has-tooltip { + cursor: pointer !important; + } + + &.error { + cursor: not-allowed !important; + } + } + .CodeMirror-lines { cursor: text; } &.resolution-on-progress { .CodeMirror-lines { - cursor: progress; user-select: none; + + .fusion-resource-link { + cursor: progress !important; + } + } + } +} + +.CodeMirror-hover-tooltip-popover, +.CodeMirror-hover-tooltip { + background-color: white; + border: 1px solid 333; + box-shadow: 0 2px 12px rgba(#333, 0.12); + border-radius: 4px; + font-size: 10pt; + overflow: hidden; + position: fixed; + z-index: 9999; + max-width: 600px; + white-space: pre-wrap; + transition: all 0.4s ease-in-out; + padding: 4px 0; + + &.popover { + background-color: white !important; + + .CodeMirror-hover-tooltip-resources-content { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + + .CodeMirror-hover-tooltip-item { + width: 100%; + padding: 4px; + align-items: center; + justify-content: flex-start; + cursor: pointer; + + .tag { + background-color: @fusion-primary-color; + } + + &:hover { + background-color: @fusion-blue-0; + color: @fusion-primary-color; + + .tag { + background-color: white; + color: @fusion-primary-color; + } + } + } + } + } + + &-content { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + row-gap: 2px; + + &.error { + .tag { + background-color: @fusion-danger-color; + } + } + + &.external { + .tag { + background-color: @fusion-warning-color; + color: #333; + } + } + + &.resource { + .tag { + background-color: @fusion-primary-color; + } + } + + &.resources { + .tag { + background-color: @fusion-primary-color; + } + } + } + + &-item { + display: flex; + align-items: center; + justify-content: center; + padding: 2px 10px 2px 5px; + overflow: hidden; + user-select: none; + + .tag { + color: white; + padding: 2px 5px; + border-radius: 4px; + margin-right: 5px; + box-shadow: 0 2px 12px rgba(#333, 0.12); + } + + .title { + font-weight: 200; + font-size: 13px; + } + + .download-icon { + width: 24px; + height: 24px; + margin-left: 10px; + color: @fusion-primary-color; + } + .key-binding { + margin-left: 10px; + color: @fusion-primary-color; } } } + +.editor-controls-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + width: 100%; +} diff --git a/src/shared/components/ResourceEditor/ResourceEditor.spec.tsx b/src/shared/components/ResourceEditor/ResourceEditor.spec.tsx index 0162b0c0d..f33982c32 100644 --- a/src/shared/components/ResourceEditor/ResourceEditor.spec.tsx +++ b/src/shared/components/ResourceEditor/ResourceEditor.spec.tsx @@ -20,22 +20,22 @@ document.createRange = () => { return range; }; + describe('ResourceEditor', () => { it('check if code editor will be rendered in the screen', async () => { const editor = React.createRef(); const onLinksFound = jest.fn(); - const { queryByText, container, getByTestId } = render( + const { queryByText, container } = render( {}} onLinksFound={onLinksFound} busy={false} keyFoldCode={() => {}} handleChange={() => {}} - loadingResolution={false} ref={editor} + fullscreen={false} /> ); await waitFor(async () => { diff --git a/src/shared/components/ResourceEditor/ResourcesLRUCache.ts b/src/shared/components/ResourceEditor/ResourcesLRUCache.ts new file mode 100644 index 000000000..698bdf927 --- /dev/null +++ b/src/shared/components/ResourceEditor/ResourcesLRUCache.ts @@ -0,0 +1,85 @@ +// NOTE: This file will be removed when delta introduce http cache headers +import { NexusClient, Resource, PaginatedList } from '@bbp/nexus-sdk'; +import LRUCache from 'lru-cache'; + +// TODO: Use nexus.httpGet to prepare for using http cache headers +// since the nexus SDK can not accept the headers as an argument +const lookByProjectResolver = async ({ + nexus, + apiEndpoint, + orgLabel, + projectLabel, + resourceId, +}: { + nexus: NexusClient; + apiEndpoint: string; + orgLabel: string; + projectLabel: string; + resourceId: string; +}): Promise => { + return await nexus.httpGet({ + path: `${apiEndpoint}/resolvers/${orgLabel}/${projectLabel}/_/${resourceId}`, + }); +}; +const lookBySearchApi = async ({ + nexus, + apiEndpoint, + resourceId, +}: { + nexus: NexusClient; + apiEndpoint: string; + resourceId: string; +}): Promise => { + return await nexus.httpGet({ + path: `${apiEndpoint}/resources?locate=${resourceId}`, + }); +}; + +export type TPagedResources = PaginatedList & { + [key: string]: any; +}; +export type TResolutionType = 'resolver-api' | 'search-api' | 'error'; +export type TResolutionData = Resource | TPagedResources | Error; +export type TResolutionReturnedData = { + data: TResolutionData; + type: TResolutionType; +}; +export type ResourceResolutionFetchFn = ( + key: string, + { fetchContext }: { fetchContext: any } +) => Promise; +type Options = LRUCache.Options; + +const Options: Options = { + max: 100, + ttl: 1000 * 60 * 30, // 30 minutes + fetchMethod: async ( + _, + undefined, + options: LRUCache.FetcherOptions + ) => { + try { + return { + data: await lookByProjectResolver(options.context), + type: 'resolver-api', + }; + } catch (error) { + try { + return { + data: await lookBySearchApi(options.context), + type: 'search-api', + }; + } catch (error) { + throw { + data: error, + type: 'error', + }; + } + } + }, +}; +const ResourceResolutionCache = new LRUCache( + Options +); + +export default ResourceResolutionCache; diff --git a/src/shared/components/ResourceEditor/editorUtils.spec.tsx b/src/shared/components/ResourceEditor/editorUtils.spec.tsx deleted file mode 100644 index 61731dcb1..000000000 --- a/src/shared/components/ResourceEditor/editorUtils.spec.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import '@testing-library/jest-dom'; -import React from 'react'; -import { resolveLinkInEditor, getNormalizedTypes } from './editorUtils'; -import { Provider } from 'react-redux'; -import { createMemoryHistory } from 'history'; -import { NexusClient, createNexusClient } from '@bbp/nexus-sdk'; -import { deltaPath } from '__mocks__/handlers/handlers'; -import configureStore from '../../../shared/store'; -import { Router } from 'react-router-dom'; -import { NexusProvider } from '@bbp/react-nexus'; -import { AnyAction, Store } from 'redux'; -import { QueryClientProvider, QueryClient } from 'react-query'; -import { setupServer } from 'msw/node'; -import { - resourceResolverApi, - resourceFromSearchApiId, - resourceFromSearchApi, - getResolverResponseObject, - getSearchApiResponseObject, -} from '../../../__mocks__/handlers/ResourceEditor/handlers'; -import { - getOrgAndProjectFromResourceObject, - getResourceLabel, -} from '../../utils'; -import { render, screen, waitFor, act, cleanup } from '../../../utils/testUtil'; -import ResolvedLinkEditorPopover from '../../molecules/ResolvedLinkEditorPopover/ResolvedLinkEditorPopover'; - -describe('getNormalizedTypes', () => { - const typesAsString = 'Resource'; - it('should return the normalized types', () => { - const result = getNormalizedTypes(typesAsString); - expect(result).toEqual(['Resource']); - }); - - const typesAsUrl = 'https://bluebrain.github.io/nexus/vocabulary/Resource'; - it('should return the normalized types', () => { - const result = getNormalizedTypes(typesAsUrl); - expect(result).toEqual(['Resource']); - }); - - const typesWithUrls = [ - 'https://bluebrain.github.io/nexus/vocabulary/Schema', - 'https://bluebrain.github.io/nexus/vocabulary/Resource', - 'https://bluebrain.github.io/nexus/vocabulary/Project', - 'Realm', - 'NeuronMorphology', - ]; - it('should return the normalized types', () => { - const result = getNormalizedTypes(typesWithUrls); - expect(result).toEqual([ - 'Schema', - 'Resource', - 'Project', - 'Realm', - 'NeuronMorphology', - ]); - }); -}); - -describe('resolveLinkInEditor', () => { - const queryClient = new QueryClient(); - let store: Store; - let server: ReturnType; - const defaultPaylaod = { top: 0, left: 0, open: true }; - let nexus: NexusClient; - let TestApp: JSX.Element; - beforeAll(() => { - server = setupServer(getResolverResponseObject, getSearchApiResponseObject); - server.listen(); - }); - - beforeAll(async () => { - const history = createMemoryHistory({}); - nexus = createNexusClient({ - fetch, - uri: deltaPath(), - }); - store = configureStore(history, { nexus }, {}); - TestApp = ( - - - - - - - - - - ); - }); - beforeEach(async () => { - await act(async () => { - await render(TestApp); - }); - }); - afterEach(async () => { - cleanup(); - }); - afterAll(() => { - server.resetHandlers(); - server.close(); - }); - // case-0: the url is not valid - it('should return null if the url is not valid', async () => { - const url = 'not a valid url'; - const result = await resolveLinkInEditor({ - nexus, - url, - defaultPaylaod, - dispatch: store.dispatch, - orgLabel: 'orgLabel', - projectLabel: 'projectLabel', - }); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results.length - ).toEqual(0); - expect( - store.getState().uiSettings.editorPopoverResolvedData.resolvedAs - ).toBeUndefined(); - expect(result).toBeNull(); - }); - // case-1: url is valid and link resolved by the project resolver - it('should show popover when the link is resolved by the project resolver if the url is valid', async () => { - const orgProject = getOrgAndProjectFromResourceObject(resourceResolverApi); - const name = getResourceLabel(resourceResolverApi); - await waitFor(async () => { - await resolveLinkInEditor({ - nexus, - defaultPaylaod: { ...defaultPaylaod, top: 400, left: 400 }, - url: resourceResolverApi['@id'], - dispatch: store.dispatch, - orgLabel: orgProject?.orgLabel, - projectLabel: orgProject?.projectLabel, - }); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results - ).toBeDefined(); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results._self - ).toEqual(resourceResolverApi._self); - expect( - store.getState().uiSettings.editorPopoverResolvedData.resolvedAs - ).toBe('resource'); - expect( - store.getState().uiSettings.editorPopoverResolvedData.error - ).toBeNull(); - }); - await waitFor( - async () => { - const nameMatch = new RegExp(name, 'i'); - const nameInScreen = await screen.findByText(nameMatch); - expect(nameInScreen).toBeInTheDocument(); - }, - { timeout: 3000 } - ); - }); - // case-2: link can not be resolved by the project resolver - // then try to find it across all projects - it('should show popover when the link is resolved by search api and resolver can not resolve it if the url is valid', async () => { - const orgProject = { - orgLabel: 'bbp', - projectLabel: 'lnmce', - }; - await waitFor(async () => { - await resolveLinkInEditor({ - nexus, - defaultPaylaod: { ...defaultPaylaod, top: 400, left: 400 }, - url: resourceFromSearchApiId, - dispatch: store.dispatch, - orgLabel: orgProject.orgLabel, - projectLabel: orgProject.projectLabel, - }); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results - ).toBeDefined(); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results.length - ).toEqual(resourceFromSearchApi._total); - expect( - store.getState().uiSettings.editorPopoverResolvedData.resolvedAs - ).toBe('resources'); - expect( - store.getState().uiSettings.editorPopoverResolvedData.error - ).toBeUndefined(); - for (const item in resourceFromSearchApi._results) { - const nameMatch = new RegExp( - getResourceLabel(resourceFromSearchApi._results[item]), - 'i' - ); - const namesInScreen = await screen.findAllByText(nameMatch); - for (const nameInScreen of namesInScreen) { - expect(nameInScreen).toBeInTheDocument(); - } - } - }); - }); - // case-3: link can not be resolved by the project resolver and search api and it's an external link - it('shoudl show popover when external link is provided', async () => { - const url = 'ftp://www.google.com'; - await waitFor(async () => { - await resolveLinkInEditor({ - nexus, - url, - defaultPaylaod: { ...defaultPaylaod, top: 400, left: 400 }, - dispatch: store.dispatch, - orgLabel: 'orgLabel', - projectLabel: 'projectLabel', - }); - expect( - store.getState().uiSettings.editorPopoverResolvedData.results._self - ).toEqual(url); - expect( - store.getState().uiSettings.editorPopoverResolvedData.resolvedAs - ).toEqual('external'); - expect( - store.getState().uiSettings.editorPopoverResolvedData.error - ).toBeUndefined(); - }); - const urlMatch = new RegExp(url, 'i'); - const urlInScreen = await screen.findByText(urlMatch); - expect(urlInScreen).toBeInTheDocument(); - }); -}); diff --git a/src/shared/components/ResourceEditor/editorUtils.ts b/src/shared/components/ResourceEditor/editorUtils.ts index 9b6efe942..4a4d519bb 100644 --- a/src/shared/components/ResourceEditor/editorUtils.ts +++ b/src/shared/components/ResourceEditor/editorUtils.ts @@ -1,176 +1,237 @@ -import { NexusClient } from '@bbp/nexus-sdk'; -import { Dispatch } from 'redux'; -import { isArray, last } from 'lodash'; -import isValidUrl, { externalLink } from '../../../utils/validUrl'; -import { fetchResourceByResolver } from '../../../subapps/admin/components/Settings/ResolversSubView'; -import { TEditorPopoverResolvedData } from '../../store/reducers/ui-settings'; +import { NexusClient, Resource } from '@bbp/nexus-sdk'; +import { has } from 'lodash'; +import isValidUrl, { + isExternalLink, + isStorageLink, + isUrlCurieFormat, +} from '../../../utils/validUrl'; import { + getNormalizedTypes, getOrgAndProjectFromResourceObject, getResourceLabel, } from '../../utils'; -import { - UISettingsActionTypes, - TUpdateJSONEditorPopoverAction, -} from '../../store/actions/ui-settings'; +import { TDELink, TDEResource } from '../../store/reducers/data-explorer'; +import ResourceResolutionCache, { + ResourceResolutionFetchFn, + TPagedResources, + TResolutionData, + TResolutionType, +} from './ResourcesLRUCache'; -export type TToken = { - string: string; - start: number; - end: number; +export type TEditorPopoverResolvedAs = + | 'resource' + | 'resources' + | 'external' + | 'error' + | undefined; +export type TEditorPopoverResolvedData = { + open: boolean; + top: number; + left: number; + results?: TDELink | TDELink[]; + resolvedAs: TEditorPopoverResolvedAs; + error?: any; +}; +type TDeltaError = Error & { + '@type': string; + details: string; }; -type TActionData = { - type: typeof UISettingsActionTypes['UPDATE_JSON_EDITOR_POPOVER']; - payload: TEditorPopoverResolvedData; +type TErrorMessage = Error & { + message: string; }; +type TReturnedResolvedData = Omit< + TEditorPopoverResolvedData, + 'top' | 'left' | 'open' +>; -const dispatchEvent = ( - dispatch: Dispatch, - data: TActionData +export const LINE_HEIGHT = 15; +export const INDENT_UNIT = 4; +const NEAR_BY = [0, 0, 0, 5, 0, -5, 5, 0, -5, 0]; +const isDownloadableLink = (resource: Resource) => { + return Boolean( + resource['@type'] === 'File' || resource['@type']?.includes('File') + ); +}; +export const mayBeResolvableLink = (url: string): boolean => { + return isValidUrl(url) && !isUrlCurieFormat(url) && !isStorageLink(url); +}; +export const getDataExplorerResourceItemArray = ( + entity: { orgLabel: string; projectLabel: string }, + data: Resource ) => { - return dispatch<{ - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER; - payload: TEditorPopoverResolvedData; - }>({ - type: data.type, - payload: data.payload, - }); + return (isDownloadableLink(data) && data._mediaType + ? [ + entity?.orgLabel, + entity?.projectLabel, + data['@id'], + data._rev, + data._mediaType, + ] + : [ + entity?.orgLabel, + entity?.projectLabel, + data['@id'], + data._rev, + ]) as TDEResource; }; -export const getNormalizedTypes = (types?: string | string[]) => { - if (types) { - if (isArray(types)) { - return types.map(item => { - if (isValidUrl(item)) { - return item.split('/').pop()!; - } - return item; - }); +export function getTokenAndPosAt(e: MouseEvent, current: CodeMirror.Editor) { + const node = e.target || e.srcElement; + const text = + (node as HTMLElement).innerText || (node as HTMLElement).textContent; + const editorRect = (e.target as HTMLElement).getBoundingClientRect(); + for (let i = 0; i < NEAR_BY.length; i += 2) { + const coords = { + left: e.pageX + NEAR_BY[i], + top: e.pageY + NEAR_BY[i + 1], + }; + const pos = current.coordsChar(coords); + const token = current.getTokenAt(pos); + if (token && token.string === text) { + return { + token, + coords: { + left: editorRect.left, + top: coords.top + LINE_HEIGHT, + }, + }; } - return [last(types.split('/'))!]; } - return []; -}; - -export async function resolveLinkInEditor({ + return { + token: null, + coords: { left: editorRect.left, top: e.pageY }, + }; +} +export async function editorLinkResolutionHandler({ nexus, - dispatch, + apiEndpoint, orgLabel, projectLabel, url, - defaultPaylaod, + fetcher, }: { nexus: NexusClient; - dispatch: Dispatch; + apiEndpoint: string; url: string; orgLabel: string; projectLabel: string; - defaultPaylaod: { top: number; left: number; open: boolean }; -}) { - if (isValidUrl(url)) { - let data; - try { - // case-1: link resolved by the project resolver - data = await fetchResourceByResolver({ + fetcher?: ResourceResolutionFetchFn; +}): Promise { + const key = `${orgLabel}/${projectLabel}/${url}`; + let data: TResolutionData; + let type: TResolutionType; + if (fetcher) { + ({ data, type } = await fetcher(key, { + fetchContext: { + nexus, + apiEndpoint, + orgLabel, + projectLabel, + resourceId: encodeURIComponent(url), + }, + })); + } else { + ({ data, type } = await ResourceResolutionCache.fetch(key, { + fetchContext: { nexus, + apiEndpoint, orgLabel, projectLabel, resourceId: encodeURIComponent(url), - }); - const entity = getOrgAndProjectFromResourceObject(data); - return dispatchEvent(dispatch, { - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - payload: { - ...defaultPaylaod, - error: null, + }, + })); + } + switch (type) { + case 'resolver-api': { + const details: Resource = data as Resource; + const entity = getOrgAndProjectFromResourceObject(details); + const isDownloadable = isDownloadableLink(details); + // case-resource: link is resolved as a resource by project resolver + // next-action: open resource editor + return { + resolvedAs: 'resource', + results: { + isDownloadable, + _self: details._self, + title: getResourceLabel(details), + types: getNormalizedTypes(details['@type']), + resource: getDataExplorerResourceItemArray( + entity ?? { orgLabel: '', projectLabel: '' }, + details + ), + }, + }; + } + case 'search-api': { + const details = data as TPagedResources; + if (!details._total || (!details._total && isExternalLink(url))) { + // case-error: link is not resolved by nither project resolver nor nexus search api + // next-action: throw error and capture it in the catch block + return { + error: 'Resource can not be resolved', + resolvedAs: 'error', + }; + } + if (details._total === 1) { + // case-resource: link is resolved as a resource by nexus search api + // next-action: open resource editor + const result = details._results[0]; + const isDownloadable = isDownloadableLink(result); + const entity = getOrgAndProjectFromResourceObject(result); + return { resolvedAs: 'resource', results: { - _self: data._self, - title: getResourceLabel(data), - types: getNormalizedTypes(data['@type']), - resource: [ - entity?.orgLabel, - entity?.projectLabel, - data['@id'], - data._rev, - ], + isDownloadable, + _self: result._self, + title: getResourceLabel(result), + types: getNormalizedTypes(result['@type']), + resource: getDataExplorerResourceItemArray( + entity ?? { orgLabel: '', projectLabel: '' }, + result + ), }, - }, - }); - } catch (error) { - try { - // case-2: link can not be resolved by the project resolver - // then try to find it across all projects - // it may be single resource, multiple resources or external resource - // if no resource found then we consider it as an error - data = await nexus.Resource.list(undefined, undefined, { - locate: url, - }); - return dispatchEvent(dispatch, { - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - payload: { - ...defaultPaylaod, - ...(externalLink(url) && !data._total - ? { - resolvedAs: 'external', - results: { - _self: url, - title: url, - types: [], - }, - } - : !data._total - ? { - error: 'No @id or _self has been resolved', - resolvedAs: 'error', - } - : { - resolvedAs: 'resources', - results: data._results.map(item => { - const entity = getOrgAndProjectFromResourceObject(item); - return { - _self: item._self, - title: getResourceLabel(item), - types: getNormalizedTypes(item['@type']), - resource: [ - entity?.orgLabel, - entity?.projectLabel, - item['@id'], - item._rev, - ], - }; - }), - }), - }, - }); - } catch (error) { - console.error('case 3: ', url, externalLink(url), '\n', error); - // case-3: if an error occured when tring both resolution method above - // we check if the resource is external - if (externalLink(url)) { - return dispatchEvent(dispatch, { - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - payload: { - ...defaultPaylaod, - resolvedAs: 'external', - results: { - _self: url, - title: url, - types: [], - }, - }, - }); - } - - // case-4: if not an external url then it will be an error - return dispatchEvent(dispatch, { - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - payload: { - ...defaultPaylaod, - error: JSON.stringify(error), - resolvedAs: 'error', + }; + } + // case-resources: link is resolved as a list of resources by nexus search api + // next-action: open resources list in the popover + return { + resolvedAs: 'resources', + results: details._results.map((item: Resource) => { + const isDownloadable = isDownloadableLink(item); + const entity = getOrgAndProjectFromResourceObject(item); + return { + isDownloadable, + _self: item._self, + title: getResourceLabel(item), + types: getNormalizedTypes(item['@type']), + resource: getDataExplorerResourceItemArray( + entity ?? { orgLabel: '', projectLabel: '' }, + item + ), + }; + }), + }; + } + case 'error': + default: { + const details = data as any; + if (isExternalLink(url)) { + return { + resolvedAs: 'external', + results: { + _self: url, + title: url, + types: [], }, - }); + }; } + // case-error: link is not resolved by nither project resolver nor nexus search api + // and it's not an external link + return { + error: has(details, 'details') + ? (details as TDeltaError).details + : (details as TErrorMessage).message ?? JSON.stringify(details), + resolvedAs: 'error', + }; } } - return null; } diff --git a/src/shared/components/ResourceEditor/index.tsx b/src/shared/components/ResourceEditor/index.tsx index c1bb50fc2..e9f0facec 100644 --- a/src/shared/components/ResourceEditor/index.tsx +++ b/src/shared/components/ResourceEditor/index.tsx @@ -1,24 +1,37 @@ import * as React from 'react'; import { Button, Switch } from 'antd'; +import { useLocation } from 'react-router'; import { CheckCircleOutlined, ExclamationCircleOutlined, SaveOutlined, } from '@ant-design/icons'; - -import { useDispatch } from 'react-redux'; -import { useNexusContext } from '@bbp/react-nexus'; +import { useSelector } from 'react-redux'; +import { AccessControl } from '@bbp/react-nexus'; import codemiror from 'codemirror'; import 'codemirror/mode/javascript/javascript'; import 'codemirror/addon/fold/foldcode'; import 'codemirror/addon/fold/foldgutter'; import 'codemirror/addon/fold/brace-fold'; -import isValidUrl from '../../../utils/validUrl'; + +import isValidUrl, { + isAllowedProtocal, + isStorageLink, + isUrlCurieFormat, +} from '../../../utils/validUrl'; import CodeEditor from './CodeEditor'; -import { TToken, resolveLinkInEditor } from './editorUtils'; +import { RootState } from '../../store/reducers'; +import { + useEditorPopover, + useEditorTooltip, + CODEMIRROR_LINK_CLASS, +} from './useEditorTooltip'; +import { DATA_EXPLORER_GRAPH_FLOW_PATH } from '../../store/reducers/data-explorer'; +import ResourceResolutionCache from './ResourcesLRUCache'; import './ResourceEditor.less'; +const AnchorLinkIcon = require('../../images/AnchorLink.svg'); export interface ResourceEditorProps { rawData: { [key: string]: any }; onSubmit: (rawData: { [key: string]: any }) => void; @@ -38,10 +51,17 @@ export interface ResourceEditorProps { onFullScreen(): void; } -export const LINE_HEIGHT = 50; -export const INDENT_UNIT = 4; const switchMarginRight = { marginRight: 5 }; +const isClickableLine = (url: string) => { + return ( + isValidUrl(url) && + isAllowedProtocal(url) && + !isUrlCurieFormat(url) && + !isStorageLink(url) + ); +}; + const ResourceEditor: React.FunctionComponent = props => { const { rawData, @@ -61,17 +81,22 @@ const ResourceEditor: React.FunctionComponent = props => { onFullScreen, showControlPanel = true, } = props; - - const nexus = useNexusContext(); - const [loadingResolution, setLoadingResolution] = React.useState(false); + const location = useLocation(); const [isEditing, setEditing] = React.useState(editing); - const [fullScreen, setFullScreen] = React.useState(false); const [valid, setValid] = React.useState(true); const [parsedValue, setParsedValue] = React.useState(rawData); const [stringValue, setStringValue] = React.useState( JSON.stringify(rawData, null, 2) ); - const dispatch = useDispatch(); + const { + dataExplorer: { fullscreen }, + oidc, + } = useSelector((state: RootState) => ({ + dataExplorer: state.dataExplorer, + oidc: state.oidc, + config: state.config, + })); + const userAuthenticated = oidc.user && oidc.user.access_token; const keyFoldCode = (cm: any) => { cm.foldCode(cm.getCursor()); }; @@ -108,43 +133,14 @@ const ResourceEditor: React.FunctionComponent = props => { }; const onLinksFound = () => { const elements = document.getElementsByClassName('cm-string'); - Array.from(elements).forEach(item => { + Array.from(elements).forEach((item, index) => { const itemSpan = item as HTMLSpanElement; - if (isValidUrl(itemSpan.innerText.replace(/^"|"$/g, ''))) { - itemSpan.style.textDecoration = 'underline'; + const url = itemSpan.innerText.replace(/^"|"$/g, ''); + if (isClickableLine(url)) { + itemSpan.classList.add(CODEMIRROR_LINK_CLASS); } }); }; - const onLinkClick = async (_: any, ev: MouseEvent) => { - setLoadingResolution(true); - ev.stopPropagation(); - const x = ev.pageX; - const y = ev.pageY; - const editorPosition = codeMirorRef.current?.coordsChar({ - left: x, - top: y, - }); - const token = (editorPosition - ? codeMirorRef.current?.getTokenAt(editorPosition) - : { start: 0, end: 0, string: '' }) as TToken; - const tokenStart = editorPosition?.ch || 0; - // const left = x - ((tokenStart - token.start) * 8); - const left = x - LINE_HEIGHT; - const top = y - LINE_HEIGHT; - const defaultPaylaod = { top, left, open: true }; - // replace the double quotes in the borns of the string because code mirror will added another double quotes - // and it will break the url - const url = (token as TToken).string.replace(/\\/g, '').replace(/\"/g, ''); - await resolveLinkInEditor({ - nexus, - dispatch, - orgLabel, - projectLabel, - url, - defaultPaylaod, - }); - setLoadingResolution(false); - }; React.useEffect(() => { setEditing(false); @@ -184,10 +180,35 @@ const ResourceEditor: React.FunctionComponent = props => { setEditing(false); }; + useEditorTooltip({ + orgLabel, + projectLabel, + isEditing, + ref: codeMirorRef, + }); + useEditorPopover({ + orgLabel, + projectLabel, + ref: codeMirorRef, + }); + + React.useEffect(() => { + return () => { + if (location.pathname !== DATA_EXPLORER_GRAPH_FLOW_PATH) { + ResourceResolutionCache.clear(); + } + }; + }, [ResourceResolutionCache, location]); + return (
{showControlPanel && (
@@ -204,69 +225,81 @@ const ResourceEditor: React.FunctionComponent = props => { )}
-
- {showFullScreen && ( - - )} - - {!expanded && !isEditing && valid && showMetadataToggle && ( +
+
+ {showFullScreen && ( +
+ Fullscreen + +
+ )} +
+
onMetadataChangeFold(checked)} + checkedChildren="Unfold" + unCheckedChildren="Fold" + checked={foldCodeMiror} + onChange={onFoldChange} style={switchMarginRight} /> - )} - {showExpanded && !isEditing && valid && ( - onFormatChangeFold(expanded)} - style={switchMarginRight} - /> - )} - {' '} - {editable && isEditing && ( - - )} + {!expanded && !isEditing && valid && showMetadataToggle && ( + onMetadataChangeFold(checked)} + style={switchMarginRight} + /> + )} + {showExpanded && !isEditing && valid && ( + onFormatChangeFold(expanded)} + style={switchMarginRight} + /> + )} + <>} + > + + + {editable && isEditing && ( + + )} +
)} {}} - onLinksFound={() => {}} - ref={codeMirorRef} + onLinksFound={onLinksFound} + fullscreen={fullscreen} />
); diff --git a/src/shared/components/ResourceEditor/useEditorTooltip.tsx b/src/shared/components/ResourceEditor/useEditorTooltip.tsx new file mode 100644 index 000000000..9e06ade0d --- /dev/null +++ b/src/shared/components/ResourceEditor/useEditorTooltip.tsx @@ -0,0 +1,413 @@ +import * as React from 'react'; +import CodeMirror from 'codemirror'; +import clsx from 'clsx'; +import { useNexusContext } from '@bbp/react-nexus'; +import { useSelector } from 'react-redux'; +import { + TEditorPopoverResolvedData, + editorLinkResolutionHandler, + getTokenAndPosAt, + mayBeResolvableLink, +} from './editorUtils'; +import { TDELink } from '../../store/reducers/data-explorer'; +import { RootState } from '../../store/reducers'; +import useResolutionActions from './useResolutionActions'; + +const downloadImg = require('../../images/DownloadingLoop.svg'); + +export const CODEMIRROR_HOVER_CLASS = 'CodeMirror-hover-tooltip'; +export const CODEMIRROR_LINK_CLASS = 'fusion-resource-link'; +type TTooltipCreator = Pick< + TEditorPopoverResolvedData, + 'error' | 'resolvedAs' | 'results' +>; + +function removePopoversFromDOM() { + const popovers = document.querySelectorAll( + `.${CODEMIRROR_HOVER_CLASS}-popover` + ); + popovers.forEach(popover => popover.remove()); +} +function removeTooltipsFromDOM() { + const tooltips = document.getElementsByClassName(CODEMIRROR_HOVER_CLASS); + tooltips && + Array.from(tooltips).forEach(tooltip => { + tooltip.remove(); + }); +} + +function createTooltipNode({ + tag, + title, + isDownloadable, +}: { + tag: string | null; + title: string; + isDownloadable?: boolean; +}) { + const tooltipItemContent = document.createElement('div'); + tooltipItemContent.className = 'CodeMirror-hover-tooltip-item'; + const nodeTag = document.createElement('div'); + nodeTag.className = 'tag'; + tag && nodeTag.appendChild(document.createTextNode(tag)); + tooltipItemContent.appendChild(nodeTag); + + const nodeTitle = document.createElement('span'); + nodeTitle.className = 'title'; + nodeTitle.appendChild(document.createTextNode(title)); + tooltipItemContent.appendChild(nodeTitle); + if (isDownloadable) { + const nodeDownload = document.createElement('img'); + nodeDownload.setAttribute('src', downloadImg); + nodeDownload.classList.add('download-icon'); + tooltipItemContent.appendChild(nodeDownload); + const keyBinding = document.createElement('span'); + keyBinding.className = 'key-binding'; + // the user has to click and press option key on mac or alt key on windows + const userAgent = navigator.userAgent; + const isMac = userAgent.indexOf('Mac') !== -1; + keyBinding.appendChild( + document.createTextNode(isMac ? '⌥ + Click' : 'Alt + Click') + ); + tooltipItemContent.appendChild(keyBinding); + } + return tooltipItemContent; +} +function createTooltipContent({ resolvedAs, error, results }: TTooltipCreator) { + const tooltipContent = document.createElement('div'); + tooltipContent.className = clsx( + `${CODEMIRROR_HOVER_CLASS}-content`, + resolvedAs && resolvedAs + ); + if (resolvedAs === 'error' && error) { + tooltipContent.appendChild( + createTooltipNode({ + tag: 'Error', + title: error, + }) + ); + return tooltipContent; + } + if (resolvedAs === 'resource') { + const result = results as TDELink; + tooltipContent.appendChild( + createTooltipNode({ + tag: result.resource + ? `${result.resource?.[0]}/${result.resource?.[1]}` + : null, + title: result.title ?? result._self, + isDownloadable: result.isDownloadable, + }) + ); + return tooltipContent; + } + if (resolvedAs === 'resources') { + tooltipContent.appendChild( + createTooltipNode({ + tag: 'Multiple', + title: `${ + (results as TDELink[]).length + } resources was found, click to list them`, + }) + ); + return tooltipContent; + } + if (resolvedAs === 'external') { + tooltipContent.appendChild( + createTooltipNode({ + tag: 'External', + title: (results as TDELink).title ?? (results as TDELink)._self, + }) + ); + return tooltipContent; + } + return null; +} + +function createPopoverContent({ + results, + onClick, +}: { + results: TDELink[]; + onClick: (result: TDELink) => void; +}) { + const tooltipContent = document.createElement('div'); + tooltipContent.className = clsx( + `${CODEMIRROR_HOVER_CLASS}-resources-content` + ); + // create node for each link in results and then append it to the tooltipContent + (results as TDELink[]).forEach((link: TDELink) => { + const linkNode = createTooltipNode({ + tag: link.resource ? `${link.resource?.[0]}/${link.resource?.[1]}` : null, + title: link.title ?? link._self, + isDownloadable: link.isDownloadable, + }); + linkNode.onclick = () => { + removePopoversFromDOM(); + onClick(link); + }; + return tooltipContent.appendChild(linkNode); + }); + return tooltipContent; +} +function useEditorTooltip({ + ref, + isEditing, + orgLabel, + projectLabel, +}: { + ref: React.MutableRefObject; + isEditing: boolean; + orgLabel: string; + projectLabel: string; +}) { + const nexus = useNexusContext(); + const { + config: { apiEndpoint }, + } = useSelector((state: RootState) => ({ + config: state.config, + })); + + const allowTooltip = !isEditing; + + React.useEffect(() => { + const currentEditor = (ref as React.MutableRefObject) + ?.current; + const editorWrapper = currentEditor.getWrapperElement(); + + function positionner(ev: MouseEvent, tooltip: HTMLDivElement) { + const editorRect = (ev.target as HTMLElement).getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + if (tooltipRect.height <= editorRect.top) { + tooltip.style.top = `${editorRect.top - tooltipRect.height}px`; + } else { + tooltip.style.top = `${editorRect.bottom}px`; + } + tooltip.style.left = `${editorRect.left}px`; + } + + function hideTooltip(tooltip: HTMLDivElement) { + if (!tooltip.parentNode) { + return; + } + tooltip.parentNode.removeChild(tooltip); + } + function showTooltip(content: HTMLDivElement, node: HTMLElement) { + const tooltip = document.createElement('div'); + tooltip.className = CODEMIRROR_HOVER_CLASS; + tooltip.appendChild(content); + document.body.appendChild(tooltip); + + function cleanup() { + if (tooltip) { + node.classList.remove('has-tooltip'); + hideTooltip(tooltip); + tooltip.remove(); + } + node.removeEventListener('mouseout', cleanup); + node.removeEventListener('click', cleanup); + node.removeEventListener('scroll', cleanup); + } + + node.addEventListener('mouseout', cleanup); + node.addEventListener('click', cleanup); + node.addEventListener('scroll', cleanup); + + const timeoutId: ReturnType = setTimeout(() => { + if (tooltip) { + hideTooltip(tooltip); + tooltip.remove(); + } + return clearTimeout(timeoutId); + }, 2000); + + return tooltip; + } + + async function onMouseOver(ev: MouseEvent) { + const node = ev.target as HTMLElement; + if (node) { + const { token } = getTokenAndPosAt(ev, currentEditor); + const content = token?.string || ''; + const url = content.replace(/\\/g, '').replace(/\"/g, ''); + if (url && mayBeResolvableLink(url)) { + node.classList.add('wait-for-tooltip'); + removeTooltipsFromDOM(); + editorLinkResolutionHandler({ + nexus, + apiEndpoint, + url, + orgLabel, + projectLabel, + }).then(({ resolvedAs, results, error }) => { + const tooltipContent = createTooltipContent({ + resolvedAs, + error, + results, + }); + if (tooltipContent) { + node.classList.remove('wait-for-tooltip'); + node.classList.add( + resolvedAs === 'error' + ? 'error' + : resolvedAs === 'resource' && + (results as TDELink).isDownloadable + ? 'downloadable' + : 'has-tooltip' + ); + const tooltip = showTooltip(tooltipContent, node); + const calculatePosition = (ev: MouseEvent) => + positionner(ev, tooltip); + editorWrapper.addEventListener('mousemove', calculatePosition); + } + }); + } + } + } + // allow the tooltip only when the editor is not in edition mode + // and the popover is not open + allowTooltip && editorWrapper.addEventListener('mouseover', onMouseOver); + // remove the event listener when not allwoed + !allowTooltip && + editorWrapper.removeEventListener('mouseover', onMouseOver); + + // cleanup + // remove the event listener when the component is unmounted + return () => { + allowTooltip && + editorWrapper.removeEventListener('mouseover', onMouseOver); + }; + }, [ + (ref as React.MutableRefObject)?.current, + allowTooltip, + ]); +} + +function useEditorPopover({ + ref, + orgLabel, + projectLabel, +}: { + ref: React.MutableRefObject; + orgLabel: string; + projectLabel: string; +}) { + const nexus = useNexusContext(); + const { + navigateResourceHandler, + downloadBinaryAsyncHandler, + } = useResolutionActions(); + const { + config: { apiEndpoint }, + } = useSelector((state: RootState) => ({ + config: state.config, + })); + + React.useEffect(() => { + const currentEditor = (ref as React.MutableRefObject) + ?.current; + const editorWrapper = currentEditor.getWrapperElement(); + function positionner(ev: MouseEvent, tooltip: HTMLDivElement) { + const editorRect = (ev.target as HTMLElement).getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + if (tooltipRect.height <= editorRect.top) { + tooltip.style.top = `${editorRect.top - tooltipRect.height}px`; + } else { + tooltip.style.top = `${editorRect.bottom}px`; + } + tooltip.style.left = `${editorRect.left}px`; + } + function showTooltip(content: HTMLDivElement, node: HTMLElement) { + const tooltip = document.createElement('div'); + tooltip.className = `${CODEMIRROR_HOVER_CLASS}-popover popover`; + tooltip.appendChild(content); + document.body.appendChild(tooltip); + return tooltip; + } + function onEditorMouseDown(ev: MouseEvent, node: HTMLElement) { + if ( + ev.target && + !node.contains(ev.target as HTMLElement) && + !(ev.target as HTMLElement).isEqualNode(node) && + (ev.target as HTMLElement).closest('.CodeMirror-wrap') + ) { + removePopoversFromDOM(); + } + editorWrapper.removeEventListener('mousedown', (ev: MouseEvent) => + onEditorMouseDown(ev, node) + ); + } + async function onMouseDown(_: CodeMirror.Editor, ev: MouseEvent) { + removeTooltipsFromDOM(); + const node = ev.target as HTMLElement; + if (node) { + const { token } = getTokenAndPosAt(ev, currentEditor); + const content = token?.string || ''; + const url = content.replace(/\\/g, '').replace(/\"/g, ''); + if (url && mayBeResolvableLink(url)) { + editorLinkResolutionHandler({ + nexus, + apiEndpoint, + url, + orgLabel, + projectLabel, + }).then(({ resolvedAs, results }) => { + switch (resolvedAs) { + case 'resources': { + const tooltipContent = createPopoverContent({ + results: results as TDELink[], + onClick: navigateResourceHandler, + }); + if (tooltipContent) { + const tooltip = showTooltip(tooltipContent, node); + positionner(ev, tooltip); + editorWrapper.addEventListener( + 'mousedown', + (ev: MouseEvent) => onEditorMouseDown(ev, node) + ); + } + break; + } + case 'resource': { + const result = results as TDELink; + // this alt for windows, and option for mac + const optionClick = ev.altKey; + if (result.isDownloadable && optionClick) { + return downloadBinaryAsyncHandler({ + orgLabel: result.resource?.[0]!, + projectLabel: result.resource?.[1]!, + resourceId: result.resource?.[2]!, + ext: result.resource?.[4] ?? 'json', + title: result.title, + }); + } + return navigateResourceHandler(result); + } + case 'external': { + window.open( + (results as TDELink)._self, + '_blank', + 'noopener noreferrer' + ); + break; + } + case 'error': + default: + break; + } + return; + }); + } + } + } + currentEditor.on('mousedown', onMouseDown); + return () => { + currentEditor.off('mousedown', onMouseDown); + }; + }, [ + (ref as React.MutableRefObject)?.current, + navigateResourceHandler, + ]); +} + +export { useEditorPopover, useEditorTooltip }; diff --git a/src/shared/components/ResourceEditor/useResolutionActions.tsx b/src/shared/components/ResourceEditor/useResolutionActions.tsx new file mode 100644 index 000000000..5512a1260 --- /dev/null +++ b/src/shared/components/ResourceEditor/useResolutionActions.tsx @@ -0,0 +1,94 @@ +import { useNexusContext } from '@bbp/react-nexus'; +import { useDispatch } from 'react-redux'; +import { useHistory, useLocation, useRouteMatch } from 'react-router'; +import { Resource } from '@bbp/nexus-sdk'; +import { + TDELink, + AddNewNodeDataExplorerGraphFlow, + InitNewVisitDataExplorerGraphView, +} from '../../store/reducers/data-explorer'; +import { + getNormalizedTypes, + getOrgAndProjectFromProjectId, + getResourceLabel, +} from '../../utils'; +import { parseResourceId } from '../Preview/Preview'; +import { download } from '../../utils/download'; +import { getDataExplorerResourceItemArray } from './editorUtils'; + +const useResolvedLinkEditorPopover = () => { + const nexus = useNexusContext(); + const dispatch = useDispatch(); + const navigate = useHistory(); + const routeMatch = useRouteMatch<{ + orgLabel: string; + projectLabel: string; + resourceId: string; + }>(`/:orgLabel/:projectLabel/resources/:resourceId`); + + const { pathname, search, state } = useLocation(); + + const navigateResourceHandler = async (resource: TDELink) => { + if (pathname === '/data-explorer/graph-flow') { + dispatch(AddNewNodeDataExplorerGraphFlow(resource)); + } else if (routeMatch?.url && routeMatch.params) { + const data = (await nexus.Resource.get( + routeMatch.params.orgLabel, + routeMatch.params.projectLabel, + routeMatch.params.resourceId + )) as Resource; + const orgProject = getOrgAndProjectFromProjectId(data._project); + dispatch( + InitNewVisitDataExplorerGraphView({ + referer: { pathname, search, state }, + source: { + _self: data._self, + title: getResourceLabel(data), + types: getNormalizedTypes(data['@type']), + resource: getDataExplorerResourceItemArray( + { + orgLabel: orgProject?.orgLabel ?? '', + projectLabel: orgProject?.projectLabel ?? '', + }, + data + ), + }, + current: resource, + }) + ); + navigate.push('/data-explorer/graph-flow'); + } + }; + const downloadBinaryAsyncHandler = async ({ + orgLabel, + projectLabel, + resourceId, + ext, + title, + }: { + orgLabel: string; + projectLabel: string; + resourceId: string; + title: string; + ext?: string; + }) => { + try { + const data = await nexus.File.get( + orgLabel, + projectLabel, + encodeURIComponent(parseResourceId(resourceId)), + { as: 'blob' } + ); + return download(title, ext ?? 'json', data); + } catch (error) { + throw error; + } + }; + + return { + navigateResourceHandler, + downloadBinaryAsyncHandler, + }; +}; + +export default useResolvedLinkEditorPopover; diff --git a/src/shared/containers/DataTableContainer.spec.tsx b/src/shared/containers/DataTableContainer.spec.tsx index c071380ea..2bb11dff5 100644 --- a/src/shared/containers/DataTableContainer.spec.tsx +++ b/src/shared/containers/DataTableContainer.spec.tsx @@ -28,7 +28,7 @@ import { deltaPath } from '__mocks__/handlers/handlers'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { QueryClient, QueryClientProvider } from 'react-query'; -import configureStore from '../../shared/store'; +import configureStore from '../store'; import { cleanup, render, screen, waitFor } from '../../utils/testUtil'; import DataTableContainer from './DataTableContainer'; import { notification } from 'antd'; diff --git a/src/shared/containers/DataTableContainer.tsx b/src/shared/containers/DataTableContainer.tsx index 61788d6a9..779d139ac 100644 --- a/src/shared/containers/DataTableContainer.tsx +++ b/src/shared/containers/DataTableContainer.tsx @@ -23,21 +23,20 @@ import { } from '@ant-design/icons'; import '../styles/data-table.less'; - import { useAccessDataForTable } from '../hooks/useAccessDataForTable'; import EditTableForm, { Projection } from '../components/EditTableForm'; import { useMutation } from 'react-query'; import { parseProjectUrl } from '../utils'; import useNotification from '../hooks/useNotification'; -import { ErrorComponent } from '../../shared/components/ErrorComponent'; +import { ErrorComponent } from '../components/ErrorComponent'; import { useSelector } from 'react-redux'; import { RootState } from 'shared/store/reducers'; import { DATA_PANEL_STORAGE, DATA_PANEL_STORAGE_EVENT, DataPanelEvent, -} from '../../shared/organisms/DataPanel/DataPanel'; -import { TResourceTableData } from '../../shared/molecules/MyDataTable/MyDataTable'; +} from '../organisms/DataPanel/DataPanel'; +import { TResourceTableData } from '../molecules/MyDataTable/MyDataTable'; export type TableColumn = { '@type': string; diff --git a/src/shared/containers/ResourceEditor.tsx b/src/shared/containers/ResourceEditor.tsx index 879f3bc3e..dbbbe57ab 100644 --- a/src/shared/containers/ResourceEditor.tsx +++ b/src/shared/containers/ResourceEditor.tsx @@ -5,14 +5,22 @@ import { Resource, NexusClient, } from '@bbp/nexus-sdk'; -import { useHistory } from 'react-router'; +import { useHistory, useLocation } from 'react-router'; import { useDispatch } from 'react-redux'; -import ResourceEditor from '../components/ResourceEditor'; +import { pick } from 'lodash'; import { useNexusContext } from '@bbp/react-nexus'; -import { getNormalizedTypes } from '../components/ResourceEditor/editorUtils'; +import ResourceEditor from '../components/ResourceEditor'; +import { getDataExplorerResourceItemArray } from '../components/ResourceEditor/editorUtils'; import useNotification, { parseNexusError } from '../hooks/useNotification'; -import { InitNewVisitDataExplorerGraphView } from '../store/reducers/data-explorer'; -import { getOrgAndProjectFromResourceObject, getResourceLabel } from '../utils'; +import { + InitDataExplorerGraphFlowFullscreenVersion, + InitNewVisitDataExplorerGraphView, +} from '../store/reducers/data-explorer'; +import { + getNormalizedTypes, + getOrgAndProjectFromResourceObject, + getResourceLabel, +} from '../utils'; const ResourceEditorContainer: React.FunctionComponent<{ resourceId: string; @@ -46,6 +54,7 @@ const ResourceEditorContainer: React.FunctionComponent<{ const nexus = useNexusContext(); const dispatch = useDispatch(); const navigate = useHistory(); + const location = useLocation(); const notification = useNotification(); const [expanded, setExpanded] = React.useState(defaultExpanded); const [editable, setEditable] = React.useState(defaultEditable); @@ -119,23 +128,28 @@ const ResourceEditorContainer: React.FunctionComponent<{ } )) as Resource; const orgProject = getOrgAndProjectFromResourceObject(data); - dispatch( - InitNewVisitDataExplorerGraphView({ - current: { - _self: data._self, - types: getNormalizedTypes(data['@type']), - title: getResourceLabel(data), - resource: [ - orgProject?.orgLabel ?? '', - orgProject?.projectLabel ?? '', - data['@id'], - data._rev, - ], - }, - limited: true, - }) - ); - navigate.push('/data-explorer/graph-flow'); + if (location.pathname === '/data-explorer/graph-flow') { + dispatch( + InitDataExplorerGraphFlowFullscreenVersion({ fullscreen: true }) + ); + } else { + dispatch( + InitNewVisitDataExplorerGraphView({ + referer: pick(location, ['pathname', 'search', 'state']), + current: { + _self: data._self, + types: getNormalizedTypes(data['@type']), + title: getResourceLabel(data), + resource: getDataExplorerResourceItemArray( + orgProject ?? { orgLabel: '', projectLabel: '' }, + data + ), + }, + fullscreen: true, + }) + ); + navigate.push('/data-explorer/graph-flow'); + } }; async function getResourceSource( nexus: NexusClient, diff --git a/src/shared/containers/ResourceViewActionsContainer.tsx b/src/shared/containers/ResourceViewActionsContainer.tsx index fa7b69667..52ba203f2 100644 --- a/src/shared/containers/ResourceViewActionsContainer.tsx +++ b/src/shared/containers/ResourceViewActionsContainer.tsx @@ -5,7 +5,7 @@ import { useNexusContext } from '@bbp/react-nexus'; import { Button, Col, Dropdown, Menu, Row, notification } from 'antd'; import { generatePath, Link, useHistory, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { uniq } from 'lodash'; +import { isArray, isString, uniq } from 'lodash'; import { makeResourceUri } from '../utils'; import { RootState } from '../store/reducers'; import { useOrganisationsSubappContext } from '../../subapps/admin'; @@ -123,10 +123,15 @@ const ResourceViewActionsContainer: React.FC<{ ); nexus.Resource.get(orgLabel, projectLabel, encodedResourceId).then( resource => { - // @ts-ignore - if (resource && resource['@type'].includes('View')) { - // @ts-ignore - setView(resource); + const resourceType = resource ? (resource as Resource)['@type'] : null; + const isView = + resourceType && isArray(resourceType) + ? resourceType.includes('View') + : isString(resourceType) + ? resourceType === 'View' + : false; + if (isView) { + setView(resource as Resource); } } ); diff --git a/src/shared/containers/ResourceViewContainer.tsx b/src/shared/containers/ResourceViewContainer.tsx index 85458bd26..95285d3d6 100644 --- a/src/shared/containers/ResourceViewContainer.tsx +++ b/src/shared/containers/ResourceViewContainer.tsx @@ -197,18 +197,14 @@ const ResourceViewContainer: React.FunctionComponent<{ const isLatest = latestResource?._rev === resource?._rev; const handleTabChange = (activeTabKey: string) => { - goToResource(orgLabel, projectLabel, resourceId, { - revision: resource ? resource._rev : undefined, - tab: activeTabKey, - }); + const newLink = `${location.pathname}${location.search}${activeTabKey}`; + history.push(newLink, location.state); }; - const handleExpanded = (expanded: boolean) => { - goToResource(orgLabel, projectLabel, resourceId, { - expanded, - revision: resource ? resource._rev : undefined, - tab: activeTabKey, - }); + const searchParams = new URLSearchParams(location.search); + searchParams.set('expanded', expanded ? 'true' : 'false'); + const newLink = `${location.pathname}?${searchParams.toString()}`; + history.push(newLink, location.state); }; const handleEditFormSubmit = async (value: any) => { @@ -315,7 +311,6 @@ const ResourceViewContainer: React.FunctionComponent<{ projectLabel, resourceId )) as Resource; - const selectedResource: Resource = rev || tag ? ((await nexus.Resource.get( @@ -339,7 +334,6 @@ const ResourceViewContainer: React.FunctionComponent<{ )) as ExpandedResource[]; const expandedResource = expandedResources[0]; - setLatestResource(resource); setResource({ // Note: we must fetch the proper, expanded @id. The @id that comes from a normal request or from the URL diff --git a/src/shared/hooks/useAccessDataForTable.tsx b/src/shared/hooks/useAccessDataForTable.tsx index d30de3019..57ad4e773 100644 --- a/src/shared/hooks/useAccessDataForTable.tsx +++ b/src/shared/hooks/useAccessDataForTable.tsx @@ -22,7 +22,7 @@ import { TableError, getStudioLocalStorageKey, getStudioTableKey, -} from '../../shared/containers/DataTableContainer'; +} from '../containers/DataTableContainer'; import { MAX_DATA_SELECTED_SIZE__IN_BYTES, MAX_LOCAL_STORAGE_ALLOWED_SIZE, @@ -47,6 +47,7 @@ import { addColumnsForES, rowRender } from '../utils/parseESResults'; import { sparqlQueryExecutor } from '../utils/querySparqlView'; import { CartContext } from './useDataCart'; import PromisePool from '@supercharge/promise-pool'; +import { normalizeString } from '../../utils/stringUtils'; export const EXPORT_CSV_FILENAME = 'nexus-query-result.csv'; export const CSV_MEDIATYPE = 'text/csv'; @@ -126,8 +127,6 @@ type ColumnSorter = ( b: Record ) => -1 | 1 | 0; -const normalizeString = (str: string) => str.trim().toLowerCase(); - const sorter = (dataIndex: string): ColumnSorter => { return ( a: { diff --git a/src/shared/images/AnchorLink.svg b/src/shared/images/AnchorLink.svg new file mode 100644 index 000000000..a573f6c5e --- /dev/null +++ b/src/shared/images/AnchorLink.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/images/DownloadingLoop.svg b/src/shared/images/DownloadingLoop.svg new file mode 100644 index 000000000..c1a71f086 --- /dev/null +++ b/src/shared/images/DownloadingLoop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/shared/images/graphNodes.svg b/src/shared/images/graphNodes.svg new file mode 100644 index 000000000..d3e8ff7e3 --- /dev/null +++ b/src/shared/images/graphNodes.svg @@ -0,0 +1 @@ +15191549165518251716144116501668154716051636151214891591185716061840152517671516143115451430191116951683168412691521165616821671155116691754170015231537153815261524154116761635162515041568145915791617174714951665159314401698152817111697167015741559176116881443170615501531179114581498155518451563159216421493159518631501146716541757156217491707176415691742171218421439 \ No newline at end of file diff --git a/src/shared/layouts/FusionMainLayout.less b/src/shared/layouts/FusionMainLayout.less index 3fc93a8a2..b80760413 100644 --- a/src/shared/layouts/FusionMainLayout.less +++ b/src/shared/layouts/FusionMainLayout.less @@ -5,15 +5,16 @@ height: 100%; min-height: 100vh !important; + &.wall { + .fusion-main-layout__content { + margin-top: 0; + } + } .project-panel, .workflow-step-view__panel { left: 80px; } - .ant-layout-content { - // margin-top: 52px; - } - .ant-menu-item, .ant-menu-inline .ant-menu-item, .ant-menu-inline .ant-menu-item:not(:last-child) { diff --git a/src/shared/lib.less b/src/shared/lib.less index 187952fc7..14367a1b5 100644 --- a/src/shared/lib.less +++ b/src/shared/lib.less @@ -38,6 +38,7 @@ @fusion-neutral-6: #bfbfbf; @fusion-neutral-7: #8c8c8c; @fusion-neutral-8: #595959; +@fusion-global-red-1: #ff4d4f; // additional colors @nexus-dark: #1d3b61; diff --git a/src/shared/modals/CreateStudio/CreateStudio.tsx b/src/shared/modals/CreateStudio/CreateStudio.tsx index 2fb9ba409..71cf1b75f 100644 --- a/src/shared/modals/CreateStudio/CreateStudio.tsx +++ b/src/shared/modals/CreateStudio/CreateStudio.tsx @@ -136,7 +136,6 @@ const createStudioResource = async ({ generateStudioResource(label, description, plugins) ); } catch (error) { - console.log('@@error create studio', error); // @ts-ignore throw new Error('Can not process create studio request', { cause: error }); } diff --git a/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx b/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx new file mode 100644 index 000000000..79d7b3092 --- /dev/null +++ b/src/shared/molecules/AdvancedMode/AdvancedMode.spec.tsx @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import { Router } from 'react-router-dom'; +import { Store } from 'redux'; + +import { createBrowserHistory, History } from 'history'; +import { ConnectedRouter } from 'connected-react-router'; +import { createNexusClient } from '@bbp/nexus-sdk'; +import AdvancedModeToggle from './AdvancedMode'; +import configureStore from '../../store'; +import { render, fireEvent, waitFor, screen } from '../../../utils/testUtil'; + +describe('AdvancedModeToggle', () => { + let history: History; + let store: Store; + let nexus; + + beforeEach(() => { + history = createBrowserHistory({ basename: '/' }); + nexus = createNexusClient({ + fetch, + uri: 'https://localhost:3000', + }); + store = configureStore(history, { nexus }, {}); + }); + afterEach(() => { + history.push('/'); + }); + it('should toggle advanced mode be in the document', async () => { + await act(async () => { + await render( + + + + + + ); + }); + await waitFor(async () => { + const toggleSwitch = await screen.getByTestId('advanced-mode-toggle'); + expect(toggleSwitch).toBeInTheDocument(); + }); + }); + it('should be checked on /data-explorer pages', () => { + history.push('/data-explorer'); + render( + + + + + + ); + + const toggleSwitch = screen.queryByTestId('advanced-mode-toggle'); + const ariaChecked = toggleSwitch?.getAttribute('aria-checked'); + expect(ariaChecked).toEqual('true'); + }); + it('should the path /data-explorer be the current path the toggle turned on', async () => { + await act(async () => { + await render( + + + + + + ); + }); + let toggleSwitch; + await waitFor(async () => { + toggleSwitch = await screen.getByTestId('advanced-mode-toggle'); + fireEvent.click(toggleSwitch); + const ariaChecked = toggleSwitch.getAttribute('aria-checked'); + console.log('@@ariaChecked', ariaChecked); + expect(ariaChecked).toEqual('true'); + }); + const currentPath = history.location.pathname; + expect(currentPath).toBe('/data-explorer'); + }); + it('should not render the toggle on blacklisted pages', () => { + history.push('/studios'); + render( + + + + + + ); + + const toggleSwitch = screen.queryByTestId('advanced-mode-toggle'); + expect(toggleSwitch).toBeNull(); + }); +}); diff --git a/src/shared/molecules/AdvancedMode/AdvancedMode.tsx b/src/shared/molecules/AdvancedMode/AdvancedMode.tsx new file mode 100644 index 000000000..0225f27a8 --- /dev/null +++ b/src/shared/molecules/AdvancedMode/AdvancedMode.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { useLocation, useHistory } from 'react-router'; +import { match as pmatch } from 'ts-pattern'; +import { Switch } from 'antd'; +import './styles.less'; + +export const advancedModeBlackList = ['/studios', '/studio']; +type TAMLocationState = { + source: string; + search: string; +}; + +const AdvancedModeToggle = () => { + const history = useHistory(); + const location = useLocation(); + const showToggle = !advancedModeBlackList.includes(location.pathname); + + const onToggle = (checked: boolean) => { + if (checked) { + history.push('/data-explorer', { + source: location.pathname, + search: location.search, + }); + } else { + history.push( + location.state.source + ? `${location.state.source}${location.state.search}` + : '/' + ); + } + }; + return pmatch(showToggle) + .with(true, () => { + return ( +
+ + Advanced Mode + Beta +
+ ); + }) + .otherwise(() => <>); +}; + +export default AdvancedModeToggle; diff --git a/src/shared/molecules/AdvancedMode/styles.less b/src/shared/molecules/AdvancedMode/styles.less new file mode 100644 index 000000000..470c8db1f --- /dev/null +++ b/src/shared/molecules/AdvancedMode/styles.less @@ -0,0 +1,47 @@ +@import '../../lib.less'; + +.advanced-mode-toggle { + display: flex; + align-items: center; + justify-content: center; + color: white; + margin-right: 15px; + .ant-switch { + margin-right: 10px; + &[aria-checked='true'] { + background-color: white !important; + .ant-switch-handle::before { + background-color: @fusion-menu-color !important; + } + } + &[aria-checked='false'] { + background-color: transparent !important; + border: 1px solid white; + .ant-switch-handle { + top: 1px !important; + } + .ant-switch-handle::before { + background-color: white !important; + } + } + } + + .advanced, + .beta { + user-select: none; + line-height: 130%; + letter-spacing: 0.01em; + } + + .advanced { + color: white; + white-space: nowrap; + margin-right: 4px; + font-weight: 400; + } + + .beta { + color: @fusion-global-red-1; + font-weight: 600; + } +} diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx new file mode 100644 index 000000000..2f259bfff --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Switch } from 'antd'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from 'shared/store/reducers'; +import { InitDataExplorerGraphFlowFullscreenVersion } from '../../store/reducers/data-explorer'; +import './styles.less'; + +const DataExplorerGraphFlowContentLimitedHeader = () => { + const dispatch = useDispatch(); + const { current, fullscreen } = useSelector( + (state: RootState) => state.dataExplorer + ); + const onStandardScreen = () => + dispatch(InitDataExplorerGraphFlowFullscreenVersion({ fullscreen: false })); + + return ( +
+
+ Fullscreen + +
+

+ {current?.title} +

+
+ ); +}; + +export default DataExplorerGraphFlowContentLimitedHeader; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx deleted file mode 100644 index 492e11a7a..000000000 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useState } from 'react'; -import { Switch } from 'antd'; -import { useSelector } from 'react-redux'; -import { RootState } from 'shared/store/reducers'; -import './styles.less'; - -const DataExplorerGraphFlowContentLimitedHeader = () => { - const { current } = useSelector((state: RootState) => state.dataExplorer); - const [write, setWrite] = useState(false); - const onSelectWrite = (checked: boolean) => setWrite(() => checked); - - return ( -
-
{current?.title}
-
- Read - - Write -
-
- ); -}; - -export default DataExplorerGraphFlowContentLimitedHeader; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx new file mode 100644 index 000000000..e53cca4d8 --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'; +import './styles.less'; + +type NavigationArrowDirection = 'back' | 'forward'; + +const NavigationArrow = ({ + direction, + visible, + title, + onClick, +}: { + direction: NavigationArrowDirection; + visible: boolean; + title: string; + onClick: () => void; +}) => { + return visible ? ( + + ) : null; +}; + +export default NavigationArrow; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationCollapseButton.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationCollapseButton.tsx new file mode 100644 index 000000000..a638556dc --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationCollapseButton.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { clsx } from 'clsx'; +import { Tooltip } from 'antd'; +import CollapseIcon from '../../components/Icons/Collapse'; +import { TNavigationStackSide } from '../../store/reducers/data-explorer'; + +const NavigationCollapseButton = ({ + side, + onExpand, +}: { + side: TNavigationStackSide; + onExpand: () => void; +}) => { + return ( + + + + ); +}; + +export default NavigationCollapseButton; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackItem.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackItem.tsx new file mode 100644 index 000000000..47e5e10a2 --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackItem.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router'; +import { Space, Tag, Tooltip } from 'antd'; +import { clsx } from 'clsx'; +import { isArray } from 'lodash'; +import { FullscreenOutlined } from '@ant-design/icons'; +import { + TDEResource, + JumpToNodeDataExplorerGraphFlow, + TNavigationStackSide, + MAX_NAVIGATION_ITEMS_IN_STACK, +} from '../../store/reducers/data-explorer'; +import { RootState } from '../../store/reducers'; +import useNavigationStackManager from '../../organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack'; +import NavigationCollapseButton from './NavigationCollapseButton'; +import './styles.less'; + +export type TNavigationStackItem = { + _self: string; + index: number; + types?: string | string[]; + title: string; + resource?: TDEResource; + side: TNavigationStackSide; +}; + +const NavigationStackItem = ({ + index, + _self, + title, + types, + resource, + side, +}: TNavigationStackItem) => { + const dispatch = useDispatch(); + const location = useLocation(); + const history = useHistory(); + const { leftNodes, rightNodes } = useSelector( + (state: RootState) => state.dataExplorer + ); + const { + onRightShrink, + onLeftShrink, + leftShrinked, + rightShrinked, + } = useNavigationStackManager(); + + const onClick = () => { + dispatch(JumpToNodeDataExplorerGraphFlow({ index, side })); + history.replace(location.pathname); + }; + + const parentNode = side === 'left' ? leftNodes : rightNodes; + const orgProject = + resource?.[0] && resource?.[1] && `${resource?.[0]}/${resource?.[1]}`; + const showLeftCollapseBtn = + side === 'left' && + !leftShrinked && + leftNodes.links.length > MAX_NAVIGATION_ITEMS_IN_STACK && + leftNodes.links.length - 1 === index; + const showRightCollapseBtn = + side === 'right' && + !rightShrinked && + rightNodes.links.length > MAX_NAVIGATION_ITEMS_IN_STACK && + index === 0; + + const collapseRightBtn = React.useCallback(() => { + return ( + showRightCollapseBtn && ( + + ) + ); + }, [showRightCollapseBtn, side, onRightShrink]); + + const collapseLeftBtn = React.useCallback(() => { + return ( + showLeftCollapseBtn && ( + + ) + ); + }, [showLeftCollapseBtn, side, onLeftShrink]); + + return ( + + {collapseLeftBtn()} +
+ ); +}; + +export default NavigationStackItem; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackShrinkedItem.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackShrinkedItem.tsx new file mode 100644 index 000000000..b31d44491 --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationStackShrinkedItem.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import { clsx } from 'clsx'; +import { MoreOutlined, PlusOutlined } from '@ant-design/icons'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../store/reducers'; +import './styles.less'; +import { TDELink } from 'shared/store/reducers/data-explorer'; + +const BORDER_ITEMS = 2; +const NavigationStackShrinkedItem = ({ + side, + links, + shrinked, + onExpand, +}: { + side: 'left' | 'right'; + shrinked: boolean; + links: TDELink[]; + onExpand: () => void; +}) => { + const count = links.length - BORDER_ITEMS; + return ( + +
+ ); +}; + +export default NavigationStackShrinkedItem; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts b/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts index 41a847ac9..9fe3f1bd3 100644 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts @@ -1 +1,5 @@ -export { default as DataExplorerGraphFlowContentLimitedHeader } from './ContentLimitedHeader'; +export { default as DEFGContentFullscreenHeader } from './ContentFullscreenHeader'; +export { default as NavigationArrow } from './NavigationArrow'; +export { default as NavigationStackItem } from './NavigationStackItem'; +export { default as NavigationStackShrinkedItem } from './NavigationStackShrinkedItem'; +export { default as NavigationCollapseButton } from './NavigationCollapseButton'; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less b/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less index b84039a78..b3a2b5346 100644 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less @@ -1,92 +1,149 @@ @import '../../lib.less'; .navigation-stack-item { - display: grid; - grid-template-rows: repeat(4, max-content); - gap: 10px; - align-items: start; - justify-items: center; - justify-content: center; - align-content: start; - border-right: 1px solid @fusion-neutral-6; - padding: 3px 5px; - height: 100%; - min-height: 0; - background-color: @fusion-main-bg; - - &.more { - background-color: white; - } + display: flex; + align-items: flex-start; - &.no-more { + &.shrinkable { display: none; - transition: display 0.2s cubic-bezier(0.075, 0.82, 0.165, 1); } - &.highlight { - border: 2px solid #377af5; - transition: border-color 0s ease-out infinite; - } + &__wrapper { + display: grid; + grid-template-rows: repeat(4, max-content); + gap: 10px; + align-items: start; + justify-items: center; + justify-content: center; + align-content: start; + padding: 3px 5px; + height: 100%; + min-height: 0; + background-color: @fusion-main-bg; + position: relative; + + &.more { + background-color: white; + } - &.shrinkable { - display: none; - } + &.no-more { + display: none; + transition: display 0.2s cubic-bezier(0.075, 0.82, 0.165, 1); + } - .icon { - margin-top: 5px !important; - padding: 4px !important; - } + .icon { + margin-top: 5px !important; + padding: 4px !important; + } - .title { - font-family: 'Titillium Web'; - font-style: normal; - font-weight: 600; - font-size: 12px; - line-height: 130%; - letter-spacing: 0.01em; - color: #003a8c; - writing-mode: vertical-lr; - text-orientation: sideways-right; - transform: rotate(180deg); - align-self: start; + .title { + font-family: 'Titillium Web'; + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 130%; + letter-spacing: 0.01em; + color: #003a8c; + writing-mode: vertical-lr; + text-orientation: sideways-right; + transform: rotate(180deg); + align-self: start; + } + + .org-project { + writing-mode: vertical-rl; + transform: rotate(-180deg); + background-color: #bfbfbfbe; + padding: 5px 1px; + font-size: 10px; + border-radius: 4px; + user-select: none; + } + + .types { + font-family: 'Titillium Web'; + font-style: normal; + font-weight: 300; + font-size: 12px; + line-height: 130%; + letter-spacing: 0.01em; + color: #8c8c8c; + writing-mode: vertical-lr; + text-orientation: mixed; + align-self: start; + transform: rotate(180deg); + } + + .count { + font-weight: 600; + font-size: 15px; + line-height: 130%; + letter-spacing: 0.01em; + color: #bfbfbf; + } + + .ellipsis { + user-select: none; + writing-mode: vertical-lr; + text-orientation: mixed; + } } - .org-project { - writing-mode: vertical-rl; - transform: rotate(-180deg); - background-color: #bfbfbfbe; - padding: 5px 1px; - font-size: 10px; - border-radius: 4px; - user-select: none; + &.right { + .navigation-stack-item__wrapper { + border-left: 1px solid @fusion-neutral-6; + } } - .types { - font-family: 'Titillium Web'; - font-style: normal; - font-weight: 300; - font-size: 12px; - line-height: 130%; - letter-spacing: 0.01em; - color: #8c8c8c; - writing-mode: vertical-lr; - text-orientation: mixed; - align-self: start; - transform: rotate(180deg); + &.left { + .navigation-stack-item__wrapper { + border-right: 1px solid @fusion-neutral-6; + } } - .count { - font-weight: 600; - font-size: 15px; - line-height: 130%; - letter-spacing: 0.01em; - color: #bfbfbf; + &.highlight { + background: rgba(#377af5, 0.12); + border: 1.5px solid #377af5; + border-top: none; + border-bottom: none; + transition: border-color 0s ease-out infinite; + -webkit-animation: vibrate 0.3s linear infinite both; + animation: vibrate 0.3s linear infinite both; } - .ellipsis { - user-select: none; - writing-mode: vertical-lr; - text-orientation: mixed; + .collapse-btn { + inset: 0; + overflow: visible; + appearance: none; + -webkit-appearance: none; + padding: 8px 8px; + background-color: @fusion-blue-8; + display: flex; + align-items: center; + justify-content: center; + margin-top: 40px; + box-shadow: 0 2px 12px rgba(#333, 0.12); + cursor: pointer; + border: none; + + &.right { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + svg { + transform: rotate(180deg); + } + } + + &.left { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + svg { + color: white; + width: 16px; + height: 16px; + } } } @@ -97,77 +154,57 @@ } } -.navigation-back-btn { - margin-top: 8px; - background: white; - box-shadow: 0 2px 12px rgba(#333, 0.12); - padding: 5px 15px; +.navigation-arrow-btn { + cursor: pointer; + background: transparent; max-width: max-content; max-height: 30px; margin-top: 10px; - border-radius: 4px; - border: 1px solid #afacacd8; - cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px; z-index: 90; - + border: none; + &:hover { + text-shadow: 0 2px 12px rgba(#333, 0.12); + span { + color: #377af5; + } + svg { + transform: scale(1.1); + transition: transform 0.2s ease-in-out; + } + } span { font-weight: 700; font-size: 16px; color: @fusion-daybreak-8; } - - &:hover { - span { - color: @fusion-daybreak-7; - } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + color: #afacacd8; } } -.degf-content__haeder { +.degf-content__header { display: flex; align-items: center; - justify-content: space-between; gap: 20px; - margin: 20px 5px; + margin: 0 5px 10px; .title { + margin-left: 50px; + margin-bottom: 0; font-weight: 700; font-size: 16px; line-height: 140%; color: @fusion-menu-color; } - - .switcher { - .ant-switch { - border: 1px solid #bfbfbf; - background-color: white !important; - margin: 0 5px; - } - - .ant-switch-handle { - top: 1px; - } - - .ant-switch-handle::before { - background-color: @fusion-menu-color !important; - } - - span { - user-select: none; - font-weight: 700; - font-size: 12px; - line-height: 130%; - letter-spacing: 0.01em; - color: @fusion-menu-color; - } - } } -.navigation-humburguer { +.navigation-collapse-btn { background: white; box-shadow: 0 2px 12px rgba(#333, 0.12); padding: 5px; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeader.tsx b/src/shared/molecules/MyDataHeader/MyDataHeader.tsx index ab66eab7f..a44bb5086 100644 --- a/src/shared/molecules/MyDataHeader/MyDataHeader.tsx +++ b/src/shared/molecules/MyDataHeader/MyDataHeader.tsx @@ -1,433 +1,11 @@ -import React, { Fragment, useReducer, useRef, useState } from 'react'; -import { - Input, - Radio, - Tag, - RadioChangeEvent, - Dropdown, - Button, - Menu, - Checkbox, - InputRef, -} from 'antd'; -import { CheckboxChangeEvent } from 'antd/lib/checkbox'; -import { CalendarOutlined, RightOutlined } from '@ant-design/icons'; -import { TagProps } from 'antd/lib/tag'; -import { useQuery } from 'react-query'; -import { useNexusContext } from '@bbp/react-nexus'; -import { capitalize, isString, startCase, pull as removeItem } from 'lodash'; -import { NexusClient } from '@bbp/nexus-sdk'; -import * as moment from 'moment'; -import * as pluralize from 'pluralize'; -import { - TDateField, - THandleMenuSelect, - THeaderFilterProps, - TDateOptions, - THeaderProps, - TTitleProps, - TTypeDateItem, - TCurrentDate, - DATE_PATTERN, -} from '../../../shared/canvas/MyData/types'; -import useClickOutside from '../../../shared/hooks/useClickOutside'; -import useMeasure from '../../../shared/hooks/useMeasure'; -import DateSeparated from '../../components/DateSeparatedInputs/DateSeparated'; +import * as React from 'react'; +import { THeaderProps } from '../../../shared/canvas/MyData/types'; +import { MyDataHeaderTitle, MyDataHeaderFilters } from './MyDataHeaderFilters'; import './styles.less'; -const Title = ({ text, label, total }: TTitleProps) => { - return ( -
- {text} - - {total} {label} - -
- ); -}; -const tagRender = (props: TagProps) => { - // @ts-ignore - const { label } = props; - const onPreventMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - return ( - - {label} - - ); -}; -const fetchGlobalSearchTypes = async (nexus: NexusClient) => { - const data = await nexus.Search.query({ - query: { - match_all: {}, - }, - aggs: { - suggestions: { - terms: { - field: '@type.keyword', - size: 1000, - }, - }, - '(missing)': { - missing: { - field: '@type.keyword', - }, - }, - }, - }); - return data.aggregations.suggestions.buckets; -}; -const dateFieldName = { - createdAt: 'Creation Date', - updatedAt: 'Update Date', -}; -const Filters = ({ - dataType, - dateField, - query, - setFilterOptions, - locate, - issuer, -}: THeaderFilterProps) => { - const popoverRef = useRef(null); - const nexus = useNexusContext(); - const [dateFilterContainer, setOpenDateFilterContainer] = useState( - false - ); - const [ - { dateEnd, dateFilterType, singleDate, dateStart }, - updateCurrentDates, - ] = useReducer( - (previous: TCurrentDate, next: Partial) => ({ - ...previous, - ...next, - }), - { - singleDate: undefined, - dateEnd: undefined, - dateStart: undefined, - dateFilterType: undefined, - } - ); - const onChangeDateType = (e: RadioChangeEvent) => { - updateCurrentDates({ - dateFilterType: e.target.value, - singleDate: '', - dateStart: '', - dateEnd: '', - }); - }; - const handleSubmitDates: React.FormEventHandler = e => { - e.preventDefault(); - setFilterOptions({ - dateFilterType, - singleDate, - dateStart, - dateEnd, - }); - }; - const onIssuerChange = (e: RadioChangeEvent) => - setFilterOptions({ issuer: e.target.value }); - const onSearchLocateChange = (e: CheckboxChangeEvent) => - setFilterOptions({ locate: e.target.checked }); - const onDatePopoverVisibleChange = () => - setOpenDateFilterContainer(state => !state); - const handleQueryChange: React.ChangeEventHandler = event => - setFilterOptions({ query: event.target.value }); - const handleDateFieldChange: THandleMenuSelect = ({ key }) => - setFilterOptions({ dateField: key as TDateField }); - const updateDate = (type: TDateOptions, date: string) => - updateCurrentDates({ - [type]: date, - }); - - const notValidForm = - !dateFilterType || - !dateField || - (dateFilterType === 'range' && (!dateStart || !dateEnd)) || - (dateFilterType === 'range' && - dateStart && - dateEnd && - moment(dateEnd).isBefore(dateStart, 'days')) || - (dateFilterType !== 'range' && !singleDate); - - const DatePickerContainer = ( - -
- - - Before - - - After - - - Range - - - {dateFilterType === 'range' ? ( - - From - { - if ( - dateEnd && - moment(dateEnd).isValid() && - moment(dateEnd).isBefore(dateStart, 'days') - ) { - return updateCurrentDates({ - dateStart: dateEnd, - dateEnd: value, - }); - } - return updateDate('dateStart', value); - }} - /> - To - { - if ( - dateStart && - moment(dateStart).isValid() && - moment(dateStart).isAfter(dateEnd, 'days') - ) { - return updateCurrentDates({ - dateStart: value, - dateEnd: dateStart, - }); - } - return updateDate('dateEnd', value); - }} - /> - - ) : ( - updateDate('singleDate', value)} - /> - )} - - -
- ); - const selectedDate = - dateFilterType === 'range' && dateStart !== '' && dateEnd !== '' - ? `${moment(dateStart).format(DATE_PATTERN)} → ${moment(dateEnd).format( - DATE_PATTERN - )}` - : singleDate - ? `${capitalize(dateFilterType)} ${moment(singleDate).format( - DATE_PATTERN - )}` - : undefined; - - const DateFieldMenu = ( - - {dateFieldName.createdAt} - {dateFieldName.updatedAt} - - ); - const { data: buckets, status: typesStatus } = useQuery({ - queryKey: ['global-search-types'], - queryFn: () => fetchGlobalSearchTypes(nexus), - }); - - const typesDataSources: TTypeDateItem[] = - typesStatus === 'success' && - buckets && - buckets.map((bucket: any) => { - return { - key: bucket.key, - label: bucket.key.split('/').pop(), - value: bucket.key, - }; - }); - const handleOnCheckType = ( - e: React.MouseEvent, - type: TTypeDateItem - ) => { - e.preventDefault(); - e.stopPropagation(); - setFilterOptions({ - dataType: dataType?.includes(type.key) - ? removeItem(dataType, type.key) - : dataType - ? [...dataType, type.key] - : [type.key], - }); - }; - const [typeInputRef, { width }] = useMeasure(); - const [dateInputRef, { width: datePopWidth }] = useMeasure< - HTMLInputElement - >(); - useClickOutside(popoverRef, onDatePopoverVisibleChange); - return ( -
- - - Created by me - - - Last updated by me - - - - - - - {dateFilterContainer && ( -
- {DatePickerContainer} -
- )} - - } - overlayStyle={{ width: datePopWidth }} - > - } - onClick={() => setOpenDateFilterContainer(state => !state)} - onChange={(e: React.ChangeEvent) => { - if (e.type === 'click') { - updateCurrentDates({ - dateFilterType: undefined, - singleDate: undefined, - dateStart: undefined, - dateEnd: undefined, - }); - setFilterOptions({ - dateFilterType: undefined, - singleDate: undefined, - dateStart: undefined, - dateEnd: undefined, - }); - } - }} - /> -
- {/* -
- -
{typesDataSources.length} types total
-
-
- {typesDataSources && - typesDataSources.map((tp: TType) => { - return ( - - {startCase(tp.label)} - - handleOnCheckType(e, tp)} - checked={dataType.includes(tp.key)} - /> - - - ); - })} -
-
- } - > - startCase(item.split('/').pop()))} - /> - */} -
- -
- - By resource id or self - -
-
-
- ); -}; - const MyDataHeader: React.FC = ({ total, - dataType, + types, dateField, query, setFilterOptions, @@ -435,17 +13,20 @@ const MyDataHeader: React.FC = ({ issuer, }) => { return ( -
- + <MyDataHeaderTitle + {...{ + total, + query, + setFilterOptions, + locate, + issuer, + }} /> - <Filters + <div className="divider" /> + <MyDataHeaderFilters {...{ - dataType, + types, dateField, query, locate, diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateFieldSelector.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateFieldSelector.tsx new file mode 100644 index 000000000..9660a92cd --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateFieldSelector.tsx @@ -0,0 +1,55 @@ +import { RightOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Menu } from 'antd'; +import { + TDateField, + THandleMenuSelect, + THeaderFilterProps, +} from 'shared/canvas/MyData/types'; + +type TDateFieldSelectorProps = Pick< + THeaderFilterProps, + 'dateField' | 'setFilterOptions' +>; + +const dateFieldName = { + createdAt: 'Creation Date', + updatedAt: 'Update Date', +}; + +const DateFieldSelector = ({ + dateField, + setFilterOptions, +}: TDateFieldSelectorProps) => { + const handleDateFieldChange: THandleMenuSelect = ({ key }) => + setFilterOptions({ dateField: key as TDateField }); + + const DateFieldMenu = ( + <Menu + onClick={handleDateFieldChange} + defaultSelectedKeys={['']} + selectedKeys={dateField ? [dateField] : undefined} + className="my-data-date-type-popover" + > + <Menu.Item key="createdAt">{dateFieldName.createdAt}</Menu.Item> + <Menu.Item key="updatedAt">{dateFieldName.updatedAt}</Menu.Item> + </Menu> + ); + return ( + <Dropdown + className="date-field-selector" + placement="bottomLeft" + trigger={['click']} + overlay={DateFieldMenu} + > + <Button + type="link" + style={{ textAlign: 'left', padding: '4px 0px', color: '#333' }} + > + {dateFieldName[dateField]} + <RightOutlined style={{ fontSize: 8, verticalAlign: 'middle' }} /> + </Button> + </Dropdown> + ); +}; + +export default DateFieldSelector; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateSelector.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateSelector.tsx new file mode 100644 index 000000000..321e559f5 --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/DateSelector.tsx @@ -0,0 +1,212 @@ +import { CalendarOutlined } from '@ant-design/icons'; +import { useNexusContext } from '@bbp/react-nexus'; +import { Button, Dropdown, Input, Radio, RadioChangeEvent } from 'antd'; +import { capitalize } from 'lodash'; +import * as moment from 'moment'; +import { Fragment, useReducer, useRef, useState } from 'react'; +import { + DATE_PATTERN, + TCurrentDate, + TDateOptions, + THeaderFilterProps, +} from '../../../canvas/MyData/types'; +import DateSeparated from '../../../components/DateSeparatedInputs/DateSeparated'; +import useClickOutside from '../../../hooks/useClickOutside'; +import useMeasure from '../../../hooks/useMeasure'; + +type TDateSelectorProps = Pick< + THeaderFilterProps, + 'dateField' | 'setFilterOptions' +>; +const DateSelector = ({ dateField, setFilterOptions }: TDateSelectorProps) => { + const popoverRef = useRef(null); + const nexus = useNexusContext(); + const [ + { dateEnd, dateFilterType, singleDate, dateStart }, + updateCurrentDates, + ] = useReducer( + (previous: TCurrentDate, next: Partial<TCurrentDate>) => ({ + ...previous, + ...next, + }), + { + singleDate: undefined, + dateEnd: undefined, + dateStart: undefined, + dateFilterType: undefined, + } + ); + const selectedDate = + dateFilterType === 'range' && dateStart !== '' && dateEnd !== '' + ? `${moment(dateStart).format(DATE_PATTERN)} → ${moment(dateEnd).format( + DATE_PATTERN + )}` + : singleDate + ? `${capitalize(dateFilterType)} ${moment(singleDate).format( + DATE_PATTERN + )}` + : undefined; + const [dateInputRef, { width: datePopWidth }] = useMeasure< + HTMLInputElement + >(); + + const onDatePopoverVisibleChange = () => + setOpenDateFilterContainer(state => !state); + const [dateFilterContainer, setOpenDateFilterContainer] = useState<boolean>( + false + ); + const updateDate = (type: TDateOptions, date: string) => + updateCurrentDates({ + [type]: date, + }); + + const onChangeDateType = (e: RadioChangeEvent) => { + updateCurrentDates({ + dateFilterType: e.target.value, + singleDate: '', + dateStart: '', + dateEnd: '', + }); + }; + const notValidForm = + !dateFilterType || + !dateField || + (dateFilterType === 'range' && (!dateStart || !dateEnd)) || + (dateFilterType === 'range' && + dateStart && + dateEnd && + moment(dateEnd).isBefore(dateStart, 'days')) || + (dateFilterType !== 'range' && !singleDate); + const handleSubmitDates: React.FormEventHandler<HTMLFormElement> = e => { + e.preventDefault(); + setFilterOptions({ + dateFilterType, + singleDate, + dateStart, + dateEnd, + }); + }; + const DatePickerContainer = ( + <Fragment> + <form className="my-data-date-content" onSubmit={handleSubmitDates}> + <Radio.Group + name="dateFilterType" + size="small" + onChange={onChangeDateType} + value={dateFilterType} + className="date-type-selector" + > + <Radio className="radio-filter" value="before"> + Before + </Radio> + <Radio className="radio-filter" value="after"> + After + </Radio> + <Radio className="radio-filter" value="range"> + Range + </Radio> + </Radio.Group> + {dateFilterType === 'range' ? ( + <Fragment> + <span className="range-born">From</span> + <DateSeparated + name="dateStart" + value={dateStart} + updateUpperDate={value => { + if ( + dateEnd && + moment(dateEnd).isValid() && + moment(dateEnd).isBefore(dateStart, 'days') + ) { + return updateCurrentDates({ + dateStart: dateEnd, + dateEnd: value, + }); + } + return updateDate('dateStart', value); + }} + /> + <span className="range-born">To</span> + <DateSeparated + name="dateStart" + value={dateEnd} + updateUpperDate={value => { + if ( + dateStart && + moment(dateStart).isValid() && + moment(dateStart).isAfter(dateEnd, 'days') + ) { + return updateCurrentDates({ + dateStart: value, + dateEnd: dateStart, + }); + } + return updateDate('dateEnd', value); + }} + /> + </Fragment> + ) : ( + <DateSeparated + name="singleDate" + value={singleDate} + updateUpperDate={value => updateDate('singleDate', value)} + /> + )} + <Button + type="ghost" + htmlType="submit" + disabled={notValidForm} + style={{ alignSelf: 'flex-end', margin: '10px 0 0' }} + > + Apply + </Button> + </form> + </Fragment> + ); + useClickOutside(popoverRef, onDatePopoverVisibleChange); + return ( + <Dropdown + placement="bottomLeft" + trigger={['click']} + overlay={ + <Fragment> + {dateFilterContainer && ( + <div ref={popoverRef} className="my-data-date-popover"> + {DatePickerContainer} + </div> + )} + </Fragment> + } + overlayStyle={{ width: datePopWidth }} + > + <Input + allowClear + // @ts-ignore + ref={dateInputRef} + placeholder="Date" + className="my-data-date-picker" + value={selectedDate} + prefix={<CalendarOutlined />} + onClick={() => setOpenDateFilterContainer(state => !state)} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + if (e.type === 'click') { + updateCurrentDates({ + dateFilterType: undefined, + singleDate: undefined, + dateStart: undefined, + dateEnd: undefined, + }); + setFilterOptions({ + dateFilterType: undefined, + singleDate: undefined, + dateStart: undefined, + dateEnd: undefined, + }); + } + }} + /> + </Dropdown> + ); +}; + +export default DateSelector; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/IssuerSelector.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/IssuerSelector.tsx new file mode 100644 index 000000000..6a6b0fd39 --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/IssuerSelector.tsx @@ -0,0 +1,27 @@ +import { Radio, RadioChangeEvent } from 'antd'; +import { THeaderProps } from 'shared/canvas/MyData/types'; + +type TIssuerSelectorProps = Pick<THeaderProps, 'issuer' | 'setFilterOptions'>; + +const IssuerSelector = ({ issuer, setFilterOptions }: TIssuerSelectorProps) => { + const onIssuerChange = (e: RadioChangeEvent) => + setFilterOptions({ issuer: e.target.value }); + + return ( + <Radio.Group + className="my-data-header-title_issuer_selector" + defaultValue={'createdBy'} + value={issuer} + onChange={onIssuerChange} + > + <Radio className="radio-filter" value="createdBy"> + Created by me + </Radio> + <Radio className="radio-filter" value="updatedBy"> + Last updated by me + </Radio> + </Radio.Group> + ); +}; + +export default IssuerSelector; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/PageTitle.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/PageTitle.tsx new file mode 100644 index 000000000..8f9ae7015 --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/PageTitle.tsx @@ -0,0 +1,13 @@ +import { TTitleProps } from 'shared/canvas/MyData/types'; +import { prettifyNumber } from '../../../../utils/formatNumber'; + +const PageTitle = ({ text, label, total }: TTitleProps) => { + return ( + <div className="my-data-header-title_heading"> + <span>{text}</span> + <span>{total ? `${prettifyNumber(total)} ${label}` : ''}</span> + </div> + ); +}; + +export default PageTitle; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/SearchInput.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/SearchInput.tsx new file mode 100644 index 000000000..71cb13378 --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/SearchInput.tsx @@ -0,0 +1,38 @@ +import { Checkbox, Input } from 'antd'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; +import { THeaderProps } from 'shared/canvas/MyData/types'; + +type TSearchInputProps = Pick< + THeaderProps, + 'locate' | 'query' | 'setFilterOptions' +>; +const SearchInput = ({ + query, + locate, + setFilterOptions, +}: TSearchInputProps) => { + const onSearchLocateChange = (e: CheckboxChangeEvent) => + setFilterOptions({ locate: e.target.checked }); + const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = event => + setFilterOptions({ query: event.target.value }); + return ( + <div className="my-data-header-title_search"> + <Input.Search + allowClear + className="my-data-search" + placeholder="Search for data" + bordered={false} + value={query} + onChange={handleQueryChange} + style={{ marginLeft: 'auto' }} + /> + <div className="filter-options"> + <Checkbox checked={locate} onChange={onSearchLocateChange}> + <span className="locate-text">By resource id or self</span> + </Checkbox> + </div> + </div> + ); +}; + +export default SearchInput; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/TypeSelector.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/TypeSelector.tsx new file mode 100644 index 000000000..918579c40 --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/TypeSelector.tsx @@ -0,0 +1,210 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { NexusClient } from '@bbp/nexus-sdk'; +import { useNexusContext } from '@bbp/react-nexus'; +import { Checkbox, Col, Dropdown, Input, Row, Select } from 'antd'; +import { isString, startCase } from 'lodash'; +import { useQuery } from 'react-query'; +import { + THeaderProps, + TType, + TTypeAggregationsResult, + TTypesAggregatedBucket, +} from '../../../canvas/MyData/types'; +import useMeasure from '../../../hooks/useMeasure'; +import isValidUrl from '../../../../utils/validUrl'; +import { prettifyNumber } from '../../../../utils/formatNumber'; + +const getTypesByAggregation = async ({ + nexus, + orgLabel, + projectLabel, +}: { + nexus: NexusClient; + orgLabel?: string; + projectLabel?: string; +}) => { + return await nexus.Resource.list(orgLabel, projectLabel, { + aggregations: true, + }); +}; + +const useTypesAggregation = ({ + nexus, + selectCallback, +}: { + nexus: NexusClient; + selectCallback: (data: any) => TType[]; +}) => { + return useQuery({ + refetchOnWindowFocus: false, + queryKey: ['types-aggregation-results'], + queryFn: () => getTypesByAggregation({ nexus }), + select: selectCallback, + }); +}; +const typesOptionsBuilder = (typeBucket: TTypesAggregatedBucket): TType => { + const typeKey = typeBucket.key; + const typeLabel = + isString(typeKey) && isValidUrl(typeKey) + ? typeKey.split('/').pop() + : typeKey; + + return { + key: typeKey, + value: typeKey, + label: startCase(typeLabel), + docCount: typeBucket.doc_count, + }; +}; + +const TypeRowRenderer = ({ + type, + checked, + onCheck, +}: { + checked: boolean; + type: TType; + onCheck(e: React.MouseEvent<HTMLElement, MouseEvent>, type: TType): void; +}) => { + return ( + <Row justify="space-between" align="top" key={type.key}> + <Col span={20}> + <span title={`${type.value}, (${type.docCount})`}>{type.label}</span> + </Col> + <Col + span={4} + style={{ + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + }} + > + <Checkbox onClick={e => onCheck(e, type)} checked={checked} /> + </Col> + </Row> + ); +}; +const TypeSelector = ({ + types, + setFilterOptions, +}: Pick<THeaderProps, 'types' | 'setFilterOptions'>) => { + const nexus = useNexusContext(); + const originTypes = useRef<TType[]>([]); + const [typeSearchValue, updateSearchType] = useState(''); + const [typesOptionsArray, setTypesOptionsArray] = useState<TType[]>([]); + + const selectCallback = useCallback((data: TTypeAggregationsResult) => { + const options = ( + data.aggregations.types?.buckets ?? ([] as TTypesAggregatedBucket[]) + ).map<TType>(item => typesOptionsBuilder(item)); + originTypes.current = options; + return options; + }, []); + + const { data: typeOptions } = useTypesAggregation({ + nexus, + selectCallback, + }); + + const onChangeTypeChange = ({ + target: { value }, + type, + }: React.ChangeEvent<HTMLInputElement>) => { + updateSearchType(value); + if (value === '' || type === 'click') { + setTypesOptionsArray(originTypes.current); + } else { + setTypesOptionsArray( + originTypes.current.filter(item => + item.label.toLowerCase().includes(value.toLowerCase()) + ) ?? [] + ); + } + }; + const onDeselectTypesChange = (value: any) => + setFilterOptions({ + types: types?.filter(item => item.value !== value), + }); + const onClearTypesChange = () => + setFilterOptions({ + types: [], + }); + + const handleOnCheckType = ( + e: React.MouseEvent<HTMLElement, MouseEvent>, + type: TType + ) => { + e.preventDefault(); + e.stopPropagation(); + setFilterOptions({ + types: types?.find(item => item.value === type.value) ? [] : [type], + }); + }; + + const [typeInputRef, { width }] = useMeasure<HTMLInputElement>(); + const renderedTypes = typeSearchValue ? typesOptionsArray : typeOptions ?? []; + return ( + <Dropdown + placement="bottom" + trigger={['click']} + overlayStyle={{ width }} + overlay={ + <div className="my-data-type-filter-overlay"> + <div className="my-data-type-filter-search-container"> + <Input.Search + allowClear + className="my-data-type-filter-search" + placeholder="Search for type" + value={typeSearchValue} + onChange={onChangeTypeChange} + /> + { + <div className="count">{`${prettifyNumber( + renderedTypes.length + )} types`}</div> + } + </div> + <div className="my-data-type-filter-content"> + {renderedTypes.length ? ( + renderedTypes.map((type: TType) => { + return ( + <TypeRowRenderer + key={type.key} + type={type} + checked={Boolean( + types?.find(item => item.key === type.key) + )} + onCheck={handleOnCheckType} + /> + ); + }) + ) : ( + <div className="no-types-content"> + <span>No types found</span> + </div> + )} + </div> + </div> + } + > + <Select + allowClear + // @ts-ignore + ref={typeInputRef} + mode="tags" + style={{ width: '100%' }} + placeholder="Type" + className="my-data-type-picker" + popupClassName="my-data-type-picker-popup" + optionLabelProp="label" + value={types} + options={undefined} + onDeselect={onDeselectTypesChange} + onClear={onClearTypesChange} + maxLength={1} + /> + </Dropdown> + ); +}; + +export default TypeSelector; diff --git a/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx new file mode 100644 index 000000000..e165e4f7d --- /dev/null +++ b/src/shared/molecules/MyDataHeader/MyDataHeaderFilters/index.tsx @@ -0,0 +1,52 @@ +import * as pluralize from 'pluralize'; +import TypeSelector from './TypeSelector'; +import DateSelector from './DateSelector'; +import DateFieldSelector from './DateFieldSelector'; +import PageTitle from './PageTitle'; +import IssuerSelector from './IssuerSelector'; +import SearchInput from './SearchInput'; +import { + THeaderFilterProps, + THeaderTitleProps, +} from '../../../canvas/MyData/types'; + +const MyDataHeaderTitle = ({ + total, + query, + setFilterOptions, + locate, + issuer, +}: THeaderTitleProps) => { + return ( + <div className="my-data-header-title"> + <div className="left"> + <PageTitle + text="My data" + label={pluralize('Dataset', Number(total))} + total={total} + /> + <IssuerSelector {...{ issuer, setFilterOptions }} /> + </div> + <div className="right"> + <SearchInput {...{ query, locate, setFilterOptions }} /> + </div> + </div> + ); +}; + +const MyDataHeaderFilters = ({ + types, + dateField, + setFilterOptions, +}: THeaderFilterProps) => { + return ( + <div className="my-data-header-actions"> + <span className="filter-heading">Filter: </span> + <DateFieldSelector {...{ dateField, setFilterOptions }} /> + <DateSelector {...{ dateField, setFilterOptions }} /> + <TypeSelector {...{ types, setFilterOptions }} /> + </div> + ); +}; + +export { MyDataHeaderFilters, MyDataHeaderTitle }; diff --git a/src/shared/molecules/MyDataHeader/styles.less b/src/shared/molecules/MyDataHeader/styles.less index 987c40a3f..35ddfee7f 100644 --- a/src/shared/molecules/MyDataHeader/styles.less +++ b/src/shared/molecules/MyDataHeader/styles.less @@ -1,47 +1,159 @@ @import '../../lib.less'; -.my-data-table-header { +.my-data-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-flow: column nowrap; + margin-top: 15px; + .divider { + width: 100%; + height: 1px; + background-color: #e8e8e8; + margin: 30px 0 18px; + } +} + +.my-data-header-title { + width: 100%; display: flex; align-items: center; justify-content: space-between; - flex-wrap: wrap; - &-title { + .left { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .right { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + } +} + +.my-data-header-title_heading { + user-select: none; + margin-right: 20px; + + span:first-child { + font-weight: 700; + font-size: 30px; + line-height: 110%; + color: @fusion-main-color; + } + + span:last-child { + font-weight: 400; + font-size: 14px; + line-height: 22px; + letter-spacing: 0.01em; + color: #8c8c8c; + padding-left: 10px; + } +} + +.my-data-header-title_issuer_selector { + width: 100%; + max-width: max-content; + + .ant-radio + span { + color: @fusion-main-color; + } +} + +.my-data-header-title_search { + width: 100%; + min-width: 250px; + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 10px; + + // margin-top: 52px; + .filter-options { + display: flex; + align-items: center; + justify-content: space-evenly; + column-gap: 10px; + width: 100%; + + .ant-checkbox-inner { + border-radius: 4px !important; + border-color: @fusion-main-color !important; + } + } + + .locate-text, + .spread-text { user-select: none; - margin: 20px 0 10px; - margin-right: 20px; + color: @fusion-main-color; + } - span:first-child { - font-family: 'Titillium Web'; - font-style: normal; - font-weight: 700; - font-size: 28px; - line-height: 110%; + .my-data-search { + border-bottom: 1px solid @fusion-main-color !important; + min-width: 350px; + width: 100%; + + .ant-btn { + border: none; + } + + .anticon.anticon-search { + color: @fusion-main-color; + } + } +} + +.my-data-header-actions { + width: 100%; + margin: 0; + display: flex; + flex-flow: row wrap; + gap: 15px; + align-items: center; + justify-content: flex-start; + align-content: center; + + .filter-heading { + font-weight: 400; + font-size: 14px; + line-height: 110%; + color: @fusion-neutral-7; + } + + .date-field-selector { + width: 100%; + max-width: max-content; + + span { color: @fusion-main-color; } + } - span:last-child { - font-family: 'Titillium Web'; - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 22px; - letter-spacing: 0.01em; - color: #8c8c8c; - padding-left: 10px; + .my-data-date-picker { + width: 100%; + max-width: 300px; + + input::placeholder, + input::-webkit-input-placeholder { + color: @fusion-main-color !important; } } - &-actions { - margin: 0; - display: grid; - gap: 15px; - grid-template-columns: max-content max-content 300px 1fr; - align-items: center; - justify-content: center; - align-content: center; + .my-data-type-picker { + width: 100%; + max-width: 300px; + + .ant-select-selection-placeholder { + color: @fusion-main-color !important; + } } } + .radio-filter { &.ant-radio-wrapper-checked { span:nth-child(2) { @@ -49,6 +161,7 @@ } } } + .date-type-selector { display: flex !important; align-items: center; @@ -56,6 +169,7 @@ gap: 15px; margin-bottom: 15px !important; } + .my-data-date { &-container { position: relative; @@ -90,10 +204,12 @@ align-items: center; justify-content: center; flex-direction: column; + p { font-size: 10px; color: red; } + .range-born { font-weight: 200; color: #40a9ff; @@ -137,39 +253,31 @@ .ant-select-selection-placeholder { color: @fusion-daybreak-10 !important; } -} -.my-data-type-filter { - &-popover { - background-color: white !important; - padding: 0 !important; + .ant-select-selection-overflow { + flex-wrap: nowrap; + overflow: scroll; + + // style the scrollbar of this ant-select-selection-overflow + &::-webkit-scrollbar { + width: 2px; + height: 2px; + } + + &::-webkit-scrollbar-thumb { + background: @fusion-daybreak-10; + } } } -.my-data-search-container { - display: flex; - flex-direction: column; - align-items: flex-start; - column-gap: 5px; - // margin-top: 52px; - .filter-options { - display: flex; - align-items: center; - justify-content: space-evenly; - column-gap: 10px; - } - .locate-text, - .spread-text { - color: #8c8c8c; - } +.my-data-type-picker-popup { + display: none; } -.my-data-search { - border-bottom: 1px solid @fusion-neutral-5 !important; - min-width: 350px; - width: 100%; - .ant-btn { - border: none; +.my-data-type-filter { + &-popover { + background-color: white !important; + padding: 0 !important; } } @@ -194,6 +302,19 @@ -ms-overflow-style: none; scrollbar-width: none; + + .no-types-content { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + span { + user-select: none; + font-size: 12px; + color: #8c8c8c; + } + } } } @@ -228,6 +349,16 @@ outline: none; box-shadow: none; } + + .ant-input-affix-wrapper { + border: none; + background-color: transparent; + + &-focused { + box-shadow: none; + outline: none; + } + } } .my-data-type-picker { diff --git a/src/shared/molecules/MyDataTable/MyDataTable.tsx b/src/shared/molecules/MyDataTable/MyDataTable.tsx index d73b27a15..82cf6a1dd 100644 --- a/src/shared/molecules/MyDataTable/MyDataTable.tsx +++ b/src/shared/molecules/MyDataTable/MyDataTable.tsx @@ -4,6 +4,7 @@ import React, { useReducer, useEffect, useState, + CSSProperties, } from 'react'; import { Button, Empty, Table, Tag, Tooltip, notification } from 'antd'; import { @@ -155,7 +156,7 @@ type TSorterProps = { onSortDescend(): void; onSortAscend(): void; }; - +const columnWhiteSpaceWrap = { whiteSpace: 'nowrap' } as CSSProperties; const Sorter = ({ onSortDescend, onSortAscend, order, name }: TSorterProps) => { if (!order) { return ( @@ -239,7 +240,7 @@ const MyDataTable: React.FC<TProps> = ({ return ( <Tooltip title={resourceId}> <Button - style={{ padding: 0 }} + style={{ padding: 0, ...columnWhiteSpaceWrap }} type="link" onClick={() => goToResource(org, project, resourceId)} > @@ -262,7 +263,7 @@ const MyDataTable: React.FC<TProps> = ({ : 'asc' : undefined; return ( - <div> + <div style={columnWhiteSpaceWrap}> organization / project {(!query || query.trim() === '') && ( <Sorter @@ -328,7 +329,7 @@ const MyDataTable: React.FC<TProps> = ({ : 'asc' : undefined; return ( - <div> + <div style={columnWhiteSpaceWrap}> updated date {(!query || query.trim() === '') && ( <Sorter @@ -356,7 +357,7 @@ const MyDataTable: React.FC<TProps> = ({ : 'asc' : undefined; return ( - <div> + <div style={columnWhiteSpaceWrap}> created date {(!query || query.trim() === '') && ( <Sorter diff --git a/src/shared/molecules/ResolvedLinkEditorPopover/ResolvedLinkEditorPopover.tsx b/src/shared/molecules/ResolvedLinkEditorPopover/ResolvedLinkEditorPopover.tsx deleted file mode 100644 index 5c1e430a2..000000000 --- a/src/shared/molecules/ResolvedLinkEditorPopover/ResolvedLinkEditorPopover.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { ReactNode, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { useHistory, useLocation, useRouteMatch } from 'react-router'; -import { useNexusContext } from '@bbp/react-nexus'; -import { Resource } from '@bbp/nexus-sdk'; -import { clsx } from 'clsx'; -import { Tag } from 'antd'; -import { match as pmatch } from 'ts-pattern'; -import { UISettingsActionTypes } from '../../store/actions/ui-settings'; -import { RootState } from '../../store/reducers'; -import { - TDELink, - InitNewVisitDataExplorerGraphView, - AddNewNodeDataExplorerGraphFlow, -} from '../../store/reducers/data-explorer'; -import { TEditorPopoverResolvedData } from '../../store/reducers/ui-settings'; -import { getOrgAndProjectFromProjectId, getResourceLabel } from '../../utils'; -import { getNormalizedTypes } from '../../components/ResourceEditor/editorUtils'; -import useOnClickOutside from '../../hooks/useClickOutside'; -import './styles.less'; - -type TResultPattern = Pick<TEditorPopoverResolvedData, 'open' | 'resolvedAs'>; - -const PopoverContainer = ({ - children, - onClickOutside, -}: { - children: ReactNode; - onClickOutside(): void; -}) => { - const ref = useRef<HTMLDivElement>(null); - const { - editorPopoverResolvedData: { top, left, resolvedAs }, - } = useSelector((state: RootState) => state.uiSettings); - - useOnClickOutside(ref, onClickOutside); - return ( - <div - data-testId="custom-link-popover" - ref={ref} - className={clsx( - 'custom-popover-token', - resolvedAs === 'error' && 'error' - )} - style={{ top, left }} - > - {children} - </div> - ); -}; - -const ResolvedLinkEditorPopover = () => { - const navigate = useHistory(); - const dispatch = useDispatch(); - const nexus = useNexusContext(); - const { pathname } = useLocation(); - const routeMatch = useRouteMatch<{ - orgLabel: string; - projectLabel: string; - resourceId: string; - }>(`/:orgLabel/:projectLabel/resources/:resourceId`); - const { - editorPopoverResolvedData: { open, error, resolvedAs, results }, - } = useSelector((state: RootState) => state.uiSettings); - const resultPattern: TResultPattern = { resolvedAs, open }; - - const onClickOutside = () => { - dispatch({ - type: UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - payload: { - open: false, - top: 0, - left: 0, - error: null, - results: [], - resolvedAs: undefined, - }, - }); - }; - const onClickLink = async (resource: TDELink) => { - onClickOutside(); - if (pathname === '/data-explorer/graph-flow') { - dispatch(AddNewNodeDataExplorerGraphFlow(resource)); - } else if (routeMatch?.url && routeMatch.params) { - const data = (await nexus.Resource.get( - routeMatch.params.orgLabel, - routeMatch.params.projectLabel, - routeMatch.params.resourceId - )) as Resource; - const orgProject = getOrgAndProjectFromProjectId(data._project); - dispatch( - InitNewVisitDataExplorerGraphView({ - source: { - _self: data._self, - title: getResourceLabel(data), - types: getNormalizedTypes(data['@type']), - resource: [ - orgProject?.orgLabel ?? '', - orgProject?.projectLabel ?? '', - data['@id'], - ], - }, - current: resource, - }) - ); - navigate.push('/data-explorer/graph-flow'); - } - }; - - return pmatch(resultPattern) - .with({ open: true, resolvedAs: 'error' }, () => ( - <PopoverContainer {...{ onClickOutside }}> - <div className="popover-btn">{error}</div> - </PopoverContainer> - )) - .with({ open: true, resolvedAs: 'resource' }, () => { - const result = results as TDELink; - return ( - <PopoverContainer {...{ onClickOutside }}> - <div className="resource" key={result._self}> - {result.resource?.[0] && result.resource?.[1] && ( - <Tag color="blue">{`${result.resource?.[0]}/${result.resource?.[1]}`}</Tag> - )} - <button - onClick={() => onClickLink(result)} - className="link popover-btn" - > - <span>{result.title ?? result.resource?.[2]}</span> - </button> - </div> - </PopoverContainer> - ); - }) - .with({ open: true, resolvedAs: 'resources' }, () => { - return ( - <PopoverContainer {...{ onClickOutside }}> - {(results as TDELink[]).map(item => ( - <div className="resource" key={item._self}> - {item.resource?.[0] && item.resource?.[1] && ( - <Tag color="blue">{`${item.resource?.[0]}/${item.resource?.[1]}`}</Tag> - )} - <button - onClick={() => onClickLink(item)} - className="link popover-btn" - > - <span>{item.title ?? item.resource?.[2]}</span> - </button> - </div> - ))} - </PopoverContainer> - ); - }) - .with({ open: true, resolvedAs: 'external' }, () => { - const result = results as TDELink; - return ( - <PopoverContainer {...{ onClickOutside }}> - <div className="resource external"> - <Tag color="yellow">External Link</Tag> - <span> - This is external Link please configure CrossProjectResolver for - your project - </span> - <button disabled className="link popover-btn"> - <span>{result.title}</span> - </button> - </div> - </PopoverContainer> - ); - }) - .otherwise(() => <></>); -}; - -export default ResolvedLinkEditorPopover; diff --git a/src/shared/molecules/ResolvedLinkEditorPopover/styles.less b/src/shared/molecules/ResolvedLinkEditorPopover/styles.less deleted file mode 100644 index 0880f0621..000000000 --- a/src/shared/molecules/ResolvedLinkEditorPopover/styles.less +++ /dev/null @@ -1,53 +0,0 @@ -.custom-popover-token { - position: absolute; - background: white; - box-shadow: 0px 2px 12px rgba(51, 51, 51, 0.12); - border-radius: 6px; - padding: 10px 12px; - z-index: 9999; - max-width: 580px; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - - .resource { - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 2px; - margin-bottom: 3px; - padding: 0 4px; - width: 100%; - overflow: hidden; - .link { - border: none; - margin: 0; - padding: 0; - width: auto; - overflow: visible; - background: transparent; - cursor: pointer; - color: blue; - white-space: pre-line; - text-overflow: ellipsis; - line-clamp: 1; - white-space: nowrap; - overflow: hidden; - width: fit-content; - } - } - - .external { - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - max-width: inherit; - padding: 10px; - } - - &.error { - background-color: rgba(255, 0, 0, 0.478); - color: white; - } -} diff --git a/src/shared/molecules/index.ts b/src/shared/molecules/index.ts index 8a021ca68..8a8c397e6 100644 --- a/src/shared/molecules/index.ts +++ b/src/shared/molecules/index.ts @@ -2,6 +2,7 @@ export { default as PresetCardItem } from './PresetCardItem/PresetCardItem'; export { default as SubAppCardItem } from './SubAppCardItem/SubAppCardItem'; export { default as MyDataTable } from './MyDataTable/MyDataTable'; export { default as MyDataHeader } from './MyDataHeader/MyDataHeader'; +export { default as AdvancedModeToggle } from './AdvancedMode/AdvancedMode'; export { PresetCardItemCompact } from './PresetCardItem/PresetCardItem'; export { PresetCardItemSkeleton } from './PresetCardItem/PresetCardItem'; diff --git a/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx b/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx index f644be773..8978494c6 100644 --- a/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx +++ b/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx @@ -3,18 +3,18 @@ import { useSelector } from 'react-redux'; import { RootState } from '../../store/reducers'; import ResourceViewContainer from '../../containers/ResourceViewContainer'; import ResourceEditorContainer from '../../containers/ResourceEditor'; -import { DataExplorerGraphFlowContentLimitedHeader } from '../../molecules/DataExplorerGraphFlowMolecules'; +import { DEFGContentFullscreenHeader } from '../../molecules/DataExplorerGraphFlowMolecules'; import './styles.less'; const DataExplorerContentPage = ({}) => { - const { current, limited } = useSelector( + const { current, fullscreen } = useSelector( (state: RootState) => state.dataExplorer ); return ( <div className="degf-content__wrapper"> - {limited ? ( + {fullscreen ? ( <Fragment> - <DataExplorerGraphFlowContentLimitedHeader /> + <DEFGContentFullscreenHeader /> <ResourceEditorContainer key={current?._self} onSubmit={() => {}} diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx new file mode 100644 index 000000000..fb68036d0 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx @@ -0,0 +1,214 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { AnyAction, Store } from 'redux'; +import { Provider } from 'react-redux'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { createNexusClient, NexusClient } from '@bbp/nexus-sdk'; +import { Router } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import { NexusProvider } from '@bbp/react-nexus'; +import { deltaPath } from '../../../__mocks__/handlers/handlers'; +import configureStore from '../../store'; +import { + ResetDataExplorerGraphFlow, + TDataExplorerState, +} from '../../store/reducers/data-explorer'; +import NavigationArrows from './NavigationArrows'; +import NavigationStack from './NavigationStack'; + +const initialDataExplorerState: TDataExplorerState = { + current: { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.417881.3', + title: 'Allen Institute for Brain Science', + types: ['Agent', 'Organization'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.417881.3', + 1, + ], + }, + leftNodes: { + links: [ + { + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/lnmce/datashapes:dataset/traces%2F460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + title: '001_141216_A1_CA1py_R_MPG', + types: ['Entity', 'Trace', 'Dataset'], + resource: [ + 'bbp', + 'lnmce', + 'https://bbp.epfl.ch/neurosciencegraph/data/traces/460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + 1, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.5333.6', + title: 'Ecole Polytechnique Federale de Lausanne', + types: ['Organization', 'prov#Agent'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.5333.6', + 1, + ], + }, + ], + shrinked: false, + }, + rightNodes: { + links: [ + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FNeuroElectroNeuronElectrophysiologicalFeature', + title: 'NeuroElectro Neuron Electrophysiological Feature', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/NeuroElectroNeuronElectrophysiologicalFeature', + 29, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }, + ], + shrinked: false, + }, + fullscreen: false, + referer: { + pathname: '/my-data', + search: '', + state: {}, + }, +}; + +const getButtonElement = (container: HTMLElement, side: 'back' | 'forward') => { + return container.querySelector( + `.navigation-arrow-btn[aria-label="${side}-arrow"]` + ); +}; +describe('NavigationStack', () => { + let app: JSX.Element; + let component: RenderResult; + let container: HTMLElement; + let rerender: (ui: React.ReactElement) => void; + let store: Store<any, AnyAction>; + let user: UserEvent; + let history: MemoryHistory<{}>; + let nexus: NexusClient; + + beforeEach(() => { + history = createMemoryHistory({}); + + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + store = configureStore(history, { nexus }, {}); + app = ( + <Provider store={store}> + <Router history={history}> + <NexusProvider nexusClient={nexus}> + <> + <NavigationArrows /> + <NavigationStack key="navigation-stack-left" side="left" /> + <NavigationStack key="navigation-stack-right" side="right" /> + </> + </NexusProvider> + </Router> + </Provider> + ); + component = render(app); + container = component.container; + rerender = component.rerender; + user = userEvent.setup(); + + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + rerender(app); + }); + + it('should render the back/forward arrows as not disabled', () => { + const backArrow = container.querySelector('[aria-label="back-arrow"]'); + const forwardArrow = getButtonElement(container, 'forward'); + expect(backArrow).toBeInTheDocument(); + expect(forwardArrow).toBeInTheDocument(); + }); + it('should left side of navigation become 3 and right side become 2 when Forward btn clicked', async () => { + const forwardArrow = getButtonElement(container, 'forward'); + expect(forwardArrow).toBeInTheDocument(); + forwardArrow && (await user.click(forwardArrow)); + rerender(app); + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(3); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(2); + }); + it('should left side of navigation become 1 and right side become 4 when Back btn clicked', async () => { + const backArrow = getButtonElement(container, 'back'); + expect(backArrow).toBeInTheDocument(); + backArrow && (await user.click(backArrow)); + rerender(app); + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(1); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(4); + }); + it('should forward btn disappear when there is no more forward navigation', async () => { + for (const _ of store.getState().dataExplorer.rightNodes.links) { + const forwardArrow = getButtonElement(container, 'forward'); + expect(forwardArrow).toBeInTheDocument(); + forwardArrow && (await user.click(forwardArrow)); + rerender(app); + } + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(5); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(0); + const forwardArrowAfterFullNavigation = getButtonElement( + container, + 'forward' + ); + expect(forwardArrowAfterFullNavigation).toBeNull(); + }); + it('should return to /my-data when there is no more back navigation', async () => { + for (const _ of store.getState().dataExplorer.leftNodes.links) { + const backArrow = getButtonElement(container, 'back'); + expect(backArrow).toBeInTheDocument(); + backArrow && (await user.click(backArrow)); + rerender(app); + } + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(0); + const lastBackArrow = getButtonElement(container, 'back'); + expect(lastBackArrow).toBeInTheDocument(); + lastBackArrow && (await user.click(lastBackArrow)); + expect(history.location.pathname).toEqual('/my-data'); + }); +}); diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx new file mode 100644 index 000000000..42febe91c --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { NavigationArrow } from '../../molecules/DataExplorerGraphFlowMolecules'; +import useNavigationStackManager from './useNavigationStack'; +import './styles.less'; + +const NavigationArrows = () => { + const { + onNavigateBack, + onNavigateForward, + backArrowVisible, + forwardArrowVisible, + } = useNavigationStackManager(); + + return ( + <div className="navigation-arrows"> + <NavigationArrow + key="navigation-arrow-back" + direction="back" + title="Back" + visible={backArrowVisible} + onClick={onNavigateBack} + /> + <NavigationArrow + key="navigation-arrow-forward" + title="Forward" + direction="forward" + visible={forwardArrowVisible} + onClick={onNavigateForward} + /> + </div> + ); +}; + +export default NavigationArrows; diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx new file mode 100644 index 000000000..044951630 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx @@ -0,0 +1,408 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { AnyAction, Store } from 'redux'; +import { Provider } from 'react-redux'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { createNexusClient, NexusClient } from '@bbp/nexus-sdk'; +import { Router } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import { NexusProvider } from '@bbp/react-nexus'; +import { deltaPath } from '../../../__mocks__/handlers/handlers'; +import configureStore from '../../store'; +import { + AddNewNodeDataExplorerGraphFlow, + ResetDataExplorerGraphFlow, + PopulateDataExplorerGraphFlow, + TDataExplorerState, + ExpandNavigationStackDataExplorerGraphFlow, + ShrinkNavigationStackDataExplorerGraphFlow, +} from '../../store/reducers/data-explorer'; +import NavigationStack from './NavigationStack'; + +const sampleDigest = + 'eyJjdXJyZW50Ijp7ImlzRG93bmxvYWRhYmxlIjpmYWxzZSwiX3NlbGYiOiJodHRwczovL2JicC5lcGZsLmNoL25leHVzL3YxL3Jlc291cmNlcy9iYnAvYXRsYXMvZGF0YXNoYXBlczp2b2x1bWV0cmljZGF0YWxheWVyL2RjYTQwZjk5LWI0OTQtNGQyYy05YTJmLWM0MDcxODAxMzhiNyIsInRpdGxlIjoiYXZlcmFnZV90ZW1wbGF0ZV8yNSIsInR5cGVzIjpbIkJyYWluVGVtcGxhdGVEYXRhTGF5ZXIiLCJWb2x1bWV0cmljRGF0YUxheWVyIl0sInJlc291cmNlIjpbImJicCIsImF0bGFzIiwiaHR0cHM6Ly9iYnAuZXBmbC5jaC9uZXVyb3NjaWVuY2VncmFwaC9kYXRhL2RjYTQwZjk5LWI0OTQtNGQyYy05YTJmLWM0MDcxODAxMzhiNyIsOF19LCJsZWZ0Tm9kZXMiOnsibGlua3MiOlt7ImlzRG93bmxvYWRhYmxlIjpmYWxzZSwiX3NlbGYiOiJodHRwczovL2JicC5lcGZsLmNoL25leHVzL3YxL3Jlc291cmNlcy9iYnAvbG5tY2UvZGF0YXNoYXBlczpkYXRhc2V0L3RyYWNlcyUyRjYwOTBiNWZlLTU0YmYtNDRiNC1hZjU5LWE0NmE5YmI4MWZhYyIsInRpdGxlIjoiMDAxXzE1MDMwNF9BMl9DQTFweV9NUEciLCJ0eXBlcyI6WyJUcmFjZSIsIkRhdGFzZXQiLCJFbnRpdHkiXSwicmVzb3VyY2UiOlsiYmJwIiwibG5tY2UiLCJodHRwczovL2JicC5lcGZsLmNoL25ldXJvc2NpZW5jZWdyYXBoL2RhdGEvdHJhY2VzLzYwOTBiNWZlLTU0YmYtNDRiNC1hZjU5LWE0NmE5YmI4MWZhYyIsMTddfSx7ImlzRG93bmxvYWRhYmxlIjpmYWxzZSwiX3NlbGYiOiJodHRwczovL2JicC5lcGZsLmNoL25leHVzL3YxL3Jlc291cmNlcy9iYnAvYXRsYXMvZGF0YXNoYXBlczphdGxhc3JlbGVhc2UvODMxYTYyNmEtYzBhZS00NjkxLThjZTgtY2ZiNzQ5MTM0NWQ5IiwidGl0bGUiOiJBbGxlbiBNb3VzZSBDQ0YgdjMiLCJ0eXBlcyI6WyJCcmFpbkF0bGFzUmVsZWFzZSIsIkF0bGFzUmVsZWFzZSJdLCJyZXNvdXJjZSI6WyJiYnAiLCJhdGxhcyIsImh0dHBzOi8vYmJwLmVwZmwuY2gvbmV1cm9zY2llbmNlZ3JhcGgvZGF0YS84MzFhNjI2YS1jMGFlLTQ2OTEtOGNlOC1jZmI3NDkxMzQ1ZDkiLDRdfV0sInNocmlua2VkIjpmYWxzZX0sInJpZ2h0Tm9kZXMiOnsibGlua3MiOlt7ImlzRG93bmxvYWRhYmxlIjpmYWxzZSwiX3NlbGYiOiJodHRwczovL2JicC5lcGZsLmNoL25leHVzL3YxL3Jlc291cmNlcy9iYnAvYXRsYXMvZGF0YXNoYXBlczphdGxhc3NwYXRpYWxyZWZlcmVuY2VzeXN0ZW0vYWxsZW5fY2NmdjNfc3BhdGlhbF9yZWZlcmVuY2Vfc3lzdGVtIiwidGl0bGUiOiJBbGxlbiBNb3VzZSBDQ0YiLCJ0eXBlcyI6WyJBdGxhc1NwYXRpYWxSZWZlcmVuY2VTeXN0ZW0iLCJCcmFpbkF0bGFzU3BhdGlhbFJlZmVyZW5jZVN5c3RlbSJdLCJyZXNvdXJjZSI6WyJiYnAiLCJhdGxhcyIsImh0dHBzOi8vYmJwLmVwZmwuY2gvbmV1cm9zY2llbmNlZ3JhcGgvZGF0YS9hbGxlbl9jY2Z2M19zcGF0aWFsX3JlZmVyZW5jZV9zeXN0ZW0iLDldfSx7ImlzRG93bmxvYWRhYmxlIjpmYWxzZSwiX3NlbGYiOiJodHRwczovL2JicC5lcGZsLmNoL25leHVzL3YxL3Jlc291cmNlcy9iYnAvYXRsYXMvZGF0YXNoYXBlczpvcmdhbml6YXRpb24vaHR0cHM6JTJGJTJGd3d3LmdyaWQuYWMlMkZpbnN0aXR1dGVzJTJGZ3JpZC40MTc4ODEuMyIsInRpdGxlIjoiQWxsZW4gSW5zdGl0dXRlIGZvciBCcmFpbiBTY2llbmNlIiwidHlwZXMiOlsiQWdlbnQiLCJPcmdhbml6YXRpb24iXSwicmVzb3VyY2UiOlsiYmJwIiwiYXRsYXMiLCJodHRwczovL3d3dy5ncmlkLmFjL2luc3RpdHV0ZXMvZ3JpZC40MTc4ODEuMyIsMV19XSwic2hyaW5rZWQiOmZhbHNlfSwicmVmZXJlciI6eyJwYXRobmFtZSI6Ii9iYnAvbG5tY2UvcmVzb3VyY2VzL2h0dHBzJTNBJTJGJTJGYmJwLmVwZmwuY2glMkZuZXVyb3NjaWVuY2VncmFwaCUyRmRhdGElMkZ0cmFjZXMlMkY2MDkwYjVmZS01NGJmLTQ0YjQtYWY1OS1hNDZhOWJiODFmYWMiLCJzZWFyY2giOiIiLCJzdGF0ZSI6eyJiYWNrZ3JvdW5kIjp7InBhdGhuYW1lIjoiL3NlYXJjaCIsInNlYXJjaCI6Ij9sYXlvdXQ9TmV1cm9uIEVsZWN0cm9waHlzaW9sb2d5IiwiaGFzaCI6IiIsImtleSI6ImdvNnNhZCJ9fX19'; +const digestCurrentSelf = + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:volumetricdatalayer/dca40f99-b494-4d2c-9a2f-c407180138b7'; +const initialDataExplorerState: TDataExplorerState = { + current: { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.417881.3', + title: 'Allen Institute for Brain Science', + types: ['Agent', 'Organization'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.417881.3', + 1, + ], + }, + leftNodes: { + links: [ + { + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/lnmce/datashapes:dataset/traces%2F460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + title: '001_141216_A1_CA1py_R_MPG', + types: ['Entity', 'Trace', 'Dataset'], + resource: [ + 'bbp', + 'lnmce', + 'https://bbp.epfl.ch/neurosciencegraph/data/traces/460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + 1, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.5333.6', + title: 'Ecole Polytechnique Federale de Lausanne', + types: ['Organization', 'prov#Agent'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.5333.6', + 1, + ], + }, + ], + shrinked: false, + }, + rightNodes: { + links: [], + shrinked: false, + }, + fullscreen: false, +}; +const fourthItemInStack = { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FAHPAmplitude', + title: 'AHP Amplitude', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/AHPAmplitude', + 28, + ], +}; +describe('NavigationStack', () => { + let app: JSX.Element; + let component: RenderResult; + let container: HTMLElement; + let rerender: (ui: React.ReactElement) => void; + let store: Store<any, AnyAction>; + let user: UserEvent; + let history: MemoryHistory<{}>; + let nexus: NexusClient; + + beforeEach(() => { + history = createMemoryHistory({}); + + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + store = configureStore(history, { nexus }, {}); + app = ( + <Provider store={store}> + <Router history={history}> + <NexusProvider nexusClient={nexus}> + <> + <NavigationStack key="navigation-stack-left" side="left" /> + <NavigationStack key="navigation-stack-right" side="right" /> + </> + </NexusProvider> + </Router> + </Provider> + ); + component = render(app); + container = component.container; + rerender = component.rerender; + user = userEvent.setup(); + }); + + it('should render the correct number of NavigationStackItem components in the state', () => { + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + rerender(app); + const navigationItems = container.querySelectorAll( + '.navigation-stack-item:not(.no-more)' + ); + expect(navigationItems.length).toBe(2); + }); + it('should render the correct number of NavigationStackItem after multiple navigation', () => { + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/_/https:%2F%2Fbbp.neuroshapes.org', + title: 'bbp.neuroshapes.org', + types: [], + resource: ['bbp', 'atlas', 'https://bbp.neuroshapes.org', 1], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }) + ); + store.dispatch(AddNewNodeDataExplorerGraphFlow(fourthItemInStack)); + rerender(app); + const navigationItemsAfterMultipleNavigation = container.querySelectorAll( + '.navigation-stack-item:not(.more)' + ); + expect(navigationItemsAfterMultipleNavigation.length).toBe(5); + const state = store.getState(); + expect(state.dataExplorer.leftNodes.links.length).toBe(5); + }); + it('should render the NavigationStackShrinkedItem when it passed MAX_NAVIGATION_ITEMS_IN_STACK', () => { + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/_/https:%2F%2Fbbp.neuroshapes.org', + title: 'bbp.neuroshapes.org', + types: [], + resource: ['bbp', 'atlas', 'https://bbp.neuroshapes.org', 1], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FNeuroElectroNeuronElectrophysiologicalFeature', + title: 'NeuroElectro Neuron Electrophysiological Feature', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/NeuroElectroNeuronElectrophysiologicalFeature', + 29, + ], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }) + ); + store.dispatch(AddNewNodeDataExplorerGraphFlow(fourthItemInStack)); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:atlasrelease/4906ab85-694f-469d-962f-c0174e901885', + title: 'Blue Brain Atlas', + types: ['AtlasRelease', 'BrainAtlasRelease'], + resource: [ + 'bbp', + 'atlas', + 'https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885', + 3, + ], + }) + ); + rerender(app); + const navigationStackShrinkedItem = container.querySelectorAll( + '.navigation-stack-item.more' + ); + expect(navigationStackShrinkedItem.length).toBe(1); + expect(navigationStackShrinkedItem).not.toBeNull(); + const navigationHiddenItems = container.querySelectorAll( + '.navigation-stack-item:not(.no-more):not(.more)[hidden]' + ); + expect(navigationHiddenItems.length).toBe(5); + }); + it('should show the collapse button when the NavigationStackShrinkedItem is clicked', async () => { + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/_/https:%2F%2Fbbp.neuroshapes.org', + title: 'bbp.neuroshapes.org', + types: [], + resource: ['bbp', 'atlas', 'https://bbp.neuroshapes.org', 1], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FNeuroElectroNeuronElectrophysiologicalFeature', + title: 'NeuroElectro Neuron Electrophysiological Feature', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/NeuroElectroNeuronElectrophysiologicalFeature', + 29, + ], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }) + ); + store.dispatch(AddNewNodeDataExplorerGraphFlow(fourthItemInStack)); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:atlasrelease/4906ab85-694f-469d-962f-c0174e901885', + title: 'Blue Brain Atlas', + types: ['AtlasRelease', 'BrainAtlasRelease'], + resource: [ + 'bbp', + 'atlas', + 'https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885', + 3, + ], + }) + ); + rerender(app); + const openShrinkedNavList = container.querySelector( + '[role="open-shrinked-nav"]' + ); + expect(openShrinkedNavList).not.toBeNull(); + expect(openShrinkedNavList).toBeInTheDocument(); + openShrinkedNavList && (await user.click(openShrinkedNavList)); + rerender(app); + const collapseBtn = container.querySelector('.collapse-btn'); + expect(collapseBtn).not.toBeNull(); + expect(collapseBtn).toBeInTheDocument(); + expect(store.getState().dataExplorer.leftNodes.shrinked).toBe(false); + }); + it('should the items in the stack be 4 when the user jump to the 5th item', async () => { + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/_/https:%2F%2Fbbp.neuroshapes.org', + title: 'bbp.neuroshapes.org', + types: [], + resource: ['bbp', 'atlas', 'https://bbp.neuroshapes.org', 1], + }) + ); + store.dispatch(AddNewNodeDataExplorerGraphFlow(fourthItemInStack)); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FNeuroElectroNeuronElectrophysiologicalFeature', + title: 'NeuroElectro Neuron Electrophysiological Feature', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/NeuroElectroNeuronElectrophysiologicalFeature', + 29, + ], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }) + ); + store.dispatch( + AddNewNodeDataExplorerGraphFlow({ + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:atlasrelease/4906ab85-694f-469d-962f-c0174e901885', + title: 'Blue Brain Atlas', + types: ['AtlasRelease', 'BrainAtlasRelease'], + resource: [ + 'bbp', + 'atlas', + 'https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885', + 3, + ], + }) + ); + rerender(app); + // select by class and role of open-naivation-item + const forthNodeNavigationItem = container.querySelector( + '.navigation-stack-item.left.item-4 .navigation-stack-item__wrapper > .icon[role="open-navigation-item"]' + ); + expect(forthNodeNavigationItem).not.toBeNull(); + expect(forthNodeNavigationItem).toBeInTheDocument(); + forthNodeNavigationItem && (await user.click(forthNodeNavigationItem)); + rerender(app); + const navigationLeftItems = container.querySelectorAll( + '.navigation-stack-item.left:not(.more)' + ); + expect(navigationLeftItems.length).toBe(4); + const navigationRightItems = container.querySelectorAll( + '.navigation-stack-item.right:not(.more)' + ); + expect(navigationRightItems.length).toBe(3); + const state = store.getState(); + expect(state.dataExplorer.current._self).toEqual(fourthItemInStack._self); + }); + it('should decode the navigation digest at first render', () => { + store.dispatch(ResetDataExplorerGraphFlow({ initialState: null })); + store.dispatch(PopulateDataExplorerGraphFlow(sampleDigest)); + rerender(app); + const dataExplorerState = store.getState().dataExplorer; + expect(dataExplorerState.leftNodes.links.length).toBe(2); + expect(dataExplorerState.rightNodes.links.length).toBe(2); + expect(dataExplorerState.current._self).toBe(digestCurrentSelf); + }); +}); diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.tsx new file mode 100644 index 000000000..95b679a46 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.tsx @@ -0,0 +1,74 @@ +import React, { Fragment } from 'react'; +import { clsx } from 'clsx'; +import { TNavigationStackSide } from '../../store/reducers/data-explorer'; +import { + NavigationStackItem, + NavigationStackShrinkedItem, +} from '../../molecules/DataExplorerGraphFlowMolecules'; +import useNavigationStackManager from './useNavigationStack'; +import './styles.less'; + +const NavigationStack = ({ side }: { side: TNavigationStackSide }) => { + const { + leftLinks, + rightLinks, + leftShrinked, + rightShrinked, + onRightExpand, + onLeftExpand, + } = useNavigationStackManager(); + const links = side === 'left' ? leftLinks : rightLinks; + const shrinked = side === 'left' ? leftShrinked : rightShrinked; + const onExpand = side === 'left' ? onLeftExpand : onRightExpand; + return ( + <div className={`navigation-stack__wrapper ${side}`}> + <div className={clsx('navigation-stack', side, shrinked && 'shrinked')}> + {links.map(({ title, types, _self, resource }, index) => { + if (index === 0) { + return ( + <Fragment> + <NavigationStackItem + key={_self} + {...{ + _self, + index, + title, + types, + resource, + side, + }} + /> + {shrinked && ( + <NavigationStackShrinkedItem + key={`shrinkable-item-${side}`} + {...{ + side, + shrinked, + links, + onExpand, + }} + /> + )} + </Fragment> + ); + } + return ( + <NavigationStackItem + key={_self} + {...{ + _self, + index, + title, + types, + resource, + side, + }} + /> + ); + })} + </div> + </div> + ); +}; + +export default NavigationStack; diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts new file mode 100644 index 000000000..c5cfa73f3 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts @@ -0,0 +1,2 @@ +export { default as NavigationArrows } from './NavigationArrows'; +export { default as NavigationStack } from './NavigationStack'; diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less new file mode 100644 index 000000000..d512000bd --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less @@ -0,0 +1,32 @@ +.navigation-stack__wrapper { + display: flex; + align-items: flex-start; + position: fixed; + gap: 10px; + top: 52px; + z-index: 6; + &.left { + left: 0; + } + &.right { + right: 0; + } +} + +.navigation-stack { + grid-area: menu; + display: grid; + grid-auto-columns: max-content; + grid-auto-flow: column; + height: 100%; + min-height: 100vh; +} + +.navigation-arrows { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 20px; +} diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts new file mode 100644 index 000000000..a03c45b2c --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts @@ -0,0 +1,71 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router'; +import { RootState } from '../../store/reducers'; +import { + DATA_EXPLORER_GRAPH_FLOW_DIGEST, + ExpandNavigationStackDataExplorerGraphFlow, + MoveForwardDataExplorerGraphFlow, + ResetDataExplorerGraphFlow, + ReturnBackDataExplorerGraphFlow, + ShrinkNavigationStackDataExplorerGraphFlow, +} from '../../store/reducers/data-explorer'; + +const useNavigationStackManager = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const location = useLocation(); + const { rightNodes, leftNodes, referer } = useSelector( + (state: RootState) => state.dataExplorer + ); + const leftShrinked = leftNodes.shrinked; + const rightShrinked = rightNodes.shrinked; + const leftLinks = leftNodes.links; + const rightLinks = rightNodes.links; + + const onLeftShrink = () => + dispatch(ShrinkNavigationStackDataExplorerGraphFlow({ side: 'left' })); + const onLeftExpand = () => + dispatch(ExpandNavigationStackDataExplorerGraphFlow({ side: 'left' })); + const onRightShrink = () => + dispatch(ShrinkNavigationStackDataExplorerGraphFlow({ side: 'right' })); + const onRightExpand = () => + dispatch(ExpandNavigationStackDataExplorerGraphFlow({ side: 'right' })); + + const backArrowVisible = leftLinks.length > 0 || !!referer?.pathname; + const forwardArrowVisible = rightLinks.length > 0; + + const onNavigateBack = () => { + if (referer?.pathname && !leftLinks.length) { + dispatch(ResetDataExplorerGraphFlow({ initialState: null })); + localStorage.removeItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST); + history.push(`${referer.pathname}${referer.search}`, { + ...referer.state, + }); + return; + } + history.replace(location.pathname); + dispatch(ReturnBackDataExplorerGraphFlow()); + }; + + const onNavigateForward = () => { + history.replace(location.pathname); + dispatch(MoveForwardDataExplorerGraphFlow()); + }; + + return { + leftShrinked, + rightShrinked, + leftLinks, + rightLinks, + onLeftShrink, + onLeftExpand, + onRightShrink, + onRightExpand, + onNavigateBack, + onNavigateForward, + backArrowVisible, + forwardArrowVisible, + }; +}; + +export default useNavigationStackManager; diff --git a/src/shared/organisms/DataPanel/DataPanel.tsx b/src/shared/organisms/DataPanel/DataPanel.tsx index 1240f6a27..d7457f3af 100644 --- a/src/shared/organisms/DataPanel/DataPanel.tsx +++ b/src/shared/organisms/DataPanel/DataPanel.tsx @@ -481,7 +481,6 @@ const DataPanel: React.FC<Props> = ({}) => { }`, }; } catch (error) { - console.log('@@error', resource.id, error); return; } }); diff --git a/src/shared/routes.ts b/src/shared/routes.ts index 084bfc6a0..cb4f2e537 100644 --- a/src/shared/routes.ts +++ b/src/shared/routes.ts @@ -6,7 +6,8 @@ import Home from '../pages/HomePage/HomePage'; import IdentityPage from '../pages/IdentityPage/IdentityPage'; import StudioRedirectView from './views/StudioRedirectView'; import MyDataPage from '../pages/MyDataPage/MyDataPage'; -import DataExplorerResolverPage from '../pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage'; +import DataExplorerGraphFlowPage from '../pages/DataExplorerGraphFlowPage/DataExplorerGraphFlowPage'; +import DataExplorerPage from '../pages/DataExplorerPage/DataExplorerPage'; type TRoutePropsExtended = RouteProps & { protected: boolean }; @@ -42,7 +43,13 @@ const routes: TRoutePropsExtended[] = [ }, { path: '/data-explorer/graph-flow', - component: DataExplorerResolverPage, + component: DataExplorerGraphFlowPage, + exact: true, + protected: true, + }, + { + path: '/data-explorer', + component: DataExplorerPage, exact: true, protected: true, }, diff --git a/src/shared/store/actions/ui-settings.ts b/src/shared/store/actions/ui-settings.ts index 9e8ca8d46..43204510f 100644 --- a/src/shared/store/actions/ui-settings.ts +++ b/src/shared/store/actions/ui-settings.ts @@ -1,19 +1,15 @@ -import { TEditorPopoverResolvedData } from '../reducers/ui-settings'; -import { FilterPayloadAction, PayloadAction } from './utils'; +import { FilterPayloadAction } from './utils'; export enum UISettingsActionTypes { CHANGE_PAGE_SIZE = 'CHANGE_PAGE_SIZE', CHANGE_HEADER_CREATION_PANEL = 'CHANGE_HEADER_CREATION_PANEL', UPDATE_CURRENT_RESOURCE_VIEW = 'UPDATE_CURRENT_RESOURCE_VIEW', UPDATE_JSON_EDITOR_POPOVER = 'UPDATE_JSON_EDITOR_POPOVER', + ENABLE_ADVANCED_MODE = 'ENABLE_ADVANCED_MODE', } type ChangePageSizeAction = FilterPayloadAction< UISettingsActionTypes.CHANGE_PAGE_SIZE, { pageSize: number } >; -export type TUpdateJSONEditorPopoverAction = PayloadAction< - UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER, - TEditorPopoverResolvedData ->; export type UISettingsActions = ChangePageSizeAction; diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 716fa1b81..4b263db7d 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -11,6 +11,7 @@ import { reducer as oidcReducer } from 'redux-oidc'; import { History } from 'history'; import { NexusClient } from '@bbp/nexus-sdk'; import reducers from './reducers'; +import { DataExplorerFlowSliceListener } from './reducers/data-explorer'; export type Services = { nexus: NexusClient; @@ -47,7 +48,8 @@ export default function configureStore( composeEnhancers( applyMiddleware( thunk.withExtraArgument({ nexus }), - routerMiddleware(history) + routerMiddleware(history), + DataExplorerFlowSliceListener.middleware ) ) ); diff --git a/src/shared/store/reducers/__tests__/ui-settings.ts b/src/shared/store/reducers/__tests__/ui-settings.ts index c6ebb3202..56a9f0ca6 100644 --- a/src/shared/store/reducers/__tests__/ui-settings.ts +++ b/src/shared/store/reducers/__tests__/ui-settings.ts @@ -24,14 +24,7 @@ describe('UISettings Reducer', () => { orgsListPageSize: 50, }, currentResourceView: null, - editorPopoverResolvedData: { - error: null, - left: 0, - open: false, - resolvedAs: undefined, - results: [], - top: 0, - }, + isAdvancedModeEnabled: false, }); }); }); diff --git a/src/shared/store/reducers/data-explorer.ts b/src/shared/store/reducers/data-explorer.ts index bfaf9a510..0c39bb652 100644 --- a/src/shared/store/reducers/data-explorer.ts +++ b/src/shared/store/reducers/data-explorer.ts @@ -1,54 +1,95 @@ -import { createSlice } from '@reduxjs/toolkit'; -import { slice, omit, clone, dropRight, nth } from 'lodash'; +import { + createListenerMiddleware, + createSlice, + isAnyOf, +} from '@reduxjs/toolkit'; +import { + slice, + clone, + dropRight, + nth, + last, + concat, + first, + drop, +} from 'lodash'; type TProject = string; type TOrganization = string; type TResourceID = string; type TVersionTag = number; -export type TDEResource = [TOrganization, TProject, TResourceID, TVersionTag]; +type TMediaType = string; + +export type TDEResourceWithoutMedia = [ + TOrganization, + TProject, + TResourceID, + TVersionTag +]; +export type TDEResourceWithMedia = [ + TOrganization, + TProject, + TResourceID, + TVersionTag, + TMediaType +]; +export type TDEResource = TDEResourceWithoutMedia | TDEResourceWithMedia; + export type TDELink = { _self: string; title: string; types?: string | string[]; resource?: TDEResource; + isDownloadable?: boolean; }; + export type TDataExplorerState = { - links: TDELink[]; + leftNodes: { + links: TDELink[]; + shrinked: boolean; + }; + rightNodes: { + links: TDELink[]; + shrinked: boolean; + }; current: TDELink | null; - shrinked: boolean; - limited: boolean; - highlightIndex: number; + referer?: { + pathname: string; + search: string; + state: Record<string, any>; + } | null; + fullscreen: boolean; }; +export type TNavigationStackSide = 'left' | 'right'; +export const DATA_EXPLORER_GRAPH_FLOW_PATH = '/data-explorer/graph-flow'; +export const DATA_EXPLORER_GRAPH_FLOW_DIGEST = 'data-explorer-last-navigation'; +export const MAX_NAVIGATION_ITEMS_IN_STACK = 3; + const initialState: TDataExplorerState = { - links: [], + leftNodes: { links: [], shrinked: false }, + rightNodes: { links: [], shrinked: false }, current: null, - shrinked: false, - limited: false, - highlightIndex: -1, + referer: null, + fullscreen: false, }; -export const DATA_EXPLORER_GRAPH_FLOW_DIGEST = 'data-explorer-last-navigation'; -export const MAX_NAVIGATION_ITEMS_IN_STACK = 5; -const calculateNewDigest = (state: TDataExplorerState) => { +const calculateDateExplorerGraphFlowDigest = (state: TDataExplorerState) => { const clonedState = clone(state); - const digest = btoa( - JSON.stringify({ - ...clonedState, - links: clonedState.links.map(i => omit(i, ['highlight'])), - current: omit(clonedState.current, ['highlight']), - }) - ); - localStorage.setItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST, digest); + const digest = btoa(JSON.stringify(clonedState)); + sessionStorage.setItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST, digest); }; const isShrinkable = (links: TDELink[]) => { return links.length > MAX_NAVIGATION_ITEMS_IN_STACK; }; +const DataExplorerFlowSliceName = 'data-explorer-graph-flow'; +const DataExplorerFlowSliceListener = createListenerMiddleware(); + export const dataExplorerSlice = createSlice({ initialState, - name: 'data-explorer-graph-flow', + name: DataExplorerFlowSliceName, reducers: { PopulateDataExplorerGraphFlow: (state, action) => { const digest = action.payload; @@ -56,7 +97,14 @@ export const dataExplorerSlice = createSlice({ try { return { ...newState, - shrinked: isShrinkable(newState.links), + leftNodes: { + links: newState.leftNodes.links, + shrinked: isShrinkable(newState.leftNodes.links), + }, + rightNodes: { + links: newState.rightNodes.links, + shrinked: isShrinkable(newState.rightNodes.links), + }, }; } catch (error) { return state; @@ -64,106 +112,262 @@ export const dataExplorerSlice = createSlice({ }, InitNewVisitDataExplorerGraphView: ( state, - { payload: { source, current, limited } } + { payload: { source, current, fullscreen, referer } } ) => { const newState = { ...state, + referer, current, - limited, - links: - source && current - ? source._self === current._self - ? [] - : [source] - : [], + fullscreen, + leftNodes: { + links: + source && current + ? source._self === current._self + ? [] + : [source] + : [], + shrinked: false, + }, }; - calculateNewDigest(newState); + calculateDateExplorerGraphFlowDigest(newState); return newState; }, AddNewNodeDataExplorerGraphFlow: (state, action) => { - const linkIndex = state.links.findIndex( - item => item._self === action.payload._self - ); - const isCurrentLink = state.current?._self === action.payload._self; - const newLinks = isCurrentLink - ? state.links - : linkIndex !== -1 - ? state.links - : [...state.links, state.current!]; - const newCurrent = - isCurrentLink || linkIndex !== -1 ? state.current : action.payload; - const newState: TDataExplorerState = { + if (action.payload._self === state.current?._self) { + console.log('@@same node'); + return state; + } + const newLink = action.payload as TDELink; + const whichSide = state.leftNodes.links.find( + link => link._self === newLink._self + ) + ? 'left' + : state.rightNodes.links.find(link => link._self === newLink._self) + ? 'right' + : null; + let leftNodesLinks: TDELink[] = []; + let rightNodesLinks: TDELink[] = []; + let current: TDELink; + switch (whichSide) { + case 'left': { + const index = state.leftNodes.links.findIndex( + link => link._self === newLink._self + ); + rightNodesLinks = concat( + slice(state.leftNodes.links, index + 1), + state.current ? [state.current] : [], + state.rightNodes.links + ); + leftNodesLinks = slice(state.leftNodes.links, 0, index); + current = state.leftNodes.links[index]; + break; + } + case 'right': { + const index = state.rightNodes.links.findIndex( + link => link._self === newLink._self + ); + // make the new link the current one + // add the links before that and the current one the left part + leftNodesLinks = concat( + state.leftNodes.links, + state.current ? [state.current] : [], + slice(state.rightNodes.links, 0, index) + ); + rightNodesLinks = slice(state.rightNodes.links, index + 1); + current = state.rightNodes.links[index]; + break; + } + case null: + default: { + leftNodesLinks = concat( + state.leftNodes.links, + state.current ? [state.current] : [] + ); + rightNodesLinks = []; + current = action.payload; + break; + } + } + const newState = { ...state, - links: newLinks, - current: newCurrent, - highlightIndex: linkIndex, - shrinked: linkIndex !== -1 ? false : isShrinkable(newLinks), + current, + leftNodes: { + links: leftNodesLinks, + shrinked: isShrinkable(leftNodesLinks), + }, + rightNodes: { + links: rightNodesLinks, + shrinked: isShrinkable(rightNodesLinks), + }, }; - calculateNewDigest(newState); return newState; }, JumpToNodeDataExplorerGraphFlow: (state, action) => { - const newLinks = slice(state.links, 0, action.payload); - const newState: TDataExplorerState = { + const index = action.payload.index as number; + const side = action.payload.side as TNavigationStackSide; + const realIndex = + side === 'left' ? index : state.leftNodes.links.length + index + 1; + const allLinks = concat( + state.leftNodes.links, + state.current ? [state.current] : [], + state.rightNodes.links + ); + const current = nth(allLinks, realIndex) as TDELink; + // construct left part + const leftNodesLinks = slice(allLinks, 0, realIndex); + const leftNodes = { + links: leftNodesLinks, + shrinked: isShrinkable(leftNodesLinks), + }; + // construct right part + const rightNodesLinks = slice(allLinks, realIndex + 1); + const rightNodes = { + links: rightNodesLinks, + shrinked: isShrinkable(rightNodesLinks), + }; + const newState = { ...state, - links: newLinks, - current: state.links[action.payload], - shrinked: isShrinkable(newLinks), + leftNodes, + rightNodes, + current, }; - calculateNewDigest(newState); return newState; }, ReturnBackDataExplorerGraphFlow: state => { - const lastItem = state.links.length ? nth(state.links, -1) : null; - const newLinks = dropRight(state.links); - const newState = lastItem - ? { - ...state, - links: newLinks, - current: lastItem, - shrinked: isShrinkable(newLinks), - } - : state; - calculateNewDigest(newState); + const newCurrent = last(state.leftNodes.links) as TDELink; + const current = state.current; + const newRightNodesLinks = concat( + current ? [current] : [], + state.rightNodes.links + ); + const newLeftNodesLinks = dropRight(state.leftNodes.links) as TDELink[]; + const rightNodes = { + links: newRightNodesLinks, + shrinked: isShrinkable(newRightNodesLinks), + }; + const leftNodes = { + links: newLeftNodesLinks, + shrinked: isShrinkable(newLeftNodesLinks), + }; + const newState = { + ...state, + rightNodes, + leftNodes, + current: newCurrent, + }; + return newState; + }, + MoveForwardDataExplorerGraphFlow: state => { + const newCurrent = first(state.rightNodes.links) as TDELink; + const current = state.current; + const newLeftNodesLinks = concat( + state.leftNodes.links, + current ? [current] : [] + ); + const newRightNodesLinks = drop(state.rightNodes.links) as TDELink[]; + const rightNodes = { + links: newRightNodesLinks, + shrinked: isShrinkable(newRightNodesLinks), + }; + const leftNodes = { + links: newLeftNodesLinks, + shrinked: isShrinkable(newLeftNodesLinks), + }; + const newState = { + ...state, + rightNodes, + leftNodes, + current: newCurrent, + }; return newState; }, - ExpandNavigationStackDataExplorerGraphFlow: state => { + ExpandNavigationStackDataExplorerGraphFlow: (state, action) => { + const side = action.payload.side as TNavigationStackSide; + const sideUpdater = + side === 'left' + ? { + leftNodes: { + ...state.leftNodes, + shrinked: false, + }, + } + : { + rightNodes: { + ...state.rightNodes, + shrinked: false, + }, + }; const newState = { ...state, - shrinked: false, + ...sideUpdater, }; - calculateNewDigest(newState); return newState; }, - ShrinkNavigationStackDataExplorerGraphFlow: state => { + ShrinkNavigationStackDataExplorerGraphFlow: (state, action) => { + const side = action.payload.side as TNavigationStackSide; + const sideUpdater = + side === 'left' + ? { + leftNodes: { + ...state.leftNodes, + shrinked: isShrinkable(state.leftNodes.links) ? true : false, + }, + } + : { + rightNodes: { + ...state.rightNodes, + shrinked: isShrinkable(state.rightNodes.links) ? true : false, + }, + }; const newState = { ...state, - shrinked: isShrinkable(state.links) ? true : false, + ...sideUpdater, }; - calculateNewDigest(newState); return newState; }, - ResetDataExplorerGraphFlow: () => { - return initialState; + ResetDataExplorerGraphFlow: (_, action) => { + return action.payload.initialState ?? initialState; }, - ResetHighlightedNodeDataExplorerGraphFlow: state => { - return { + InitDataExplorerGraphFlowFullscreenVersion: ( + state, + { payload: { fullscreen } }: { payload: { fullscreen?: boolean } } + ) => { + const newState = { ...state, - highlightIndex: -1, + fullscreen: fullscreen ?? !state.fullscreen, }; + return newState; }, }, }); + export const { PopulateDataExplorerGraphFlow, InitNewVisitDataExplorerGraphView, AddNewNodeDataExplorerGraphFlow, - JumpToNodeDataExplorerGraphFlow, ExpandNavigationStackDataExplorerGraphFlow, ShrinkNavigationStackDataExplorerGraphFlow, + JumpToNodeDataExplorerGraphFlow, ReturnBackDataExplorerGraphFlow, + MoveForwardDataExplorerGraphFlow, ResetDataExplorerGraphFlow, - ResetHighlightedNodeDataExplorerGraphFlow, + InitDataExplorerGraphFlowFullscreenVersion, } = dataExplorerSlice.actions; +const DataExplorerMiddlewareMatcher = isAnyOf( + InitNewVisitDataExplorerGraphView, + AddNewNodeDataExplorerGraphFlow, + ExpandNavigationStackDataExplorerGraphFlow, + ShrinkNavigationStackDataExplorerGraphFlow, + JumpToNodeDataExplorerGraphFlow, + ReturnBackDataExplorerGraphFlow, + MoveForwardDataExplorerGraphFlow, + InitDataExplorerGraphFlowFullscreenVersion +); +export { + DataExplorerFlowSliceName, + DataExplorerMiddlewareMatcher, + DataExplorerFlowSliceListener, + calculateDateExplorerGraphFlowDigest, +}; export default dataExplorerSlice.reducer; diff --git a/src/shared/store/reducers/ui-settings.ts b/src/shared/store/reducers/ui-settings.ts index e847e7cc1..4ee338243 100644 --- a/src/shared/store/reducers/ui-settings.ts +++ b/src/shared/store/reducers/ui-settings.ts @@ -4,7 +4,6 @@ import { UISettingsActions, UISettingsActionTypes, } from '../actions/ui-settings'; -import { TDELink } from './data-explorer'; export const DEFAULT_UI_SETTINGS: UISettingsState = { openCreationPanel: false, @@ -15,34 +14,14 @@ export const DEFAULT_UI_SETTINGS: UISettingsState = { linksListPageSize: 10, }, currentResourceView: null, - editorPopoverResolvedData: { - top: 0, - left: 0, - open: false, - results: [], - error: null, - resolvedAs: undefined, - }, -}; -export type TEditorPopoverResolvedAs = - | 'resource' - | 'resources' - | 'external' - | 'error' - | undefined; -export type TEditorPopoverResolvedData = { - open: boolean; - top: number; - left: number; - results?: TDELink | TDELink[]; - resolvedAs: TEditorPopoverResolvedAs; - error?: any; + isAdvancedModeEnabled: false, }; + export interface UISettingsState { openCreationPanel: boolean; pageSizes: { [key: string]: number }; currentResourceView: Resource | null; - editorPopoverResolvedData: TEditorPopoverResolvedData; + isAdvancedModeEnabled: boolean; } export default function uiSettingsReducer( @@ -70,18 +49,10 @@ export default function uiSettingsReducer( currentResourceView: action.payload, }; } - case UISettingsActionTypes.UPDATE_JSON_EDITOR_POPOVER: { + case UISettingsActionTypes.ENABLE_ADVANCED_MODE: { return { ...state, - editorPopoverResolvedData: { - ...state.editorPopoverResolvedData, - open: action.payload.open, - top: action.payload.top, - left: action.payload.left, - results: action.payload.results, - error: action.payload.error, - resolvedAs: action.payload.resolvedAs, - }, + isAdvancedModeEnabled: action.payload ?? !state.isAdvancedModeEnabled, }; } default: diff --git a/src/shared/styles/data-table.less b/src/shared/styles/data-table.less index 7a37b738e..6ad52939b 100644 --- a/src/shared/styles/data-table.less +++ b/src/shared/styles/data-table.less @@ -27,10 +27,12 @@ h3.ant-typography.table-title { margin: 0 20px; } -.ant-pagination-options .ant-pagination-options-size-changer { - display: none !important; -} - .studio-table-container { margin-bottom: 60px; // Leave space for the data-panel that shows cart objects } + +.studio-table-container + .ant-pagination-options + .ant-pagination-options-size-changer { + display: none !important; +} diff --git a/src/shared/utils/__tests__/normalized-types.spec.ts b/src/shared/utils/__tests__/normalized-types.spec.ts new file mode 100644 index 000000000..f8f807ed2 --- /dev/null +++ b/src/shared/utils/__tests__/normalized-types.spec.ts @@ -0,0 +1,33 @@ +import { getNormalizedTypes } from '..'; + +describe('getNormalizedTypes', () => { + const typesAsString = 'Resource'; + it('should return the normalized types', () => { + const result = getNormalizedTypes(typesAsString); + expect(result).toEqual(['Resource']); + }); + + const typesAsUrl = 'https://bluebrain.github.io/nexus/vocabulary/Resource'; + it('should return the normalized types', () => { + const result = getNormalizedTypes(typesAsUrl); + expect(result).toEqual(['Resource']); + }); + + const typesWithUrls = [ + 'https://bluebrain.github.io/nexus/vocabulary/Schema', + 'https://bluebrain.github.io/nexus/vocabulary/Resource', + 'https://bluebrain.github.io/nexus/vocabulary/Project', + 'Realm', + 'NeuronMorphology', + ]; + it('should return the normalized types', () => { + const result = getNormalizedTypes(typesWithUrls); + expect(result).toEqual([ + 'Schema', + 'Resource', + 'Project', + 'Realm', + 'NeuronMorphology', + ]); + }); +}); diff --git a/src/shared/utils/datapanel.ts b/src/shared/utils/datapanel.ts index 62daf5f37..8866689ac 100644 --- a/src/shared/utils/datapanel.ts +++ b/src/shared/utils/datapanel.ts @@ -133,7 +133,6 @@ export const toLocalStorageResources = ( }, ]; } catch (err) { - console.log('@error Failed to serialize resource for localStorage.', err); Sentry.captureException(err, { extra: { resource, diff --git a/src/shared/utils/download.ts b/src/shared/utils/download.ts index 4c5c35fc3..2de99721f 100644 --- a/src/shared/utils/download.ts +++ b/src/shared/utils/download.ts @@ -1,11 +1,14 @@ +import { fileExtensionFromResourceEncoding } from '../../utils/contentTypes'; + export const download = (filename: string, mediaType: string, data: any) => { const blob = new Blob([data], { type: mediaType }); + const extention = fileExtensionFromResourceEncoding(mediaType); if (window.navigator.msSaveBlob) { window.navigator.msSaveBlob(blob, filename); } else { const link = document.createElement('a'); link.href = URL.createObjectURL(blob); - link.download = filename; + link.download = extention ? `${filename}.${extention}` : filename; link.click(); } }; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 89f9b9452..d2a1d8dc8 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -2,12 +2,13 @@ import { Resource, Identity } from '@bbp/nexus-sdk'; import { isMatch, isMatchWith, - isRegExp, isMatchWithCustomizer, pick, isArray, + last, } from 'lodash'; import * as moment from 'moment'; +import isValidUrl from '../../utils/validUrl'; /** * getProp utility - an alternative to lodash.get @@ -301,6 +302,7 @@ export function getResourceLabel( resource.prefLabel ?? resource.label ?? resourceName ?? + resource._filename ?? labelOf(resource['@id']) ?? labelOf(resource._self) ); @@ -729,3 +731,18 @@ export function isNumeric(str: string | number) { !isNaN(str as any) && !isNaN(parseFloat(str)) // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... ); // ...and ensure strings of whitespace fail } + +export const getNormalizedTypes = (types?: string | string[]) => { + if (types) { + if (isArray(types)) { + return types.map(item => { + if (isValidUrl(item)) { + return item.split('/').pop()!; + } + return item; + }); + } + return [last(types.split('/'))!]; + } + return []; +}; diff --git a/src/shared/views/ResourceView.tsx b/src/shared/views/ResourceView.tsx index f7aa9a1da..0e07ded51 100644 --- a/src/shared/views/ResourceView.tsx +++ b/src/shared/views/ResourceView.tsx @@ -1,9 +1,18 @@ import * as React from 'react'; +import { useHistory } from 'react-router'; +import { clsx } from 'clsx'; import ResourceViewContainer from '../containers/ResourceViewContainer'; -const ResourceView: React.FunctionComponent = props => { +const ResourceView: React.FunctionComponent = () => { + const { location } = useHistory(); + const background = !!(location.state as any)?.background; return ( - <div className="resource-view view-container"> + <div + className={clsx( + 'resource-view view-container', + background && 'background' + )} + > <ResourceViewContainer /> </div> ); diff --git a/src/subapps/admin/components/Settings/ResolversSubView.tsx b/src/subapps/admin/components/Settings/ResolversSubView.tsx index 3584cca21..8aeead0ca 100644 --- a/src/subapps/admin/components/Settings/ResolversSubView.tsx +++ b/src/subapps/admin/components/Settings/ResolversSubView.tsx @@ -11,6 +11,8 @@ import ReactJson from 'react-json-view'; import { easyValidURL } from '../../../../utils/validUrl'; import './styles.less'; import { Link } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from 'shared/store/reducers'; type Props = {}; type TDataType = { diff --git a/src/subapps/admin/index.tsx b/src/subapps/admin/index.tsx index 523ea1918..565609f83 100644 --- a/src/subapps/admin/index.tsx +++ b/src/subapps/admin/index.tsx @@ -50,7 +50,6 @@ export const AdminSubappProviderHOC = (component: React.FunctionComponent) => { export const RedirectAdmin: React.FunctionComponent = props => { const location = useLocation(); const route = useRouteMatch(); - console.log('@@location', get(route.params, '0'), route); return ( <Redirect to={{ diff --git a/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx new file mode 100644 index 000000000..f651285a6 --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx @@ -0,0 +1,683 @@ +import { + doesResourceContain, + getAllPaths, + checkPathExistence, +} from './PredicateSelector'; + +describe('DataExplorerSpec-Utils', () => { + it('shows all paths for resources', () => { + const resources = [ + { + '@id': '123', + distribution: [ + { name: 'foo', label: ['label1', 'label2'] }, + { filename: 'foo2', label: 'label3' }, + ], + }, + { + '@id': '123', + distribution: [ + { + name: 'foo', + label: ['label1', 'label2'], + contentType: [ + { mimeType: 'application/json' }, + { + mimeType: 'application/csv', + extension: ['csv'], + possibleExtensions: ['2', '2'], + }, + ], + }, + { filename: 'foo2', label: 'label3' }, + ], + contributors: { + name: { firstname: 'Michael', lastname: 'Angelo' }, + }, + }, + ]; + const actual = getAllPaths(resources); + const expectedPaths = [ + 'contributors', + 'contributors.name', + 'contributors.name.firstname', + 'contributors.name.lastname', + 'distribution', + 'distribution.contentType', + 'distribution.contentType.extension', + 'distribution.contentType.mimeType', + 'distribution.contentType.possibleExtensions', + 'distribution.filename', + 'distribution.label', + 'distribution.name', + '@id', + ]; + expect(actual).toEqual(expectedPaths); + }); + + it('sorts path starting with underscore at the end of the list', () => { + const resources = [ + { + _author: { name: 'Archer', designation: 'spy' }, + name: 'nameValue', + _createdAt: '12 Jan 2020', + }, + { + _updatedAt: 'some time ago', + name: 'anotherNameValue', + _createdAt: '12 September 2020', + _project: 'secret project', + }, + ]; + const expectedPaths = [ + 'name', + '_createdAt', + '_project', + '_updatedAt', + + '_author', + '_author.designation', + '_author.name', + ]; + const receivedPaths = getAllPaths(resources); + + expect(receivedPaths).toEqual(expectedPaths); + }); + + it('checks if path exists in resource', () => { + const resource = { + foo: 'some value', + nullValue: null, + undefinedValue: undefined, + emptyString: '', + emptyArray: [], + emptyObject: {}, + distribution: [ + { + name: 'sally', + label: { + official: 'official', + unofficial: 'unofficial', + emptyArray: [], + emptyString: '', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + }, + { + name: 'sally', + sillyname: 'soliloquy', + label: [ + { + official: 'official', + emptyArray: [], + emptyString: '', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + { + official: 'official', + unofficial: 'unofficial', + emptyArray: [1], + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + ], + }, + ], + }; + expect(checkPathExistence(resource, 'bar')).toEqual(false); + expect(checkPathExistence(resource, 'nullValue')).toEqual(true); + expect(checkPathExistence(resource, 'undefinedValue')).toEqual(true); + expect(checkPathExistence(resource, 'emptyString')).toEqual(true); + expect(checkPathExistence(resource, 'emptyArray')).toEqual(true); + expect(checkPathExistence(resource, 'emptyObject')).toEqual(true); + + expect(checkPathExistence(resource, 'foo')).toEqual(true); + expect(checkPathExistence(resource, 'foo.xyz')).toEqual(false); + expect(checkPathExistence(resource, 'foo.distribution')).toEqual(false); + + expect(checkPathExistence(resource, 'distribution')).toEqual(true); + expect(checkPathExistence(resource, 'distribution.name')).toEqual(true); + expect(checkPathExistence(resource, 'distribution.name.sillyname')).toEqual( + false + ); + expect( + checkPathExistence(resource, 'distribution.name.sillyname.pancake') + ).toEqual(false); + expect( + checkPathExistence(resource, 'distribution.name.label.pancake') + ).toEqual(false); + expect( + checkPathExistence(resource, 'distribution.label.unofficial') + ).toEqual(true); // TODO: Add opposite + expect( + checkPathExistence(resource, 'distribution.label.extended.prefix') + ).toEqual(true); + expect( + checkPathExistence(resource, 'distribution.label.extended.suffix') + ).toEqual(true); // Add opposite + expect( + checkPathExistence(resource, 'distribution.label.extended.notexisting') + ).toEqual(false); // Add opposite + expect(checkPathExistence(resource, 'distribution.foo')).toEqual(false); + expect(checkPathExistence(resource, 'distribution.emptyArray')).toEqual( + false + ); + expect( + checkPathExistence(resource, 'distribution.label.emptyArray') + ).toEqual(true); + expect( + checkPathExistence(resource, 'distribution.label.emptyString') + ).toEqual(true); // Add opposite + }); + + it('check if path exists in resource with nested array', () => { + const resource = { + distribution: [ + { + foo: 'foovalue', + filename: ['filename1'], + }, + { + foo: 'foovalue', + }, + ], + objPath: { + filename: ['filename1'], + }, + }; + expect( + checkPathExistence(resource, 'distribution.filename', 'exists') + ).toEqual(true); + expect( + checkPathExistence(resource, 'distribution.filename', 'does-not-exist') + ).toEqual(true); + expect( + checkPathExistence(resource, 'objPath.filename', 'does-not-exist') + ).toEqual(false); + expect(checkPathExistence(resource, 'objPath.filename', 'exists')).toEqual( + true + ); + }); + + it('checks if path is missing in resource', () => { + const resource = { + foo: 'some value', + nullValue: null, + undefinedValue: undefined, + emptyString: '', + emptyArray: [], + emptyObject: {}, + distribution: [ + { + name: 'sally', + label: { + official: 'official', + unofficial: 'unofficial', + emptyArray: [], + emptyString: '', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + }, + { + name: 'sally', + sillyname: 'soliloquy', + label: [ + { + official: 'official', + emptyArray: [], + emptyString: '', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + { + official: 'official', + unofficial: 'unofficial', + emptyArray: [1], + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + ], + }, + ], + }; + expect(checkPathExistence(resource, 'bar', 'does-not-exist')).toEqual(true); + expect(checkPathExistence(resource, 'nullValue', 'does-not-exist')).toEqual( + false + ); + expect( + checkPathExistence(resource, 'undefinedValue', 'does-not-exist') + ).toEqual(false); + expect( + checkPathExistence(resource, 'emptyString', 'does-not-exist') + ).toEqual(false); + expect( + checkPathExistence(resource, 'emptyArray', 'does-not-exist') + ).toEqual(false); + expect( + checkPathExistence(resource, 'emptyObject', 'does-not-exist') + ).toEqual(false); + + expect(checkPathExistence(resource, 'foo', 'does-not-exist')).toEqual( + false + ); + expect(checkPathExistence(resource, 'foo.xyz', 'does-not-exist')).toEqual( + true + ); + expect( + checkPathExistence(resource, 'foo.distribution', 'does-not-exist') + ).toEqual(true); + + expect( + checkPathExistence(resource, 'distribution', 'does-not-exist') + ).toEqual(false); + expect( + checkPathExistence(resource, 'distribution.name', 'does-not-exist') + ).toEqual(false); + expect( + checkPathExistence( + resource, + 'distribution.name.sillyname', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.name.sillyname.pancake', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.name.label.pancake', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.label.unofficial', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.label.official', + 'does-not-exist' + ) + ).toEqual(false); + expect( + checkPathExistence( + resource, + 'distribution.label.extended.prefix', + 'does-not-exist' + ) + ).toEqual(false); + expect( + checkPathExistence( + resource, + 'distribution.label.extended.suffix', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.label.extended.notexisting', + 'does-not-exist' + ) + ).toEqual(true); + expect( + checkPathExistence(resource, 'distribution.foo', 'does-not-exist') + ).toEqual(true); + expect( + checkPathExistence(resource, 'distribution.emptyArray', 'does-not-exist') + ).toEqual(true); + expect( + checkPathExistence( + resource, + 'distribution.label.emptyArray', + 'does-not-exist' + ) + ).toEqual(false); + expect( + checkPathExistence( + resource, + 'distribution.label.emptyString', + 'does-not-exist' + ) + ).toEqual(true); + }); + + it('checks if array strings can be checked for contains', () => { + const resource = { + '@id': + 'https://bluebrain.github.io/nexus/vocabulary/defaultElasticSearchIndex', + '@type': ['ElasticSearchView', 'View'], + }; + expect(doesResourceContain(resource, '@type', '')).toEqual(true); + expect(doesResourceContain(resource, '@type', 'ElasticSearchView')).toEqual( + true + ); + expect(doesResourceContain(resource, '@type', 'File')).toEqual(false); + }); + + it('checks if path has a specific value', () => { + const resource = { + foo: 'some value', + bar: 42, + distribution: [ + { + name: 'sally', + filename: 'billy', + label: { + official: 'official', + unofficial: 'rebel', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + label: [ + { + official: 'official', + extended: [{ prefix: '1', suffix: 2 }, { prefix: '1' }], + }, + { + official: 'official', + unofficial: 'rebel', + extended: [{ prefix: 1, suffix: '2' }, { prefix: '1' }], + }, + ], + }, + ], + }; + expect(doesResourceContain(resource, 'foo', '')).toEqual(true); + expect(doesResourceContain(resource, 'foo', 'some value')).toEqual(true); + expect(doesResourceContain(resource, 'foo', '2')).toEqual(false); + expect(doesResourceContain(resource, 'bar', '42')).toEqual(true); + expect(doesResourceContain(resource, 'distribution.name', 'sally')).toEqual( + true + ); + expect( + doesResourceContain(resource, 'distribution.sillyname', 'sally') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.filename', 'billy') + ).toEqual(true); + expect( + doesResourceContain(resource, 'distribution.label', 'madeUpLabel') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.official', 'official') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.label.official', 'official') + ).toEqual(true); + expect( + doesResourceContain(resource, 'distribution.label.unofficial', 'official') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.label.unofficial', 'rebel') + ).toEqual(true); + expect( + doesResourceContain(resource, 'distribution.label.extended.prefix', '1') + ).toEqual(true); + expect( + doesResourceContain(resource, 'distribution.label.extended.prefix', '10') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.label.extended.suffix', '1') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.label.extended.suffix', '2') + ).toEqual(true); + expect( + doesResourceContain( + resource, + 'distribution.label.extended.nonexisting', + '2' + ) + ).toEqual(false); + }); + + it('ignores case when checking for contains value', () => { + const resource = { + distribution: [ + { + name: 'sally', + filename: 'billy', + label: ['ChiPmunK'], + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + }, + ], + }; + expect( + doesResourceContain(resource, 'distribution.filename', 'BiLLy') + ).toEqual(true); + expect( + doesResourceContain(resource, 'distribution.filename', 'Lilly') + ).toEqual(false); + expect( + doesResourceContain(resource, 'distribution.label', 'chipmunk') + ).toEqual(true); + }); + + it('checks if value is a substring in existing path when checking for contains', () => { + const resource = { + distribution: [ + { + name: 'sally', + filename: 'billy', + label: ['ChiPmunK'], + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + }, + ], + }; + expect( + doesResourceContain(resource, 'distribution.filename', 'lly') + ).toEqual(true); + }); + + it('checks if path exists in resource', () => { + const resource = { + distribution: [ + { + name: 'sally', + filename: 'billy', + label: ['ChiPmunK'], + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + label: { foo: 'foovalut', bar: 'barvalue' }, + }, + ], + }; + expect(checkPathExistence(resource, 'topLevelNotExisting')).toEqual(false); + }); + + it('checks if resource does not contain value in path', () => { + const resource = { + distribution: [ + { + name: 'sally', + filename: 'billy', + label: ['ChiPmunK'], + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + label: { foo: 'foovalut', bar: 'barvalue' }, + }, + ], + }; + + expect( + doesResourceContain(resource, 'distribution', 'sally', 'does-not-contain') + ).toEqual(true); + + expect( + doesResourceContain( + resource, + 'distribution.name', + 'sally', + 'does-not-contain' + ) + ).toEqual(false); + + expect( + doesResourceContain( + resource, + 'distribution.filename', + 'billy', + 'does-not-contain' + ) + ).toEqual(true); + + expect( + doesResourceContain( + resource, + 'distribution.filename', + 'popeye', + 'does-not-contain' + ) + ).toEqual(true); + }); + + it('checks if resource does not contain value for nested paths', () => { + const resource = { + distribution: [ + { + name: 'sally', + filename: 'billy', + label: ['ChiPmunK'], + nested: [{ prop1: 'value1', prop2: ['value2', 'value3'] }], + }, + { + name: 'sally', + sillyname: 'soliloquy', + filename: 'bolly', + label: { foo: 'foovalut', bar: 'barvalue' }, + nested: [{ prop1: 'value1', prop2: ['value2', 'value5'] }], + }, + ], + }; + + expect( + doesResourceContain( + resource, + 'distribution.label', + 'chipmunk', + 'does-not-contain' + ) + ).toEqual(true); + + expect( + doesResourceContain( + resource, + 'distribution.label', + 'crazy', + 'does-not-contain' + ) + ).toEqual(true); + + expect( + doesResourceContain( + resource, + 'distribution.nested', + 'crazy', + 'does-not-contain' + ) + ).toEqual(true); + + expect( + doesResourceContain( + resource, + 'distribution.nested.prop2', + 'value2', + 'does-not-contain' + ) + ).toEqual(true); // This is expected since the in the arrays ([`value2`, `value3`] & [`value2`, `value5`]) there is atleast 1 element (`value3` in the 1st array and value5 in the 2nd) that does not contain "value2" + + expect( + doesResourceContain( + resource, + 'distribution.nested.prop2', + 'value', + 'does-not-contain' + ) + ).toEqual(false); + + expect( + doesResourceContain( + resource, + 'distribution.nested.prop2', + 'value5', + 'does-not-contain' + ) + ).toEqual(true); + }); + + it('does not throw when checking for non existence on a path when resource has primitve value', () => { + const resource = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + { + '1Point': { + '@id': 'nsg:1Point', + }, + '2DContour': { + '@id': 'nsg:2DContour', + }, + '3DContour': { + '@id': 'nsg:3DContour', + }, + '3Point': { + '@id': 'nsg:3Point', + }, + '@vocab': + 'https://bbp-nexus.epfl.ch/vocabs/bbp/neurosciencegraph/core/v0.1.0/', + Derivation: { + '@id': 'prov:Derivation', + }, + xsd: 'http://www.w3.org/2001/XMLSchema#', + }, + ], + '@id': 'https://bbp.epfl.ch/nexus/search/neuroshapes', + _constrainedBy: + 'https://bluebrain.github.io/nexus/schemas/unconstrained.json', + _createdAt: '2019-02-11T14:15:14.020Z', + _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman', + _deprecated: false, + _incoming: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/incoming', + _outgoing: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/outgoing', + _project: + 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public', + _rev: 1, + _schemaProject: + 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public', + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes', + _updatedAt: '2019-02-11T14:15:14.020Z', + _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman', + }; + + expect( + checkPathExistence(resource, '@context.@vocab', 'does-not-exist') + ).toEqual(true); + }); +}); diff --git a/src/subapps/dataExplorer/DataExplorer.spec.tsx b/src/subapps/dataExplorer/DataExplorer.spec.tsx new file mode 100644 index 000000000..7b72f386b --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorer.spec.tsx @@ -0,0 +1,1001 @@ +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 userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import { + dataExplorerPageHandler, + filterByProjectHandler, + getCompleteResources, + getMockResource, + sourceResourceHandler, +} from '__mocks__/handlers/DataExplorer/handlers'; +import { deltaPath } from '__mocks__/handlers/handlers'; +import { setupServer } from 'msw/node'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { render, screen, waitFor } from '../../utils/testUtil'; +import { DataExplorer } from './DataExplorer'; +import { AllProjects } from './ProjectSelector'; +import { getColumnTitle } from './DataExplorerTable'; +import { + CONTAINS, + DOES_NOT_CONTAIN, + DOES_NOT_EXIST, + EXISTS, + getAllPaths, +} from './PredicateSelector'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import configureStore from '../../shared/store'; +import { ALWAYS_DISPLAYED_COLUMNS, isNexusMetadata } from './DataExplorerUtils'; + +describe('DataExplorer', () => { + const defaultTotalResults = 500_123; + const mockResourcesOnPage1: Resource[] = getCompleteResources(); + const mockResourcesForPage2: Resource[] = [ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]; + + const server = setupServer( + dataExplorerPageHandler(undefined, defaultTotalResults), + sourceResourceHandler(), + filterByProjectHandler() + ); + const history = createMemoryHistory({}); + + let container: HTMLElement; + let user: UserEvent; + let component: RenderResult; + let dataExplorerPage: JSX.Element; + + beforeEach(async () => { + server.listen(); + const queryClient = new QueryClient(); + const nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + const store = configureStore(history, { nexus }, {}); + + dataExplorerPage = ( + <Provider store={store}> + <QueryClientProvider client={queryClient}> + <Router history={history}> + <NexusProvider nexusClient={nexus}> + <DataExplorer /> + </NexusProvider> + </Router> + </QueryClientProvider> + </Provider> + ); + + component = render(dataExplorerPage); + + container = component.container; + user = userEvent.setup(); + await expectRowCountToBe(mockResourcesOnPage1.length); + }); + + afterEach(async () => { + server.resetHandlers(); + await userEvent.click(container); // Close any open dropdowns + }); + + afterAll(() => { + server.resetHandlers(); + server.close(); + }); + + const DropdownSelector = '.ant-select-dropdown'; + const DropdownOptionSelector = 'div.ant-select-item-option-content'; + const CustomOptionSelector = 'div.ant-select-item-option-content > span'; + + const PathMenuLabel = 'path-selector'; + const PredicateMenuLabel = 'predicate-selector'; + const ProjectMenuLabel = 'project-filter'; + const TypeMenuLabel = 'type-filter'; + + const expectRowCountToBe = async (expectedRowsCount: number) => { + return await waitFor(() => { + const rows = visibleTableRows(); + expect(rows.length).toEqual(expectedRowsCount); + return rows; + }); + }; + + const waitForHeaderToBeHidden = async () => { + return await waitFor(() => { + const dataExplorerHeader = document.querySelector( + '.data-explorer-header' + ) as HTMLDivElement; + expect(dataExplorerHeader).not.toBeVisible(); + }); + }; + + const waitForHeaderToBeVisible = async () => { + return await waitFor(() => { + const dataExplorerHeader = document.querySelector( + '.data-explorer-header' + ) as HTMLDivElement; + expect(dataExplorerHeader).toBeVisible(); + }); + }; + + const expectColumHeaderToExist = async (name: string) => { + const nameReg = new RegExp(getColumnTitle(name), 'i'); + const header = await screen.getByText(nameReg, { + selector: 'th .ant-table-column-title', + exact: false, + }); + expect(header).toBeInTheDocument(); + return header; + }; + + const getColumnSorter = async (colName: string) => { + const column = await expectColumHeaderToExist(colName); + return column.closest('.ant-table-column-sorters'); + }; + + const getTotalColumns = () => { + return Array.from(container.querySelectorAll('th')); + }; + + const expectColumHeaderToNotExist = async (name: string) => { + expect(expectColumHeaderToExist(name)).rejects.toThrow(); + }; + + const getTextForColumn = async (resource: Resource, colName: string) => { + const row = await screen.getByTestId(resource._self); + + const allCellsForRow = Array.from(row.childNodes); + const colIndex = Array.from( + container.querySelectorAll('th') + ).findIndex(header => + header.innerHTML.match(new RegExp(getColumnTitle(colName), 'i')) + ); + return allCellsForRow[colIndex].textContent; + }; + + const getRowForResource = async (resource: Resource) => { + const row = await screen.getByTestId(resource._self); + expect(row).toBeInTheDocument(); + return row!; + }; + + const openProjectAutocomplete = async () => { + const projectAutocomplete = await getProjectAutocomplete(); + await userEvent.click(projectAutocomplete); + return projectAutocomplete; + }; + + const searchForProject = async (searchTerm: string) => { + const projectAutocomplete = await openProjectAutocomplete(); + await userEvent.clear(projectAutocomplete); + await userEvent.type(projectAutocomplete, searchTerm); + return projectAutocomplete; + }; + + const expectProjectOptionsToMatch = async (searchTerm: string) => { + const projectOptions = await screen.getAllByRole('option'); + expect(projectOptions.length).toBeGreaterThan(0); + projectOptions.forEach(option => { + expect(option.innerHTML).toMatch(new RegExp(searchTerm, 'i')); + }); + }; + + const projectFromRow = (row: Element) => { + const projectColumn = row.querySelector('td'); // first column is the project column + return projectColumn?.textContent; + }; + + const typeFromRow = (row: Element) => { + const typeColumn = row.querySelectorAll('td')[1]; // second column is the type column + return typeColumn?.textContent; + }; + + const columnTextFromRow = (row: Element, colName: string) => { + const column = row.querySelector(`td.data-explorer-column-${colName}`); + return column?.textContent; + }; + + const visibleTableRows = () => { + return container.querySelectorAll('table tbody tr.data-explorer-row'); + }; + + const getProjectAutocomplete = async () => { + return await screen.getByLabelText('project-filter', { + selector: 'input', + }); + }; + + const getDropdownOption = async ( + optionLabel: string, + selector: string = DropdownOptionSelector + ) => + await screen.getByText(new RegExp(`${optionLabel}$`, 'i'), { + selector, + }); + + const getRowsForNextPage = async ( + resources: Resource[], + total: number = 300 + ) => { + server.use( + sourceResourceHandler(resources), + dataExplorerPageHandler(resources, total) + ); + + const pageInput = await screen.getByRole('listitem', { name: '2' }); + expect(pageInput).toBeInTheDocument(); + + await user.click(pageInput); + + await expectRowCountToBe(3); + }; + + const getInputForLabel = async (label: string) => { + return (await screen.getByLabelText(label, { + selector: 'input', + })) as HTMLInputElement; + }; + + const getSelectedValueInMenu = async (menuLabel: string) => { + const input = await getInputForLabel(menuLabel); + return input + .closest('.ant-select-selector') + ?.querySelector('.ant-select-selection-item')?.innerHTML; + }; + + const openMenuFor = async (ariaLabel: string) => { + const menuInput = await getInputForLabel(ariaLabel); + await userEvent.click(menuInput, { pointerEventsCheck: 0 }); + await act(async () => { + fireEvent.mouseDown(menuInput); + }); + const menuDropdown = document.querySelector(DropdownSelector); + expect(menuDropdown).toBeInTheDocument(); + return menuDropdown; + }; + + const selectPath = async (path: string) => { + await selectOptionFromMenu(PathMenuLabel, path, CustomOptionSelector); + }; + + const selectPredicate = async (predicate: string) => { + await selectOptionFromMenu(PredicateMenuLabel, predicate); + }; + + const selectOptionFromMenu = async ( + menuAriaLabel: string, + optionLabel: string, + optionSelector?: string + ) => { + await openMenuFor(menuAriaLabel); + const option = await getDropdownOption(optionLabel, optionSelector); + await userEvent.click(option, { pointerEventsCheck: 0 }); + }; + + /** + * @returns All options visible in the currently open dropdown menu in the DOM. + * NOTE: Since antd menus use virtual scroll, not all options inside the menu are visible. + * This function only returns those options that are visible. + */ + const getVisibleOptionsFromMenu = ( + selector: string = DropdownOptionSelector + ) => { + const menuDropdown = document.querySelector(DropdownSelector); + return Array.from(menuDropdown?.querySelectorAll(selector) ?? []); + }; + + const getTotalSizeOfDataset = async (expectedCount: string) => { + const totalFromBackend = await screen.getByText('Total:'); + const totalCount = within(totalFromBackend).getByText( + new RegExp(`${expectedCount} dataset`, 'i') + ); + return totalCount; + }; + + const getSizeOfCurrentlyLoadedData = async (expectedCount: number) => { + const totalFromBackend = await screen.getByText( + 'Sample loaded for review:' + ); + const totalCount = within(totalFromBackend).getByText( + new RegExp(`${expectedCount}`, 'i') + ); + return totalCount; + }; + + const getFilteredResultsCount = async (expectedCount: number = 0) => { + const filteredCountLabel = await screen.queryByText('Filtered:'); + if (!filteredCountLabel) { + return filteredCountLabel; + } + const filteredCount = within(filteredCountLabel).getByText( + new RegExp(`${expectedCount}`, 'i') + ); + return filteredCount; + }; + + const updateResourcesShownInTable = async ( + resources: Resource[] = mockResourcesForPage2 + ) => { + await expectRowCountToBe(10); + await getRowsForNextPage(resources); + await expectRowCountToBe(resources.length); + }; + + const getResetProjectButton = async () => { + return await screen.getByTestId('reset-project-button'); + }; + + const showMetadataSwitch = async () => + await screen.getByLabelText('Show metadata'); + + const showEmptyDataCellsSwitch = async () => + await screen.getByLabelText('Show empty data cells'); + + const resetPredicate = async () => { + const resetPredicateButton = await screen.getByRole('button', { + name: /reset predicate/i, + }); + await userEvent.click(resetPredicateButton); + }; + + const expectRowsInOrder = async (expectedOrder: Resource[]) => { + for await (const [index, row] of visibleTableRows().entries()) { + const text = await columnTextFromRow(row, 'author'); + if (expectedOrder[index].author) { + expect(text).toMatch(JSON.stringify(expectedOrder[index].author)); + } else { + expect(text).toMatch(/No data/i); + } + } + }; + + const expectDataExplorerHeaderToExist = async () => { + const pro = await getProjectAutocomplete(); + expect(pro).toBeVisible(); + const type = await getInputForLabel(TypeMenuLabel); + expect(type).toBeVisible(); + const predicate = await getInputForLabel(PathMenuLabel); + expect(predicate).toBeVisible(); + const totalResultsCount = await getTotalSizeOfDataset('500,123'); + expect(totalResultsCount).toBeVisible(); + const metadataSwitch = await showMetadataSwitch(); + expect(metadataSwitch).toBeVisible(); + const showEmptyCellsToggle = await showEmptyDataCellsSwitch(); + expect(showEmptyCellsToggle).toBeVisible(); + }; + + const scrollWindow = async (yPosition: number) => { + await fireEvent.scroll(window, { target: { scrollY: yPosition } }); + }; + + const getButtonByLabel = async (label: string) => { + const buttonElement = await screen.getByRole('button', { + name: label, + }); + return buttonElement; + }; + + const expandHeaderButton = async () => + await getButtonByLabel('expand-header'); + + const collapseHeaderButton = async () => + await getButtonByLabel('collapse-header'); + + const clickExpandHeaderButton = async () => { + const expandHeaderButtonElement = await expandHeaderButton(); + + await userEvent.click(expandHeaderButtonElement); + }; + + const clickCollapseHeaderButton = async () => { + const collapseHeaderButtonElement = await collapseHeaderButton(); + await userEvent.click(collapseHeaderButtonElement); + }; + + it('shows columns for fields that are only in source data', async () => { + await expectRowCountToBe(10); + const column = await expectColumHeaderToExist('userProperty1'); + expect(column).toBeInTheDocument(); + }); + + it('shows rows for all fetched resources', async () => { + await expectRowCountToBe(10); + }); + + it('shows only user columns for each top level property by default', async () => { + await expectRowCountToBe(10); + const seenColumns = new Set(); + + for (const mockResource of mockResourcesOnPage1) { + for (const topLevelProperty of Object.keys(mockResource)) { + if (!seenColumns.has(topLevelProperty)) { + seenColumns.add(topLevelProperty); + + if (ALWAYS_DISPLAYED_COLUMNS.has(topLevelProperty)) { + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); + } else if (isNexusMetadata(topLevelProperty)) { + expect( + expectColumHeaderToExist(getColumnTitle(topLevelProperty)) + ).rejects.toThrow(); + } else { + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); + } + } + } + } + + expect(seenColumns.size).toBeGreaterThan(1); + }); + + it('shows user columns for all top level properties when show user metadata clicked', async () => { + await expectRowCountToBe(10); + const showMetadataButton = await showMetadataSwitch(); + await userEvent.click(showMetadataButton); + + const seenColumns = new Set(); + + for (const mockResource of mockResourcesOnPage1) { + for (const topLevelProperty of Object.keys(mockResource)) { + if (!seenColumns.has(topLevelProperty)) { + seenColumns.add(topLevelProperty); + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); + } + } + } + + expect(seenColumns.size).toBeGreaterThan(1); + }); + + it('shows project as the first column', async () => { + await expectRowCountToBe(10); + const firstColumn = container.querySelector('th.data-explorer-column'); + expect(firstColumn?.textContent).toMatch(/project/i); + }); + + it('shows type as the second column', async () => { + await expectRowCountToBe(10); + const secondColumn = container.querySelectorAll( + 'th.data-explorer-column' + )[1]; + expect(secondColumn?.textContent).toMatch(/type/i); + }); + + it('updates columns when new page is selected', async () => { + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await expectColumHeaderToExist('author'); + await expectColumHeaderToExist('edition'); + await expectColumHeaderToExist('year'); + }); + + it('updates page size', async () => { + await expectRowCountToBe(10); + + const mock100Resources: Resource[] = []; + + for (let i = 0; i < 100; i = i + 1) { + mock100Resources.push(getMockResource(`self${i}`, {})); + } + + server.use(dataExplorerPageHandler(mock100Resources)); + + const pageSizeChanger = await screen.getByRole('combobox', { + name: 'Page Size', + }); + await userEvent.click(pageSizeChanger); + const twentyRowsOption = await screen.getByTitle('20 / page'); + await userEvent.click(twentyRowsOption, { pointerEventsCheck: 0 }); + await expectRowCountToBe(20); + }); + + it('shows No data text when values are missing for a column', async () => { + await expectRowCountToBe(10); + const resourceWithMissingProperty = mockResourcesOnPage1.find( + res => !('specialProperty' in res) + )!; + const textForSpecialProperty = await getTextForColumn( + resourceWithMissingProperty, + 'specialProperty' + ); + expect(textForSpecialProperty).toMatch(/No data/i); + }); + + it('shows No data text when values is undefined', async () => { + await expectRowCountToBe(10); + const resourceWithUndefinedProperty = mockResourcesOnPage1.find( + res => res.specialProperty === undefined + )!; + const textForSpecialProperty = await getTextForColumn( + resourceWithUndefinedProperty, + 'specialProperty' + ); + expect(textForSpecialProperty).toMatch(/No data/i); + }); + + it('does not show No data text when values is null', async () => { + await expectRowCountToBe(10); + const resourceWithUndefinedProperty = mockResourcesOnPage1.find( + res => res.specialProperty === null + )!; + const textForSpecialProperty = await getTextForColumn( + resourceWithUndefinedProperty, + 'specialProperty' + ); + expect(textForSpecialProperty).not.toMatch(/No data/i); + expect(textForSpecialProperty).toMatch(/null/); + }); + + it('does not show No data when value is empty string', async () => { + await expectRowCountToBe(10); + const resourceWithEmptyString = mockResourcesOnPage1.find( + res => res.specialProperty === '' + )!; + + const textForSpecialProperty = await getTextForColumn( + resourceWithEmptyString, + 'specialProperty' + ); + expect(textForSpecialProperty).not.toMatch(/No data/i); + expect(textForSpecialProperty).toEqual('""'); + }); + + it('does not show No data when value is empty array', async () => { + await expectRowCountToBe(10); + const resourceWithEmptyArray = mockResourcesOnPage1.find( + res => + Array.isArray(res.specialProperty) && res.specialProperty.length === 0 + )!; + + const textForSpecialProperty = await getTextForColumn( + resourceWithEmptyArray, + 'specialProperty' + ); + expect(textForSpecialProperty).not.toMatch(/No data/i); + expect(textForSpecialProperty).toEqual('[]'); + }); + + it('does not show No data when value is empty object', async () => { + await expectRowCountToBe(10); + const resourceWithEmptyObject = mockResourcesOnPage1.find( + res => + typeof res.specialProperty === 'object' && + res.specialProperty !== null && + !Array.isArray(res.specialProperty) && + Object.keys(res.specialProperty).length === 0 + )!; + + const textForSpecialProperty = await getTextForColumn( + resourceWithEmptyObject, + 'specialProperty' + ); + expect(textForSpecialProperty).not.toMatch(/No data/i); + expect(textForSpecialProperty).toEqual('{}'); + }); + + it('shows resources filtered by the selected project', async () => { + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + + visibleTableRows().forEach(row => + expect(projectFromRow(row)).toMatch(/unhcr/i) + ); + }); + + it('resets selected project when user clicks reset button', async () => { + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + + expect(visibleTableRows().length).toBeLessThan(10); + + const resetProjectButton = await getResetProjectButton(); + await userEvent.click(resetProjectButton); + await expectRowCountToBe(10); + }); + + it('shows all projects when allProjects option is selected', async () => { + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + + expect(visibleTableRows().length).toBeLessThan(10); + + const resetProjectButton = await getResetProjectButton(); + await userEvent.click(resetProjectButton); + await selectOptionFromMenu(ProjectMenuLabel, AllProjects); + + await expectRowCountToBe(10); + }); + + it('shows autocomplete options for project filter', async () => { + await searchForProject('bbp'); + await expectProjectOptionsToMatch('bbp'); + + await searchForProject('bbc'); + await expectProjectOptionsToMatch('bbc'); + }); + + it('shows resources filtered by the selected type', async () => { + await expectRowCountToBe(10); + await selectOptionFromMenu(TypeMenuLabel, 'file', CustomOptionSelector); + + visibleTableRows().forEach(row => + expect(typeFromRow(row)).toMatch(/file/i) + ); + }); + + it('only shows types that exist in selected project in type autocomplete', async () => { + await expectRowCountToBe(10); + + await openMenuFor(TypeMenuLabel); + const optionBefore = await getDropdownOption( + 'Dataset', + CustomOptionSelector + ); + expect(optionBefore).toBeInTheDocument(); + + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + await expectRowCountToBe(2); + + await openMenuFor(TypeMenuLabel); + expect( + getDropdownOption('Dataset', CustomOptionSelector) + ).rejects.toThrowError(); + }); + + it('shows paths as options in a select menu of path selector', async () => { + await expectRowCountToBe(10); + await openMenuFor('path-selector'); + + const pathOptions = getVisibleOptionsFromMenu(CustomOptionSelector); + + const expectedPaths = getAllPaths(mockResourcesOnPage1); + expect(expectedPaths.length).toBeGreaterThanOrEqual( + Object.keys(mockResourcesOnPage1[0]).length + ); + + pathOptions.forEach((path, index) => { + expect(path.innerHTML).toMatch( + new RegExp(`${expectedPaths[index]}$`, 'i') + ); + }); + + expect(pathOptions.length).toBeGreaterThanOrEqual(3); // Since antd options in a select menu are displayed in a virtual list (by default), not all expected options are in the DOM. + }); + + it('shows resources that have path missing', async () => { + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await selectPath('author'); + await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); + await expectRowCountToBe(1); + + await selectPath('edition'); + await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); + await expectRowCountToBe(2); + }); + + it('shows resources that contains value provided by user', async () => { + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await selectPath('author'); + await userEvent.click(container); + await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); + const valueInput = await screen.getByPlaceholderText('Search for...'); + await userEvent.type(valueInput, 'iggy'); + await expectRowCountToBe(2); + + await userEvent.clear(valueInput); + + await userEvent.type(valueInput, 'goldilocks'); + await expectRowCountToBe(0); + }); + + it('shows all resources when the user has not typed anything in the value filter', async () => { + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await selectPath('author'); + await userEvent.click(container); + await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); + await expectRowCountToBe(3); + }); + + 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 selectPath('author'); + await userEvent.click(container); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + await expectRowCountToBe(2); + }); + + it('filters by resources that do not contain value provided by user', async () => { + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await selectPath('author'); + await userEvent.click(container); + await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_CONTAIN); + const valueInput = await screen.getByPlaceholderText('Search for...'); + await userEvent.type(valueInput, 'iggy'); + await expectRowCountToBe(2); + + await userEvent.clear(valueInput); + await userEvent.type(valueInput, 'goldilocks'); + await expectRowCountToBe(3); + + await userEvent.clear(valueInput); + await userEvent.type(valueInput, 'piggy'); + await expectRowCountToBe(2); + + await userEvent.clear(valueInput); + await userEvent.type(valueInput, 'arch'); + await expectRowCountToBe(3); + }); + + it('navigates to resource view when user clicks on row', async () => { + await expectRowCountToBe(10); + + expect(history.location.pathname).not.toContain('self1'); + + const firstDataRow = await getRowForResource(mockResourcesOnPage1[0]); + await userEvent.click(firstDataRow); + + expect(history.location.pathname).toContain('self1'); + }); + + it('shows total size of dataset', async () => { + await expectRowCountToBe(10); + const totalBackendCount = await getTotalSizeOfDataset('500,123'); + expect(totalBackendCount).toBeVisible(); + }); + + it('shows results currently loaded in frontend', async () => { + await expectRowCountToBe(10); + const loadedDataCount = await getSizeOfCurrentlyLoadedData(10); + expect(loadedDataCount).toBeVisible(); + }); + + it('shows updated total from backend when user searches by project', async () => { + await expectRowCountToBe(10); + const totalBackendBefore = await getTotalSizeOfDataset('500,123'); + expect(totalBackendBefore).toBeVisible(); + + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + await expectRowCountToBe(2); + + const totalBackendAfter = await getTotalSizeOfDataset('2'); + expect(totalBackendAfter).toBeVisible(); + }); + + it('does not show filtered count if predicate is not selected', async () => { + await expectRowCountToBe(10); + const totalFromFrontend = await getFilteredResultsCount(); + expect(totalFromFrontend).toEqual(null); + }); + + it('shows total filtered count if predicate is selected', async () => { + await expectRowCountToBe(10); + const totalFromFrontendBefore = await getFilteredResultsCount(); + expect(totalFromFrontendBefore).toEqual(null); + + await updateResourcesShownInTable([ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]); + + await selectPath('author'); + await userEvent.click(container); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + await expectRowCountToBe(2); + + const totalFromFrontendAfter = await getFilteredResultsCount(2); + expect(totalFromFrontendAfter).toBeVisible(); + }); + + it('shows column for metadata path even if toggle for show metadata is off', async () => { + const metadataProperty = '_createdBy'; + await expectRowCountToBe(10); + + await expectColumHeaderToNotExist(metadataProperty); + + const originalColumns = getTotalColumns().length; + + await selectPath(metadataProperty); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + + await expectColumHeaderToExist(metadataProperty); + expect(getTotalColumns().length).toEqual(originalColumns + 1); + + await resetPredicate(); + expect(getTotalColumns().length).toEqual(originalColumns); + }); + + it('resets predicate fields when reset predicate clicked', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + + await selectPath('author'); + await selectPredicate(EXISTS); + + const selectedPathBefore = await getSelectedValueInMenu(PathMenuLabel); + expect(selectedPathBefore).toMatch(/author/); + + await expectRowCountToBe(2); + + await resetPredicate(); + + await expectRowCountToBe(3); + + const selectedPathAfter = await getSelectedValueInMenu(PathMenuLabel); + expect(selectedPathAfter).toBeFalsy(); + }); + + it('only shows predicate menu if path is selected', async () => { + await expectRowCountToBe(10); + expect(openMenuFor(PredicateMenuLabel)).rejects.toThrow(); + await selectPath('@type'); + expect(openMenuFor(PredicateMenuLabel)).resolves.not.toThrow(); + }); + + it('sorts table columns', async () => { + const dataSource = [ + getMockResource('self1', { author: 'tweaty', edition: 1 }), + getMockResource('self2', { edition: 2001 }), + getMockResource('self3', { year: 2013, author: 'piggy' }), + ]; + await updateResourcesShownInTable(dataSource); + + await expectRowsInOrder(dataSource); + + const authorColumnSorter = await getColumnSorter('author'); + await userEvent.click(authorColumnSorter!); + + await expectRowsInOrder([dataSource[1], dataSource[2], dataSource[0]]); + }); + + it('does not show "No data" cell if "Show empty data cells" toggle is turned off', async () => { + await expectRowCountToBe(10); + const resourceWithMissingProperty = mockResourcesOnPage1.find( + res => !('specialProperty' in res) + )!; + const textForSpecialProperty = await getTextForColumn( + resourceWithMissingProperty, + 'specialProperty' + ); + expect(textForSpecialProperty).toMatch(/No data/i); + + const button = await showEmptyDataCellsSwitch(); + await userEvent.click(button); + + const textForSpecialPropertyAfter = await getTextForColumn( + resourceWithMissingProperty, + 'specialProperty' + ); + expect(textForSpecialPropertyAfter).toMatch(''); + }); + + it('show data explorer header by default', async () => { + await expectDataExplorerHeaderToExist(); + }); + + it('hides data explorer header when user scrolls past its height', async () => { + await expectDataExplorerHeaderToExist(); + + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + expect(expectDataExplorerHeaderToExist()).rejects.toThrow(); + }); + + it('shows expand header button when data explorer is not visible', async () => { + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + await clickExpandHeaderButton(); + + await expectDataExplorerHeaderToExist(); + }); + + it('collapses header again when collapse button is clicked', async () => { + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + await clickExpandHeaderButton(); + await expectDataExplorerHeaderToExist(); + + await clickCollapseHeaderButton(); + expect(expectDataExplorerHeaderToExist()).rejects.toThrow(); + }); + + it('hides expand header button when user scrolls up', async () => { + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + expect(await expandHeaderButton()).toBeVisible(); + + await scrollWindow(0); + await waitForHeaderToBeVisible(); + + expect(expandHeaderButton()).rejects.toThrow(); + }); + + it('hides collapse header button when user scrolls up', async () => { + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + await clickExpandHeaderButton(); + expect(await collapseHeaderButton()).toBeVisible(); + + await scrollWindow(0); + expect(collapseHeaderButton()).rejects.toThrow(); + }); + + it('does not reset values in filters when header was hidden due to scroll', async () => { + await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); + await selectOptionFromMenu(TypeMenuLabel, 'file', CustomOptionSelector); + await selectPath('@type'); + + await scrollWindow(500); + await waitForHeaderToBeHidden(); + + await scrollWindow(0); + await waitForHeaderToBeVisible(); + + const projectInput = await getInputForLabel(ProjectMenuLabel); + expect(projectInput.value).toMatch(new RegExp('unhcr', 'i')); + + const typeInput = await getSelectedValueInMenu(TypeMenuLabel); + expect(typeInput).toMatch(new RegExp('file', 'i')); + + const pathInput = await getSelectedValueInMenu(PathMenuLabel); + expect(pathInput).toMatch(new RegExp('@type', 'i')); + }); + + it('resets predicate search term when different predicate verb is selected', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + await selectPath('author'); + await selectPredicate(CONTAINS); + const valueInput = await screen.getByPlaceholderText('Search for...'); + await userEvent.type(valueInput, 'iggy'); + + await selectPredicate(EXISTS); + + await selectPredicate(DOES_NOT_CONTAIN); + + const valueInputAfter = await screen.getByPlaceholderText('Search for...'); + expect((valueInputAfter as HTMLInputElement).value).not.toEqual('iggy'); + }); +}); diff --git a/src/subapps/dataExplorer/DataExplorer.tsx b/src/subapps/dataExplorer/DataExplorer.tsx new file mode 100644 index 000000000..dda2c2a9d --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorer.tsx @@ -0,0 +1,170 @@ +import { Resource } from '@bbp/nexus-sdk'; +import { Spin, Switch } from 'antd'; +import React, { useMemo, useReducer, useState } from 'react'; +import { DataExplorerTable } from './DataExplorerTable'; +import { + columnFromPath, + isUserColumn, + sortColumns, + usePaginatedExpandedResources, +} from './DataExplorerUtils'; +import { ProjectSelector } from './ProjectSelector'; +import { PredicateSelector } from './PredicateSelector'; +import { DatasetCount } from './DatasetCount'; +import { TypeSelector } from './TypeSelector'; +import './styles.less'; +import { DataExplorerCollapsibleHeader } from './DataExplorerCollapsibleHeader'; +import Loading from '../../shared/components/Loading'; + +export interface DataExplorerConfiguration { + pageSize: number; + offset: number; + orgAndProject?: [string, string]; + type: string | undefined; + predicate: ((resource: Resource) => boolean) | null; + selectedPath: string | null; +} + +export const DataExplorer: React.FC<{}> = () => { + const [showMetadataColumns, setShowMetadataColumns] = useState(false); + const [showEmptyDataCells, setShowEmptyDataCells] = useState(true); + const [headerHeight, setHeaderHeight] = useState<number>(0); + + const [ + { pageSize, offset, orgAndProject, predicate, type, selectedPath }, + updateTableConfiguration, + ] = useReducer( + ( + previous: DataExplorerConfiguration, + next: Partial<DataExplorerConfiguration> + ) => ({ ...previous, ...next }), + { + pageSize: 50, + offset: 0, + orgAndProject: undefined, + type: undefined, + predicate: null, + selectedPath: null, + } + ); + + const { data: resources, isLoading } = usePaginatedExpandedResources({ + pageSize, + offset, + orgAndProject, + type, + }); + + const currentPageDataSource: Resource[] = resources?._results || []; + + const displayedDataSource = predicate + ? currentPageDataSource.filter(predicate) + : currentPageDataSource; + + const memoizedColumns = useMemo( + () => + columnsFromDataSource( + currentPageDataSource, + showMetadataColumns, + selectedPath + ), + [currentPageDataSource, showMetadataColumns, selectedPath] + ); + + return ( + <div className="data-explorer-contents"> + {isLoading && <Spin className="loading" />} + + <DataExplorerCollapsibleHeader + onVisibilityChange={offsetHeight => { + setHeaderHeight(offsetHeight); + }} + > + <div className="data-explorer-filters"> + <ProjectSelector + onSelect={(orgLabel?: string, projectLabel?: string) => { + if (orgLabel && projectLabel) { + updateTableConfiguration({ + orgAndProject: [orgLabel, projectLabel], + }); + } else { + updateTableConfiguration({ orgAndProject: undefined }); + } + }} + /> + <TypeSelector + orgAndProject={orgAndProject} + onSelect={selectedType => { + updateTableConfiguration({ type: selectedType }); + }} + /> + <PredicateSelector + dataSource={currentPageDataSource} + onPredicateChange={updateTableConfiguration} + /> + </div> + + <div className="flex-container"> + <DatasetCount + nexusTotal={resources?._total ?? 0} + totalOnPage={resources?._results?.length ?? 0} + totalFiltered={predicate ? displayedDataSource.length : undefined} + /> + <div className="data-explorer-toggles"> + <Switch + defaultChecked={false} + checked={showMetadataColumns} + onClick={isChecked => setShowMetadataColumns(isChecked)} + id="show-metadata-columns" + className="data-explorer-toggle" + /> + <label htmlFor="show-metadata-columns">Show metadata</label> + + <Switch + defaultChecked={true} + checked={showEmptyDataCells} + onClick={isChecked => setShowEmptyDataCells(isChecked)} + id="show-empty-data-cells" + className="data-explorer-toggle" + /> + <label htmlFor="show-empty-data-cells">Show empty data cells</label> + </div> + </div> + </DataExplorerCollapsibleHeader> + <DataExplorerTable + isLoading={isLoading} + dataSource={displayedDataSource} + columns={memoizedColumns} + total={resources?._total} + pageSize={pageSize} + offset={offset} + updateTableConfiguration={updateTableConfiguration} + showEmptyDataCells={showEmptyDataCells} + tableOffsetFromTop={headerHeight} + /> + </div> + ); +}; + +export const columnsFromDataSource = ( + resources: Resource[], + showMetadataColumns: boolean, + selectedPath: string | null +): string[] => { + const columnNames = new Set<string>(); + + resources.forEach(resource => { + Object.keys(resource).forEach(key => columnNames.add(key)); + }); + + if (showMetadataColumns) { + return Array.from(columnNames).sort(sortColumns); + } + + const selectedMetadataColumn = columnFromPath(selectedPath); + return Array.from(columnNames) + .filter( + colName => isUserColumn(colName) || colName === selectedMetadataColumn + ) + .sort(sortColumns); +}; diff --git a/src/subapps/dataExplorer/DataExplorerCollapsibleHeader.tsx b/src/subapps/dataExplorer/DataExplorerCollapsibleHeader.tsx new file mode 100644 index 000000000..2c9e77777 --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorerCollapsibleHeader.tsx @@ -0,0 +1,126 @@ +import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react'; +import './styles.less'; +import { Button } from 'antd'; +import { FilterIcon } from '../../shared/components/Icons/FilterIcon'; +import { CloseOutlined } from '@ant-design/icons'; +import { throttle } from 'lodash'; + +interface Props { + children: ReactNode; + onVisibilityChange: (offsetHeight: number) => void; +} + +export const DataExplorerCollapsibleHeader: React.FC<Props> = ({ + children, + onVisibilityChange, +}: Props) => { + const [headerBottom, setHeaderBottom] = useState(0); + const [headerOutOfViewport, setHeaderOutOfViewport] = useState(false); + const [headerExpanded, setHeaderExpanded] = useState(false); + + const headerRef = useRef<HTMLDivElement>(null); + + useLayoutEffect(() => { + const headerY = + headerRef.current?.getBoundingClientRect().bottom ?? + FUSION_TITLEBAR_HEIGHT; + setHeaderBottom(headerY); + onVisibilityChange(headerY); + }, []); + + useScrollPosition( + (currentYPosition: number) => { + const shouldHide = currentYPosition > headerBottom; + if (shouldHide !== headerOutOfViewport) { + toggleHeaderVisibility(shouldHide); + } + if (!headerOutOfViewport) { + setHeaderExpanded(false); + } + }, + 100, // throttle time in ms for scroll event + [headerBottom, headerOutOfViewport] + ); + + const toggleHeaderVisibility = (shouldHide: boolean) => { + setHeaderOutOfViewport(shouldHide); + onVisibilityChange(shouldHide ? FUSION_TITLEBAR_HEIGHT : headerBottom); + }; + + return ( + <> + <div + className="data-explorer-header" + ref={headerRef} + style={{ + display: !headerOutOfViewport || headerExpanded ? 'block' : 'none', + }} + > + {children} + </div> + {headerOutOfViewport && ( + <> + {headerExpanded ? ( + <Button + icon={<CloseOutlined />} + onClick={() => { + setHeaderExpanded(false); + onVisibilityChange(FUSION_TITLEBAR_HEIGHT); + }} + shape="circle" + className="toggle-header-buttons" + aria-label="collapse-header" + style={{ top: 60, left: 10 }} + /> + ) : ( + <Button + icon={<FilterIcon />} + onClick={() => { + setHeaderExpanded(true); + onVisibilityChange(headerBottom); + }} + shape="circle" + aria-label="expand-header" + className="toggle-header-buttons" + /> + )} + </> + )} + </> + ); +}; + +export const FUSION_TITLEBAR_HEIGHT = 52; // height in pixels of the blue fixed header on every page of fusion. + +const isBrowser = typeof window !== `undefined`; + +export const getScrollYPosition = (): number => { + if (!isBrowser) return 0; + + return window.scrollY; +}; + +export function useScrollPosition( + effect: (currentYPosition: number) => void, + waitMs: number, + deps: React.DependencyList +) { + const yPosition = useRef(getScrollYPosition()); + + const callBack = () => { + const currentPosition = getScrollYPosition(); + effect(currentPosition); + yPosition.current = currentPosition; + }; + + useLayoutEffect(() => { + const throttledCallback = throttle(callBack, waitMs, { + leading: true, + }); + window.addEventListener('scroll', throttledCallback); + + return () => { + window.removeEventListener('scroll', throttledCallback); + }; + }, deps); +} diff --git a/src/subapps/dataExplorer/DataExplorerTable.tsx b/src/subapps/dataExplorer/DataExplorerTable.tsx new file mode 100644 index 000000000..f742c1f7e --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorerTable.tsx @@ -0,0 +1,215 @@ +import { Resource } from '@bbp/nexus-sdk'; +import { Empty, Table, Tooltip } from 'antd'; +import { ColumnType, TablePaginationConfig } from 'antd/lib/table'; +import { isArray, isString, startCase } from 'lodash'; +import React from 'react'; +import { makeOrgProjectTuple } from '../../shared/molecules/MyDataTable/MyDataTable'; +import isValidUrl from '../../utils/validUrl'; +import { NoDataCell } from './NoDataCell'; +import './styles.less'; +import { DataExplorerConfiguration } from './DataExplorer'; +import { useHistory, useLocation } from 'react-router-dom'; +import { makeResourceUri, parseProjectUrl } from '../../shared/utils'; +import { clsx } from 'clsx'; +import { FUSION_TITLEBAR_HEIGHT } from './DataExplorerCollapsibleHeader'; + +interface TDataExplorerTable { + isLoading: boolean; + dataSource: Resource[]; + total?: number; + pageSize: number; + offset: number; + updateTableConfiguration: React.Dispatch<Partial<DataExplorerConfiguration>>; + columns: string[]; + showEmptyDataCells: boolean; + tableOffsetFromTop: number; +} + +type TColumnNameToConfig = Map<string, ColumnType<Resource>>; + +export const DataExplorerTable: React.FC<TDataExplorerTable> = ({ + isLoading, + dataSource, + columns, + total, + pageSize, + offset, + updateTableConfiguration, + showEmptyDataCells, + tableOffsetFromTop, +}: TDataExplorerTable) => { + const history = useHistory(); + const location = useLocation(); + + const allowedTotal = total ? (total > 10000 ? 10000 : total) : undefined; + + const tablePaginationConfig: TablePaginationConfig = { + pageSize, + total: allowedTotal, + pageSizeOptions: [10, 20, 50], + position: ['bottomLeft'], + defaultPageSize: 50, + defaultCurrent: 0, + current: offset / pageSize + 1, + onChange: (page, _) => + updateTableConfiguration({ offset: (page - 1) * pageSize }), + onShowSizeChange: (_, size) => { + updateTableConfiguration({ pageSize: size, offset: 0 }); + }, + showQuickJumper: true, + showSizeChanger: true, + }; + + const goToResource = (resource: Resource) => { + const resourceId = resource['@id'] ?? resource._self; + const [orgLabel, projectLabel] = parseProjectUrl(resource._project); + + history.push(makeResourceUri(orgLabel, projectLabel, resourceId), { + background: location, + }); + }; + + return ( + <div + style={{ + display: 'block', + position: 'absolute', + top: tableOffsetFromTop, + left: 0, + padding: '0 52px', + background: '#f5f5f5', + height: 'fit-content', + minHeight: '100%', + }} + > + <Table<Resource> + columns={columnsConfig(columns, showEmptyDataCells)} + dataSource={dataSource} + rowKey={record => record._self} + onRow={resource => ({ + onClick: _ => goToResource(resource), + 'data-testid': resource._self, + })} + loading={{ spinning: isLoading, indicator: <></> }} + bordered={false} + className={clsx( + 'data-explorer-table', + tableOffsetFromTop === FUSION_TITLEBAR_HEIGHT && + 'data-explorer-header-collapsed' + )} + rowClassName="data-explorer-row" + scroll={{ x: 'max-content' }} + locale={{ + emptyText() { + return isLoading ? <></> : <Empty />; + }, + }} + pagination={tablePaginationConfig} + sticky={{ offsetHeader: tableOffsetFromTop }} + /> + </div> + ); +}; + +/** + * For each resource in the resources array, it creates column configuration for all its keys (if the column config for that key does not already exist). + */ +export const columnsConfig = ( + columnNames: string[], + showEmptyDataCells: boolean +): ColumnType<Resource>[] => { + const colNameToConfig = new Map( + columnNames.length === 0 ? [] : initialTableConfig(showEmptyDataCells) + ); + + for (const columnName of columnNames) { + if (!colNameToConfig.has(columnName)) { + colNameToConfig.set(columnName, { + ...defaultColumnConfig(columnName, showEmptyDataCells), + }); + } + } + + return Array.from(colNameToConfig.values()); +}; + +export const getColumnTitle = (colName: string) => + startCase(colName).toUpperCase(); + +const defaultColumnConfig = ( + colName: string, + showEmptyDataCells: boolean +): ColumnType<Resource> => { + return { + key: colName, + title: getColumnTitle(colName), + dataIndex: colName, + className: `data-explorer-column data-explorer-column-${colName}`, + sorter: (a, b) => { + return JSON.stringify(a[colName] ?? '').localeCompare( + JSON.stringify(b[colName] ?? '') + ); + }, + render: text => { + if (text === undefined && showEmptyDataCells) { + // Text will also be undefined if a certain resource does not have `colName` as its property + return <NoDataCell />; + } + return <>{JSON.stringify(text)}</>; + }, + }; +}; + +const initialTableConfig = (showEmptyDataCells: boolean) => { + const colNameToConfig: TColumnNameToConfig = new Map(); + const projectKey = '_project'; + const typeKey = '@type'; + + const projectConfig: ColumnType<Resource> = { + ...defaultColumnConfig(projectKey, showEmptyDataCells), + title: 'PROJECT', + render: text => { + if (text) { + const { org, project } = makeOrgProjectTuple(text); + return `${org}/${project}`; + } + return showEmptyDataCells && <NoDataCell />; + }, + sorter: (a, b) => { + const tupleA = makeOrgProjectTuple(a[projectKey] ?? ''); + const tupleB = makeOrgProjectTuple(b[projectKey] ?? ''); + + return (tupleA.project ?? '').localeCompare(tupleB.project); + }, + }; + const typeConfig: ColumnType<Resource> = { + ...defaultColumnConfig(typeKey, showEmptyDataCells), + title: 'TYPE', + render: text => { + let types = ''; + if (isArray(text)) { + types = text + .map(item => (isValidUrl(item) ? item.split('/').pop() : item)) + .join('\n'); + } else if (isString(text) && isValidUrl(text)) { + types = text.split('/').pop() ?? ''; + } else { + types = text; + } + return types ? ( + <Tooltip + title={() => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>} + > + <div style={{ whiteSpace: 'pre-wrap' }}>{types}</div> + </Tooltip> + ) : ( + showEmptyDataCells && <NoDataCell /> + ); + }, + }; + + colNameToConfig.set(projectKey, projectConfig); + colNameToConfig.set(typeKey, typeConfig); + + return colNameToConfig; +}; diff --git a/src/subapps/dataExplorer/DataExplorerUtils.tsx b/src/subapps/dataExplorer/DataExplorerUtils.tsx new file mode 100644 index 000000000..73fe2562f --- /dev/null +++ b/src/subapps/dataExplorer/DataExplorerUtils.tsx @@ -0,0 +1,184 @@ +import { Resource } from '@bbp/nexus-sdk'; +import { useNexusContext } from '@bbp/react-nexus'; +import PromisePool from '@supercharge/promise-pool'; +import { notification } from 'antd'; +import { useQuery } from 'react-query'; +import { makeOrgProjectTuple } from '../../shared/molecules/MyDataTable/MyDataTable'; +import { isString } from 'lodash'; + +export const usePaginatedExpandedResources = ({ + pageSize, + offset, + orgAndProject, + type, +}: PaginatedResourcesParams) => { + const nexus = useNexusContext(); + + return useQuery({ + queryKey: ['data-explorer', { pageSize, offset, orgAndProject, type }], + retry: false, + queryFn: async () => { + const resultWithPartialResources = await nexus.Resource.list( + orgAndProject?.[0], + orgAndProject?.[1], + { + type, + from: offset, + size: pageSize, + } + ); + + // If we failed to fetch the expanded source for some resources, we can use the compact/partial resource as a fallback. + const fallbackResources: Resource[] = []; + const { results: expandedResources } = await PromisePool.withConcurrency( + 4 + ) + .for(resultWithPartialResources._results) + .handleError(async (err, partialResource) => { + console.log( + `@@error in fetching resource with id: ${partialResource['@id']}`, + err + ); + fallbackResources.push(partialResource); + return; + }) + .process(async partialResource => { + if (partialResource._project) { + const { org, project } = makeOrgProjectTuple( + partialResource._project + ); + + return (await nexus.Resource.get( + org, + project, + encodeURIComponent(partialResource['@id']), + { annotate: true } + )) as Resource; + } + + return partialResource; + }); + return { + ...resultWithPartialResources, + _results: [...expandedResources, ...fallbackResources], + }; + }, + onError: error => { + notification.error({ + message: 'Error loading data from the server', + description: isString(error) ? ( + error + ) : isObject(error) ? ( + <div> + <strong>{(error as any)['@type']}</strong> + <div>{(error as any)['details']}</div> + </div> + ) : ( + '' + ), + }); + }, + staleTime: Infinity, + }); +}; + +export const useAggregations = ( + bucketName: 'projects' | 'types', + orgAndProject?: string[] +) => { + const nexus = useNexusContext(); + return useQuery({ + queryKey: ['data-explorer-aggregations', orgAndProject], + retry: false, + queryFn: async () => { + return await nexus.Resource.list(orgAndProject?.[0], orgAndProject?.[1], { + aggregations: true, + }); + }, + select: data => { + return ( + ((data as unknown) as AggregationsResult).aggregations[bucketName] + ?.buckets ?? ([] as AggregatedBucket[]) + ); + }, + onError: error => { + notification.error({ message: 'Aggregations could not be fetched' }); + }, + staleTime: Infinity, + }); +}; + +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); + const columnB = columnFromPath(b); + + if (!isUserColumn(columnA) && !isUserColumn(columnB)) { + return a.localeCompare(b); + } + if (!isUserColumn(columnA)) { + return 1; + } + if (!isUserColumn(columnB)) { + return -1; + } + // Neither a, nor b are userColumns. Now, we want to "ALWAYS_SORTED_COLUMNS" to appear below other user defined columns like "contributions" + if (ALWAYS_DISPLAYED_COLUMNS.has(a) && ALWAYS_DISPLAYED_COLUMNS.has(b)) { + return a.localeCompare(b); + } + if (ALWAYS_DISPLAYED_COLUMNS.has(a)) { + return 1; + } + if (ALWAYS_DISPLAYED_COLUMNS.has(b)) { + return -1; + } + return a.localeCompare(b); +}; + +export const columnFromPath = (path: string | null) => + path?.split('.')[0] ?? ''; + +export const isUserColumn = (colName: string) => { + return ALWAYS_DISPLAYED_COLUMNS.has(colName) || !isNexusMetadata(colName); +}; + +export const ALWAYS_DISPLAYED_COLUMNS = new Set([ + '_project', + '_createdAt', + '_updatedAt', +]); + +const UNDERSCORE = '_'; + +const METADATA_COLUMNS = new Set(['@id', '@context']); + +export const isNexusMetadata = (colName: string) => + METADATA_COLUMNS.has(colName) || colName.startsWith(UNDERSCORE); + +export const isObject = (value: any) => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +export interface AggregationsResult { + '@context': string; + total: number; + aggregations: { + projects: AggregatedProperty; + types: AggregatedProperty; + }; +} + +export type AggregatedProperty = { + buckets: AggregatedBucket[]; + doc_count_error_upper_bound: number; + sum_other_doc_count: number; +}; + +export type AggregatedBucket = { key: string; doc_count: number }; + +interface PaginatedResourcesParams { + pageSize: number; + offset: number; + orgAndProject?: string[]; + type?: string; +} diff --git a/src/subapps/dataExplorer/DatasetCount.tsx b/src/subapps/dataExplorer/DatasetCount.tsx new file mode 100644 index 000000000..173a85e21 --- /dev/null +++ b/src/subapps/dataExplorer/DatasetCount.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import * as pluralize from 'pluralize'; +import { isNil } from 'lodash'; +import './styles.less'; + +interface Props { + nexusTotal: number; + totalFiltered?: number; + totalOnPage: number; +} + +export const DatasetCount: React.FC<Props> = ({ + nexusTotal, + totalOnPage, + totalFiltered, +}: Props) => { + return ( + <div className="data-explorer-count"> + <span> + Total:{' '} + <b> + {nexusTotal.toLocaleString(`en-US`)}{' '} + {pluralize('dataset', nexusTotal ?? 0)} + </b>{' '} + </span> + + <span> + Sample loaded for review: <b>{totalOnPage}</b> + </span> + + {!isNil(totalFiltered) && ( + <span> + Filtered: <b>{totalFiltered}</b> + </span> + )} + </div> + ); +}; diff --git a/src/subapps/dataExplorer/NoDataCell.tsx b/src/subapps/dataExplorer/NoDataCell.tsx new file mode 100644 index 000000000..2146f8d14 --- /dev/null +++ b/src/subapps/dataExplorer/NoDataCell.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export const NoDataCell: React.FC<{}> = () => { + return ( + <div + style={{ + color: '#CE2A2A', + backgroundColor: '#ffd9d9', + fontWeight: 600, + lineHeight: '17.5px', + padding: '5px 10px', + }} + > + No data + </div> + ); +}; diff --git a/src/subapps/dataExplorer/PredicateSelector.tsx b/src/subapps/dataExplorer/PredicateSelector.tsx new file mode 100644 index 000000000..5155761f5 --- /dev/null +++ b/src/subapps/dataExplorer/PredicateSelector.tsx @@ -0,0 +1,380 @@ +import { UndoOutlined } from '@ant-design/icons'; +import { Resource } from '@bbp/nexus-sdk'; +import { Button, Form, Input, Select } from 'antd'; +import { FormInstance } from 'antd/es/form'; +import { DefaultOptionType } from 'antd/lib/cascader'; +import React, { useMemo, useRef } from 'react'; +import { normalizeString } from '../../utils/stringUtils'; +import { DataExplorerConfiguration } from './DataExplorer'; +import { + columnFromPath, + isObject, + isUserColumn, + sortColumns, +} from './DataExplorerUtils'; +import './styles.less'; + +interface Props { + dataSource: Resource[]; + onPredicateChange: React.Dispatch<Partial<DataExplorerConfiguration>>; +} + +export const PredicateSelector: React.FC<Props> = ({ + dataSource, + onPredicateChange, +}: Props) => { + const formRef = useRef<FormInstance>(null); + + const predicateFilterOptions: PredicateFilterOptions[] = [ + { value: EXISTS }, + { value: DOES_NOT_EXIST }, + { value: CONTAINS }, + { value: DOES_NOT_CONTAIN }, + ]; + + const allPathOptions = useMemo( + () => pathOptions([...getAllPaths(dataSource)]), + [dataSource] + ); + + const predicateSelected = ( + path: string, + predicate: PredicateFilterOptions['value'] | null, + searchTerm: string | null + ) => { + if (!path || !predicate) { + onPredicateChange({ predicate: null, selectedPath: null }); + } + + switch (predicate) { + case EXISTS: + onPredicateChange({ + predicate: (resource: Resource) => + checkPathExistence(resource, path, 'exists'), + selectedPath: path, + }); + break; + case DOES_NOT_EXIST: + onPredicateChange({ + predicate: (resource: Resource) => + checkPathExistence(resource, path, 'does-not-exist'), + selectedPath: path, + }); + break; + case CONTAINS: + if (searchTerm) { + onPredicateChange({ + predicate: (resource: Resource) => + doesResourceContain(resource, path, searchTerm, 'contains'), + selectedPath: path, + }); + } else { + onPredicateChange({ predicate: null, selectedPath: null }); + } + break; + case DOES_NOT_CONTAIN: + if (searchTerm) { + onPredicateChange({ + predicate: (resource: Resource) => + doesResourceContain( + resource, + path, + searchTerm, + 'does-not-contain' + ), + selectedPath: path, + }); + } else { + onPredicateChange({ predicate: null, selectedPath: null }); + } + + break; + default: + onPredicateChange({ predicate: null, selectedPath: null }); + } + }; + + const getFormFieldValue = (fieldName: string) => { + return formRef.current?.getFieldValue(fieldName) ?? ''; + }; + + const setFormField = (fieldName: string, fieldValue: string) => { + if (formRef.current) { + formRef.current.setFieldValue(fieldName, fieldValue); + } + }; + + const onReset = () => { + const form = formRef.current; + if (form) { + form.resetFields(); + } + + onPredicateChange({ predicate: null, selectedPath: null }); + }; + + const shouldShowValueInput = + getFormFieldValue(PREDICATE_FIELD) === CONTAINS || + getFormFieldValue(PREDICATE_FIELD) === DOES_NOT_CONTAIN; + + return ( + <Form ref={formRef} name="predicate-selection" className="form-container"> + <span className="label">with </span> + + <Form.Item name="path" noStyle> + <Select + options={allPathOptions} + showSearch={true} + onSelect={pathLabel => { + setFormField(PATH_FIELD, pathLabel); + predicateSelected( + pathLabel, + getFormFieldValue(PREDICATE_FIELD), + getFormFieldValue(SEARCH_TERM_FIELD) + ); + }} + 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. + /> + </Form.Item> + + {getFormFieldValue(PATH_FIELD) && ( + <> + <span className="label">= </span> + + <Form.Item name="predicate" noStyle> + <Select + options={predicateFilterOptions} + onSelect={(predicateLabel: PredicateFilterOptions['value']) => { + setFormField(PREDICATE_FIELD, predicateLabel); + setFormField(SEARCH_TERM_FIELD, ''); + + predicateSelected( + getFormFieldValue(PATH_FIELD), + predicateLabel, + '' + ); + }} + aria-label="predicate-selector" + className="select-menu reduced-width" + popupClassName="search-menu" + autoFocus={true} + allowClear={true} + onClear={() => { + predicateSelected(getFormFieldValue(PATH_FIELD), null, ''); + }} + /> + </Form.Item> + </> + )} + + {shouldShowValueInput && ( + <Form.Item name="searchTerm" noStyle> + <Input + placeholder="Search for..." + aria-label="predicate-value-input" + className="predicate-value-input" + allowClear={false} + autoFocus={true} + onChange={event => { + const term = event.target.value; + setFormField(SEARCH_TERM_FIELD, term); + predicateSelected( + getFormFieldValue(PATH_FIELD), + getFormFieldValue(PREDICATE_FIELD), + term + ); + }} + /> + </Form.Item> + )} + + <Button + onClick={onReset} + disabled={!getFormFieldValue(PATH_FIELD)} + type="text" + className="text-button" + > + Reset predicate <UndoOutlined /> + </Button> + </Form> + ); +}; + +export const DOES_NOT_EXIST = 'Does not exist'; +export const EXISTS = 'Exists'; +export const CONTAINS = 'Contains'; +export const DOES_NOT_CONTAIN = 'Does not contain'; + +const PATH_FIELD = 'path'; +const PREDICATE_FIELD = 'predicate'; +const SEARCH_TERM_FIELD = 'searchTerm'; + +export type PredicateFilterT = + | typeof DOES_NOT_EXIST + | typeof EXISTS + | typeof CONTAINS + | typeof DOES_NOT_CONTAIN + | null; + +type PredicateFilterOptions = { + value: Exclude<PredicateFilterT, null>; +}; + +// Creates <Option /> element for each path. Also adds a class of "first-metadata-path" for the first path generated for a metadata column. +export const pathOptions = (paths: string[]) => { + let firstMetadataFound = false; + const pathOptions: DefaultOptionType[] = []; + + paths.forEach(path => { + const column = columnFromPath(path); + const isFirstMetadataPath = !isUserColumn(column) && !firstMetadataFound; + + pathOptions.push({ + value: path, + label: ( + <span + className={isFirstMetadataPath ? 'first-metadata-path' : ''} + title={path} + > + {path} + </span> + ), + }); + + if (isFirstMetadataPath) { + firstMetadataFound = true; + } + }); + return pathOptions; +}; + +export const getAllPaths = (objects: { [key: string]: any }[]): string[] => { + return Array.from(getPathsForResource(objects, '')).sort(sortColumns); +}; + +const getPathsForResource = ( + resource: { [key: string]: any } | { [key: string]: any }[], + currentPath: string, + paths: Set<string> = new Set() +) => { + if (Array.isArray(resource)) { + resource.forEach(res => getPathsForResource(res, currentPath, paths)); + } else if (typeof resource === 'object' && resource !== null) { + const keys = Object.keys(resource); + for (const key of keys) { + const path = currentPath ? `${currentPath}.${key}` : `${key}`; + paths.add(path); + getPathsForResource(resource[key], path, paths); + } + } + return paths; +}; + +export const checkPathExistence = ( + resource: { [key: string]: any }, + path: string, + criteria: 'exists' | 'does-not-exist' = 'exists' +): boolean => { + if (isObject(resource) && path in resource) { + return criteria === 'exists' ? true : false; + } + + const subpaths = path.split('.'); + + for (const subpath of subpaths) { + const valueAtSubpath = resource[subpath]; + const remainingPath = subpaths.slice(1); + if (isObject(resource) && !(subpath in resource)) { + return criteria === 'exists' ? false : true; + } + + if (Array.isArray(valueAtSubpath)) { + return valueAtSubpath.some(value => + checkPathExistence(value, remainingPath.join('.'), criteria) + ); + } + if (isObject(valueAtSubpath)) { + return checkPathExistence( + valueAtSubpath, + remainingPath.join('.'), + criteria + ); + } + break; + } + + return criteria === 'exists' ? false : true; +}; + +/** + * Returns true if `path` in resource matches the crtieria (ie contains or does not contain) for the given value. + * + * If resource is an array, then the return value is true if any one element in that array matches the criteria. + */ +export const doesResourceContain = ( + resource: { [key: string]: any }, + path: string, + value: string, + criteria: 'contains' | 'does-not-contain' = 'contains' +): boolean => { + if (isPrimitiveValue(resource)) { + return isSubstringOf(String(resource), value, criteria === 'contains'); + } + + const subpaths = path.split('.'); + + for (const subpath of subpaths) { + const valueAtSubpath = resource[subpath]; + const remainingPath = subpaths.slice(1); + if (Array.isArray(valueAtSubpath)) { + return valueAtSubpath.some(arrayElement => { + return doesResourceContain( + arrayElement, + remainingPath.join('.'), + value, + criteria + ); + }); + } + if (isObject(valueAtSubpath)) { + return doesResourceContain( + valueAtSubpath, + remainingPath.join('.'), + value, + criteria + ); + } + return isSubstringOf( + String(valueAtSubpath), + value, + criteria === 'contains' + ); + } + return isSubstringOf(String(resource), value, criteria === 'contains'); +}; + +const isSubstringOf = ( + text: string, + maybeSubstring: string, + shouldContain: boolean +) => { + if (shouldContain) { + return normalizeString(text).includes(normalizeString(maybeSubstring)); + } + return !normalizeString(text).includes(normalizeString(maybeSubstring)); +}; + +// Returns true if value is not an array, object, or function. +const isPrimitiveValue = (value: any) => { + return !Array.isArray(value) && !isObject(value); +}; diff --git a/src/subapps/dataExplorer/ProjectSelector.tsx b/src/subapps/dataExplorer/ProjectSelector.tsx new file mode 100644 index 000000000..4d7535501 --- /dev/null +++ b/src/subapps/dataExplorer/ProjectSelector.tsx @@ -0,0 +1,75 @@ +import { AutoComplete, Input } from 'antd'; +import React, { useState } from 'react'; +import { makeOrgProjectTuple } from '../../shared/molecules/MyDataTable/MyDataTable'; +import { AggregatedBucket, useAggregations } from './DataExplorerUtils'; +import './styles.less'; +import { CloseOutlined, SearchOutlined } from '@ant-design/icons'; +import { normalizeString } from '../../utils/stringUtils'; + +interface Props { + onSelect: (orgLabel?: string, projectLabel?: string) => void; +} + +export const ProjectSelector: React.FC<Props> = ({ onSelect }: Props) => { + const { data: projects } = useAggregations('projects'); + const [showClearIcon, setShowClearIcon] = useState(false); + + const allOptions = [ + { value: AllProjects, label: AllProjects }, + ...(projects?.map(projectToOption) ?? []), + ]; + + return ( + <div className="form-container"> + <span className="label">Project: </span> + <AutoComplete + options={allOptions} + onSearch={text => { + setShowClearIcon(text ? true : false); + }} + filterOption={(searchTerm, option) => { + if (!option) { + return false; + } + return normalizeString(option.value).includes(searchTerm); + }} + onSelect={text => { + if (text === AllProjects) { + setShowClearIcon(false); + onSelect(undefined, undefined); + } else { + const [org, project] = text.split('/'); + setShowClearIcon(true); + onSelect(org, project); + } + }} + allowClear={true} + clearIcon={<CloseOutlined data-testid="reset-project-button" />} + onClear={() => onSelect(undefined, undefined)} + aria-label="project-filter" + bordered={false} + className="search-input" + popupClassName="search-menu" + data-testid="project-filter" + > + <Input + size="middle" + addonAfter={showClearIcon ? <CloseOutlined /> : <SearchOutlined />} + placeholder={AllProjects} // Antd doesn't like adding placeholder along with 'allowClear' in AutoComplete element, so it needs to be added to this Input element. (https://github.com/ant-design/ant-design/issues/26760). + /> + </AutoComplete> + </div> + ); +}; + +export const AllProjects = 'All Projects'; + +const projectToOption = ( + projectBucket: AggregatedBucket +): { value: string; label: string } => { + const { org, project } = makeOrgProjectTuple(projectBucket.key); + return { + value: `${org}/${project}`, + label: `${org}/${project}`, + }; +}; diff --git a/src/subapps/dataExplorer/TypeSelector.tsx b/src/subapps/dataExplorer/TypeSelector.tsx new file mode 100644 index 000000000..4d04b058b --- /dev/null +++ b/src/subapps/dataExplorer/TypeSelector.tsx @@ -0,0 +1,98 @@ +import { CloseOutlined, SearchOutlined } from '@ant-design/icons'; +import * as Sentry from '@sentry/browser'; +import { isString } from 'lodash'; +import React, { useEffect, useRef, useState } from 'react'; +import { normalizeString } from '../../utils/stringUtils'; +import isValidUrl from '../../utils/validUrl'; +import { AggregatedBucket, useAggregations } from './DataExplorerUtils'; +import './styles.less'; +import Select, { DefaultOptionType } from 'antd/lib/select'; + +interface Props { + orgAndProject?: string[]; + onSelect: (type: string | undefined) => void; +} + +export const TypeSelector: React.FC<Props> = ({ + onSelect, + orgAndProject, +}: Props) => { + const { data: aggregatedTypes, isSuccess } = useAggregations( + 'types', + orgAndProject + ); + const [showClearIcon, setShowClearIcon] = useState(false); + const allOptions = [...(aggregatedTypes?.map(typeToOption) ?? [])]; + const [displayedOptions, setDisplayedOptions] = useState(allOptions); + + const optionsRef = useRef(allOptions); + + useEffect(() => { + if (isSuccess) { + optionsRef.current = [...(aggregatedTypes?.map(typeToOption) ?? [])]; + setDisplayedOptions(optionsRef.current); + } + }, [isSuccess, aggregatedTypes]); + + return ( + <div className="form-container"> + <span className="label">Type: </span> + <Select + labelInValue + options={displayedOptions} + onSearch={text => { + const filteredOptions = optionsRef.current?.filter(option => + normalizeString(option.key).includes(text) + ); + setDisplayedOptions(filteredOptions); + }} + filterOption={false} + onSelect={(text, option) => { + setShowClearIcon(true); + onSelect(option.key); + }} + removeIcon={true} + suffixIcon={showClearIcon ? <CloseOutlined /> : <SearchOutlined />} + showSearch={true} + allowClear={true} + clearIcon={<CloseOutlined data-testid="reset-type-button" />} + onClear={() => { + setDisplayedOptions(optionsRef.current); + setShowClearIcon(false); + onSelect(undefined); + }} + placeholder="All types" + aria-label="type-filter" + bordered={false} + className="search-input" + popupClassName="search-menu" + ></Select> + </div> + ); +}; + +const typeToOption = (typeBucket: AggregatedBucket): TypeOption => { + const typeKey = typeBucket.key; + + const typeLabel = + isString(typeKey) && isValidUrl(typeKey) + ? typeKey.split('/').pop() + : typeKey; + + if (!typeLabel) { + Sentry.captureException('Invalid type received from delta', { + extra: { + typeBucket, + }, + }); + } + + return { + value: typeKey, + label: <span title={typeKey}>{typeLabel}</span>, + key: typeKey, + id: typeKey, + }; +}; + +type TypeOption = DefaultOptionType; diff --git a/src/subapps/dataExplorer/predicate-filter.svg b/src/subapps/dataExplorer/predicate-filter.svg new file mode 100644 index 000000000..171a2c4ee --- /dev/null +++ b/src/subapps/dataExplorer/predicate-filter.svg @@ -0,0 +1,5 @@ +<svg width="9" height="8" viewBox="0 0 9 8" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M3.60103 0.866528H5.399C6.14655 0.866528 6.74985 1.44746 6.74985 2.16861V4.76091H7.65012V2.16861C7.65012 0.968968 6.64311 0 5.39899 0H3.60102L3.60103 0.866528ZM5.399 6.93347H3.60103C2.85643 6.93347 2.25018 6.34888 2.25018 5.63347V3.46653H1.34991V5.63347C1.34991 6.82739 2.35903 7.8 3.60104 7.8H5.39901L5.399 6.93347Z" fill="#BFBFBF"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2.1181 2.72678C1.94261 2.55778 1.65742 2.55778 1.48193 2.72678L0.13194 4.02678C-0.0439799 4.19619 -0.0439799 4.4704 0.13194 4.63981C0.307438 4.80881 0.59262 4.80881 0.768118 4.63981L1.80001 3.6835L2.83191 4.63981C3.00741 4.80881 3.29259 4.80881 3.46809 4.63981C3.64401 4.4704 3.64401 4.19619 3.46809 4.02678L2.1181 2.72678Z" fill="#BFBFBF"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M7.19999 4.11653L6.16809 3.16022C5.99259 2.99122 5.70741 2.99122 5.53191 3.16022C5.35599 3.32962 5.35599 3.60384 5.53191 3.77325L6.8819 5.07325C7.05739 5.24225 7.34258 5.24225 7.51807 5.07325L8.86806 3.77325C9.04398 3.60384 9.04398 3.32962 8.86806 3.16022C8.69256 2.99122 8.40738 2.99122 8.23188 3.16022L7.19999 4.11653Z" fill="#BFBFBF"/> +</svg> \ No newline at end of file diff --git a/src/subapps/dataExplorer/styles.less b/src/subapps/dataExplorer/styles.less new file mode 100644 index 000000000..7ef14836f --- /dev/null +++ b/src/subapps/dataExplorer/styles.less @@ -0,0 +1,274 @@ +@import '../../shared/lib.less'; + +.data-explorer-contents { + margin-bottom: 52px; + padding-left: 40px; + background: @fusion-main-bg; + width: 100%; + margin-top: 100px; + + .loading { + margin: 40vh auto; + z-index: 100; + position: relative; + } + + .toggle-header-buttons { + z-index: 5; + position: fixed; + top: 100px; + left: 20px; + color: @fusion-blue-8; + background: white; + &:active, + &:focus { + background: white; + border-color: @fusion-gray-4; + color: @fusion-blue-8; + } + } + + .data-explorer-header { + background: white; + position: fixed; + top: 52px; // Fusion titlebar height + left: 0; + width: 100vw; + padding: 20px 52px; + z-index: 2; + } + + .data-explorer-filters { + display: flex; + margin-bottom: 28px; + } + + .flex-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .data-explorer-count { + color: @fusion-neutral-7; + margin-left: 20px; + + span { + margin-right: 24px; + } + } + + .data-explorer-toggles { + label { + margin-left: 6px; + color: @fusion-blue-8; + } + .data-explorer-toggle { + border: 1px solid @fusion-blue-8; + box-sizing: content-box; + margin-left: 30px; + + &[aria-checked='true'] { + background-color: @fusion-blue-8; + .ant-switch-handle::before { + background-color: white; + } + } + &[aria-checked='false'] { + background-color: transparent; + .ant-switch-handle::before { + background-color: @fusion-blue-8; + } + } + } + } + + .text-button { + color: @fusion-gray-7; + } + + .form-container { + display: flex; + align-items: center; + flex-direction: row; + max-width: fit-content; + margin: 0 10px; + + .ant-form-item { + margin-bottom: 0; + } + + .label { + font-size: 12px; + font-weight: 300; + line-height: 16px; + letter-spacing: 0.01em; + text-align: left; + color: @fusion-blue-8; + margin: 0 10px; + } + + .search-input { + border-bottom: 1px solid @fusion-neutral-7; + margin-left: 8px !important; + color: @fusion-blue-8 !important; + width: 200px; + + input { + color: @fusion-blue-8 !important; + border: none; + background: transparent; + font-weight: 700; + + &::placeholder { + color: @fusion-neutral-7; + font-weight: 400; + } + } + + .ant-select-selection-item { + span { + color: @fusion-blue-8 !important; + border: none; + background: @fusion-main-bg; + font-weight: 700; + } + } + + input:focus { + border: none !important; + } + + .ant-input-group-addon { + background: transparent; + border: none; + } + + .anticon-search, + .anticon-close { + color: @fusion-blue-8; + } + } + + .select-menu { + .ant-select-selector { + color: @fusion-blue-8 !important; + background-color: transparent !important; + max-width: max-content; + min-width: 200px; + + .ant-select-selection-item { + font-weight: 700; + } + } + + .ant-select-arrow { + color: @fusion-blue-8; + font-weight: 700; + } + } + .select-menu.reduced-width { + .ant-select-selector { + min-width: 140px; + width: max-content; + } + } + } + + .select-menu.greyed-out { + .ant-select-selector { + color: @fusion-neutral-6 !important; + background-color: transparent !important; + text-transform: lowercase; + border: none; + max-width: max-content; + min-width: unset; + display: flex; + align-items: center; + } + + .ant-select-selector::before { + content: url('./predicate-filter.svg'); + width: 10px; + margin-right: 4px; + } + + .ant-select-arrow { + display: none; + } + + .ant-select-selection-item { + font-weight: 700; + padding-right: 0; + width: max-content; + } + } + + .predicate-value-input { + color: @fusion-blue-8; + background: transparent; + width: 200px; + } +} + +.data-explorer-header-collapsed { + .ant-table-header { + box-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.08); + } +} + +.data-explorer-table { + table { + width: auto; + min-width: 90vw !important; // This is needed to make sure that the table headers and table body columns are aligned when the table is rerendered (eg when user selects a predicate, project, or, type). + } + .ant-table { + background: @fusion-main-bg; + } + + .ant-table-thead > tr > th.data-explorer-column { + background-color: #f5f5f5 !important; + font-family: 'Titillium Web'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 130%; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #8c8c8c; + height: 52px; + min-width: 72px; + } + + .ant-table-thead > tr > th::before { + font-size: 0px; // Removes the little vertical divider between table header cells + } + + th.ant-table-cell.ant-table-cell-scrollbar { + background-color: #f5f5f5 !important; + } + + .ant-table-body tr td { + background-color: @fusion-main-bg; + min-width: 72px; + color: @fusion-blue-8; + } + + .ant-table-tbody > tr.data-explorer-row > td { + border-bottom: 1px solid #d9d9d9; + } +} + +.search-menu { + .ant-select-item-option-content { + color: @fusion-blue-8; + width: 100%; + .first-metadata-path { + width: 100%; + display: block; + border-top: 1px solid @medium-gray; + padding-top: 4px; + } + } +} diff --git a/src/utils/formatNumber.ts b/src/utils/formatNumber.ts index ea3935e93..98043844c 100644 --- a/src/utils/formatNumber.ts +++ b/src/utils/formatNumber.ts @@ -8,4 +8,12 @@ function formatNumber(num: number) { return num; } +const prettifyNumber = (num: number | string): string => { + // use Intl.NumberFormat to format the number + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat + return new Intl.NumberFormat('en-US').format(Number(num)); +}; + +export { prettifyNumber }; + export default formatNumber; diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts new file mode 100644 index 000000000..8e50df14a --- /dev/null +++ b/src/utils/stringUtils.ts @@ -0,0 +1 @@ +export const normalizeString = (str: string) => str.trim().toLowerCase(); diff --git a/src/utils/validUrl.ts b/src/utils/validUrl.ts index d446d37e2..cc641c87e 100644 --- a/src/utils/validUrl.ts +++ b/src/utils/validUrl.ts @@ -27,9 +27,22 @@ function isUrlCurieFormat(str: string) { return curiePattern.test(str); } -function externalLink(url: string): boolean { - return !url.startsWith('https://') && !url.startsWith('http://'); +function isExternalLink(url: string): boolean { + return !url.startsWith('https://bbp.epfl.ch'); } -export { easyValidURL, isUrlCurieFormat, externalLink }; +function isStorageLink(url: string): boolean { + return url.startsWith('file:///gpfs'); +} +function isAllowedProtocal(url: string): boolean { + return url.startsWith('https://') || url.startsWith('http://'); +} + +export { + easyValidURL, + isUrlCurieFormat, + isExternalLink, + isStorageLink, + isAllowedProtocal, +}; export default isValidUrl; diff --git a/yarn.lock b/yarn.lock index 06f2d1b8c..2e4929556 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12645,6 +12645,11 @@ lowlight@~1.11.0: fault "^1.0.2" highlight.js "~9.13.0" +lru-cache@7.18.3: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz"