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 (
+
+
+
+
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}
- />
- )}
- }
- type="primary"
- size="small"
- onClick={handleSubmit}
- disabled={!valid || !editable || !isEditing}
- >
- Save
- {' '}
- {editable && isEditing && (
-
- )}
+ {!expanded && !isEditing && valid && showMetadataToggle && (
+ onMetadataChangeFold(checked)}
+ style={switchMarginRight}
+ />
+ )}
+ {showExpanded && !isEditing && valid && (
+ onFormatChangeFold(expanded)}
+ style={switchMarginRight}
+ />
+ )}
+ <>>}
+ >
+ }
+ type="primary"
+ size="small"
+ onClick={handleSubmit}
+ disabled={!valid || !editable || !isEditing}
+ >
+ Save
+
+
+ {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 @@
+
\ 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 (
+
+ {collapseRightBtn()}
+
+
+ {orgProject && {orgProject}}
+ {decodeURIComponent(_self)}
+
+ }
+ >
+
+
+ {orgProject &&
{orgProject}}
+ {title &&
{title}
}
+ {types && (
+
+ {isArray(types) ? (
+
+ {types.map(t => (
+ {t}
+ ))}
+
+ ) : (
+ types
+ )}
+
+ )}
+
+ {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 (
+
+
+ Open {count} other resources
}
+ >
+
+
+
{count}
+
+
+
+ );
+};
+
+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 = (
-
-
-
- );
- 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 = (
-
- );
- 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 (
-
-
+
-
+ ;
+
+const dateFieldName = {
+ createdAt: 'Creation Date',
+ updatedAt: 'Update Date',
+};
+
+const DateFieldSelector = ({
+ dateField,
+ setFilterOptions,
+}: TDateFieldSelectorProps) => {
+ const handleDateFieldChange: THandleMenuSelect = ({ key }) =>
+ setFilterOptions({ dateField: key as TDateField });
+
+ const DateFieldMenu = (
+
+ );
+ return (
+
+
+
+ );
+};
+
+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) => ({
+ ...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(
+ 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 = e => {
+ e.preventDefault();
+ setFilterOptions({
+ dateFilterType,
+ singleDate,
+ dateStart,
+ dateEnd,
+ });
+ };
+ const DatePickerContainer = (
+
+
+
+ );
+ useClickOutside(popoverRef, onDatePopoverVisibleChange);
+ return (
+
+ {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,
+ });
+ }
+ }}
+ />
+
+ );
+};
+
+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;
+
+const IssuerSelector = ({ issuer, setFilterOptions }: TIssuerSelectorProps) => {
+ const onIssuerChange = (e: RadioChangeEvent) =>
+ setFilterOptions({ issuer: e.target.value });
+
+ return (
+
+
+ Created by me
+
+
+ Last updated by me
+
+
+ );
+};
+
+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 (
+
+ {text}
+ {total ? `${prettifyNumber(total)} ${label}` : ''}
+
+ );
+};
+
+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 = event =>
+ setFilterOptions({ query: event.target.value });
+ return (
+
+
+
+
+ By resource id or self
+
+
+
+ );
+};
+
+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, type: TType): void;
+}) => {
+ return (
+
+
+ {type.label}
+
+
+ onCheck(e, type)} checked={checked} />
+
+
+ );
+};
+const TypeSelector = ({
+ types,
+ setFilterOptions,
+}: Pick) => {
+ const nexus = useNexusContext();
+ const originTypes = useRef([]);
+ const [typeSearchValue, updateSearchType] = useState('');
+ const [typesOptionsArray, setTypesOptionsArray] = useState([]);
+
+ const selectCallback = useCallback((data: TTypeAggregationsResult) => {
+ const options = (
+ data.aggregations.types?.buckets ?? ([] as TTypesAggregatedBucket[])
+ ).map(item => typesOptionsBuilder(item));
+ originTypes.current = options;
+ return options;
+ }, []);
+
+ const { data: typeOptions } = useTypesAggregation({
+ nexus,
+ selectCallback,
+ });
+
+ const onChangeTypeChange = ({
+ target: { value },
+ type,
+ }: React.ChangeEvent) => {
+ 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,
+ type: TType
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setFilterOptions({
+ types: types?.find(item => item.value === type.value) ? [] : [type],
+ });
+ };
+
+ const [typeInputRef, { width }] = useMeasure();
+ const renderedTypes = typeSearchValue ? typesOptionsArray : typeOptions ?? [];
+ return (
+
+
+
+ {
+
{`${prettifyNumber(
+ renderedTypes.length
+ )} types`}
+ }
+
+
+ {renderedTypes.length ? (
+ renderedTypes.map((type: TType) => {
+ return (
+
item.key === type.key)
+ )}
+ onCheck={handleOnCheckType}
+ />
+ );
+ })
+ ) : (
+
+ No types found
+
+ )}
+
+
+ }
+ >
+
+
+ );
+};
+
+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 (
+
+ );
+};
+
+const MyDataHeaderFilters = ({
+ types,
+ dateField,
+ setFilterOptions,
+}: THeaderFilterProps) => {
+ return (
+
+ Filter:
+
+
+
+
+ );
+};
+
+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 = ({
return (