diff --git a/Dockerfile b/Dockerfile index 97f9e87..83c03fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,27 @@ -# node-sass 4.14.1 requires node version <= 14 for Alpine Linux -# See: https://github.com/sass/node-sass/releases/tag/v4.14.1 -FROM node:16-alpine as build-deps -WORKDIR /usr/src/app -RUN pwd && ls -COPY yarn.lock ./ +ARG NODE_PARENT=node:16-alpine + +FROM ${NODE_PARENT} as frontend + +ENV BUILDDIR=/app + +RUN apk add git +RUN npm i -g @craco/craco + +WORKDIR ${BUILDDIR} +COPY package.json ${BUILDDIR} +COPY yarn.lock ${BUILDDIR} +COPY nginx/default.conf ${BUILDDIR} + RUN yarn install -COPY . ./ +COPY . ${BUILDDIR} RUN yarn build -COPY public/ ./public/ -COPY src/ ./src/ +FROM nginx:1.19.3-alpine + +RUN cat /etc/nginx/conf.d/default.conf + +COPY --from=frontend /app/default.conf /etc/nginx/conf.d/default.conf + +COPY --from=frontend /app/build /usr/share/nginx/html/ -EXPOSE 3000 -CMD yarn run start +EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md index 30b101c..c4dae68 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ Deployed version : https://metacell.github.io/sds-viewer/ The SDS Viewer can now be launched directly from datasets and models on the SPARC Portal (https://sparc.science/). From the landing page for your dataset or model of interest, simply click the SDS Viewer button, it will launch the viewer with it already loaded. In addition, users can load SPARC datasets using two other methods: -1) Loading a SPARC Dataset from list: +1) Loading a SPARC Dataset from app: - Click on 'SPARC Datasets' button, it's located on the lower left corner. - - On the window that opens up, select the dataset you want to load. + - On the window that opens up, select the dataset you want to load. You can search + by dataset title and id. ![image](https://user-images.githubusercontent.com/4562825/166984322-83b4a8c2-aa29-4e6d-96e9-bcf4d125a3a9.png) - After selection, click 'Done' - Dataset will be loaded. @@ -21,27 +22,36 @@ The SDS Viewer can now be launched directly from datasets and models on the SPAR This will open up the SDS Viewer with the dataset already loaded. ##### Loaded dataset example ##### -![Screenshot 2023-09-21 at 3 50 49 PM](https://github.com/MetaCell/sds-viewer/assets/4562825/e7247cf1-df5e-498d-a418-4cbc7f4c4de2) +![image](https://github.com/MetaCell/sds-viewer/assets/4562825/9ea43afd-28cc-4b37-8c72-96be2f821f1a) + ##### SPARC Dataset used ##### -![Screenshot 2023-09-21 at 3 53 33 PM](https://github.com/MetaCell/sds-viewer/assets/4562825/f3e287ed-f93a-436b-b3b0-b85cb1c0857c) +![image](https://github.com/MetaCell/sds-viewer/assets/4562825/16d878e2-d5bb-4dbd-9695-dfbd7ae5207f) ### Navigating the SDS Viewer - - Users can search for subjects, folders and files on the sidebar. Selecting an item on the sidebar will display the Metadata for it and zoom the Graph to its corresponding node. -![Screenshot 2023-09-21 at 4 04 23 PM](https://github.com/MetaCell/sds-viewer/assets/4562825/b64ea659-607f-42f7-b58f-edb01e31ab40) + - Users can search for subjects, folders and files on the sidebar. Selecting an item on the sidebar will display the Metadata for it and zoom the Graph to its corresponding folder or file. +![image](https://github.com/MetaCell/sds-viewer/assets/4562825/7b013f5a-eead-4996-b7d2-20b3bf35a294) - - Selecting an item on the Graph will display its Metadata. + - Selecting an item on the Graph will display its Metadata. Users can view Metadata for the Dataset's Subjects and Samples, along with its folders and files contents. Users can find links to the SPARC Portal for Subjects, Samples, Folders and Files. ![image](https://user-images.githubusercontent.com/4562825/186723085-c6573146-82dc-4fb7-ae95-588f7b1e4842.png) - - Navigating the Graph Viewer can be done with the mouse. There's also controlers on the bottom right that allows the user to change the Layout view, zoom in/out, reset the view to its original state and expand all data in the viewer. + + - Navigating the Graph Viewer can be done with the mouse. There are also controllers on the bottom right that allow user to change the Graph Layout view, zoom in/out of the graph, reset the Layout to its original state and expand/collapse all data in the viewer. ![controllers](https://github.com/MetaCell/sds-viewer/assets/99416933/30aa8bb3-ec61-46d8-9f83-55ade15b95c0) - - Multiple Datasets can be loaded at the same time, which will open a new Graph Viewer Component for each dataset. -![multiple](https://github.com/MetaCell/sds-viewer/assets/99416933/a74fa033-ccd4-4609-b50f-852ce44d347a) + - Use the Metadata Settings button to control which properties to view on the Metadata panel. Toggle on and off properties on each Object type and click Save. + +![image](https://github.com/MetaCell/sds-viewer/assets/4562825/6385b5a1-3598-4815-8aa1-f1223debe063) +![image](https://github.com/MetaCell/sds-viewer/assets/4562825/d5876581-2dfd-4f13-9213-d46907e443c8) + + + - Multiple Datasets can be loaded at the same time, a new Graph Viewer Component will be opened for each dataset. + +![Multiple](https://github.com/MetaCell/sds-viewer/assets/4562825/9abe621a-a406-4e6b-8d6a-165622014425) ### Datasets Used diff --git a/deploy/k8s/codefresh.yaml b/deploy/k8s/codefresh.yaml new file mode 100644 index 0000000..1b2f2dd --- /dev/null +++ b/deploy/k8s/codefresh.yaml @@ -0,0 +1,34 @@ +version: "1.0" +stages: + - "clone" + - "build" + - "deploy" +steps: + clone: + stage: "clone" + title: "Cloning SDS Viewer" + type: "git-clone" + repo: "metacell/sds-viewer" + revision: "${{CF_BRANCH}}" + build: + stage: "build" + title: "Building SDS Viewer" + type: "build" + image_name: "sds-viewer" + tag: "${{CF_SHORT_REVISION}}" + dockerfile: Dockerfile + working_directory: ./sds-viewer + buildkit: true + registry: "${{CODEFRESH_REGISTRY}}" + deploy: + stage: "deploy" + title: "Deploying SDS Viewer" + image: codefresh/cf-deploy-kubernetes + tag: latest + working_directory: ./sds-viewer/deploy/k8s + commands: + - /cf-deploy-kubernetes sds_viewer.yaml + - /cf-deploy-kubernetes ingress.yaml + environment: + - KUBECONTEXT=${{CLUSTER_NAME}} + - KUBERNETES_NAMESPACE=${{NAMESPACE}} \ No newline at end of file diff --git a/deploy/k8s/ingress_tpl.yaml b/deploy/k8s/ingress.yaml old mode 100755 new mode 100644 similarity index 57% rename from deploy/k8s/ingress_tpl.yaml rename to deploy/k8s/ingress.yaml index befd530..5edc109 --- a/deploy/k8s/ingress_tpl.yaml +++ b/deploy/k8s/ingress.yaml @@ -1,29 +1,26 @@ -apiVersion: cert-manager.io/v1alpha2 +apiVersion: cert-manager.io/v1 kind: Issuer metadata: - name: 'letsencrypt-sds_viewer' + name: 'letsencrypt-sds-viewer' spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: filippo@metacell.us privateKeySecretRef: - name: letsencrypt-sds_viewer + name: letsencrypt-sds-viewer solvers: - http01: ingress: - ingressName: sds_viewer + class: nginx --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: - cert-manager.io/issuer: letsencrypt-sds_viewer + cert-manager.io/issuer: letsencrypt-sds-viewer kubernetes.io/ingress.class: nginx kubernetes.io/tls-acme: 'true' - nginx.ingress.kubernetes.io/ssl-redirect: 'true' - nginx.ingress.kubernetes.io/proxy-body-size: 512m - nginx.ingress.kubernetes.io/from-to-www-redirect: 'true' - name: sds_viewer + name: sds-viewer-nginx-ingress spec: rules: - host: "{{DOMAIN}}" @@ -31,7 +28,7 @@ spec: paths: - backend: service: - name: sds_viewer + name: sds-viewer port: number: 80 path: / @@ -39,4 +36,4 @@ spec: tls: - hosts: - "{{DOMAIN}}" - secretName: sds_viewer-tls + secretName: sds-viewer-tls diff --git a/deploy/k8s/sds_viewer.yaml b/deploy/k8s/sds_viewer.yaml new file mode 100644 index 0000000..7b67d58 --- /dev/null +++ b/deploy/k8s/sds_viewer.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sds-viewer +spec: + selector: + matchLabels: + app: sds-viewer + replicas: 1 + template: + metadata: + labels: + app: sds-viewer + spec: + containers: + - name: sds-viewer + image: "gcr.io/metacellllc/sds-viewer:{{CF_SHORT_REVISION}}" + imagePullPolicy: "IfNotPresent" + ports: + - containerPort: 80 + livenessProbe: + failureThreshold: 3 + httpGet: + path: /index.html + port: 80 + scheme: HTTP + initialDelaySeconds: 45 + periodSeconds: 30 + timeoutSeconds: 2 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /index.html + port: 80 + scheme: HTTP + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 2 + resources: + limits: + cpu: 1500m + memory: 768Mi + requests: + cpu: 500m + memory: 768Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: sds-viewer +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + selector: + app: sds-viewer diff --git a/deploy/k8s/sds_viewer_tpl.yaml b/deploy/k8s/sds_viewer_tpl.yaml deleted file mode 100755 index 95bb514..0000000 --- a/deploy/k8s/sds_viewer_tpl.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sds_viewer -spec: - selector: - matchLabels: - app: sds_viewer - replicas: 1 - template: - metadata: - labels: - app: sds_viewer - spec: - containers: - - name: sds_viewer - image: "{{REGISTRY}}sds_viewer:{{TAG}}" - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 80 - resources: - requests: - memory: "64Mi" - cpu: "25m" - limits: - memory: "128Mi" - cpu: "100m" ---- -apiVersion: v1 -kind: Service -metadata: - name: sds_viewer -spec: - type: LoadBalancer - ports: - - port: 80 - targetPort: 80 - selector: - app: sds_viewer diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..8c5795f --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,32 @@ +upstream sds-viewer { + server sds-viewer:8000; +} + +server { + listen 80; + + location / { + root /usr/share/nginx/html/; + # index index.html index.htm; + try_files $uri /index.html; + } + + location /sds-viewer/ { + root /usr/share/nginx/html/; + # index index.html index.htm; + try_files $uri /index.html; + } + + location ~* ^/(admin|api|logged-out|login|sds-viewer|complete|disconnect|__debug__)/.*$ { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + proxy_pass http://sds-viewer; + } + + location /static/ { + autoindex on; + alias /usr/share/nginx/html/static/; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 48b1431..c6376f4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "MIT", "private": true, - "homepage": "http://metacell.github.io/sds-viewer", + "homepage": "./", "dependencies": { "@craco/craco": "^6.1.2", "@frogcat/ttl2jsonld": "^0.0.7", @@ -31,6 +31,7 @@ "n3": "^1.13.0", "puppeteer": "13.5.1", "react": "^17.0.2", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^17.0.2", "react-hot-loader": "^4.13.0", "react-redux": "^7.2.4", diff --git a/public/images/graph/age.svg b/public/images/graph/age.svg index fdeb314..fd6f908 100644 --- a/public/images/graph/age.svg +++ b/public/images/graph/age.svg @@ -1,38 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/graph/dataset.svg b/public/images/graph/dataset.svg index 0bd9038..569b878 100644 --- a/public/images/graph/dataset.svg +++ b/public/images/graph/dataset.svg @@ -1,3 +1,223 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/graph/sex.svg b/public/images/graph/sex.svg index 435d97e..81b2c4b 100644 --- a/public/images/graph/sex.svg +++ b/public/images/graph/sex.svg @@ -1,33 +1,33 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/graph/species.svg b/public/images/graph/species.svg index 2c9fc42..4f749df 100644 --- a/public/images/graph/species.svg +++ b/public/images/graph/species.svg @@ -1,36 +1,36 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/graph/strains.svg b/public/images/graph/strains.svg index 0c7d29d..9181a41 100644 --- a/public/images/graph/strains.svg +++ b/public/images/graph/strains.svg @@ -1,38 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.js b/src/App.js index 3abe8b4..7949ee4 100644 --- a/src/App.js +++ b/src/App.js @@ -18,7 +18,6 @@ import { NodeViewWidget } from './app/widgets'; import { addWidget } from '@metacell/geppetto-meta-client/common/layout/actions'; import { WidgetStatus } from "@metacell/geppetto-meta-client/common/layout/model"; import DatasetsListSplinter from "./components/DatasetsListViewer/DatasetsListSplinter"; - import config from './config/app.json'; const App = () => { @@ -59,7 +58,6 @@ const App = () => { tree: await splinter.getTree(), splinter: splinter }; - console.log("Graph ", _dataset.graph); dispatch(addDataset(_dataset)); dispatch(addWidget({ @@ -132,8 +130,8 @@ const App = () => { }; const splinter = new DatasetsListSplinter(undefined, file.data); let graph = await splinter.getGraph(); - console.log("Graph ", graph); let datasets = graph.nodes.filter((node) => node?.attributes?.hasDoi); + let version = config.version const match = datasets.find( node => node.attributes?.hasDoi?.[0]?.includes(doi)); if ( match ) { const datasetID = match.name; @@ -142,6 +140,20 @@ const App = () => { setLoading(false); setInitialised(false); } + + let datasetStorage = {}; + if ( version !== undefined && JSON.parse(localStorage.getItem(config.datasetsStorage))?.version !== version ) { + let parsedDatasets = [] + datasets.forEach( node => { + parsedDatasets.push({ name : node.name , doi : node.attributes?.hasDoi?.[0], label : node.attributes ? node.attributes?.label?.[0]?.toLowerCase() : null}); + }); + datasetStorage = { + version : version, + datasets : parsedDatasets + } + + localStorage.setItem(config.datasetsStorage, JSON.stringify(datasetStorage)); + } }; useEffect(() => { @@ -151,9 +163,24 @@ const App = () => { if (doi && doi !== "" ) { if ( doiMatch ){ - const fileHandler = new FileHandler(); - const summaryURL = config.repository_url + config.available_datasets; - fileHandler.get_remote_file(summaryURL, loadDatsetFromDOI); + let version = config.version; + const storage = JSON.parse(localStorage.getItem(config.datasetsStorage)); + const storageVersion = storage?.version + if ( storageVersion === version ) { + let storedDatasetsInfo = JSON.parse(localStorage.getItem(config.datasetsStorage)); + const match = storedDatasetsInfo.datasets.find( node => node?.doi.includes(doi)); + if ( match ) { + const datasetID = match.name; + loadFiles(datasetID); + } else { + setLoading(false); + setInitialised(false); + } + } else { + const fileHandler = new FileHandler(); + const summaryURL = config.repository_url + config.available_datasets; + fileHandler.get_remote_file(summaryURL, loadDatsetFromDOI); + } } } }, []); diff --git a/src/components/DatasetsListViewer/DatasetsListDialog.js b/src/components/DatasetsListViewer/DatasetsListDialog.js index b63ef04..9edbd57 100644 --- a/src/components/DatasetsListViewer/DatasetsListDialog.js +++ b/src/components/DatasetsListViewer/DatasetsListDialog.js @@ -106,8 +106,24 @@ const DatasetsListDialog = (props) => { let datasets = graph.nodes.filter((node) => node?.attributes?.hasUriApi); datasets.forEach( node => node.attributes ? node.attributes.lowerCaseLabel = node.attributes?.label?.[0]?.toLowerCase() : null ); datasets = datasets.filter( node => node?.attributes?.statusOnPlatform?.[0]?.includes(PUBLISHED) ); - dispatch(setDatasetsList(datasets)); - setFilteredDatasets(datasets); + + + let version = config.version; + let datasetStorage = {}; + if ( version !== undefined && JSON.parse(localStorage.getItem(config.datasetsStorage))?.version !== version ) { + let parsedDatasets = [] + datasets.forEach( node => { + parsedDatasets.push({ name : node.name , doi : node.attributes?.hasDoi?.[0], label : node.attributes ? node.attributes.lowerCaseLabel : null}); + }); + datasetStorage = { + version : version, + datasets : parsedDatasets + } + + localStorage.setItem(config.datasetsStorage, JSON.stringify(datasetStorage)); + dispatch(setDatasetsList(datasetStorage.datasets)); + setFilteredDatasets(datasetStorage.datasets); + } }; const summaryURL = config.repository_url + config.available_datasets; fileHandler.get_remote_file(summaryURL, callback); @@ -116,7 +132,7 @@ const DatasetsListDialog = (props) => { const handleChange = (event) => { const lowerCaseSearch = event.target.value.toLowerCase(); let filtered = datasets.filter((dataset) => - dataset.attributes.lowerCaseLabel.includes(lowerCaseSearch) || dataset.name.includes(lowerCaseSearch) + dataset.label?.includes(lowerCaseSearch) || dataset.name?.includes(lowerCaseSearch) ); setSearchField(lowerCaseSearch); setFilteredDatasets(filtered); @@ -139,7 +155,15 @@ const DatasetsListDialog = (props) => { } useEffect(() => { - open && datasets.length === 0 && loadDatasets(); + if ( open && datasets.length === 0 ) { + if ( localStorage.getItem(config.datasetsStorage) ) { + let storedDatasetsInfo = JSON.parse(localStorage.getItem(config.datasetsStorage)); + dispatch(setDatasetsList(storedDatasetsInfo.datasets)); + setFilteredDatasets(storedDatasetsInfo.datasets); + } else { + loadDatasets(); + } + } }); return ( @@ -202,7 +226,7 @@ const DatasetsListDialog = (props) => { className="dataset_list_text" dangerouslySetInnerHTML={{ __html: - getFormattedListTex(dataset.attributes?.label[0]) + getFormattedListTex(dataset.label) }} /> } diff --git a/src/components/DatasetsListViewer/DatasetsListSplinter.js b/src/components/DatasetsListViewer/DatasetsListSplinter.js index 553ae3d..c3284f0 100644 --- a/src/components/DatasetsListViewer/DatasetsListSplinter.js +++ b/src/components/DatasetsListViewer/DatasetsListSplinter.js @@ -254,7 +254,7 @@ class Splinter { dataset_node.proxies = dataset_node.proxies.concat(ontology_node.proxies); dataset_node.level = 1; this.nodes.set(dataset_node.id, dataset_node); - this.nodes.delete(ontology_node.id); + // this.nodes.delete(ontology_node.id); // fix links that were pointing to the ontology let temp_edges = this.edges.map(link => { if (link.source === ontology_node.id) { diff --git a/src/components/EmptyContainer.js b/src/components/EmptyContainer.js index fdd04f3..7064492 100644 --- a/src/components/EmptyContainer.js +++ b/src/components/EmptyContainer.js @@ -18,7 +18,7 @@ const EmptyContainer = (props) => { color='primary' onClick={() => props.setOpenDatasetsListDialog(true)} > - + { SPARC_DATASETS } + + { IMPORT_TEXT } } diff --git a/src/components/GraphViewer/GraphViewer.js b/src/components/GraphViewer/GraphViewer.js index 787f68d..c070e47 100644 --- a/src/components/GraphViewer/GraphViewer.js +++ b/src/components/GraphViewer/GraphViewer.js @@ -2,216 +2,54 @@ import * as d3 from 'd3-force-3d' import Menu from '@material-ui/core/Menu'; import { IconButton, Tooltip,Typography, Box, Link, MenuItem, CircularProgress } from '@material-ui/core'; import React, { useState, useEffect } from 'react'; -import ZoomInIcon from '@material-ui/icons/ZoomIn'; import LayersIcon from '@material-ui/icons/Layers'; import HelpIcon from '@material-ui/icons/Help'; -import ZoomOutIcon from '@material-ui/icons/ZoomOut'; import RefreshIcon from '@material-ui/icons/Refresh'; import UnfoldMoreIcon from '@material-ui/icons/UnfoldMore'; import UnfoldLessIcon from '@material-ui/icons/UnfoldLess'; import BugReportIcon from '@material-ui/icons/BugReport'; import { selectInstance } from '../../redux/actions'; import { useSelector, useDispatch } from 'react-redux'; -import FormatAlignCenterIcon from '@material-ui/icons/FormatAlignCenter'; import GeppettoGraphVisualization from '@metacell/geppetto-meta-ui/graph-visualization/Graph'; -import { GRAPH_SOURCE } from '../../constants'; +import {detailsLabel, GRAPH_SOURCE} from '../../constants'; import { rdfTypes, typesModel } from '../../utils/graphModel'; +import { getPrunedTree,paintNode, collapseSubLevels, GRAPH_COLORS, TOP_DOWN, LEFT_RIGHT, RADIAL_OUT, + ZOOM_SENSITIVITY,ZOOM_DEFAULT, ONE_SECOND } from '../../utils/GraphViewerHelper'; import config from "./../../config/app.json"; - -const NODE_FONT = '500 5px Inter, sans-serif'; -const ONE_SECOND = 1000; -const LOADING_TIME = 1000; -const ZOOM_DEFAULT = 1; -const ZOOM_SENSITIVITY = 0.2; -const GRAPH_COLORS = { - link: '#CFD4DA', - linkHover : 'purple', - hoverRect: '#CFD4DA', - textHoverRect: '#3779E1', - textHover: 'white', - textColor: '#2E3A59', - collapsedFolder : 'red' -}; -const TOP_DOWN = { - label : "Tree View", - layout : "td", - maxNodesLevel : (graph) => { - return graph.hierarchyVariant; - } -}; -const RADIAL_OUT = { - label : "Radial View", - layout : "null", - maxNodesLevel : (graph) => { - return graph.radialVariant - } -}; - -const nodeSpace = 50; - -const roundRect = (ctx, x, y, width, height, radius, color, alpha) => { - if (width < 2 * radius) radius = width / 2; - if (height < 2 * radius) radius = height / 2; - ctx.globalAlpha = alpha || 1; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.arcTo(x + width, y, x + width, y + height, radius); - ctx.arcTo(x + width, y + height, x, y + height, radius); - ctx.arcTo(x, y + height, x, y, radius); - ctx.arcTo(x, y, x + width, y, radius); - ctx.closePath(); - ctx.fill(); -}; +import AddRoundedIcon from '@material-ui/icons/AddRounded'; +import RemoveRoundedIcon from '@material-ui/icons/RemoveRounded'; +import {ViewTypeIcon} from "../../images/Icons"; + +const styles = { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 0, + margin: 'auto', + color: "#11bffe", + size: "55rem" +} const GraphViewer = (props) => { const dispatch = useDispatch(); - const updateNodes = (nodes, conflictNode, positionsMap, level, index) => { - let matchIndex = index; - for ( let i = 0; i < index ; i++ ) { - let conflict = nodes.find ( n => !n.collapsed && n?.parent?.id === nodes[i]?.parent?.id) - if ( conflict === undefined ){ - conflict = nodes.find ( n => !n.collapsed ) - if ( conflict === undefined ){ - conflict = conflictNode; - } - } - matchIndex = nodes.findIndex( n => n.id === conflict.id ); - let furthestLeft = conflict?.xPos; - if ( nodes[i].collapsed ) { - furthestLeft = conflict.xPos - ((((matchIndex - i )/2)) * nodeSpace ); - nodes[i].xPos =furthestLeft; - } - positionsMap[level] = furthestLeft + nodeSpace; - nodes[i].fx = nodes[i].xPos; - nodes[i].fy = 50 * nodes[i].level; - } - } - - const getPrunedTree = () => { - let nodesById = Object.fromEntries(window.datasets[props.graph_id].graph?.nodes?.map(node => [node.id, node])); - window.datasets[props.graph_id].graph?.links?.forEach(link => { - const source = link.source.id; - const target = link.target.id; - const linkFound = !nodesById[source]?.childLinks?.find( l => - source === l.source.id && target === l.target.id - ); - if ( linkFound ) { - nodesById[source]?.childLinks?.push(link); - } - }); - - let visibleNodes = []; - const visibleLinks = []; - - let levelsMap = window.datasets[props.graph_id].graph.levelsMap; - // // Calculate level with max amount of nodes - let maxLevel = Object.keys(levelsMap).reduce((a, b) => levelsMap[a].filter( l => !l.collapsed ).length > levelsMap[b].filter( l => !l.collapsed ).length ? a : b); - - (function traverseTree(node = nodesById[window.datasets[props.graph_id].graph?.nodes?.[0].id]) { - visibleNodes.push(node); - if (node.collapsed) return; - // let childLinks = node.childLinks?.filter( link => !link.source.collapsed && !link.target.collapsed ); - visibleLinks.push(...node.childLinks); - let nodes = node.childLinks.map(link => (typeof link.target) === 'object' ? link.target : nodesById[link.target]); - nodes?.forEach(traverseTree); - })(); // IIFE - - if ( selectedLayout.layout === TOP_DOWN.layout ){ - let levels = {}; - visibleNodes.forEach( n => { - if ( levels[n.level] ){ - levels[n.level].push(n); - } else { - levels[n.level] = [n]; - } - }) - - // Calculate level with max amount of nodes - let highestLevel = Object.keys(levels).length; - let maxLevel = Object.keys(levels).reduce((a, b) => levels[a].length > levels[b].length ? a : b); - let maxLevelNodes = levels[maxLevel]; - - // Space between nodes - // The furthestLeft a node can be - let furthestLeft = 0 - (Math.ceil(maxLevelNodes.length)/2 * nodeSpace ); - let positionsMap = {}; - - let levelsMapKeys = Object.keys(levels); - - levelsMapKeys.forEach( level => { - furthestLeft = 0 - (Math.ceil(levels[level].length)/2 * nodeSpace ); - positionsMap[level] = furthestLeft + nodeSpace; - levels[level]?.sort( (a, b) => { - if (a?.id < b?.id) { - return -1; - } - if (a?.id > b?.id) { - return 1; - } - return 1; - }); - }); - - // Start assigning the graph from the bottom up - let neighbors = 0; - levelsMapKeys.reverse().forEach( level => { - let collapsedInLevel = levels[level].filter( n => n.collapsed); - let notcollapsedInLevel = levels[level].filter( n => !n.collapsed); - levels[level].forEach ( (n, index) => { - neighbors = n?.neighbors?.filter(neighbor => { return neighbor.level > n.level }); - if ( !n.collapsed ) { - if ( neighbors?.length > 0 ) { - let max = Number.MIN_SAFE_INTEGER, min = Number.MAX_SAFE_INTEGER; - neighbors.forEach( neighbor => { - if ( neighbor.xPos > max ) { max = neighbor.xPos }; - if ( neighbor.xPos <= min ) { min = neighbor.xPos }; - }); - n.xPos = min === max ? min : min + ((max - min) * .5); - positionsMap[n.level] = n.xPos + nodeSpace; - if ( notcollapsedInLevel?.length > 0 && collapsedInLevel.length > 0) { - updateNodes(levels[level], n, positionsMap, level, index); - } - positionsMap[n.level] = n.xPos + nodeSpace; - n.fx = n.xPos; - n.fy = 50 * n.level; - } else { - n.xPos = positionsMap[n.level] + nodeSpace; - positionsMap[n.level] = n.xPos; - n.fx = n.xPos; - n.fy = 50 * n.level; - - } - }else { - n.xPos = positionsMap[n.level] + nodeSpace; - positionsMap[n.level] = n.xPos ; - n.fx = n.xPos; - n.fy = 50 * n.level; - } - }) - }); - } - - const graph = { nodes : visibleNodes, links : visibleLinks, levelsMap : levelsMap, hierarchyVariant : maxLevel * 20 }; - console.log("Graph ", graph); - return graph; - }; - const graphRef = React.useRef(null); const [hoverNode, setHoverNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [highlightNodes, setHighlightNodes] = useState(new Set()); const [highlightLinks, setHighlightLinks] = useState(new Set()); - const [selectedLayout, setSelectedLayout] = React.useState(RADIAL_OUT); + const [selectedLayout, setSelectedLayout] = React.useState(LEFT_RIGHT); const [layoutAnchorEl, setLayoutAnchorEl] = React.useState(null); - const [cameraPosition, setCameraPosition] = useState({ x : 0 , y : 0 }); const open = Boolean(layoutAnchorEl); const [loading, setLoading] = React.useState(false); const [data, setData] = React.useState({ nodes : [], links : []}); const nodeSelected = useSelector(state => state.sdsState.instance_selected.graph_node); + const nodeClickSource = useSelector(state => state.sdsState.instance_selected.source); const groupSelected = useSelector(state => state.sdsState.group_selected.graph_node); const [collapsed, setCollapsed] = React.useState(true); + const [previouslySelectedNodes, setPreviouslySelectedNodes] = useState(new Set()); + let triggerCenter = false; const handleLayoutClick = (event) => { setLayoutAnchorEl(event.currentTarget); @@ -225,21 +63,16 @@ const GraphViewer = (props) => { handleLayoutClose() setSelectedLayout(target); setForce(); + setTimeout( () => { + resetCamera(); + },100) }; - const collapseSubLevels = (node, collapsed, children) => { - node?.childLinks?.forEach( n => { - if ( collapsed !== undefined ) n.target.collapsed = collapsed; - collapseSubLevels(n.target, collapsed, children); - children.links = children.links + 1; - }); - } - const handleNodeLeftClick = (node, event) => { if ( node.type === rdfTypes.Subject.key || node.type === rdfTypes.Sample.key || node.type === rdfTypes.Collection.key ) { - node.collapsed = !node.collapsed; collapseSubLevels(node, node.collapsed, { links : 0 }); - const updatedData = getPrunedTree(); + node.collapsed = !node.collapsed; + let updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); setData(updatedData); } handleNodeHover(node); @@ -252,6 +85,8 @@ const GraphViewer = (props) => { source: GRAPH_SOURCE })); } + const divElement = document.getElementById(node.id + detailsLabel); + divElement?.scrollIntoView({ behavior: 'smooth' }); }; const handleLinkColor = link => { @@ -271,16 +106,18 @@ const GraphViewer = (props) => { const handleNodeRightClick = (node, event) => { graphRef?.current?.ggv?.current.centerAt(node.x, node.y, ONE_SECOND); graphRef?.current?.ggv?.current.zoom(2, ONE_SECOND); - //setCameraPosition({ x : node.x , y : node.y }); }; const expandAll = (event) => { window.datasets[props.graph_id].graph?.nodes?.forEach( node => { collapsed ? node.collapsed = !collapsed : node.collapsed = node?.type === typesModel.NamedIndividual.subject.type; }) - let updatedData = getPrunedTree(); + let updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); setData(updatedData); setCollapsed(!collapsed) + setTimeout( () => { + resetCamera(); + },10) } /** @@ -319,7 +156,7 @@ const GraphViewer = (props) => { }; const setForce = () => { - if ( selectedLayout.layout !== TOP_DOWN.layout ){ + if ( selectedLayout.layout !== TOP_DOWN.layout || selectedLayout.layout !== LEFT_RIGHT.layout ){ let force = -100; graphRef?.current?.ggv?.current.d3Force('link').distance(0).strength(1); graphRef?.current?.ggv?.current.d3Force("charge").strength(force * 2); @@ -333,21 +170,22 @@ const GraphViewer = (props) => { const onEngineStop = () => { setForce(); + selectedNode && handleNodeRightClick(nodeSelected) } useEffect(() => { - const updatedData = getPrunedTree(); + const updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); setData(updatedData); setLoading(true); setForce(); setTimeout ( () => { setLoading(false); setForce(); - }, LOADING_TIME); + }, ONE_SECOND); }, []); useEffect(() => { - const updatedData = getPrunedTree(); + const updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); setData(updatedData); },[selectedLayout]); @@ -356,7 +194,7 @@ const GraphViewer = (props) => { let visibleNodes = e.detail; let match = visibleNodes?.find( v => v?._attributes?.id === props.graph_id ); if ( match ) { - const updatedData = getPrunedTree(); + const updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); setData(updatedData); setTimeout( timeout => { setForce() @@ -369,43 +207,68 @@ const GraphViewer = (props) => { let match = visibleNodes?.find( v => v?._attributes?.id === props.graph_id ); if ( match ) { resetCamera(); - let center = graphRef?.current?.ggv?.current.centerAt(); - setCameraPosition({ x : center?.x , y : center?.y }); } }); }); useEffect(() => { - if ( groupSelected ) { + if ( groupSelected && groupSelected?.dataset_id?.includes(props.graph_id)) { setSelectedNode(groupSelected); handleNodeHover(groupSelected); graphRef?.current?.ggv?.current.centerAt(groupSelected.x, groupSelected.y, ONE_SECOND); graphRef?.current?.ggv?.current.zoom(2, ONE_SECOND); } - },[groupSelected]) + },[groupSelected]) + + useEffect(() => { + if (selectedNode) { + setPreviouslySelectedNodes(prev => new Set([...prev, selectedNode.id])); + } + }, [selectedNode]); useEffect(() => { - if ( nodeSelected ) { + let sameDataset = nodeSelected?.tree_reference?.dataset_id?.includes(props.graph_id) || + nodeSelected?.dataset_id?.includes(props.graph_id) + || nodeSelected?.attributes?.dataset_id?.includes(props.graph_id); + if ( nodeSelected && sameDataset) { if ( nodeSelected?.id !== selectedNode?.id ){ let node = nodeSelected; - let collapsed = nodeSelected.collapsed - while ( node?.parent && !collapsed ) { - node = node.parent; - collapsed = node.collapsed + let collapsed = node.collapsed + let parent = node.parent; + let prevNode = node; + while ( parent && parent?.collapsed ) { + prevNode = parent; + parent = parent.parent; } - if ( collapsed ) { - node.collapsed = !node.collapsed; - collapseSubLevels(node, node.collapsed, { links : 0 }); - const updatedData = getPrunedTree(); - setData(updatedData); + + if ( prevNode && nodeSelected.collapsed && nodeClickSource === "TREE") { + if ( prevNode.type == rdfTypes.Subject.key || prevNode.type == rdfTypes.Sample.key || + prevNode.type == rdfTypes.Collection.key ) { + prevNode.collapsed = false; + collapseSubLevels(prevNode, false, { links : 0 }); + let updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); + setData(updatedData); + } + if ( node.parent?.type == rdfTypes.Subject.key || node.parent?.type == rdfTypes.Sample.key || + node.parent?.type == rdfTypes.Collection.key ) { + collapseSubLevels(node.parent, true, { links : 0 }); + let updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); + setData(updatedData); + } else { + collapseSubLevels(node, true, { links : 0 }); + let updatedData = getPrunedTree(props.graph_id, selectedLayout.layout); + setData(updatedData); + } + setSelectedNode(nodeSelected); + handleNodeHover(nodeSelected); + graphRef?.current?.ggv?.current.centerAt(nodeSelected.x, nodeSelected.y, ONE_SECOND); } - setSelectedNode(nodeSelected); - handleNodeHover(nodeSelected); - graphRef?.current?.ggv?.current.centerAt(nodeSelected.x, nodeSelected.y, ONE_SECOND); - graphRef?.current?.ggv?.current.zoom(2, ONE_SECOND); } else { handleNodeHover(nodeSelected); + graphRef?.current?.ggv?.current.centerAt(nodeSelected.x, nodeSelected.y, ONE_SECOND); } + const divElement = document.getElementById(nodeSelected.id + detailsLabel); + divElement?.scrollIntoView({ behavior: 'smooth' }); } },[nodeSelected]) @@ -440,102 +303,10 @@ const GraphViewer = (props) => { setHighlightNodes(highlightNodes); } - const paintNode = React.useCallback( - (node, ctx) => { - const size = 7.5; - const nodeImageSize = [size * 2.4, size * 2.4]; - const hoverRectDimensions = [size * 4.2, size * 4.2]; - const hoverRectPosition = [node.x - hoverRectDimensions[0]/2, node.y - hoverRectDimensions[1]/2]; - const textHoverPosition = [ - hoverRectPosition[0], - hoverRectPosition[1] + hoverRectDimensions[1], - ]; - const hoverRectBorderRadius = 1; - ctx.beginPath(); - - try { - ctx.drawImage( - node?.img, - node.x - size, - node.y - size, - ...nodeImageSize - ); - } catch (error) { - const img = new Image(); - img.src = rdfTypes.Unknown.image; - node.img = img; - - // Add default icon if new icon wasn't found under images - ctx.drawImage( - node?.img, - node.x - size - 1, - node.y - size, - ...nodeImageSize - ); - } - - ctx.font = NODE_FONT; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - let nodeName = node.name; - if (nodeName.length > 10) { - nodeName = nodeName.substr(0, 9).concat('...'); - } else if ( Array.isArray(nodeName) ){ - nodeName = nodeName[0]?.substr(0, 9).concat('...'); - } - const textProps = [nodeName, node.x, textHoverPosition[1]]; - if (node === hoverNode || node?.id === selectedNode?.id || node?.id === nodeSelected?.id ) { - // image hover - roundRect( - ctx, - ...hoverRectPosition, - ...hoverRectDimensions, - hoverRectBorderRadius, - GRAPH_COLORS.hoverRec, - 0.3 - ); - // text node name hover - roundRect( - ctx, - ...textHoverPosition, - hoverRectDimensions[0], - hoverRectDimensions[1] / 4, - hoverRectBorderRadius, - GRAPH_COLORS.textHoverRect - ); - // reset canvas fill color - ctx.fillStyle = GRAPH_COLORS.textHover; - } else { - ctx.fillStyle = GRAPH_COLORS.textColor; - } - ctx.fillText(...textProps); - if ( node.childLinks?.length && node.collapsed ) { - let children = { links : 0 }; - collapseSubLevels(node, undefined, children) - const collapsedNodes = [children.links, node.x, textHoverPosition[1]]; - ctx.fillStyle = GRAPH_COLORS.collapsedFolder; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(...collapsedNodes); - ctx.fillStyle = GRAPH_COLORS.textColor; - } - }, - [hoverNode] - ); - return (
{ loading? - + : { data={data} // Create the Graph as 2 Dimensional d2={true} - cooldownTicks={selectedLayout.layout === TOP_DOWN.layout ? 0 : data?.nodes?.length} + cooldownTicks={ ( selectedLayout.layout === TOP_DOWN.layout || selectedLayout.layout === LEFT_RIGHT.layout) ? 0 : data?.nodes?.length } onEngineStop={onEngineStop} // Links properties linkColor = {handleLinkColor} linkWidth={2} - dagLevelDistance={selectedLayout.layout === TOP_DOWN.layout ? 60 : 0} + dagLevelDistance={( selectedLayout.layout !== TOP_DOWN.layout && selectedLayout.layout !== LEFT_RIGHT.layout ) ? 0 : 60} linkDirectionalParticles={1} - forceRadial={selectedLayout.layout === TOP_DOWN.layout ? 0 : 15} + forceRadial={( selectedLayout.layout !== TOP_DOWN.layout && selectedLayout.layout !== LEFT_RIGHT.layout ) ? 15 : 0} linkDirectionalParticleWidth={link => highlightLinks.has(link) ? 4 : 0} linkCanvasObjectMode={'replace'} onLinkHover={handleLinkHover} // Override drawing of canvas objects, draw an image as a node - nodeCanvasObject={paintNode} + nodeCanvasObject={(node, ctx) => paintNode(node, ctx, hoverNode, selectedNode, nodeSelected, previouslySelectedNodes)} nodeCanvasObjectMode={node => 'replace'} nodeVal = { node => { if ( selectedLayout.layout === TOP_DOWN.layout ){ node.fx = node.xPos; node.fy = 50 * node.level; + } else if ( selectedLayout.layout === LEFT_RIGHT.layout ){ + node.fx = 50 * node.level; + node.fy = node.yPos; } return 100 / (node.level + 1); }} @@ -583,11 +357,6 @@ const GraphViewer = (props) => { controls={
- - - - - { > handleLayoutChange(RADIAL_OUT)}>{RADIAL_OUT.label} handleLayoutChange(TOP_DOWN)}>{TOP_DOWN.label} + handleLayoutChange(LEFT_RIGHT)}>{LEFT_RIGHT.label} + + + + + zoomIn()}> - + zoomOut()}> - + resetCamera()}> @@ -621,7 +396,7 @@ const GraphViewer = (props) => {
- + Version 1 window.open(config.issues_url, '_blank')}> diff --git a/src/components/NodeDetailView/Details/CollectionDetails.js b/src/components/NodeDetailView/Details/CollectionDetails.js index 4add3b3..45a3794 100644 --- a/src/components/NodeDetailView/Details/CollectionDetails.js +++ b/src/components/NodeDetailView/Details/CollectionDetails.js @@ -1,42 +1,50 @@ -import React from "react"; import { Box, - Typography + Divider, + Typography, } from "@material-ui/core"; -import Links from './Views/Links'; import SimpleLabelValue from './Views/SimpleLabelValue'; +import SimpleLinkedChip from './Views/SimpleLinkedChip'; +import Links from './Views/Links'; import { detailsLabel } from '../../../constants'; +import { isValidUrl } from './utils'; +import { useSelector } from 'react-redux' + const CollectionDetails = (props) => { const { node } = props; - let title = ""; - let idDetails = ""; - // both tree and graph nodes are present, extract data from both - if (node?.tree_node && node?.graph_node) { - title = node.graph_node?.name; - idDetails = node.graph_node?.id + detailsLabel; - // the below is the case where we have data only from the tree/hierarchy - } else if (node?.tree_node) { - title = node.tree_node?.basename; - idDetails = node.tree_node?.id + detailsLabel; - // the below is the case where we have data only from the graph - } else { - title = node.graph_node?.name; - idDetails = node.graph_node?.id + detailsLabel; - } - + const collectionPropertiesModel = useSelector(state => state.sdsState.metadata_model.collection); return ( - + + - - { node.graph_node?.attributes?.publishedURI && node.graph_node?.attributes?.publishedURI !== "" - ? ( - SPARC Portal Link - - ) - : <> - } + + + {collectionPropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node?.tree_node?.[property.property] || node?.graph_node?.attributes?.[property.property]; + if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "string" ){ + return () + } + + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/DatasetDetails.js b/src/components/NodeDetailView/Details/DatasetDetails.js index a63ddd5..5e9d0a0 100644 --- a/src/components/NodeDetailView/Details/DatasetDetails.js +++ b/src/components/NodeDetailView/Details/DatasetDetails.js @@ -1,151 +1,97 @@ -import React from "react"; import { Box, Typography, - List, - ListItemText, + Divider, + IconButton } from "@material-ui/core"; +import { useState, useEffect } from "react"; import Links from './Views/Links'; import SimpleLinkedChip from './Views/SimpleLinkedChip'; -import USER from "../../../images/user.svg"; import SimpleLabelValue from './Views/SimpleLabelValue'; import { detailsLabel } from '../../../constants'; +import { isValidUrl } from './utils'; +import { useSelector } from 'react-redux' +import {DatasetIcon} from "../../../images/Icons"; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import Tooltip from '@material-ui/core/Tooltip'; const DatasetDetails = (props) => { const { node } = props; - const nodes = window.datasets[node.dataset_id].splinter.nodes; + const datasetPropertiesModel = useSelector(state => state.sdsState.metadata_model.dataset); + const [copiedDOI, setCopiedDOI] = useState({}); - let title = ""; - let label = ""; - let idDetails = ""; - let description = ""; - // both tree and graph nodes are present, extract data from both - if (node?.tree_node && node?.graph_node) { - idDetails = node.graph_node?.id + detailsLabel; - label = node?.graph_node.attributes?.label?.[0]; - title = node?.graph_node.attributes?.title?.[0]; - description = node?.graph_node.attributes?.description?.[0]; - // the below is the case where we have data only from the tree/hierarchy - } else if (node?.tree_node) { - label = node?.tree_node?.basename; - idDetails = node?.tree_node?.id + detailsLabel; - // the below is the case where we have data only from the graph - } else { - idDetails = node.graph_node?.id + detailsLabel; - label = node.graph_node?.attributes?.label?.[0]; - } - - let latestUpdate = "Not defined." - if (node?.graph_node?.attributes?.latestUpdate !== undefined) { - latestUpdate = new Date(node.graph_node.attributes?.latestUpdate?.[0]) - } - - let contactPerson = []; - if (node.graph_node.attributes?.hasResponsiblePrincipalInvestigator !== undefined) { - node.graph_node.attributes?.hasResponsiblePrincipalInvestigator.map(user => { - const contributor = nodes.get(user); - contactPerson.push({ - name: contributor?.name, - designation: 'Principal Investigator', - img: USER - }); - return user; - }); - } - - if (node.graph_node.attributes?.hasContactPerson !== undefined) { - node.graph_node.attributes?.hasContactPerson.map(user => { - const contributor = nodes.get(user); - contactPerson.push({ - name: contributor?.name, - designation: 'Contributor', - img: USER - }); - return user; + useEffect( () => { + let properties = {}; + datasetPropertiesModel?.map( property => { + if ( property.link ){ + properties[property.label] =false; + } }); - } - - const DETAILS_LIST = [ - { - title: 'Error Index', - value: node.graph_node.attributes?.errorIndex - }, - { - title: 'Template Schema Version', - value: node.graph_node.attributes?.hasDatasetTemplateSchemaVersion - }, - { - title: 'Experiment Modality', - value: node.graph_node.attributes?.hasExperimentalModality - } - ]; + setCopiedDOI(properties) + }, [] ); return ( - + + + - - { node.graph_node.attributes?.hasDoi && node.graph_node.attributes?.hasDoi?.[0] !== "" - ? ( - Label - - ) - : () - } - - - - - - About - + + + Dataset Details + {datasetPropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node.graph_node.attributes[property.property]?.[0]; - - Protocol Techniques - - + if ( property.link ){ + const value = node.graph_node.attributes[property.link.property]?.[0]; + return ( + {property.label} + + + { + navigator.clipboard.writeText(value); + const newClipboardState = { ...copiedDOI, [property.label] : true}; + setCopiedDOI(newClipboardState) + }}> + + + + + { property.link?.asText ? {value} : } + + + ) + } - - - { - DETAILS_LIST?.map((item, index) => ( - - {item?.title} - {item?.value} - - )) + if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) } - - - { node.graph_node.attributes?.hasExperimentalApproach !== undefined - ? ( - ) - : <> - } + if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } + + if ( typeof propValue === "string" ){ + return () + } - { node.graph_node.attributes?.hasDoi !== undefined - ? ( - Links - - ) - : <> - } - { node.graph_node.attributes?.hasAdditionalFundingInformation !== undefined - ? ( - ) - : <> - } - { node.graph_node.attributes?.statusOnPlatform !== undefined - ? ( - ) - : <> - } - { node.graph_node.attributes?.hasLicense !== undefined - ? ( - ) - : <> - } + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/Details.js b/src/components/NodeDetailView/Details/Details.js index b5b3a90..957081e 100644 --- a/src/components/NodeDetailView/Details/Details.js +++ b/src/components/NodeDetailView/Details/Details.js @@ -1,6 +1,6 @@ -import React from "react"; import { Box, + Divider, } from "@material-ui/core"; import SimpleLabelValue from './Views/SimpleLabelValue'; import { detailsLabel } from '../../../constants'; @@ -26,6 +26,7 @@ const UnknownDetails = (props) => { return ( + diff --git a/src/components/NodeDetailView/Details/FileDetails.js b/src/components/NodeDetailView/Details/FileDetails.js index af9c009..4bd21df 100644 --- a/src/components/NodeDetailView/Details/FileDetails.js +++ b/src/components/NodeDetailView/Details/FileDetails.js @@ -1,106 +1,53 @@ -import React from "react"; import { Box, Typography, - List, - ListItemText, + Divider, } from "@material-ui/core"; import Links from './Views/Links'; -import SimpleLinkedChip from './Views/SimpleLinkedChip'; import SimpleLabelValue from './Views/SimpleLabelValue'; +import SimpleLinkedChip from './Views/SimpleLinkedChip'; import { detailsLabel } from '../../../constants'; +import { useSelector } from 'react-redux' +import { isValidUrl } from './utils'; const FileDetails = (props) => { const { node } = props; - - let title = ""; - let idDetails = ""; - // both tree and graph nodes are present, extract data from both - if (node?.tree_node && node?.graph_node) { - title = node.tree_node.basename; - idDetails = node.tree_node.id + detailsLabel; - // the below is the case where we have data only from the tree/hierarchy - } else if (node.graph_node) { - idDetails = node.graph_node.id + detailsLabel; - title = node.graph_node.attributes?.label?.[0]; - // the below is the case where we have data only from the graph - } else { - title = node.tree_node.basename; - idDetails = node.tree_node.id + detailsLabel; - } - - let latestUpdate = "Not defined." - if (node?.graph_node.attributes?.updated !== undefined) { - latestUpdate = node?.attributes?.updated; - } - - const DETAILS_LIST = [ - { - title: 'Mimetype', - value: node?.graph_node?.attributes?.mimetype - }, - { - title: 'Size Bytes', - value: node?.graph_node?.attributes?.size - } - ]; + const filePropertiesModel = useSelector(state => state.sdsState.metadata_model.file); return ( - + + - { node.graph_node?.attributes?.identifier - ? ( - {"File Details"} - - ) - : () - } - { node.graph_node?.attributes?.publishedURI && node.graph_node?.attributes?.publishedURI !== "" - ? ( - Published Dataset File - - ) - : <> - } - {latestUpdate ? - - : (<> ) - } - { node?.tree_node?.uri_human !== undefined - ? ( - - ) - : (<> ) - } + + + {filePropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node?.tree_node?.[property.property] || node?.graph_node?.attributes?.[property.property]; + if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } - { node?.tree_node?.checksums !== undefined - ? (<> - - - ) - : (<> ) - } + else if ( typeof propValue === "string" ){ + return () + } - - - { - DETAILS_LIST?.map((item, index) => ( - - {item?.title} - {item?.value} - - )) + else if ( typeof propValue === "number" ){ + return () } - - - { node?.graph_node?.attributes?.hasUriHuman !== undefined - ? ( - Links - - ) - : <> - } + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/GroupDetails.js b/src/components/NodeDetailView/Details/GroupDetails.js index dbfad60..9a68798 100644 --- a/src/components/NodeDetailView/Details/GroupDetails.js +++ b/src/components/NodeDetailView/Details/GroupDetails.js @@ -1,33 +1,53 @@ -import React from "react"; import { Box, + Divider, + Typography } from "@material-ui/core"; +import Links from './Views/Links'; +import SimpleLinkedChip from './Views/SimpleLinkedChip'; import SimpleLabelValue from './Views/SimpleLabelValue'; import { detailsLabel } from '../../../constants'; +import { useSelector } from 'react-redux' +import { isValidUrl } from './utils'; const GroupDetails = (props) => { const { node } = props; - - let title = ""; - let idDetails = ""; - // both tree and graph nodes are present, extract data from both - if (node?.tree_node && node?.graph_node) { - title = node.graph_node?.name; - idDetails = node.graph_node?.id + detailsLabel; - // the below is the case where we have data only from the tree/hierarchy - } else if (node?.tree_node) { - title = node.tree_node?.basename; - idDetails = node.tree_node?.id + detailsLabel; - // the below is the case where we have data only from the graph - } else { - title = node.graph_node?.name; - idDetails = node.graph_node?.id + detailsLabel; - } + const groupPropertiesModel = useSelector(state => state.sdsState.metadata_model.group); return ( - + + - + + + {groupPropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node.graph_node[property.property]; + if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "string" ){ + return () + } + + else if ( typeof propValue === "number" ){ + return () + } + + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/PersonDetails.js b/src/components/NodeDetailView/Details/PersonDetails.js index e4f8e5f..0d273b2 100644 --- a/src/components/NodeDetailView/Details/PersonDetails.js +++ b/src/components/NodeDetailView/Details/PersonDetails.js @@ -1,10 +1,9 @@ -import React from "react"; import { Box, + Divider, Typography, } from "@material-ui/core"; import Links from './Views/Links'; -import SimpleLabelValue from './Views/SimpleLabelValue'; import { detailsLabel } from '../../../constants'; const PersonDetails = (props) => { @@ -28,6 +27,7 @@ const PersonDetails = (props) => { return ( + Person Details diff --git a/src/components/NodeDetailView/Details/Primary.js b/src/components/NodeDetailView/Details/Primary.js index f5eb739..2e8b0a9 100644 --- a/src/components/NodeDetailView/Details/Primary.js +++ b/src/components/NodeDetailView/Details/Primary.js @@ -1,4 +1,3 @@ -import React from "react"; import { Box, Typography, diff --git a/src/components/NodeDetailView/Details/ProtocolDetails.js b/src/components/NodeDetailView/Details/ProtocolDetails.js index 9a56695..d1e967c 100644 --- a/src/components/NodeDetailView/Details/ProtocolDetails.js +++ b/src/components/NodeDetailView/Details/ProtocolDetails.js @@ -1,6 +1,6 @@ -import React from "react"; import { Box, + Divider, Typography, } from "@material-ui/core"; import Links from './Views/Links'; @@ -29,6 +29,7 @@ const ProtocolDetails = (props) => { return ( + { node.graph_node.attributes?.hasUriHuman && node.graph_node.attributes?.hasUriHuman[0] !== "" ? ( diff --git a/src/components/NodeDetailView/Details/SampleDetails.js b/src/components/NodeDetailView/Details/SampleDetails.js index 68801df..3ea77c9 100644 --- a/src/components/NodeDetailView/Details/SampleDetails.js +++ b/src/components/NodeDetailView/Details/SampleDetails.js @@ -1,54 +1,51 @@ -import React from "react"; import { Box, + Divider, Typography, } from "@material-ui/core"; import SimpleLabelValue from './Views/SimpleLabelValue'; +import SimpleLinkedChip from './Views/SimpleLinkedChip'; import Links from './Views/Links'; -import { iterateSimpleValue } from './utils'; import { detailsLabel } from '../../../constants'; +import { isValidUrl } from './utils'; +import { useSelector } from 'react-redux' + const SampleDetails = (props) => { const { node } = props; - let title = ""; - let idDetails = ""; - // both tree and graph nodes are present, extract data from both - if (node?.tree_node && node?.graph_node) { - idDetails = node.tree_node.id + detailsLabel; - title = node.tree_node.basename; - // the below is the case where we have data only from the tree/hierarchy - } else if (node?.graph_node) { - idDetails = node.graph_node.id + detailsLabel; - title = node.graph_node.attributes?.label ? node.graph_node.attributes?.label[0] : ""; - // the below is the case where we have data only from the graph - } else { - idDetails = node.tree_node.id + detailsLabel; - title = "Undefined Sample name"; - } + const samplePropertiesModel = useSelector(state => state.sdsState.metadata_model.sample); return ( - + + - { node.graph_node.attributes?.hasUriHuman && node.graph_node.attributes?.hasUriHuman[0] !== "" - ? ( - {"Sample Details"} - Label - ) - : () - } - - { node.graph_node?.attributes?.publishedURI && node.graph_node?.attributes?.publishedURI !== "" - ? ( - SPARC Portal Link - - ) - : <> - } - - { iterateSimpleValue('Assigned group', node?.graph_node?.attributes?.hasAssignedGroup) } - { iterateSimpleValue('Digital artifact', node?.graph_node?.attributes?.hasDigitalArtifactThatIsAboutIt) } - { iterateSimpleValue('Extracted from Anatomical region', node?.graph_node?.attributes?.wasExtractedFromAnatomicalRegion) } + + + {samplePropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node.graph_node.attributes[property.property]?.[0]; + if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } + + else if ( typeof propValue === "string" ){ + return () + } + + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/Secondary.js b/src/components/NodeDetailView/Details/Secondary.js index 68eef3c..53c4c5f 100644 --- a/src/components/NodeDetailView/Details/Secondary.js +++ b/src/components/NodeDetailView/Details/Secondary.js @@ -1,4 +1,3 @@ -import React from "react"; import { Box, Typography, diff --git a/src/components/NodeDetailView/Details/SubjectDetails.js b/src/components/NodeDetailView/Details/SubjectDetails.js index fa19c57..652ce87 100644 --- a/src/components/NodeDetailView/Details/SubjectDetails.js +++ b/src/components/NodeDetailView/Details/SubjectDetails.js @@ -1,44 +1,19 @@ -import React from "react"; import { Box, + Divider, Typography, } from "@material-ui/core"; -import SimpleChip from './Views/SimpleChip'; import SimpleLinkedChip from './Views/SimpleLinkedChip'; import SimpleLabelValue from './Views/SimpleLabelValue'; import Links from './Views/Links'; -import { iterateSimpleValue, simpleValue } from './utils'; import { detailsLabel } from '../../../constants'; +import { isValidUrl } from './utils'; +import { useSelector } from 'react-redux' const SubjectDetails = (props) => { const { node } = props; - node.graph_node.dataset_id = node.dataset_id; - let title = ""; - let idDetails = ""; - // both tree and graph nodes are present, extract data from both - if (node.tree_node && node.graph_node) { - idDetails = node?.tree_node?.id + detailsLabel; - title = node?.tree_node?.basename; - // the below is the case where we have data only from the tree/hierarchy - } else if (node?.graph_node) { - idDetails = node?.graph_node?.id + detailsLabel; - title = node.graph_node.name; - // the below is the case where we have data only from the graph - } else { - idDetails = node.tree_node.id + detailsLabel; - title = node.tree_node.basename; - } - const DETAILS_LIST = [ - { - title: 'Weight Unit', - value: node.graph_node.attributes?.weightUnit - }, - { - title: 'Weight Value', - value: node.graph_node.attributes?.weightValue - } - ]; + const subjectPropertiesModel = useSelector(state => state.sdsState.metadata_model.subject); const getGroupNode = (groupName, node)=> { let n = node.graph_node.parent; @@ -46,93 +21,51 @@ const SubjectDetails = (props) => { while ( n && !match ) { if ( n.name === groupName ) { match = true; - }else { + } else { n = n.parent; } } - n.dataset_id = node.dataset_id; - return n; } return ( - + + - { node.graph_node.attributes?.hasUriHuman && node.graph_node.attributes?.hasUriHuman[0] !== "" - ? ( - {"Subject Details"} - Label - - ) - : (( - Subject Details - - )) - } + + + {subjectPropertiesModel?.map( property => { + if ( property.visible ){ + const propValue = node.graph_node.attributes[property.property]?.[0]; + if ( property.isGroup ){ + return ( + {property.label} + + ) + } - { node.graph_node?.attributes?.publishedURI && node.graph_node?.attributes?.publishedURI !== "" - ? ( - SPARC Portal Link - - ) - : <> - } + else if ( isValidUrl(propValue) ){ + return ( + {property.label} + + ) + } - { node.graph_node?.attributes?.hasAgeCategory - ? ( - Age Category - - ) - : <> - } - { (node.graph_node.attributes?.ageValue && node.graph_node.attributes?.ageUnit) - ? simpleValue('Age', node.graph_node.attributes?.ageValue + ' ' + node.graph_node.attributes?.ageUnit) - : (node.graph_node.attributes?.ageBaseUnit && node.graph_node.attributes?.ageBaseValue) - ? simpleValue('Age', node.graph_node.attributes?.ageBaseValue + ' ' + node.graph_node.attributes?.ageBaseUnit) - : <> - } + else if ( typeof propValue === "object" ){ + return ( + {property.label} + + ) + } - { (node.graph_node.attributes?.weightUnit && node.graph_node.attributes?.weightValue) - ? simpleValue('Weight', node.graph_node.attributes?.weightValue + ' ' + node.graph_node.attributes?.weightUnit) - : <> - } + else if ( typeof propValue === "string" ){ + return () + } - { node.graph_node?.attributes?.biologicalSex - ? ( - Biological Sex - - ) - : <> - } - { node.graph_node?.attributes?.specimenHasIdentifier && node.graph_node?.attributes?.specimenHasIdentifier !== "" - ? ( - Specimen identifier - - ) - : <> - } - { node.graph_node?.attributes?.subjectSpecies - ? ( - Species - - ) - : <> - } - { node.graph_node?.attributes?.subjectStrain - ? ( - Strains - - ) - : <> - } - { node.graph_node?.attributes?.hasAssignedGroup && node.graph_node?.attributes?.hasAssignedGroup.length > 0 - ? ( - Assigned Groups - - ) - : <> - } + return (<> ) + } + })} ); diff --git a/src/components/NodeDetailView/Details/Views/Breadcrumbs.js b/src/components/NodeDetailView/Details/Views/Breadcrumbs.js index 6778ca5..1eeea42 100644 --- a/src/components/NodeDetailView/Details/Views/Breadcrumbs.js +++ b/src/components/NodeDetailView/Details/Views/Breadcrumbs.js @@ -9,14 +9,14 @@ const HeaderBreadcrumbs = (props) => { const { links } = props; const goToLink = id => { const divElement = document.getElementById(id + detailsLabel); - divElement.scrollIntoView({ behavior: 'smooth' }); + divElement?.scrollIntoView({ behavior: 'smooth' }); } return ( } aria-label="breadcrumb" + maxItems={2} > { links && links.pages ? ( @@ -27,7 +27,10 @@ const HeaderBreadcrumbs = (props) => { )) ) : null } - {goToLink(links?.current.id)}} className="breadcrumb_selected">{links?.current.text} + {goToLink(links?.current.id)}} + className="breadcrumb_selected">{links?.current.text} {/* Close */} diff --git a/src/components/NodeDetailView/Details/utils.js b/src/components/NodeDetailView/Details/utils.js index 1ad8c7e..3f554c1 100644 --- a/src/components/NodeDetailView/Details/utils.js +++ b/src/components/NodeDetailView/Details/utils.js @@ -18,3 +18,13 @@ export const simpleValue = (label, value) => { return (<> ); } } + +export const isValidUrl = (urlString) => { + var urlPattern = new RegExp('^(https?:\\/\\/)?'+ // validate protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // validate domain name + '((\\d{1,3}\\.){3}\\d{1,3}))'+ // validate OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // validate port and path + '(\\?[;&a-z\\d%_.~+=-]*)?'+ // validate query string + '(\\#[-a-z\\d_]*)?$','i'); // validate fragment locator + return ( typeof urlString === "string" && urlString?.startsWith("http")); +} \ No newline at end of file diff --git a/src/components/NodeDetailView/NodeDetailView.js b/src/components/NodeDetailView/NodeDetailView.js index 707efc8..1ca30e2 100644 --- a/src/components/NodeDetailView/NodeDetailView.js +++ b/src/components/NodeDetailView/NodeDetailView.js @@ -1,18 +1,20 @@ -import React from "react"; -import { Box } from "@material-ui/core"; +import {Box} from "@material-ui/core"; import NodeFooter from "./Footers/Footer"; import DetailsFactory from './factory'; -import { useSelector } from 'react-redux' import Breadcrumbs from "./Details/Views/Breadcrumbs"; -import { IconButton, Tooltip, Link } from '@material-ui/core'; -import HelpIcon from '@material-ui/icons/Help'; +import { IconButton, Tooltip } from '@material-ui/core'; import { subject_key, protocols_key, contributors_key } from '../../constants'; -import config from "./../../config/app.json"; +import {TuneRounded} from "@material-ui/icons"; +import { useSelector, useDispatch } from 'react-redux' +import { toggleSettingsPanelVisibility } from '../../redux/actions'; const NodeDetailView = (props) => { + const dispatch = useDispatch(); + var otherDetails = undefined; const factory = new DetailsFactory(); const nodeSelected = useSelector(state => state.sdsState.instance_selected); + const showSettingsContent = useSelector(state => state.sdsState.settings_panel_visible); const nodeDetails = factory.createDetails(nodeSelected); let links = { pages: [], @@ -22,81 +24,65 @@ const NodeDetailView = (props) => { } }; var path = [] - if (nodeSelected?.tree_node?.path !== undefined && nodeSelected?.tree_node !== null) { - path = [...nodeSelected.tree_node.path] - path.shift(); - otherDetails = path.reverse().map( singleNode => { - const tree_node = window.datasets[nodeSelected.dataset_id].splinter.tree_map.get(singleNode); - const new_node = { - dataset_id: nodeSelected.dataset_id, - graph_node: tree_node.graph_reference, - tree_node: tree_node - } - // I don't like the check on primary and derivative below since this depends on the data - // but it's coming as a feature request, so I guess it can stay there. - if (new_node.tree_node.id !== subject_key - && new_node.tree_node.id !== contributors_key - && new_node.tree_node.id !== protocols_key - && new_node.tree_node.basename !== 'primary' - && new_node.tree_node.basename !== 'derivative') { - links.pages.push({ - id: singleNode, - title: tree_node.text, - href: '#' - }); - return factory.createDetails(new_node).getDetail() - } - return <> ; - }); - links.current = { - id: nodeSelected.tree_node.id, - text: nodeSelected.tree_node.text - }; - } else { - path = []; - var latestNodeVisited = nodeSelected; - while ( latestNodeVisited.graph_node.parent !== undefined ) { - path.push(latestNodeVisited.graph_node.parent.id); - latestNodeVisited = { - tree_node: undefined, - graph_node: latestNodeVisited.graph_node.parent - }; + var latestNodeVisited = nodeSelected; + while ( latestNodeVisited?.graph_node?.parent !== undefined ) { + path.push(latestNodeVisited?.graph_node?.parent?.id); + latestNodeVisited = { + tree_node: undefined, + graph_node: latestNodeVisited?.graph_node?.parent }; + }; - otherDetails = path.reverse().map( singleNode => { - const graph_node = window.datasets[nodeSelected.dataset_id].splinter.nodes.get(singleNode); - const new_node = { - dataset_id: nodeSelected.dataset_id, - graph_node: graph_node, - tree_node: graph_node.tree_reference - } - if (new_node.graph_node.id !== subject_key - && new_node.graph_node.id !== contributors_key - && new_node.graph_node.id !== protocols_key) { - links.pages.push({ - id: singleNode, - title: graph_node.name, - href: '#' - }); - return factory.createDetails(new_node).getDetail() - } - return <> ; - }); + otherDetails = path.reverse().map( singleNode => { + const graph_node = window.datasets[nodeSelected.dataset_id].splinter.nodes.get(singleNode); + const new_node = { + dataset_id: nodeSelected.dataset_id, + graph_node: graph_node, + tree_node: graph_node.tree_reference + } + if (new_node?.graph_node?.id !== subject_key + && new_node?.graph_node?.id !== contributors_key + && new_node?.graph_node?.id !== protocols_key) { + links.pages.push({ + id: singleNode, + title: graph_node.name, + href: '#' + }); + return factory.createDetails(new_node).getDetail() + } + return <> ; + }); + if ( nodeSelected?.graph_node ){ links.current = { - id: nodeSelected.graph_node.id, - text: nodeSelected.graph_node.name + id: nodeSelected?.graph_node?.id, + text: nodeSelected?.graph_node?.name }; } + const toggleContent = () => { + dispatch(toggleSettingsPanelVisibility(!showSettingsContent)); + }; return ( - {/**{ nodeDetails.getHeader() }*/} - { otherDetails } - { nodeDetails.getDetail() } + { + showSettingsContent && nodeDetails.getSettings ? nodeDetails.getSettings() : null + } + { + !showSettingsContent ? + <> + { otherDetails } + {nodeDetails.getDetail()} + : null + } + { !showSettingsContent && + + + + } ); }; diff --git a/src/components/NodeDetailView/factory.js b/src/components/NodeDetailView/factory.js index ec4ca8d..baf846d 100644 --- a/src/components/NodeDetailView/factory.js +++ b/src/components/NodeDetailView/factory.js @@ -12,9 +12,9 @@ import SampleDetails from './Details/SampleDetails'; import DatasetDetails from './Details/DatasetDetails'; import SubjectDetails from './Details/SubjectDetails'; import ProtocolDetails from './Details/ProtocolDetails'; -import CollectionDetails from './Details/CollectionDetails'; import GroupDetails from './Details/GroupDetails'; - +import CollectionDetails from './Details/CollectionDetails' +import Settings from "./settings/Settings" var DetailsFactory = function () { this.createDetails = function (node) { let details = null; @@ -64,7 +64,7 @@ const Collection = function (node) { nodeDetail.getHeader = () => { return ( <> - {/* */} + ) }; @@ -72,7 +72,7 @@ const Collection = function (node) { nodeDetail.getDetail = () => { return ( <> - {/* */} + ) }; @@ -80,8 +80,16 @@ const Collection = function (node) { nodeDetail.getAll = () => { return ( <> - {/* - */} + + + + ) + } + + nodeDetail.getSettings = () => { + return ( + <> + ) } @@ -116,6 +124,14 @@ const Group = function (node) { ) } + + nodeDetail.getSettings = () => { + return ( + <> + + + ) + } return nodeDetail; }; @@ -147,6 +163,14 @@ const Dataset = function (node) { ) } + + nodeDetail.getSettings = () => { + return ( + <> + + + ) + } return nodeDetail; }; @@ -209,6 +233,14 @@ const Sample = function (node) { ) } + + nodeDetail.getSettings = () => { + return ( + <> + + + ) + } return nodeDetail; }; @@ -240,6 +272,14 @@ const Subject = function (node) { ) } + + nodeDetail.getSettings = () => { + return ( + <> + + + ) + } return nodeDetail; }; @@ -271,6 +311,14 @@ const File = function (node) { ) } + + nodeDetail.getSettings = () => { + return ( + <> + + + ) + } return nodeDetail; }; diff --git a/src/components/NodeDetailView/settings/Settings.js b/src/components/NodeDetailView/settings/Settings.js new file mode 100644 index 0000000..dd7ab7e --- /dev/null +++ b/src/components/NodeDetailView/settings/Settings.js @@ -0,0 +1,38 @@ +import React from "react"; +import { Box, Button } from "@material-ui/core"; +import SettingsGroup from "./SettingsGroup"; +import { useSelector, useDispatch } from 'react-redux' +import { toggleSettingsPanelVisibility } from '../../../redux/actions'; +const Settings = () => { + const dispatch = useDispatch(); + const showSettingsContent = useSelector(state => state.sdsState.settings_panel_visible); + const metaDataPropertiesModel = useSelector(state => state.sdsState.metadata_model); + const save = () => { + dispatch(toggleSettingsPanelVisibility(!showSettingsContent)); + }; + return ( + + { + Object.keys(metaDataPropertiesModel).map(group => ) + } + + + + + ); +}; + +export default Settings; diff --git a/src/components/NodeDetailView/settings/SettingsGroup.js b/src/components/NodeDetailView/settings/SettingsGroup.js new file mode 100644 index 0000000..deb3230 --- /dev/null +++ b/src/components/NodeDetailView/settings/SettingsGroup.js @@ -0,0 +1,39 @@ +import React, { useState } from "react"; +import { Box } from "@material-ui/core"; +import { DragDropContext, Droppable } from "react-beautiful-dnd"; +import SettingsListItems from "./SettingsListItems"; +import { useDispatch } from "react-redux"; +import { updateMetaDataItemsOrder } from "../../../redux/actions"; +const SettingsGroup = ({ title, group }) => { + const [items, setItems] = useState(group); + const dispatch = useDispatch(); + + const handleDragEnd = result => { + if (!result.destination) return; + + const itemsCopy = [...items]; + const [reorderedItem] = itemsCopy.splice(result.source.index, 1); + itemsCopy.splice(result.destination.index, 0, reorderedItem); + + setItems(itemsCopy); + dispatch(updateMetaDataItemsOrder({ groupTitle: title, newItemsOrder: itemsCopy })); + }; + + return ( + + + + {provided => ( + + )} + + + + ); +}; + +export default SettingsGroup; diff --git a/src/components/NodeDetailView/settings/SettingsItem.js b/src/components/NodeDetailView/settings/SettingsItem.js new file mode 100644 index 0000000..602e228 --- /dev/null +++ b/src/components/NodeDetailView/settings/SettingsItem.js @@ -0,0 +1,88 @@ +import React from "react"; +import { useDispatch } from 'react-redux'; +import { toggleMetadataItemVisibility } from '../../../redux/actions'; + +import { + Typography, + ListItemText, + ListItem, + ListItemSecondaryAction, + IconButton, + Tooltip +} from "@material-ui/core"; +import ReorderIcon from "@material-ui/icons/Reorder"; +import RemoveCircleOutlineIcon from "@material-ui/icons/RemoveCircleOutline"; +import AddCircleOutlineIcon from "@material-ui/icons/AddCircleOutline"; +import VisibilityOffRoundedIcon from "@material-ui/icons/VisibilityOffRounded"; +const SettingsItem = props => { + const { groupTitle, item } = props; + const dispatch = useDispatch(); + const toggleItemDisabled = () => dispatch(toggleMetadataItemVisibility(groupTitle, item.key)) + + return ( + + {item.visible ? ( + + ) : ( + + )} + + {item.label} + + } + /> + + + + + {!item.visible ? ( + + + ) : ( + + + )} + + + + ); +}; + +export default SettingsItem; diff --git a/src/components/NodeDetailView/settings/SettingsListItems.js b/src/components/NodeDetailView/settings/SettingsListItems.js new file mode 100644 index 0000000..955c9a6 --- /dev/null +++ b/src/components/NodeDetailView/settings/SettingsListItems.js @@ -0,0 +1,57 @@ +import React from "react"; +import { + Box, + Typography, + List, + ListSubheader, +} from "@material-ui/core"; +import { Draggable } from "react-beautiful-dnd"; +import SettingsItem from "./SettingsItem"; +const SettingsListItems = props => { + const { provided, items, title } = props; + return ( + + + + {title.charAt(0).toUpperCase() + title.slice(1)} + + + + } + > + {items.map((item, index) => ( + + {provided => ( + + + + )} + + ))} + {provided.placeholder} + + ); +}; + +export default SettingsListItems; diff --git a/src/components/Sidebar/Footer.js b/src/components/Sidebar/Footer.js index c6c9409..cd97cfa 100644 --- a/src/components/Sidebar/Footer.js +++ b/src/components/Sidebar/Footer.js @@ -1,6 +1,5 @@ -import Plus from '../../images/plus.svg'; import { ADD_DATASET } from '../../constants'; -import { Box, Button, Typography } from '@material-ui/core'; +import {Box, Button, Divider, Typography} from '@material-ui/core'; import config from "./../../config/app.json"; @@ -8,30 +7,35 @@ const SidebarFooter = (props) => { return ( + { props.local ? : null } - - Powered by MetaCell - + + + { + props.expand && + Powered by MetaCell + + } + ); }; diff --git a/src/components/Sidebar/Header.js b/src/components/Sidebar/Header.js index 6d6b272..fb7ad49 100644 --- a/src/components/Sidebar/Header.js +++ b/src/components/Sidebar/Header.js @@ -7,11 +7,10 @@ import { InputAdornment, Button, } from '@material-ui/core'; -import ToggleRight from '../../images/toggle-right.svg'; import Logo from '../../images/logo.svg'; import ToggleLeft from '../../images/toggle-left.svg'; import Search from '../../images/search.svg'; - +import KeyboardTabIcon from '@material-ui/icons/KeyboardTab'; const SidebarHeader = (props) => { const { expand, setExpand, setSearchTerm, searchTerm } = props; const handleChange = ( e ) => { @@ -21,8 +20,8 @@ const SidebarHeader = (props) => { return ( Logo - setExpand(!expand)}> - Toggle + setExpand(!expand)} className='shrink-btn'> + {!expand ? : Toggle} {expand && ( diff --git a/src/components/Sidebar/List.js b/src/components/Sidebar/List.js index 60c51d2..001ab8e 100644 --- a/src/components/Sidebar/List.js +++ b/src/components/Sidebar/List.js @@ -1,62 +1,58 @@ -import React from 'react'; -import { - Box, - IconButton, -} from '@material-ui/core'; -import Search from '../../images/search.svg'; +import React, {useEffect} from 'react'; +import {Box, IconButton} from '@material-ui/core'; import Typography from '@material-ui/core/Typography'; import InstancesTreeView from './TreeView/InstancesTreeView'; -import { useSelector } from 'react-redux' +import {useSelector} from 'react-redux'; +import SearchRoundedIcon from '@material-ui/icons/SearchRounded'; const SidebarContent = (props) => { const { expand, setExpand, searchTerm } = props; - const datasets = useSelector(state => state.sdsState.datasets); + + const datasets = useSelector((state) => state.sdsState.datasets); + const nodeSelected = useSelector((state) => state.sdsState.instance_selected); + useEffect(() => { + if (nodeSelected?.tree_node?.id) { + const selectedNodeElement = document.getElementById(nodeSelected?.tree_node?.id); + + if (selectedNodeElement) { + selectedNodeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } + } + }, [nodeSelected]); + const renderContent = () => { if (datasets?.length > 0) { return ( - <> - Uploaded Instances - - { datasets.map((id, index) => ) } - - + <> + Uploaded Instances + + {datasets.map((id, index) => ( + + ))} + + ); } else { return ( - <> - - No instances to display yet. - - + <> + No instances to display yet. + ); } }; return ( - - {!expand ? ( - setExpand(!expand)} - > - Search - - ) : ( renderContent() ) - } - - ) -} + + {!expand ? ( + setExpand(!expand)} className='shrink-btn'> + + + ) : ( + renderContent() + )} + + ); +}; export default SidebarContent; - - -// ( -// <> -// Uploaded Instances -// { datasets.map( id => { -// return -// }) -// } -// -// ) diff --git a/src/components/Sidebar/TreeView/InstancesTreeView.js b/src/components/Sidebar/TreeView/InstancesTreeView.js index 48bb1bc..65da03d 100644 --- a/src/components/Sidebar/TreeView/InstancesTreeView.js +++ b/src/components/Sidebar/TreeView/InstancesTreeView.js @@ -20,22 +20,31 @@ const InstancesTreeView = (props) => { const [items, setItems] = useState(datasets); const widgets = useSelector(state => state.widgets); - const onNodeSelect = (e, nodeId) => { + const onNodeSelect = (e, nodeId, isOpenFile = false) => { const node = window.datasets[dataset_id].splinter.tree_map.get(nodeId); - dispatch(selectInstance({ - dataset_id: dataset_id, - graph_node: node?.graph_reference?.id, - tree_node: node?.id, - source: TREE_SOURCE - })); - if (widgets[dataset_id] !== undefined) { - widgets[dataset_id].status = WidgetStatus.ACTIVE; - dispatch(layoutActions.updateWidget(widgets[dataset_id])); - } - if (widgets[dataset_id] !== undefined) { - widgets[dataset_id].status = WidgetStatus.ACTIVE; - dispatch(layoutActions.updateWidget(widgets[dataset_id])); + + if (isOpenFile) { + const publishedURI = node.graph_reference?.attributes?.publishedURI; + if (publishedURI) { + window.open(publishedURI, '_blank'); + } + } else { + dispatch(selectInstance({ + dataset_id: dataset_id, + graph_node: node?.graph_reference?.id || node?.id, + tree_node: node?.id, + source: TREE_SOURCE + })); + if (widgets[dataset_id] !== undefined) { + widgets[dataset_id].status = WidgetStatus.ACTIVE; + dispatch(layoutActions.updateWidget(widgets[dataset_id])); + } + if (widgets[dataset_id] !== undefined) { + widgets[dataset_id].status = WidgetStatus.ACTIVE; + dispatch(layoutActions.updateWidget(widgets[dataset_id])); + } } + }; const onNodeToggle = (e, nodeIds) => { @@ -127,11 +136,11 @@ const InstancesTreeView = (props) => { { labelIcon: DATASET, iconClass: 'dataset' } : itemLength > 0 ? { labelIcon: FOLDER, iconClass: 'folder' } : { labelIcon: FILE, iconClass: 'file' }; - return ( { const { @@ -18,6 +19,7 @@ const StyledTreeItem = (props) => { return ( @@ -29,6 +31,11 @@ const StyledTreeItem = (props) => { variant="body2" className="labelText"> {labelText} + {props.iconClass === 'file' && window.datasets[dataset].splinter.tree_map.get(props.nodeId)?.graph_reference?.attributes?.publishedURI != undefined ? + { + onNodeSelect(event, props.nodeId, true); + event.preventDefault(); + }}> : null} {labelInfo > 0 ? ( ( + + + + +); + +export const ViewTypeIcon = (props) => ( + + + +); + +export const LabelIcon = (props) => ( + + + +); \ No newline at end of file diff --git a/src/redux/actions.js b/src/redux/actions.js index 5fb0672..62adbc2 100644 --- a/src/redux/actions.js +++ b/src/redux/actions.js @@ -4,13 +4,15 @@ export const SET_DATASET_LIST = 'SET_DATASET_LIST' export const SELECT_INSTANCE = 'SELECT_INSTANCE' export const TRIGGER_ERROR = 'TRIGGER_ERROR' export const SELECT_GROUP = 'SELECT_GROUP' +export const TOGGLE_METADATA_SETTINGS = 'TOGGLE_METADATA_SETTINGS' +export const TOGGLE_METADATA_ITEM_VISIBILITY = 'TOGGLE_METADATA_ITEM_VISIBILITY' +export const UPDATE_METADATA_ITEMS_ORDER = 'UPDATE_METADATA_ITEMS_ORDER' export const addDataset = dataset => ({ type: ADD_DATASET, data: { dataset: dataset }, }); - export const deleteDataset = dataset_id => ({ type: DELETE_DATASET, data: { dataset_id: dataset_id }, @@ -44,4 +46,25 @@ export const selectGroup = instance => ({ export const triggerError = message => ({ type: TRIGGER_ERROR, data: { error_message: message }, -}); \ No newline at end of file +}); + +export const toggleSettingsPanelVisibility = visible => ({ + type: TOGGLE_METADATA_SETTINGS, + data: { visible: visible }, +}); + + +export const toggleMetadataItemVisibility = (groupTitle, itemId) => ({ + type: TOGGLE_METADATA_ITEM_VISIBILITY, + data: { + groupTitle, + itemId, + }, +}); + +export const updateMetaDataItemsOrder = ({ groupTitle, newItemsOrder }) => { + return { + type: UPDATE_METADATA_ITEMS_ORDER, + payload: { title: groupTitle, newItemsOrder }, + }; +}; \ No newline at end of file diff --git a/src/redux/initialState.js b/src/redux/initialState.js index 8e8a69f..ccf8d90 100644 --- a/src/redux/initialState.js +++ b/src/redux/initialState.js @@ -1,6 +1,17 @@ import * as Actions from './actions'; import * as LayoutActions from '@metacell/geppetto-meta-client/common/layout/actions'; +import { rdfTypes } from "../utils/graphModel"; +import {TOGGLE_METADATA_ITEM_VISIBILITY, UPDATE_METADATA_ITEMS_ORDER} from "./actions"; +const savedMetadataModel = localStorage.getItem("metadata_model"); +const initialMetadataModel = savedMetadataModel ? JSON.parse(savedMetadataModel) : { + dataset: [...rdfTypes.Dataset.properties], + subject: [...rdfTypes.Subject.properties], + sample: [...rdfTypes.Sample.properties], + collection : [...rdfTypes.Collection.properties], + group: [...rdfTypes.Group.properties], + file: [...rdfTypes.File.properties] +}; export const sdsInitialState = { "sdsState": { datasets: [], @@ -19,7 +30,9 @@ export const sdsInitialState = { tree_node: null, source: "" }, - layout : {} + layout : {}, + settings_panel_visible : false, + metadata_model : initialMetadataModel } }; @@ -105,8 +118,44 @@ export default function sdsClientReducer(state = {}, action) { }; } break; + case TOGGLE_METADATA_ITEM_VISIBILITY: + const { groupTitle, itemId } = action.data; + const updatedMetadataModel = { ...state.metadata_model }; + const groupIndex = updatedMetadataModel[groupTitle].findIndex(item => item.key === itemId); + + if (groupIndex !== -1) { + const itemToToggle = updatedMetadataModel[groupTitle][groupIndex]; + itemToToggle.visible = !itemToToggle.visible; + + // Toggle visibility first, then reorder items + updatedMetadataModel[groupTitle].sort((a, b) => { + if (a.visible === b.visible) { + // Preserve the original order for items with the same visibility + return updatedMetadataModel[groupTitle].indexOf(a) - updatedMetadataModel[groupTitle].indexOf(b); + } + }); + } + localStorage.setItem("metadata_model", JSON.stringify(updatedMetadataModel)); + + return { + ...state, + metadata_model: { ...updatedMetadataModel } + }; + case UPDATE_METADATA_ITEMS_ORDER: + const { title, newItemsOrder } = action.payload; + const updatedMetadataModelOrder = { + ...state.metadata_model, + [title]: newItemsOrder, + }; + localStorage.setItem("metadata_model", JSON.stringify(updatedMetadataModelOrder)); + return { + ...state, + metadata_model: updatedMetadataModelOrder, + }; case LayoutActions.layoutActions.SET_LAYOUT: return { ...state, layout : action.data.layout}; + case Actions.TOGGLE_METADATA_SETTINGS: + return { ...state, settings_panel_visible : action.data.visible}; default: return state; } diff --git a/src/styles/constant.js b/src/styles/constant.js index 06065d8..fdf6c1c 100644 --- a/src/styles/constant.js +++ b/src/styles/constant.js @@ -11,7 +11,7 @@ const vars = { noInstanceColor: 'rgba(255, 255, 255, 0.6)', inputTextColor: 'rgba(255, 255, 255, 0.8)', iconButtonHover: 'rgba(255, 255, 255, 0.2)', - radius: 8, + radius: '.5rem', gutter: 16, whiteColor: '#FFFFFF', sidebarIconColor: 'rgba(221, 221, 221, 0.8)', @@ -35,9 +35,19 @@ const vars = { matlab: '#6FC386', nifti: '#7747F6', volume: '#3779E1', - sideBarLabelColor: 'rgba(46, 58, 89, 0.4)', + sideBarLabelColor: '#435070', treeBorderColor: '#4E5261', scrollbarBg: 'rgba(0, 0, 0, 0.24)', + gray800: '#0F162B', + gray400: '#586482', + gray25: '#F0F1F2', + grey700: '#212B45', + grey500: '#435070', + grey100: '#C9CDD6', + grey400: '#586482', + grey50: '#E1E3E8', + grey25: '#F0F1F2', + grey600: '#2E3A59', }; export default vars; diff --git a/src/theme.js b/src/theme.js index eb68849..03a26d7 100644 --- a/src/theme.js +++ b/src/theme.js @@ -19,13 +19,11 @@ const { whiteColor, outlinedButtonHover, primaryBgColor, - inputTextColor, scrollbarBg, iconButtonHover, primaryTransition, fontFamily, barSuccessColor, - noInstanceColor, gutter, errorColor, tabsBgColor, @@ -43,6 +41,16 @@ const { chipBgColor, progressErrorBg, treeBorderColor, + grey700, + grey500, + grey100, + grey400, + grey50, + grey25, + grey600, + gray800, + gray400, + gray25 } = vars; const theme = createTheme({ @@ -94,12 +102,14 @@ const theme = createTheme({ display: 'inline-flex', alignItems: 'center', height: '1.375rem', - marginTop: '.25rem', + marginTop: '.5rem', marginRight: '.375rem', '& .MuiChip-label': { - padding: '0 .375rem', + padding: '0.25rem 0.375rem', fontSize: '.75rem', - color: primaryTextColor, + color: gray400, + backgroundColor: gray25, + borderRadius: '0.3125rem' }, }, }, @@ -306,12 +316,12 @@ const theme = createTheme({ MuiFilledInput: { root: { fontFamily, - backgroundColor: lightBorderColor, + backgroundColor: grey500, height: '2.375rem', - borderRadius: `${radius}px !important`, + borderRadius: `${radius} !important`, paddingRight: `0.4375rem !important`, '&:hover': { - backgroundColor: lightBorderColor, + backgroundColor: grey500, }, '& .MuiInputAdornment-positionStart': { marginTop: `0 !important`, @@ -322,9 +332,11 @@ const theme = createTheme({ paddingBottom: 0, fontSize: '0.75rem', letterSpacing: '-0.01em', - color: inputTextColor, + color: grey100, '&::placeholder': { - color: inputTextColor, + color: grey100, + fontWeight: '400', + fontSize: '.75rem' }, }, adornedEnd: { @@ -358,6 +370,7 @@ const theme = createTheme({ label: { textTransform: 'none', display: 'flex', + fontWeight: 600, '& img': { marginRight: '.25rem', }, @@ -376,6 +389,7 @@ const theme = createTheme({ outlinedPrimary: { borderColor: primaryColor, color: primaryColor, + padding: '0.75rem', '&:hover': { backgroundColor: outlinedButtonHover, }, @@ -393,6 +407,9 @@ const theme = createTheme({ display: 'flex', overflow: 'hidden', }, + '.sidebar-body': { + boxShadow: '0px -75px 49px -41px #212B45 inset', + }, '.scrollbar': { overflow: 'auto', '&::-webkit-scrollbar': { @@ -414,6 +431,24 @@ const theme = createTheme({ display: 'none', }, }, + '& .overlay-button-container': { + position: 'sticky', + bottom: 0, + zIndex: 1000, + padding: '20px 0', + background: 'linear-gradient(180deg, rgb(255 255 255 / 87%) 8%, #FFF 100%)', + display: 'flex', + justifyContent: 'center', + }, + '& .overlay-button': { + padding: '10px 20px', + width: '3rem', + height: '3rem', + backgroundColor: 'rgba(46, 58, 89, 0.10)', + color: '#2E3A59', + borderRadius: '50%', + cursor: 'pointer', + }, '.dialog': { '&_body': { background: dialogBodyBgColor, @@ -448,7 +483,7 @@ const theme = createTheme({ '.sidebar': { width: '18.75rem', overflow: 'hidden', - backgroundColor: secondaryColor, + backgroundColor: grey700, height: '100vh', flexShrink: 0, padding: '1rem 0.75rem', @@ -483,10 +518,17 @@ const theme = createTheme({ padding: 0, width: '2.25rem', minWidth: '0.0625rem', - fontSize: 0, margin: '0 auto', display: 'block', height: '2.25rem', + '&.shrink-btn': { + backgroundColor: grey25, + color: grey600, + + '& .MuiSvgIcon-root': { + fontSize: '1rem', + } + } }, }, '&:not(.shrink)': { @@ -592,6 +634,16 @@ const theme = createTheme({ fontSize: 0, backgroundImage: `url(${FILE})`, }, + '& .labelText': { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + + '& .MuiSvgIcon-root': { + fontSize: '.875rem', + color: grey50 + } + } }, '& .folder': { '& .labelIcon': { @@ -625,7 +677,7 @@ const theme = createTheme({ }, '& .labelCaption': { height: '1rem', - backgroundColor: lightBorderColor, + backgroundColor: grey400, padding: '0 0.25rem', display: 'flex', alignItems: 'center', @@ -635,7 +687,7 @@ const theme = createTheme({ lineHeight: '0.75rem', minWidth: '2rem', justifyContent: 'center', - color: noInstanceColor, + color: grey50, letterSpacing: '-0.01em', '& img': { marginLeft: '0.125rem', @@ -786,7 +838,7 @@ const theme = createTheme({ height: '100%', fontWeight: '600', letterSpacing: '-0.01em', - color: noInstanceColor, + color: grey100, textAlign: 'center', }, }, @@ -942,13 +994,16 @@ const theme = createTheme({ '& .MuiBreadcrumbs-li': { lineHeight: '1.5', '& a': { - color: placeHolderColor, cursor: 'pointer', lineHeight: 'normal', + color: '#475467', + fontSize: '0.75rem', + fontWeight: 500, }, }, '& .MuiBreadcrumbs-separator': { margin: '0 .5rem', + color: '#9198AB' }, }, '&_body': { @@ -1038,12 +1093,22 @@ const theme = createTheme({ '&+ .tab-content': { borderTop: `.0625rem solid ${tabsBorderColor}`, }, + '& .title-container':{ + display: 'flex', + alignItems: 'center', + marginBottom: '1.3rem', + + '& h3': { + marginBottom: 0, + marginLeft: '.25rem' + } + }, '& h3': { fontSize: '1.125rem', fontWeight: '500', lineHeight: '1.375rem', letterSpacing: '-0.03em', - color: primaryTextColor, + color: gray800, marginBottom: '1.3rem', }, '& .tab-content-row': { @@ -1056,6 +1121,7 @@ const theme = createTheme({ fontSize: '.75rem', lineHeight: '1rem', color: primaryColor, + marginTop: '.5rem', '&:not(:last-child)': { marginRight: '.75rem', @@ -1077,6 +1143,10 @@ const theme = createTheme({ color: sideBarLabelColor, '&+ p': { marginTop: '.25rem', + color: gray400, + fontSize: '.75rem', + fontWeight: '400', + lineHeight: '1rem', }, }, '&> p': { @@ -1159,6 +1229,7 @@ const theme = createTheme({ bottom: '0', right : '0rem', zIndex: '100', + padding: '.5rem' }, }, }, diff --git a/src/utils/GraphViewerHelper.js b/src/utils/GraphViewerHelper.js new file mode 100644 index 0000000..5dad9cf --- /dev/null +++ b/src/utils/GraphViewerHelper.js @@ -0,0 +1,305 @@ +import React, {useCallback} from 'react'; +import { rdfTypes } from './graphModel'; +import { current } from '@reduxjs/toolkit'; +import * as d3 from 'd3'; + +export const NODE_FONT = '500 5px Inter, sans-serif'; +export const ONE_SECOND = 1000; +export const ZOOM_DEFAULT = 1; +export const ZOOM_SENSITIVITY = 0.2; +export const GRAPH_COLORS = { + link: '#CFD4DA', + linkHover : 'purple', + hoverRect: '#CFD4DA', + textHoverRect: '#3779E1', + textHover: 'white', + textColor: '#2E3A59', + collapsedFolder : 'red', + nodeSeen: '#E1E3E8', + textBGSeen: '#6E4795' +}; +export const TOP_DOWN = { + label : "Tree View", + layout : "td", + maxNodesLevel : (graph) => { + return graph.hierarchyVariant; + } +}; +export const LEFT_RIGHT = { + label : "Vertical Layout", + layout : "lr", + maxNodesLevel : (graph) => { + return graph.hierarchyVariant; + } +}; +export const RADIAL_OUT = { + label : "Radial View", + layout : "null", + maxNodesLevel : (graph) => { + return graph.radialVariant + } +}; + +export const nodeSpace = 60; + +/** + * Create background for Nodes on Graph Viewer. + * @param {*} ctx - Canvas context rendering + * @param {*} x - x position of node, used to draw background + * @param {*} y - y position of node, used to draw background + * @param {*} width - needed width of background + * @param {*} height - needed height of background + * @param {*} radius - Radius of background + * @param {*} color - color used for the background + * @param {*} alpha - alpha color + */ +const roundRect = (ctx, x, y, width, height, radius, color, alpha) => { + if (width < 2 * radius) radius = width / 2; + if (height < 2 * radius) radius = height / 2; + ctx.globalAlpha = alpha || 1; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.arcTo(x + width, y, x + width, y + height, radius); + ctx.arcTo(x + width, y + height, x, y + height, radius); + ctx.arcTo(x, y + height, x, y, radius); + ctx.arcTo(x, y, x + width, y, radius); + ctx.closePath(); + ctx.fill(); +}; + +export const paintNode = (node, ctx, hoverNode, selectedNode, nodeSelected, previouslySelectedNodes) => { + const size = 7.5; + const nodeImageSize = [size * 2.4, size * 2.4]; + const hoverRectDimensions = [size * 4.2, size * 4.2]; + const hoverRectPosition = [node.x - hoverRectDimensions[0]/2, node.y - hoverRectDimensions[1]/2]; + const textHoverPosition = [ + hoverRectPosition[0], + hoverRectPosition[1] + hoverRectDimensions[1], + ]; + const hoverRectBorderRadius = 1; + ctx.beginPath(); + + try { + ctx.drawImage( + node?.img, + node.x - size, + node.y - size, + ...nodeImageSize + ); + } catch (error) { + const img = new Image(); + img.src = rdfTypes.Unknown.image; + node.img = img; + + // Add default icon if new icon wasn't found under images + ctx.drawImage( + node?.img, + node.x - size - 1, + node.y - size, + ...nodeImageSize + ); + } + + ctx.font = NODE_FONT; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + let nodeName = node.name; + if (nodeName.length > 10) { + nodeName = nodeName.substr(0, 9).concat('...'); + } else if ( Array.isArray(nodeName) ){ + nodeName = nodeName[0]?.substr(0, 9).concat('...'); + } + const textProps = [nodeName, node.x, textHoverPosition[1]]; + if (node === hoverNode || node?.id === selectedNode?.id || node?.id === nodeSelected?.id ) { + // image hover + roundRect( + ctx, + ...hoverRectPosition, + ...hoverRectDimensions, + hoverRectBorderRadius, + GRAPH_COLORS.hoverRec, + 0.3 + ); + // text node name hover + roundRect( + ctx, + ...textHoverPosition, + hoverRectDimensions[0], + hoverRectDimensions[1] / 4, + hoverRectBorderRadius, + GRAPH_COLORS.textHoverRect + ); + // reset canvas fill color + ctx.fillStyle = GRAPH_COLORS.textHover; + } else if (previouslySelectedNodes.has(node.id)) { + // Apply different style previously selected nodes + roundRect( + ctx, + ...hoverRectPosition, + ...hoverRectDimensions, + hoverRectBorderRadius, + GRAPH_COLORS.nodeSeen, + 0.3 + ); + roundRect( + ctx, + ...textHoverPosition, + hoverRectDimensions[0], + hoverRectDimensions[1] / 4, + hoverRectBorderRadius, + GRAPH_COLORS.textBGSeen + ); + ctx.fillStyle = GRAPH_COLORS.textHover; + } else { + ctx.fillStyle = GRAPH_COLORS.textColor; + } + ctx.fillText(...textProps); + if ( node.childLinks?.length && node.collapsed ) { + let children = { links : 0 }; + collapseSubLevels(node, true, children) + const collapsedNodes = [children.links, node.x, textHoverPosition[1]]; + ctx.fillStyle = GRAPH_COLORS.collapsedFolder; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(...collapsedNodes); + ctx.fillStyle = GRAPH_COLORS.textColor; + } + } + +export const collapseSubLevels = (node, collapsed, children) => { + node?.childLinks?.forEach( n => { + if ( collapsed !== undefined ) { + n.target.collapsed = collapsed; + collapseSubLevels(n.target, collapsed, children); + children.links = children.links + 1; + } + }); +} + + +const hierarchy = (data) =>{ + return d3.hierarchy(data); +} + +const dendrogram = (data) => { + const dendrogramGenerator = d3.cluster().nodeSize([1, 100]) + .separation(function(a,b){ + return 1 + d3.sum([a,b].map(function(d){ + return 15 + })) + }); + return dendrogramGenerator(hierarchy(data)); +} + + /** + * Create Graph ID + * @param {*} graph_id - ID of dataset we need the tree for + * @param {*} layout - The desired layout in which we will display the data e.g. Tree, Vertical, Radial + * @returns + */ +export const getPrunedTree = (graph_id, layout) => { + let nodesById = Object.fromEntries(window.datasets[graph_id].graph?.nodes?.map(node => [node.id, node])); + window.datasets[graph_id].graph?.links?.forEach(link => { + const source = link.source.id; + const target = link.target.id; + const linkFound = !nodesById[source]?.childLinks?.find( l => + source === l.source.id && target === l.target.id + ); + if ( linkFound ) { + nodesById[source]?.childLinks?.push(link); + } + }); + + let visibleNodes = []; + const visibleLinks = []; + + let levelsMap = window.datasets[graph_id].graph.levelsMap; + // // Calculate level with max amount of nodes + + (function traverseTree(node = nodesById[window.datasets[graph_id].graph?.nodes?.[0].id]) { + visibleNodes.push(node); + if (node.collapsed) return; + // let childLinks = node.childLinks?.filter( link => !link.source.collapsed && !link.target.collapsed ); + visibleLinks.push(...node.childLinks); + let nodes = node.childLinks.map(link => (typeof link.target) === 'object' ? link.target : nodesById[link.target]); + nodes?.forEach(traverseTree); + })(); // IIFE + + let levels = {}; + visibleNodes.forEach( n => { + if ( levels[n.level] ){ + levels[n.level].push(n); + } else { + levels[n.level] = [n]; + } + }) + + + // Calculate level with max amount of nodes + let maxLevel = parseInt(Object.keys(levels).reduce((a, b) => levels[a].length > levels[b].length ? a : b)); + + + let root = levelsMap["1"]?.[0]; + + let data = { + type : "node", + name : root?.id, + value : levelsMap["1"]?.[0]?.level - 1, + children : [] + }; + + function traverse(node, data) { + if (node === null) { + return; + } + node.neighbors?.forEach( n => { + if ( visibleNodes?.find( node => node.id === n.id ) ) { + if ( n.neighbors?.length > 1 ) { + if ( n?.level > node.level ) { + let node = { + type : "node", + name : n.id, + value : n?.level - 1, + children : [] + } + data.children.push(node); + traverse(n, node) + } + } else { + data.children.push({type : "leaf", + name : n.id, + value : n?.level - 1}) + } + } + }); + } + + traverse(root,data) + + // Use D3 cluster to give position to nodes + const allNodes = dendrogram(data).descendants(); + let mapNodes = {}; + allNodes.forEach( n => mapNodes[n.data?.name] = n ); + + // Assign position of nodes + visibleNodes.forEach( n => { + if ( layout === TOP_DOWN.layout ) { + if ( mapNodes[n.id] ) { + n.xPos = mapNodes[n.id].x + n.fx = n.xPos; + n.fy = 50 * n.level; + } + } + if ( layout === LEFT_RIGHT.layout ) { + if ( mapNodes[n.id] ) { + n.yPos = mapNodes[n.id].x + n.fy = n.yPos; + n.fx = 50 * n.level; + } + } + }) + + const graph = { nodes : visibleNodes, links : visibleLinks, levelsMap : levelsMap, hierarchyVariant : maxLevel * 20 }; + return graph; + }; diff --git a/src/utils/Splinter.js b/src/utils/Splinter.js index 6285ce5..4736500 100644 --- a/src/utils/Splinter.js +++ b/src/utils/Splinter.js @@ -14,6 +14,7 @@ import { protocols_key, contributors_key, SUBJECTS_LEVEL, PROTOCOLS_LEVEL, CRONTRIBUTORS_LEVEL } from '../constants'; +import * as d3 from 'd3'; const N3 = require('n3'); const ttl2jsonld = require('@frogcat/ttl2jsonld').parse; @@ -185,33 +186,44 @@ class Splinter { if (this.nodes === undefined || this.edges === undefined) { await this.processDataset(); } - - let filteredNodes = this.forced_nodes?.filter( n => n.type !== rdfTypes.UBERON.key && n.type !== rdfTypes.Award.key && !(n.type === rdfTypes.Collection.key && n.children_counter === 0)); + let filteredNodes = [...new Set(this.forced_nodes?.filter( n => n.type !== rdfTypes.UBERON.key && n.type !== rdfTypes.Award.key && !(n.type === rdfTypes.Collection.key && n.children_counter === 0)))] + filteredNodes = filteredNodes.filter( node => { + if ( node.type === rdfTypes.Sample.key ) { + if ( node.attributes.hasFolderAboutIt !== undefined ){ + return true; + } else { + return true; + } + } else { + return true; + } + }) let cleanLinks = []; let that = this; + + // Count how many subjects each Group has filteredNodes?.forEach( n => { if ( n.type === rdfTypes.Subject.key ) { let keys = Object.keys(that.groups); keys.forEach( key => { if ( n.attributes ) { if ( n?.attributes[key] ) { - that.groups[key][n.attributes[key][0]].subjects += 1; + let groupKeys = Object.keys(that.groups[key]); + groupKeys.forEach( groupKey => { + if ( n?.attributes[key][0] === groupKey ) { + const groupNodes = filteredNodes?.filter(n => n.name == groupKey); + groupNodes?.forEach( groupNode => { + if ( n?.id?.includes(groupNode.id) ) { + groupNode.subjects += 1; + } + }) + } + }) } } }) } - if ( n.type === rdfTypes.Sample.key ) { - let keys = Object.keys(that.groups); - keys.forEach( key => { - if ( n.attributes ){ - if ( n?.attributes[key] ) { - that.groups[key][n.attributes[key][0]].samples += 1; - } - } - }) - } }) - console.log("Force edges ", this.forced_edges) // Assign neighbors, to highlight links this.forced_edges.forEach(link => { @@ -220,7 +232,11 @@ class Splinter { if ( !existingLing ) { const a = this.nodes.get( link.source ); const b = this.nodes.get( link.target ); - if ( a && b && ( a?.type !== rdfTypes.Award.key && b?.type !== rdfTypes.Award.key ) && !((a?.type === rdfTypes.Collection.key && a.children_counter < 1 ) || ( b?.type === rdfTypes.Collection.key && b.children_counter < 1))) { + const awardEmpty = ( a?.type !== rdfTypes.Award.key && b?.type !== rdfTypes.Award.key ); + const collectionEmpty = ((a?.type === rdfTypes.Collection.key && a.children_counter < 1 ) || ( b?.type === rdfTypes.Collection.key && b.children_counter < 1)); + const sampleEmpty = ((a?.type === rdfTypes.Sample.key && a.children_counter < 1 ) || ( b?.type === rdfTypes.Sample.key && b.children_counter < 1)) + const sameLevels = a?.level === b?.level; + if ( a && b && awardEmpty && !collectionEmpty && !sampleEmpty && !sameLevels) { !a.neighbors && (a.neighbors = []); !b.neighbors && (b.neighbors = []); if ( !a.neighbors.find( n => n.id === b.id )){ @@ -245,9 +261,18 @@ class Splinter { } } }); + + let newCleanLinks = cleanLinks.filter(link => { + + const collectionEmpty = ((link?.target?.type === rdfTypes.Collection.key && link?.target?.neighbors?.length <= 1 ) || ( link?.source?.type === rdfTypes.Collection.key && link?.source?.neighbors?.length <= 1)); + if ( collectionEmpty ) { + return false; + } + return true; + }); return { nodes: filteredNodes, - links: cleanLinks, + links: newCleanLinks, levelsMap : this.levelsMap }; } @@ -338,7 +363,7 @@ class Splinter { } else { this.nodes.set(node.id, { id: node.id, - attributes: {}, + attributes: {publishedURI : ""}, types: [], name: node.value, proxies: [], @@ -441,7 +466,6 @@ class Splinter { // we might need to display some of its properties, so we merge them. let dataset_node = undefined; let ontology_node = undefined; - // cast each node to the right type, also keep trace of the dataset and ontology nodes. this.nodes.forEach((value, key) => { value.type = this.get_type(value); @@ -519,7 +543,6 @@ class Splinter { } const groupID = parent.id + "_" + target_node.attributes[key]?.[0].replace(/\s/g, ""); - if ( this.nodes.get(groupID) === undefined ) { let name = target_node.attributes[key]?.[0]; @@ -537,7 +560,8 @@ class Splinter { childLinks : [], samples : 0, subjects : 0, - publishedURI : "" + publishedURI : "", + dataset_id : this.dataset_id }; let nodeF = this.factory.createNode(groupNode); const img = new Image(); @@ -668,7 +692,7 @@ class Splinter { target_node.level = protocols.level + 1; target_node.parent = protocols; this.nodes.set(target_node.id, target_node); - } else if (link.source === id && target_node.type === rdfTypes.Sample.key) { + } else if (link.source === id && target_node.type === rdfTypes.Sample.key ) { link.source = target_node.attributes.derivedFrom[0]; target_node.level = subjects.level + 2; target_node.parent = this.nodes.get(target_node.attributes.derivedFrom[0]); @@ -697,12 +721,20 @@ class Splinter { let nodesToRemove = []; this.forced_nodes.forEach((node, index, array) => { + if (node.type === rdfTypes.Dataset.key) { + if (node.attributes?.hasProtocol !== undefined) { + let source = this.nodes.get(node.attributes.hasProtocol[0]); + if ( source !== undefined ) { + node.attributes.hasProtocol[0] = source.attributes.hasDoi?.[0]; + } + } + } + if (node.type === rdfTypes.Sample.key) { if (node.attributes.derivedFrom !== undefined) { let source = this.nodes.get(node.attributes.derivedFrom[0]); if ( source !== undefined ) { source.children_counter++ - //this.nodes.set(node.attributes.derivedFrom[0], source); array[index].level = source.level + 1; this.forced_edges.push({ source: node.attributes.derivedFrom[0], @@ -711,31 +743,31 @@ class Splinter { } } - if (node.attributes?.relativePath !== undefined) { - node.attributes.publishedURI = - Array.from(this.nodes)[0][1].attributes.hasUriPublished[0]?.replace("datasets", "file") + - "/1?path=files/" + - node.attributes?.relativePath; + if (node.attributes?.hasFolderAboutIt !== undefined) { + node.attributes.hasFolderAboutIt = + [Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + node.tree_reference?.dataset_relative_path]; } } if (node.type === rdfTypes.Subject.key) { - if (node.attributes?.specimenHasIdentifier !== undefined) { - let source = this.nodes.get(node.attributes.specimenHasIdentifier[0]); + if (node.attributes?.animalSubjectIsOfStrain !== undefined) { + let source = this.nodes.get(node.attributes.animalSubjectIsOfStrain[0]); if ( source !== undefined ) { - node.attributes.specimenHasIdentifier[0] = source.attributes.label[0]; + node.attributes.animalSubjectIsOfStrain[0] = source.attributes.label[0]; } } - if (node.attributes?.subjectSpecies !== undefined) { - let source = this.nodes.get(node.attributes.subjectSpecies[0]); + if (node.attributes?.animalSubjectIsOfSpecies !== undefined) { + let source = this.nodes.get(node.attributes.animalSubjectIsOfSpecies[0]); if ( source !== undefined ) { - node.attributes.subjectSpecies[0] = source.attributes.label[0]; + node.attributes.animalSubjectIsOfSpecies[0] = source.attributes.label[0]; } } - if (node.attributes?.biologicalSex !== undefined) { - let source = this.nodes.get(node.attributes.biologicalSex[0]); + if (node.attributes?.hasBiologicalSex !== undefined) { + let source = this.nodes.get(node.attributes.hasBiologicalSex[0]); if ( source !== undefined ) { - node.attributes.biologicalSex[0] = source.attributes.label[0]; + node.attributes.hasBiologicalSex[0] = source.attributes.label[0]; } } @@ -753,11 +785,11 @@ class Splinter { } } - if (node.tree_reference?.dataset_relative_path !== undefined) { - node.attributes.publishedURI = - Array.from(this.nodes)[0][1].attributes.hasUriPublished[0]?.replace("datasets", "file") + - "/1?path=files/" + - node.tree_reference?.dataset_relative_path; + if (node.attributes?.hasFolderAboutIt !== undefined) { + node.attributes.hasFolderAboutIt = + [Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + node.tree_reference?.dataset_relative_path]; } } @@ -767,10 +799,11 @@ class Splinter { } if (node.attributes?.relativePath !== undefined) { + node.attributes.dataset_id = this.dataset_id; node.attributes.publishedURI = - Array.from(this.nodes)[0][1].attributes.hasUriPublished[0]?.replace("datasets", "file") + - "/1?path=files/" + - node.attributes?.relativePath; + Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + node.attributes?.relativePath.substr(0, node.attributes?.relativePath.lastIndexOf("/")); } } @@ -781,9 +814,9 @@ class Splinter { if (node.attributes?.relativePath !== undefined) { node.attributes.publishedURI = - Array.from(this.nodes)[0][1].attributes.hasUriPublished[0]?.replace("datasets", "file") + - "/1?path=files/" + - node.attributes?.relativePath; + Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + node.attributes?.relativePath; } } @@ -870,22 +903,41 @@ class Splinter { if (value.attributes !== undefined && value.attributes.hasFolderAboutIt !== undefined) { value.attributes.hasFolderAboutIt.forEach(folder => { let jsonNode = this.tree_map.get(folder); - let newNode = this.buildFolder(jsonNode, value); + const splitName = jsonNode.dataset_relative_path.split('/'); + let newName = jsonNode.basename; + if ( value.type === rdfTypes.Subject.key && value.attributes?.localId?.[0] == splitName[splitName.length - 1] ) { + newName = splitName[0] + } + + if ( value.type === rdfTypes.Sample.key && value.attributes?.localId?.[0] == splitName[splitName.length - 1] ) { + newName = splitName[0] + "/" + newName + } + + let parentNode = value; + let newNode = this.buildFolder(jsonNode, newName, parentNode); + + if ( value.type === rdfTypes.Sample.key) { + newNode.remote_id = jsonNode.basename + '_' + newName; + newNode.uri_api = newNode.remote_id + // this.tree_parents_map2.delete(jsonNode.remote_id); + } + + let folderChildren = this.tree_parents_map2.get(newNode.parent_id)?.map(child => { child.parent_id = newNode.uri_api + child.collapsed = true; return child; }); if (!this.filterNode(newNode) && (this.nodes.get(newNode.remote_id)) === undefined) { - this.linkToNode(newNode, value); + this.linkToNode(newNode, parentNode); } if (this.tree_parents_map2.get(newNode.uri_api) === undefined) { this.tree_parents_map2.set(newNode.uri_api, folderChildren); this.tree_parents_map2.delete(newNode.parent_id); folderChildren?.forEach(child => { - const child_node = this.nodes.get(this.proxies_map.get(child.uri_api)); - if (!this.filterNode(child) && child_node?.type !== rdfTypes.Sample.key) { + if (!this.filterNode(child) ) { this.linkToNode(child, this.nodes.get(newNode.remote_id)); } }); @@ -894,8 +946,7 @@ class Splinter { this.tree_parents_map2.set(newNode.uri_api, tempChildren); this.tree_parents_map2.delete(newNode.parent_id); tempChildren?.forEach(child => { - const child_node = this.nodes.get(this.proxies_map.get(child.uri_api)); - if (!this.filterNode(child) && child_node?.type !== rdfTypes.Sample.key) { + if (!this.filterNode(child) ) { this.linkToNode(child, this.nodes.get(newNode.remote_id)); } }); @@ -905,42 +956,41 @@ class Splinter { }); } - buildFolder(item) { + buildFolder(item, newName) { let copiedItem = {...item}; - let newName = copiedItem.dataset_relative_path.split('/')[0]; copiedItem.parent_id = copiedItem.remote_id; - copiedItem.remote_id = copiedItem.basename + '_' + newName; copiedItem.uri_api = copiedItem.remote_id; copiedItem.basename = newName; - // copiedItem.basename = copiedItem.remote_id; return copiedItem; } linkToNode(node, parent) { - let level = parent.level; - if (parent.type === rdfTypes.Sample.key) { + let level = parent?.level; + if (parent?.type === rdfTypes.Sample.key) { if (parent.attributes.derivedFrom !== undefined) { level = this.nodes.get(parent.attributes.derivedFrom[0])?.level + 1; } } - parent.children_counter++; const new_node = this.buildNodeFromJson(node, level); + if ( parent ) { + parent.children_counter++; new_node.parent = parent; new_node.id = parent.id + new_node.id; - node.remote_id = new_node.id; this.forced_edges.push({ - source: parent.id, - target: new_node.id + source: parent?.id, + target: new_node?.id }); new_node.childLinks = []; - new_node.collapsed = new_node.type === typesModel.NamedIndividual.subject.type - this.nodes.set(new_node.id, this.factory.createNode(new_node)); - var children = this.tree_parents_map2.get(node.remote_id); - if (children?.length > 0) { - children.forEach(child => { - !this.filterNode(child) && this.linkToNode(child, new_node); - }); + if ( !this.nodes.get(new_node.id) ) { + this.nodes.set(new_node.id, this.factory.createNode(new_node)); + var children = this.tree_parents_map2.get(node.remote_id); + if (children?.length > 0) { + children.forEach(child => { + !this.filterNode(child) && this.linkToNode(child, new_node); + }); + } + } } } @@ -986,9 +1036,14 @@ class Splinter { // generate the Graph this.forced_nodes = Array.from(this.nodes).map(([key, value]) => { - let tree_node = this.tree_map.get(value.id); + const id = value?.id?.match(/https?:\/\/[^\s]+/)?.[0] || ""; + let tree_node = this.tree_map.get(id); if (tree_node) { value.tree_reference = tree_node; + tree_node.publishedURI = + Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + tree_node?.dataset_relative_path.substr(0, tree_node?.dataset_relative_path.lastIndexOf("/")); this.nodes.set(key, value); tree_node.graph_reference = value; this.tree_map.set(value.id, tree_node); @@ -996,6 +1051,10 @@ class Splinter { value.proxies.every(proxy => { tree_node = this.tree_map.get(proxy); if (tree_node) { + tree_node.publishedURI = + Array.from(this.nodes)[0][1].attributes.hasUriPublished[0] + + "?datasetDetailsTab=files&path=files/" + + tree_node?.dataset_relative_path.substr(0, tree_node?.dataset_relative_path.lastIndexOf("/")); value.tree_reference = tree_node; this.nodes.set(key, value); tree_node.graph_reference = value; @@ -1039,6 +1098,14 @@ class Splinter { if ( node.graph_reference === undefined ) { node.graph_reference = this.findReference(node.uri_api); } + if ( node.graph_reference === undefined ) { + const fn = (hashMap, str) => [...hashMap.keys()].find(k => k.includes(str)) + const graph_reference = fn(this.nodes, node.id) + + if ( graph_reference ) { + node.graph_reference = this.findReference(graph_reference); + } + } this.tree_map.set(node.id, node); const newNode = { id: node.uri_api, @@ -1066,4 +1133,4 @@ class Splinter { } } -export default Splinter; +export default Splinter; \ No newline at end of file diff --git a/src/utils/graphModel.js b/src/utils/graphModel.js index dab315f..2feaece 100644 --- a/src/utils/graphModel.js +++ b/src/utils/graphModel.js @@ -22,6 +22,12 @@ export const rdfTypes = { "key": "hasUriHuman", "property": "hasUriHuman", "label": "To be filled" + }, + { + "type" : "owl", + "key" : "versionInfo", + "property" : "versionInfo", + "label" : "Version" } ] }, @@ -49,15 +55,45 @@ export const rdfTypes = { "properties": [ { "type": "rdfs", - "key": "label", - "property": "label", - "label": "To be filled" + "key": "relativePath", + "property": "relativePath", + "label": "Name", + "visible" : true + }, + { + "type": "rdfs", + "key": "name", + "property": "name", + "label": "Name", + "visible" : true + }, + { + "type": "rdfs", + "key": "mimetype", + "property": "mimetype", + "label": "Mimetype", + "visible" : true + }, + { + "type": "rdfs", + "key": "status", + "property": "status", + "label": "Status", + "visible" : true + }, + { + "type": "rdfs", + "key": "timestamp_updated", + "property": "timestamp_updated", + "label": "Updated On", + "visible" : true }, { "type": "TEMP", - "key": "hasUriHuman", - "property": "hasUriHuman", - "label": "To be filled" + "key": "publishedURI", + "property": "publishedURI", + "label": "Find in SPARC Portal", + "visible" : true } ] }, @@ -66,10 +102,18 @@ export const rdfTypes = { "key": "Group", "properties": [ { - "type": "rdfs", - "key": "label", - "property": "label", - "label": "To be filled" + "type": "TEMP", + "key": "name", + "property": "name", + "label": "Name", + "visible" : true + }, + { + "type": "TEMP", + "key": "subjects", + "property": "subjects", + "label": "Number of Subjects", + "visible" : true } ] }, @@ -81,127 +125,275 @@ export const rdfTypes = { "type": "rdfs", "key": "label", "property": "label", - "label": "To be filled" - }, - { - "type": "TEMP", - "key": "hasUriHuman", - "property": "hasUriHuman", - "label": "To be filled" - }, - { - "type": "TEMP", - "key": "hasUriPublished", - "property": "hasUriPublished", - "label": "To be filled" + "label": "Title", + "visible" : true, + "link" : { + "property" : "hasUriPublished", + "asText" : true + } }, { "type": "dc", "key": "title", "property": "title", - "label": "To be filled" + "label": "Label", + "visible" : true }, { "type": "dc", "key": "description", "property": "description", - "label": "To be filled" + "label": "Description", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasUriPublished", + "property": "hasUriPublished", + "label": "Published URI", + "visible" : true + }, + { + "type": "TEMP", + "key": "contentsWereUpdatedAtTime", + "property": "latestUpdate", + "label": "Contents Updated On", + "visible" : true }, { "type": "isAbout", "key": "", "property": "isAbout", - "label": "To be filled" + "label": "About", + "visible" : true }, { "type": "TEMP", - "key": "contentsWereUpdatedAtTime", - "property": "latestUpdate", - "label": "To be filled" + "key": "protocolEmploysTechnique", + "property": "protocolEmploysTechnique", + "label": "Protocol Employs Technique", + "visible" : true }, { "type": "TEMP", "key": "errorIndex", "property": "errorIndex", - "label": "To be filled" + "label": "Error Index", + "visible" : true }, { "type": "TEMP", - "key": "hasAwardNumber", - "property": "hasAwardNumber", - "label": "To be filled" + "key": "hasDatasetTemplateSchemaVersion", + "property": "hasDatasetTemplateSchemaVersion", + "label": "Template Schema Version", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasExperimentalModality", + "property": "hasExperimentalModality", + "label": "Experimental Modality", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasExperimentalApproach", + "property": "hasExperimentalApproach", + "label": "Experimental Approach", + "visible" : true }, { "type": "TEMP", "key": "hasDoi", "property": "hasDoi", - "label": "To be filled" + "label": "DOI", + "visible" : true, + "link" : { + "property" : "hasUriPublished", + "asText" : true + } }, { "type": "TEMP", - "key": "hasDatasetTemplateSchemaVersion", - "property": "hasDatasetTemplateSchemaVersion", - "label": "To be filled" + "key": "hasAdditionalFundingInformation", + "property": "hasAdditionalFundingInformation", + "label": "Additional Funding Information", + "visible" : true }, { "type": "TEMP", - "key": "hasExperimentalModality", - "property": "hasExperimentalModality", - "label": "To be filled" + "key": "statusOnPlatform", + "property": "statusOnPlatform", + "label": "Status On Platform", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasLicense", + "property": "hasLicense", + "label": "License", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasUriHuman", + "property": "hasUriHuman", + "label": "Pensieve Link", + "visible" : true + }, + { + "type": "TEMP", + "key": "curationIndex", + "property": "curationIndex", + "label": "Curation Index", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasAwardNumber", + "property": "hasAwardNumber", + "label": "Award Number", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasExpectedNumberOfSamples", + "property": "hasExpectedNumberOfSamples", + "label": "Expected Number of Samples", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasExpectedNumberOfSubjects", + "property": "hasExpectedNumberOfSubjects", + "label": "Expected Number of Subjects", + "visible" : true }, { "type": "TEMP", "key": "hasResponsiblePrincipalInvestigator", "property": "hasResponsiblePrincipalInvestigator", - "label": "To be filled" + "label": "Responsible Principal Investigator", + "visible" : true }, { "type": "TEMP", "key": "hasUriApi", "property": "hasUriApi", - "label": "To be filled" + "label": "URI API", + "visible" : false }, { "type": "TEMP", "key": "hasProtocol", "property": "hasProtocol", - "label": "To be filled" + "label": "Protocol", + "visible" : true }, { "type": "TEMP", "key": "hasUriHuman", "property": "hasUriHuman", - "label": "To be filled" + "label": "Pennsieve Dataset Link", + "visible" : true }, { "type": "TEMP", - "key": "protocolEmploysTechnique", - "property": "protocolEmploysTechnique", - "label": "To be filled" + "key": "hasNumberOfContributors", + "property": "hasNumberOfContributors", + "label": "Number of Contributors", + "visible" : true }, { "type": "TEMP", - "key": "hasAdditionalFundingInformation", - "property": "hasAdditionalFundingInformation", - "label": "To be filled" + "key": "hasNumberOfDirectories", + "property": "hasNumberOfDirectories", + "label": "Number of Directories", + "visible" : true }, { "type": "TEMP", - "key": "hasExperimentalApproach", - "property": "hasExperimentalApproach", - "label": "To be filled" + "key": "hasNumberOfFiles", + "property": "hasNumberOfFiles", + "label": "Number of Files", + "visible" : true }, { "type": "TEMP", - "key": "hasLicense", - "property": "hasLicense", - "label": "To be filled" + "key": "hasNumberOfPerformances", + "property": "hasNumberOfPerformances", + "label": "Number of Performances", + "visible" : true }, { "type": "TEMP", - "key": "statusOnPlatform", - "property": "statusOnPlatform", - "label": "To be filled" + "key": "hasNumberOfSamples", + "property": "hasNumberOfSamples", + "label": "Number of Samples", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasNumberOfSubjects", + "property": "hasNumberOfSubjects", + "label": "Number of Subjects", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasPathErrorReport", + "property": "hasPathErrorReport", + "label": "Path Error Report", + "visible" : false + }, + { + "type": "TEMP", + "key": "hasSizeInBytes", + "property": "hasSizeInBytes", + "label": "Size In Bytes", + "visible" : true + }, + { + "type": "TEMP", + "key": "milestoneCompletionDate", + "property": "milestoneCompletionDate", + "label": "Milestone Completion Date", + "visible" : true + }, + { + "type": "TEMP", + "key": "speciesCollectedFrom", + "property": "speciesCollectedFrom", + "label": "Species Collected From", + "visible" : true + }, + { + "type": "TEMP", + "key": "submissionIndex", + "property": "submissionIndex", + "label": "Submission Index", + "visible" : true + }, + { + "type": "TEMP", + "key": "unclassifiedIndex", + "property": "unclassifiedIndex", + "label": "Unclassified Index", + "visible" : true + }, + { + "type": "TEMP", + "key": "wasCreatedAtTime", + "property": "wasCreatedAtTime", + "label": "Created At", + "visible" : true + }, + { + "type": "TEMP", + "key": "wasUpdatedAtTime", + "property": "wasUpdatedAtTime", + "label": "Updated Last On", + "visible" : true } ] }, @@ -211,15 +403,59 @@ export const rdfTypes = { "properties": [ { "type": "rdfs", - "key": "label", - "property": "label", - "label": "To be filled" + "key": "basename", + "property": "basename", + "label": "Basename", + "visible" : true + }, + { + "type": "rdfs", + "key": "timestamp_updated", + "property": "timestamp_updated", + "label": "Updated On", + "visible" : true }, { "type": "TEMP", - "key": "hasUriHuman", - "property": "hasUriHuman", - "label": "To be filled" + "key": "mimetype", + "property": "mimetype", + "label": "Mimetype", + "visible" : true + }, + { + "type": "TEMP", + "key": "size", + "property": "size", + "label": "Size", + "visible" : true + }, + { + "type": "TEMP", + "key": "uri_human", + "property": "uri_human", + "label": "URI Link", + "visible" : false + }, + { + "type": "TEMP", + "key": "uri_api", + "property": "uri_api", + "label": "URI API", + "visible" : false + }, + { + "type": "TEMP", + "key": "status", + "property": "status", + "label": "Status", + "visible" : true + }, + { + "type": "TEMP", + "key": "publishedURI", + "property": "publishedURI", + "label": "Find in SPARC Portal", + "visible" : true } ] }, @@ -227,102 +463,195 @@ export const rdfTypes = { "image": "./images/graph/folder.svg", "key": "Subject", "properties": [ - { - "type": "sparc", - "key": "animalSubjectIsOfSpecies", - "property": "subjectSpecies", - "label": "to be filled" - }, { "type": "TEMP", - "key": "hasFolderAboutIt", - "property": "hasFolderAboutIt", - "label": "Folder that contains collection and files about the sample" - }, - { - "type": "sparc", - "key": "animalSubjectIsOfStrain", - "property": "subjectStrain", - "label": "to be filled" + "key": "localId", + "property": "localId", + "label": "Label", + "visible" : true }, { "type": "TEMP", - "key": "hasAge", - "property": "age", - "label": "to be filled" + "key": "hasUriHuman", + "property": "hasUriHuman", + "label": "URI Human", + "visible" : true }, { "type": "TEMP", "key": "hasAgeCategory", "property": "hasAgeCategory", - "label": "to be filled" + "label": "Age Category", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasAge", + "property": "hasAge", + "label": "Age", + "visible" : true }, { "type": "TEMP", "key": "hasAgeMin", "property": "hasAgeMin", - "label": "to be filled" + "label": "Age Min", + "visible" : true }, { "type": "TEMP", "key": "hasAgeMax", "property": "hasAgeMax", - "label": "to be filled" + "label": "Age Max", + "visible" : true + }, + { + "type": "sparc", + "key": "hasBiologicalSex", + "property": "hasBiologicalSex", + "label": "Biological Sex", + "visible" : true, + "isGroup" : true + }, + { + "type": "sparc", + "key": "specimenHasIdentifier", + "property": "specimenHasIdentifier", + "label": "Specimen has Identifier", + "visible" : true, + "isGroup" : false + }, + { + "type": "sparc", + "key": "animalSubjectIsOfSpecies", + "property": "animalSubjectIsOfSpecies", + "label": "Subject Species", + "visible" : true, + "isGroup" : true + }, + { + "type": "sparc", + "key": "animalSubjectIsOfStrain", + "property": "animalSubjectIsOfStrain", + "label": "Subject Strain", + "visible" : true, + "isGroup" : true }, { "type": "TEMP", "key": "hasAssignedGroup", "property": "hasAssignedGroup", - "label": "to be filled" + "label": "Assigned Group", + "visible" : true }, { - "type": "sparc", - "key": "hasBiologicalSex", - "property": "biologicalSex", - "label": "to be filled" + "type": "TEMP", + "key": "hasGenotype", + "property": "hasGenotype", + "label": "Genotype", + "visible" : true }, { "type": "TEMP", - "key": "localId", - "property": "identifier", - "label": "to be filled" + "key": "experimental_file", + "property": "experimental_file", + "label": "Experimental File", + "visible" : true }, { "type": "TEMP", - "key": "hasDerivedInformationAsParticipant", - "property": "hasDerivedInformationAsParticipant", - "label": "to be filled" + "key": "reference_atlas", + "property": "reference_atlas", + "label": "Reference Atlas", + "visible" : true }, { - "type": "sparc", - "key": "specimenHasIdentifier", - "property": "specimenHasIdentifier", - "label": "to be filled" + "type": "TEMP", + "key": "hasFolderAboutIt", + "property": "hasFolderAboutIt", + "label": "Folder About It", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasDerivedInformationAsParticipant", + "property": "hasDerivedInformationAsParticipant", + "label": "Derived Information as Participant", + "visible" : false }, { "type": "TEMP", "key": "participantInPerformanceOf", "property": "participantInPerformanceOf", - "label": "to be filled" + "label": "Participant In Performance Of", + "visible" : true + } + ], + "additional_properties": [ + { + "label": "Age unit", + "property": "ageUnit", + "path": [ "TEMP:hasAge", "TEMP:hasUnit", "@id" ], + "trimType": "unit:", + "type": "string" }, { - "type": "rdfs", - "key": "label", - "property": "label", - "label": "To be filled" + "label": "Age value", + "property": "ageValue", + "path": [ "TEMP:hasAge", "rdf:value" ], + "innerPath": "@value", + "trimType": "", + "type": "digit" }, { - "type": "TEMP", - "key": "hasUriHuman", - "property": "hasUriHuman", - "label": "To be filled" + "label": "Age base unit", + "property": "ageBaseUnit", + "path": [ "TEMP:hasAge", "TEMP:asBaseUnits", "TEMP:hasUnit", "@id" ], + "trimType": "unit:", + "type": "string" + }, + { + "label": "Age base value", + "property": "ageBaseValue", + "path": [ "TEMP:hasAge", "TEMP:asBaseUnits", "rdf:value" ], + "innerPath": "@value", + "trimType": "", + "type": "digit" + }, + { + "label": "Weight unit", + "property": "weightUnit", + "path": [ "sparc:animalSubjectHasWeight", "TEMP:hasUnit", "@id" ], + "trimType": "unit:", + "type": "string" }, + { + "label": "Weight value", + "property": "weightValue", + "path": [ "sparc:animalSubjectHasWeight", "rdf:value", "@value" ], + "trimType": "", + "type": "digit" + } + ] + }, + "Performance": { + "image": "./images/graph/folder.svg", + "key": "Performance", + "properties": [ { "type": "TEMP", - "key": "publishedURI", - "property": "publishedURI", - "label": "to be filled" + "key": "localId", + "property": "localId", + "label": "Label", + "visible" : true }, + { + "type": "TEMP", + "key": "participantInPerformanceOf", + "property": "participantInPerformanceOf", + "label": "Participant In Performance Of", + "visible" : true + } ], "additional_properties": [ { @@ -375,65 +704,89 @@ export const rdfTypes = { "image": "./images/graph/folder.svg", "key": "Sample", "properties": [ + { + "type": "rdfs", + "key": "label", + "property": "label", + "label": "Label", + "visible" : true + }, + { + "type": "TEMP", + "key": "hasUriHuman", + "property": "hasUriHuman", + "label": "Human URI", + "visible" : true + }, { "type": "TEMP", "key": "hasFolderAboutIt", "property": "hasFolderAboutIt", - "label": "Folder that contains collection and files about the sample" + "label": "Find in SPARC Portal", + "visible" : true }, { "type": "TEMP", "key": "wasDerivedFromSubject", "property": "derivedFrom", - "label": "Derived from the subject" + "label": "Derived from Subject", + "visible" : false }, { "type": "TEMP", "key": "localId", - "property": "identifier", - "label": "Unique instance identifier" + "property": "localId", + "label": "Local ID", + "visible" : true }, { "type": "TEMP", "key": "hasAssignedGroup", "property": "hasAssignedGroup", - "label": "to be filled" + "label": "Assigned Group", + "visible" : true }, { "type": "TEMP", "key": "hasDerivedInformationAsParticipant", "property": "hasDerivedInformationAsParticipant", - "label": "to be filled" + "label": "Derived Information as Participant", + "visible" : false }, { "type": "TEMP", "key": "hasDigitalArtifactThatIsAboutIt", "property": "hasDigitalArtifactThatIsAboutIt", - "label": "Unique instance identifier" - }, - { - "type": "TEMP", - "key": "participantInPerformanceOf", - "property": "participantInPerformanceOf", - "label": "Unique instance identifier" + "label": "Digital Artifact", + "visible" : true }, { "type": "TEMPRAW", "key": "wasExtractedFromAnatomicalRegion", "property": "wasExtractedFromAnatomicalRegion", - "label": "Unique instance identifier" + "label": "Extracted From Anatomical Region", + "visible" : true }, { - "type": "rdfs", - "key": "label", - "property": "label", - "label": "To be filled" + "type": "TEMPRAW", + "key": "sample_anatomical_location", + "property": "sample_anatomical_location", + "label": "Sample Anatomical Location", + "visible" : true + }, + { + "type": "TEMPRAW", + "key": "sample_type", + "property": "sample_type", + "label": "Sample Type", + "visible" : true }, { "type": "TEMP", - "key": "hasUriHuman", - "property": "hasUriHuman", - "label": "To be filled" + "key": "participantInPerformanceOf", + "property": "participantInPerformanceOf", + "label": "Participant in Performance Of", + "visible" : true } ] }, @@ -442,29 +795,61 @@ export const rdfTypes = { "key": "Person", "properties": [ { - "type": "sparc", - "key": "firstName", - "property": "firstName", - "label": "To be filled" + "type": "rdfs", + "key": "label", + "property": "label", + "label": "Name", + "visible" : true }, { "type": "sparc", "key": "lastName", "property": "lastName", - "label": "To be filled" + "label": "Last Name", + "visible" : false + }, + { + "type": "sparc", + "key": "firstName", + "property": "firstName", + "label": "First Name", + "visible" : false }, { "type": "TEMP", "key": "middleName", "property": "middleName", - "label": "To be filled" + "label": "Middle Name", + "visible" : false + }, + { + "type": "sparc", + "key": "hasORCIDId", + "property": "hasORCIDId", + "label": "ORCID Id", + "visible" : false }, { "type": "TEMP", "key": "hasAffiliation", "property": "hasAffiliation", - "label": "To be filled" + "label": "Affiliation", + "visible" : true }, + { + "type": "TEMP", + "key": "hasDataRemoteUserId", + "property": "hasDataRemoteUserId", + "label": "Data Remote User ID", + "visible" : true + }, + { + "type": "TEMP", + "key": "contributorTo", + "property": "contributorTo", + "label": "Contributor To", + "visible" : true + } ] }, "Protocol": { @@ -475,19 +860,32 @@ export const rdfTypes = { "type": "rdfs", "key": "label", "property": "label", - "label": "To be filled" + "label": "Label", + "visible" : true + }, + { + "type": "TEMP", + "key": "protocolHasNumberOfSteps", + "property": "protocolHasNumberOfSteps", + "label": "Number of Steps", + "visible" : true }, { "type": "TEMP", "key": "hasUriHuman", "property": "hasUriHuman", - "label": "To be filled" + "label": "Human URI", + "visible" : true }, { "type": "TEMP", - "key": "protocolHasNumberOfSteps", - "property": "protocolHasNumberOfSteps", - "label": "To be filled" + "key": "hasDoi", + "property": "hasDoi", + "label": "DOI", + "visible" : true, + "link" : { + "property" : "hasUriPublished" + } } ] }, @@ -563,6 +961,24 @@ export const rdfTypes = { } ] }, + "NamedIndividual": { + "image": "./images/graph/files/default_file.svg", + "key": "UBERON", + "properties": [ + { + "type": "rdfs", + "key": "label", + "property": "label", + "label": "To be filled" + }, + { + "type": "TEMP", + "key": "hasUriHuman", + "property": "hasUriHuman", + "label": "To be filled" + } + ] + }, "Unknown": { "image": "./images/graph/files/default_file.svg", "key": "Unknown", @@ -599,7 +1015,10 @@ export const typesModel = { }, RRID: { "type": "RRID", - } + }, + Protocol: { + "type": "Protocol" + }, }, "Class": { NCBITaxon: { @@ -610,7 +1029,10 @@ export const typesModel = { }, UBERON: { "type": "UBERON", - } + }, + Protocol: { + "type": "Protocol" + }, }, "sparc": { Protocol: { diff --git a/src/utils/nodesFactory.js b/src/utils/nodesFactory.js index 4a037ed..256a43b 100644 --- a/src/utils/nodesFactory.js +++ b/src/utils/nodesFactory.js @@ -151,6 +151,12 @@ const Protocol = function (node, ttlTypes) { const Sample = function (node, ttlTypes) { node.img = createImage(node); extractProperties(node, ttlTypes); + if (node.attributes?.identifier !== undefined) { + node.name = node.attributes?.identifier[0]; + } else { + let namesArray = node.name.split("/"); + node.name = namesArray[namesArray.length - 1]; + } return node; }; diff --git a/yarn.lock b/yarn.lock index 1081921..c37197d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5321,6 +5321,13 @@ css-blank-pseudo@^0.1.4: dependencies: postcss "^7.0.5" +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -11733,7 +11740,7 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -memoize-one@^5.0.0: +memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -14423,6 +14430,11 @@ quickselect@^2.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + raf@^3.2.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -14494,6 +14506,19 @@ react-app-polyfill@^2.0.0: regenerator-runtime "^0.13.7" whatwg-fetch "^3.4.1" +react-beautiful-dnd@^13.1.1: + version "13.1.1" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" + integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-color@^2.17.3: version "2.19.3" resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.19.3.tgz#ec6c6b4568312a3c6a18420ab0472e146aa5683d" @@ -14792,6 +14817,18 @@ react-redux@^5.0.6: react-is "^16.6.0" react-lifecycles-compat "^3.0.0" +react-redux@^7.2.0: + version "7.2.9" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" + integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-redux@^7.2.4: version "7.2.8" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de" @@ -17302,6 +17339,11 @@ tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== +tiny-invariant@^1.0.6: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + tiny-warning@^1.0.0, tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -17929,6 +17971,11 @@ url@^0.11.0, url@~0.11.0: punycode "1.3.2" querystring "0.2.0" +use-memo-one@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" + integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"