diff --git a/src/__mocks__/handlers/DataExplorer/handlers.ts b/src/__mocks__/handlers/DataExplorer/handlers.ts index 60d9597bf..523e8b574 100644 --- a/src/__mocks__/handlers/DataExplorer/handlers.ts +++ b/src/__mocks__/handlers/DataExplorer/handlers.ts @@ -1,13 +1,19 @@ import { rest } from 'msw'; import { deltaPath } from '__mocks__/handlers/handlers'; -import { Project, Resource } from '@bbp/nexus-sdk'; +import { Resource } from '@bbp/nexus-sdk'; import { AggregatedBucket, AggregationsResult, } from 'subapps/dataExplorer/DataExplorerUtils'; +export const getCompleteResources = ( + resources: Resource[] = defaultPartialResources +) => { + return resources.map(res => ({ ...res, ...propertiesOnlyInSource })); +}; + export const dataExplorerPageHandler = ( - mockResources: Resource[], + partialResources: Resource[] = defaultPartialResources, total: number = 300 ) => { return rest.get(deltaPath(`/resources`), (req, res, ctx) => { @@ -23,8 +29,8 @@ export const dataExplorerPageHandler = ( ], _total: total, _results: passedType - ? mockResources.filter(res => res['@type'] === passedType) - : mockResources, + ? partialResources.filter(res => res['@type'] === passedType) + : partialResources, _next: 'https://bbp.epfl.ch/nexus/v1/resources?size=50&sort=@id&after=%5B1687269183553,%22https://bbp.epfl.ch/neurosciencegraph/data/31e22529-2c36-44f0-9158-193eb50526cd%22%5D', }; @@ -32,7 +38,38 @@ export const dataExplorerPageHandler = ( }); }; -export const filterByProjectHandler = (mockResources: Resource[]) => { +const propertiesOnlyInSource = { userProperty1: { subUserProperty1: 'bar' } }; + +export const sourceResourceHandler = ( + partialResources: Resource[] = defaultPartialResources +) => { + return rest.get( + deltaPath(`/resources/:org/:project/_/:id`), + (req, res, ctx) => { + const { id } = req.params; + const decodedId = decodeURIComponent(id as string); + + const partialResource = partialResources.find( + resource => resource['@id'] === decodedId + ); + if (partialResource) { + return res( + ctx.status(200), + ctx.json({ ...partialResource, ...propertiesOnlyInSource }) + ); + } + + return res( + ctx.status(200), + ctx.json(getMockResource(decodedId, { ...propertiesOnlyInSource })) + ); + } + ); +}; + +export const filterByProjectHandler = ( + mockResources: Resource[] = defaultPartialResources +) => { return rest.get(deltaPath(`/resources/:org/:project`), (req, res, ctx) => { if (req.url.searchParams.has('aggregations')) { return res( @@ -180,7 +217,7 @@ export const getMockResource = ( _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/antonel', }); -export const defaultMockResult: Resource[] = [ +const defaultPartialResources: Resource[] = [ getMockResource('self1', {}), getMockResource( 'self2', diff --git a/src/server/index.tsx b/src/server/index.tsx index 379d42283..b0ff06aae 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -150,7 +150,7 @@ app.get('*', async (req: express.Request, res: express.Response) => { current: null, leftNodes: { links: [], shrinked: false }, rightNodes: { links: [], shrinked: false }, - limited: false, + fullscreen: false, }, }; diff --git a/src/shared/App.less b/src/shared/App.less index 7d47123bc..aeafdfde5 100644 --- a/src/shared/App.less +++ b/src/shared/App.less @@ -9,27 +9,40 @@ margin: 52px auto 0; display: flex; background: #f5f5f5 !important; + &.-unconstrained-width { padding: 2em; max-width: none; } + &.resource-view { - max-width: 60%; + max-width: 1320px; background-color: @primary-card; - background-image: linear-gradient( - 315deg, - @primary-card 0%, - @subtle-white 74% - ); min-height: calc(100vh - 40px); transition: background-image ease-out 1s; + + &.background { + max-width: 60%; + background-image: linear-gradient( + 315deg, + @primary-card 0%, + @subtle-white 74% + ); + + .resource-details { + .highShadow(); + padding: 1em; + width: 100%; + background-color: @background-color-subtle; + } + } + .resource-details { - .highShadow(); - padding: 1em; + background-color: @fusion-main-bg; width: 100%; - background-color: @background-color-subtle; } } + &.data-explorer-container { width: fit-content; min-width: calc(100vw - 1rem); @@ -55,6 +68,7 @@ .ant-alert-warning { margin: 1em 0; } + section.links { width: 48%; } @@ -64,6 +78,7 @@ .identities-list { margin: 0; padding: 0; + .list-item { cursor: auto; } @@ -76,6 +91,7 @@ .ant-pagination-item { margin-right: 2px; } + .ant-list-pagination { text-align: center; } @@ -83,6 +99,7 @@ .ant-input-affix-wrapper .ant-input-suffix { color: rgba(0, 0, 0, 0.2); } + .ant-upload.ant-upload-drag .ant-upload { padding: @default-pad; } @@ -97,6 +114,7 @@ .studio-view { padding: 0 2em; + .workspace { display: flex; width: 100%; @@ -104,6 +122,7 @@ min-width: 800px; min-height: 600px; } + .studio-back-button { margin-bottom: 5px; } @@ -211,3 +230,29 @@ outline: none; border-radius: 0; } + +.full-screen-switch__wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + span { + color: @fusion-blue-8; + } + .full-screen-switch { + border-color: #2e76bf !important; + background: linear-gradient( + 0deg, + rgba(0, 58, 140, 0.3), + rgba(0, 58, 140, 0.3) + ), + linear-gradient(0deg, rgba(46, 118, 191, 0.2), rgba(46, 118, 191, 0.2)); + border: 1px solid #003a8c4d; + .ant-switch-handle { + top: 1px; + &::before { + background: @fusion-daybreak-10; + } + } + } +} diff --git a/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx b/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx new file mode 100644 index 000000000..56c30e4e0 --- /dev/null +++ b/src/shared/canvas/DataExplorerGraphFlow/DataExplorerGraphFlowEmpty.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +const DataExplorerGraphFlowEmpty = () => { + return ( +
+
+ nodes +
No data explorer graph flow
+
+ Please select a node from any resource view editor to start exploring +
+
+
+ ); +}; + +export default DataExplorerGraphFlowEmpty; diff --git a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx index 23191c941..6a0511cdc 100644 --- a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx +++ b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.spec.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import React from 'react'; -import { waitFor } from '@testing-library/react'; +import { RenderResult, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { NexusClient, createNexusClient } from '@bbp/nexus-sdk'; import { AnyAction, Store } from 'redux'; @@ -9,10 +9,11 @@ import { createMemoryHistory, MemoryHistory } from 'history'; import { Router } from 'react-router-dom'; import { setupServer } from 'msw/node'; import { deltaPath } from '__mocks__/handlers/handlers'; -import { cleanup, render, act, screen } from '../../../utils/testUtil'; +import { cleanup, render, screen } from '../../../utils/testUtil'; import { DATA_EXPLORER_GRAPH_FLOW_DIGEST, InitNewVisitDataExplorerGraphView, + TDataExplorerState, } from '../../../shared/store/reducers/data-explorer'; import configureStore from '../../store'; import DateExplorerGraphFlow from './DateExplorerGraphFlow'; @@ -21,8 +22,10 @@ import { getDataExplorerGraphFlowResourceObject, getDataExplorerGraphFlowResourceObjectTags, } from '../../../__mocks__/handlers/DataExplorerGraphFlow/handlers'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent from '@testing-library/user-event'; -const initialDataExplorerState = { +const initialDataExplorerState: TDataExplorerState = { current: { isDownloadable: false, _self: initialResource._self, @@ -30,16 +33,22 @@ const initialDataExplorerState = { types: initialResource['@type'], resource: ['public', 'sscx', initialResource['@id'], initialResource._rev], }, - links: [], - shrinked: false, - highlightIndex: -1, - limited: false, + leftNodes: { links: [], shrinked: false }, + rightNodes: { links: [], shrinked: false }, + fullscreen: false, }; + describe('DataExplorerGraphFlow', () => { + let server: ReturnType; + let app: JSX.Element; + let container: HTMLElement; + let rerender: (ui: React.ReactElement) => void; let store: Store; + let user: UserEvent; let history: MemoryHistory<{}>; - let server: ReturnType; let nexus: NexusClient; + let component: RenderResult; + beforeAll(async () => { nexus = createNexusClient({ fetch, @@ -78,8 +87,15 @@ describe('DataExplorerGraphFlow', () => { cleanup(); }); - it('should render the name of the resource', async () => { - const App: JSX.Element = ( + beforeEach(() => { + history = createMemoryHistory({}); + + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + store = configureStore(history, { nexus }, {}); + app = ( @@ -88,40 +104,33 @@ describe('DataExplorerGraphFlow', () => { ); - await act(async () => { - await render(App); - }); + component = render(app); + container = component.container; + rerender = component.rerender; + user = userEvent.setup(); + }); + + it('should render the name of the resource', async () => { store.dispatch( InitNewVisitDataExplorerGraphView({ current: initialDataExplorerState.current, - limited: false, + fullscreen: false, }) ); - + rerender(app); const resourceTitle = await waitFor(() => screen.getByText(initialResource.name) ); expect(resourceTitle).toBeInTheDocument(); }); it('should clean the data explorer state when quit the page', async () => { - const App: JSX.Element = ( - - - - - - - - ); - await act(async () => { - await render(App); - }); store.dispatch( InitNewVisitDataExplorerGraphView({ current: initialDataExplorerState.current, - limited: false, + fullscreen: false, }) ); + rerender(app); history.push('/another-page'); const dataExplorerState = store.getState().dataExplorer; const sessionStorageItem = sessionStorage.getItem( @@ -131,6 +140,26 @@ describe('DataExplorerGraphFlow', () => { expect(dataExplorerState.leftNodes.links.length).toBe(0); expect(dataExplorerState.rightNodes.links.length).toBe(0); expect(dataExplorerState.current).toBeNull(); - expect(dataExplorerState.limited).toBe(false); + expect(dataExplorerState.fullscreen).toBe(false); + }); + + it('should the fullscren toggle present in the screen if the user in fullscreen mode', async () => { + store.dispatch( + InitNewVisitDataExplorerGraphView({ + current: initialDataExplorerState.current, + fullscreen: true, + }) + ); + rerender(app); + const fullscreenSwitch = container.querySelector( + 'button[aria-label="fullscreen switch"]' + ); + const fullscreenTitle = container.querySelector( + 'h1[aria-label="fullscreen title"]' + ); + expect(fullscreenSwitch).toBeInTheDocument(); + expect(fullscreenTitle).toBeInTheDocument(); + await user.click(fullscreenSwitch as HTMLButtonElement); + expect(store.getState().dataExplorer.fullscreen).toBe(false); }); }); diff --git a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx index 5413a260b..20df74f14 100644 --- a/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx +++ b/src/shared/canvas/DataExplorerGraphFlow/DateExplorerGraphFlow.tsx @@ -9,10 +9,14 @@ import { PopulateDataExplorerGraphFlow, ResetDataExplorerGraphFlow, } from '../../store/reducers/data-explorer'; -import NavigationStack from '../../organisms/DataExplorerGraphFlowNavigationStack/NavigationStack'; +import { + NavigationArrows, + NavigationStack, +} from '../../organisms/DataExplorerGraphFlowNavigationStack'; import DataExplorerContentPage from '../../organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent'; -import ResourceResolutionCache from '../../components/ResourceEditor/ResourcesLRUCache'; import useNavigationStackManager from '../../organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack'; +import ResourceResolutionCache from '../../components/ResourceEditor/ResourcesLRUCache'; +import DataExplorerGraphFlowEmpty from './DataExplorerGraphFlowEmpty'; import './styles.less'; @@ -30,10 +34,6 @@ const DataExplorerGraphFlow = () => { rightShrinked, leftLinks, rightLinks, - onLeftShrink, - onLeftExpand, - onRightShrink, - onRightExpand, } = useNavigationStackManager(); useEffect(() => { @@ -61,32 +61,15 @@ const DataExplorerGraphFlow = () => { ResourceResolutionCache.clear(); }; }, [ResourceResolutionCache]); - if (current === null) { - return ( -
-
- nodes -
No data explorer graph flow
-
- Please select a node from any resource view editor to start - exploring -
-
-
- ); - } - return ( + + return !current ? ( + + ) : (
{
)}
+
{!!rightLinks.length && ( diff --git a/src/shared/canvas/DataExplorerGraphFlow/styles.less b/src/shared/canvas/DataExplorerGraphFlow/styles.less index e0942bc71..91dcb0816 100644 --- a/src/shared/canvas/DataExplorerGraphFlow/styles.less +++ b/src/shared/canvas/DataExplorerGraphFlow/styles.less @@ -9,7 +9,7 @@ justify-content: flex-start; background-color: @fusion-main-bg; gap: 10px; - + margin-top: 52px; &.no-links { grid-template-columns: 1fr; } diff --git a/src/shared/components/ResourceEditor/ResourceEditor.less b/src/shared/components/ResourceEditor/ResourceEditor.less index a4da533ae..69f041ec0 100644 --- a/src/shared/components/ResourceEditor/ResourceEditor.less +++ b/src/shared/components/ResourceEditor/ResourceEditor.less @@ -69,18 +69,19 @@ &.wait-for-tooltip { cursor: progress !important; } + &.has-tooltip { cursor: pointer !important; } + &.error { cursor: not-allowed !important; } - &.downloadable { - } + &::after { content: ''; display: inline-block; - background-image: url(../../images/AnchorLink.svg); + background-image: var(--resource-link-anchor-icon); background-repeat: no-repeat; background-size: 14px 14px; width: 14px; @@ -96,12 +97,14 @@ &.resolution-on-progress { .CodeMirror-lines { user-select: none; + .fusion-resource-link { cursor: progress !important; } } } } + .CodeMirror-hover-tooltip-popover, .CodeMirror-hover-tooltip { background-color: white; @@ -116,25 +119,31 @@ white-space: pre-wrap; transition: all 0.4s ease-in-out; padding: 4px 0; + &.popover { background-color: white !important; + .CodeMirror-hover-tooltip-resources-content { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; + .CodeMirror-hover-tooltip-item { width: 100%; padding: 4px; align-items: center; justify-content: flex-start; cursor: pointer; + .tag { background-color: @fusion-primary-color; } + &:hover { background-color: @fusion-blue-0; color: @fusion-primary-color; + .tag { background-color: white; color: @fusion-primary-color; @@ -143,6 +152,7 @@ } } } + &-content { display: flex; flex-direction: column; @@ -183,6 +193,7 @@ padding: 2px 10px 2px 5px; overflow: hidden; user-select: none; + .tag { color: white; padding: 2px 5px; @@ -204,3 +215,11 @@ } } } + +.editor-controls-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + width: 100%; +} diff --git a/src/shared/components/ResourceEditor/index.tsx b/src/shared/components/ResourceEditor/index.tsx index ef38c4660..e9f0facec 100644 --- a/src/shared/components/ResourceEditor/index.tsx +++ b/src/shared/components/ResourceEditor/index.tsx @@ -7,7 +7,7 @@ import { SaveOutlined, } from '@ant-design/icons'; import { useSelector } from 'react-redux'; -import { useNexusContext } from '@bbp/react-nexus'; +import { AccessControl } from '@bbp/react-nexus'; import codemiror from 'codemirror'; import 'codemirror/mode/javascript/javascript'; @@ -29,9 +29,9 @@ import { } from './useEditorTooltip'; import { DATA_EXPLORER_GRAPH_FLOW_PATH } from '../../store/reducers/data-explorer'; import ResourceResolutionCache from './ResourcesLRUCache'; - import './ResourceEditor.less'; +const AnchorLinkIcon = require('../../images/AnchorLink.svg'); export interface ResourceEditorProps { rawData: { [key: string]: any }; onSubmit: (rawData: { [key: string]: any }) => void; @@ -89,9 +89,8 @@ const ResourceEditor: React.FunctionComponent = props => { JSON.stringify(rawData, null, 2) ); const { - dataExplorer: { limited }, + dataExplorer: { fullscreen }, oidc, - config: { apiEndpoint }, } = useSelector((state: RootState) => ({ dataExplorer: state.dataExplorer, oidc: state.oidc, @@ -205,6 +204,11 @@ const ResourceEditor: React.FunctionComponent = props => {
{showControlPanel && (
@@ -221,58 +225,69 @@ const ResourceEditor: React.FunctionComponent = props => { )}
-
- {showFullScreen && ( - - )} - - {!expanded && !isEditing && valid && showMetadataToggle && ( +
+
+ {showFullScreen && ( +
+ Fullscreen + +
+ )} +
+
onMetadataChangeFold(checked)} + checkedChildren="Unfold" + unCheckedChildren="Fold" + checked={foldCodeMiror} + onChange={onFoldChange} style={switchMarginRight} /> - )} - {showExpanded && !isEditing && valid && ( - onFormatChangeFold(expanded)} - style={switchMarginRight} - /> - )} - {userAuthenticated && ( - - )}{' '} - {editable && isEditing && ( - - )} + + + {editable && isEditing && ( + + )} +
)} @@ -284,7 +299,7 @@ const ResourceEditor: React.FunctionComponent = props => { handleChange={handleChange} keyFoldCode={keyFoldCode} onLinksFound={onLinksFound} - fullscreen={limited} + fullscreen={fullscreen} />
); diff --git a/src/shared/components/ResourceEditor/useResolutionActions.tsx b/src/shared/components/ResourceEditor/useResolutionActions.tsx index 5f1772589..5512a1260 100644 --- a/src/shared/components/ResourceEditor/useResolutionActions.tsx +++ b/src/shared/components/ResourceEditor/useResolutionActions.tsx @@ -14,6 +14,7 @@ import { } from '../../utils'; import { parseResourceId } from '../Preview/Preview'; import { download } from '../../utils/download'; +import { getDataExplorerResourceItemArray } from './editorUtils'; const useResolvedLinkEditorPopover = () => { const nexus = useNexusContext(); @@ -44,11 +45,13 @@ const useResolvedLinkEditorPopover = () => { _self: data._self, title: getResourceLabel(data), types: getNormalizedTypes(data['@type']), - resource: [ - orgProject?.orgLabel ?? '', - orgProject?.projectLabel ?? '', - data['@id'], - ], + resource: getDataExplorerResourceItemArray( + { + orgLabel: orgProject?.orgLabel ?? '', + projectLabel: orgProject?.projectLabel ?? '', + }, + data + ), }, current: resource, }) diff --git a/src/shared/containers/ResourceEditor.tsx b/src/shared/containers/ResourceEditor.tsx index 26de03c9c..dbbbe57ab 100644 --- a/src/shared/containers/ResourceEditor.tsx +++ b/src/shared/containers/ResourceEditor.tsx @@ -13,7 +13,7 @@ import ResourceEditor from '../components/ResourceEditor'; import { getDataExplorerResourceItemArray } from '../components/ResourceEditor/editorUtils'; import useNotification, { parseNexusError } from '../hooks/useNotification'; import { - InitDataExplorerGraphFlowLimitedVersion, + InitDataExplorerGraphFlowFullscreenVersion, InitNewVisitDataExplorerGraphView, } from '../store/reducers/data-explorer'; import { @@ -129,7 +129,9 @@ const ResourceEditorContainer: React.FunctionComponent<{ )) as Resource; const orgProject = getOrgAndProjectFromResourceObject(data); if (location.pathname === '/data-explorer/graph-flow') { - dispatch(InitDataExplorerGraphFlowLimitedVersion(true)); + dispatch( + InitDataExplorerGraphFlowFullscreenVersion({ fullscreen: true }) + ); } else { dispatch( InitNewVisitDataExplorerGraphView({ @@ -143,7 +145,7 @@ const ResourceEditorContainer: React.FunctionComponent<{ data ), }, - limited: true, + fullscreen: true, }) ); navigate.push('/data-explorer/graph-flow'); diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx new file mode 100644 index 000000000..2f259bfff --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentFullscreenHeader.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Switch } from 'antd'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from 'shared/store/reducers'; +import { InitDataExplorerGraphFlowFullscreenVersion } from '../../store/reducers/data-explorer'; +import './styles.less'; + +const DataExplorerGraphFlowContentLimitedHeader = () => { + const dispatch = useDispatch(); + const { current, fullscreen } = useSelector( + (state: RootState) => state.dataExplorer + ); + const onStandardScreen = () => + dispatch(InitDataExplorerGraphFlowFullscreenVersion({ fullscreen: false })); + + return ( +
+
+ Fullscreen + +
+

+ {current?.title} +

+
+ ); +}; + +export default DataExplorerGraphFlowContentLimitedHeader; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx deleted file mode 100644 index 41914dedf..000000000 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/ContentLimitedHeader.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useState } from 'react'; -import { Switch } from 'antd'; -import { useSelector, useDispatch } from 'react-redux'; -import { RootState } from 'shared/store/reducers'; -import { InitDataExplorerGraphFlowLimitedVersion } from '../../store/reducers/data-explorer'; -import './styles.less'; - -const DataExplorerGraphFlowContentLimitedHeader = () => { - const dispatch = useDispatch(); - const { current, limited } = useSelector( - (state: RootState) => state.dataExplorer - ); - const onSelectWrite = (checked: boolean) => { - dispatch(InitDataExplorerGraphFlowLimitedVersion(!checked)); - }; - - return ( -
-
{current?.title}
-
- Read - - Write -
-
- ); -}; - -export default DataExplorerGraphFlowContentLimitedHeader; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx new file mode 100644 index 000000000..e53cca4d8 --- /dev/null +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/NavigationArrow.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'; +import './styles.less'; + +type NavigationArrowDirection = 'back' | 'forward'; + +const NavigationArrow = ({ + direction, + visible, + title, + onClick, +}: { + direction: NavigationArrowDirection; + visible: boolean; + title: string; + onClick: () => void; +}) => { + return visible ? ( + + ) : null; +}; + +export default NavigationArrow; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts b/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts index e538b978e..9fe3f1bd3 100644 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/index.ts @@ -1,4 +1,5 @@ -export { default as DataExplorerGraphFlowContentLimitedHeader } from './ContentLimitedHeader'; +export { default as DEFGContentFullscreenHeader } from './ContentFullscreenHeader'; +export { default as NavigationArrow } from './NavigationArrow'; export { default as NavigationStackItem } from './NavigationStackItem'; export { default as NavigationStackShrinkedItem } from './NavigationStackShrinkedItem'; export { default as NavigationCollapseButton } from './NavigationCollapseButton'; diff --git a/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less b/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less index a08136a77..b3a2b5346 100644 --- a/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less +++ b/src/shared/molecules/DataExplorerGraphFlowMolecules/styles.less @@ -154,78 +154,54 @@ } } -.navigation-back-btn { - margin-top: 8px; - background: white; - box-shadow: 0 2px 12px rgba(#333, 0.12); - padding: 5px 15px; +.navigation-arrow-btn { + cursor: pointer; + background: transparent; max-width: max-content; max-height: 30px; margin-top: 10px; - border-radius: 4px; - border: 1px solid #afacacd8; - cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px; z-index: 90; - + border: none; + &:hover { + text-shadow: 0 2px 12px rgba(#333, 0.12); + span { + color: #377af5; + } + svg { + transform: scale(1.1); + transition: transform 0.2s ease-in-out; + } + } span { font-weight: 700; font-size: 16px; color: @fusion-daybreak-8; } - - &:hover { - span { - color: @fusion-daybreak-7; - } - } - - &.go-back-to-referer { - margin-left: 40px; + &:disabled { + cursor: not-allowed; + opacity: 0.5; + color: #afacacd8; } } -.degf-content__haeder { +.degf-content__header { display: flex; align-items: center; - justify-content: space-between; gap: 20px; - margin: 20px 5px; + margin: 0 5px 10px; .title { + margin-left: 50px; + margin-bottom: 0; font-weight: 700; font-size: 16px; line-height: 140%; color: @fusion-menu-color; } - - .switcher { - .ant-switch { - border: 1px solid #bfbfbf; - background-color: white !important; - margin: 0 5px; - } - - .ant-switch-handle { - top: 1px; - } - - .ant-switch-handle::before { - background-color: @fusion-menu-color !important; - } - - span { - user-select: none; - font-weight: 700; - font-size: 12px; - line-height: 130%; - letter-spacing: 0.01em; - color: @fusion-menu-color; - } - } } .navigation-collapse-btn { @@ -239,35 +215,3 @@ border: 1px solid #afacacd8; cursor: pointer; } - -@keyframes vibrate { - 0% { - -webkit-transform: translate(0); - transform: translate(0); - } - - 20% { - -webkit-transform: translate(-1.5px, 1.5px); - transform: translate(-1.5px, 1.5px); - } - - 40% { - -webkit-transform: translate(-1.5px, -1.5px); - transform: translate(-1.5px, -1.5px); - } - - 60% { - -webkit-transform: translate(1.5px, 1.5px); - transform: translate(1.5px, 1.5px); - } - - 80% { - -webkit-transform: translate(1.5px, -1.5px); - transform: translate(1.5px, -1.5px); - } - - 100% { - -webkit-transform: translate(0); - transform: translate(0); - } -} diff --git a/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx b/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx index f644be773..8978494c6 100644 --- a/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx +++ b/src/shared/organisms/DataExplorerGraphFlowContent/DataExplorerGraphFlowContent.tsx @@ -3,18 +3,18 @@ import { useSelector } from 'react-redux'; import { RootState } from '../../store/reducers'; import ResourceViewContainer from '../../containers/ResourceViewContainer'; import ResourceEditorContainer from '../../containers/ResourceEditor'; -import { DataExplorerGraphFlowContentLimitedHeader } from '../../molecules/DataExplorerGraphFlowMolecules'; +import { DEFGContentFullscreenHeader } from '../../molecules/DataExplorerGraphFlowMolecules'; import './styles.less'; const DataExplorerContentPage = ({}) => { - const { current, limited } = useSelector( + const { current, fullscreen } = useSelector( (state: RootState) => state.dataExplorer ); return (
- {limited ? ( + {fullscreen ? ( - + {}} diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx new file mode 100644 index 000000000..fb68036d0 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.spec.tsx @@ -0,0 +1,214 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { AnyAction, Store } from 'redux'; +import { Provider } from 'react-redux'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { createNexusClient, NexusClient } from '@bbp/nexus-sdk'; +import { Router } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import { NexusProvider } from '@bbp/react-nexus'; +import { deltaPath } from '../../../__mocks__/handlers/handlers'; +import configureStore from '../../store'; +import { + ResetDataExplorerGraphFlow, + TDataExplorerState, +} from '../../store/reducers/data-explorer'; +import NavigationArrows from './NavigationArrows'; +import NavigationStack from './NavigationStack'; + +const initialDataExplorerState: TDataExplorerState = { + current: { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.417881.3', + title: 'Allen Institute for Brain Science', + types: ['Agent', 'Organization'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.417881.3', + 1, + ], + }, + leftNodes: { + links: [ + { + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/lnmce/datashapes:dataset/traces%2F460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + title: '001_141216_A1_CA1py_R_MPG', + types: ['Entity', 'Trace', 'Dataset'], + resource: [ + 'bbp', + 'lnmce', + 'https://bbp.epfl.ch/neurosciencegraph/data/traces/460bfa2e-cb7d-4420-a448-2030a6bf4ae4', + 1, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/bbp/atlas/datashapes:organization/https:%2F%2Fwww.grid.ac%2Finstitutes%2Fgrid.5333.6', + title: 'Ecole Polytechnique Federale de Lausanne', + types: ['Organization', 'prov#Agent'], + resource: [ + 'bbp', + 'atlas', + 'https://www.grid.ac/institutes/grid.5333.6', + 1, + ], + }, + ], + shrinked: false, + }, + rightNodes: { + links: [ + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/datashapes:ontologyentity/https:%2F%2Fbbp.epfl.ch%2Fontologies%2Fcore%2Fefeatures%2FNeuroElectroNeuronElectrophysiologicalFeature', + title: 'NeuroElectro Neuron Electrophysiological Feature', + types: ['Class'], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://bbp.epfl.ch/ontologies/core/efeatures/NeuroElectroNeuronElectrophysiologicalFeature', + 29, + ], + }, + { + isDownloadable: false, + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/https:%2F%2Fneuroshapes.org', + title: 'neuroshapes.org', + types: [], + resource: [ + 'neurosciencegraph', + 'datamodels', + 'https://neuroshapes.org', + 161, + ], + }, + ], + shrinked: false, + }, + fullscreen: false, + referer: { + pathname: '/my-data', + search: '', + state: {}, + }, +}; + +const getButtonElement = (container: HTMLElement, side: 'back' | 'forward') => { + return container.querySelector( + `.navigation-arrow-btn[aria-label="${side}-arrow"]` + ); +}; +describe('NavigationStack', () => { + let app: JSX.Element; + let component: RenderResult; + let container: HTMLElement; + let rerender: (ui: React.ReactElement) => void; + let store: Store; + let user: UserEvent; + let history: MemoryHistory<{}>; + let nexus: NexusClient; + + beforeEach(() => { + history = createMemoryHistory({}); + + nexus = createNexusClient({ + fetch, + uri: deltaPath(), + }); + store = configureStore(history, { nexus }, {}); + app = ( + + + + <> + + + + + + + + ); + component = render(app); + container = component.container; + rerender = component.rerender; + user = userEvent.setup(); + + store.dispatch( + ResetDataExplorerGraphFlow({ initialState: initialDataExplorerState }) + ); + rerender(app); + }); + + it('should render the back/forward arrows as not disabled', () => { + const backArrow = container.querySelector('[aria-label="back-arrow"]'); + const forwardArrow = getButtonElement(container, 'forward'); + expect(backArrow).toBeInTheDocument(); + expect(forwardArrow).toBeInTheDocument(); + }); + it('should left side of navigation become 3 and right side become 2 when Forward btn clicked', async () => { + const forwardArrow = getButtonElement(container, 'forward'); + expect(forwardArrow).toBeInTheDocument(); + forwardArrow && (await user.click(forwardArrow)); + rerender(app); + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(3); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(2); + }); + it('should left side of navigation become 1 and right side become 4 when Back btn clicked', async () => { + const backArrow = getButtonElement(container, 'back'); + expect(backArrow).toBeInTheDocument(); + backArrow && (await user.click(backArrow)); + rerender(app); + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(1); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(4); + }); + it('should forward btn disappear when there is no more forward navigation', async () => { + for (const _ of store.getState().dataExplorer.rightNodes.links) { + const forwardArrow = getButtonElement(container, 'forward'); + expect(forwardArrow).toBeInTheDocument(); + forwardArrow && (await user.click(forwardArrow)); + rerender(app); + } + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(5); + expect(store.getState().dataExplorer.rightNodes.links.length).toEqual(0); + const forwardArrowAfterFullNavigation = getButtonElement( + container, + 'forward' + ); + expect(forwardArrowAfterFullNavigation).toBeNull(); + }); + it('should return to /my-data when there is no more back navigation', async () => { + for (const _ of store.getState().dataExplorer.leftNodes.links) { + const backArrow = getButtonElement(container, 'back'); + expect(backArrow).toBeInTheDocument(); + backArrow && (await user.click(backArrow)); + rerender(app); + } + expect(store.getState().dataExplorer.leftNodes.links.length).toEqual(0); + const lastBackArrow = getButtonElement(container, 'back'); + expect(lastBackArrow).toBeInTheDocument(); + lastBackArrow && (await user.click(lastBackArrow)); + expect(history.location.pathname).toEqual('/my-data'); + }); +}); diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx new file mode 100644 index 000000000..42febe91c --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationArrows.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { NavigationArrow } from '../../molecules/DataExplorerGraphFlowMolecules'; +import useNavigationStackManager from './useNavigationStack'; +import './styles.less'; + +const NavigationArrows = () => { + const { + onNavigateBack, + onNavigateForward, + backArrowVisible, + forwardArrowVisible, + } = useNavigationStackManager(); + + return ( +
+ + +
+ ); +}; + +export default NavigationArrows; diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx index cb9238e1d..044951630 100644 --- a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/NavigationStack.spec.tsx @@ -73,7 +73,7 @@ const initialDataExplorerState: TDataExplorerState = { links: [], shrinked: false, }, - limited: false, + fullscreen: false, }; const fourthItemInStack = { isDownloadable: false, diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts new file mode 100644 index 000000000..c5cfa73f3 --- /dev/null +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/index.ts @@ -0,0 +1,2 @@ +export { default as NavigationArrows } from './NavigationArrows'; +export { default as NavigationStack } from './NavigationStack'; diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less index 5575a9b71..d512000bd 100644 --- a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/styles.less @@ -21,3 +21,12 @@ height: 100%; min-height: 100vh; } + +.navigation-arrows { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 20px; +} diff --git a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts index d37d1e06b..a03c45b2c 100644 --- a/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts +++ b/src/shared/organisms/DataExplorerGraphFlowNavigationStack/useNavigationStack.ts @@ -2,13 +2,19 @@ import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router'; import { RootState } from '../../store/reducers'; import { + DATA_EXPLORER_GRAPH_FLOW_DIGEST, ExpandNavigationStackDataExplorerGraphFlow, + MoveForwardDataExplorerGraphFlow, + ResetDataExplorerGraphFlow, + ReturnBackDataExplorerGraphFlow, ShrinkNavigationStackDataExplorerGraphFlow, } from '../../store/reducers/data-explorer'; const useNavigationStackManager = () => { const dispatch = useDispatch(); - const { rightNodes, leftNodes } = useSelector( + const history = useHistory(); + const location = useLocation(); + const { rightNodes, leftNodes, referer } = useSelector( (state: RootState) => state.dataExplorer ); const leftShrinked = leftNodes.shrinked; @@ -25,6 +31,27 @@ const useNavigationStackManager = () => { const onRightExpand = () => dispatch(ExpandNavigationStackDataExplorerGraphFlow({ side: 'right' })); + const backArrowVisible = leftLinks.length > 0 || !!referer?.pathname; + const forwardArrowVisible = rightLinks.length > 0; + + const onNavigateBack = () => { + if (referer?.pathname && !leftLinks.length) { + dispatch(ResetDataExplorerGraphFlow({ initialState: null })); + localStorage.removeItem(DATA_EXPLORER_GRAPH_FLOW_DIGEST); + history.push(`${referer.pathname}${referer.search}`, { + ...referer.state, + }); + return; + } + history.replace(location.pathname); + dispatch(ReturnBackDataExplorerGraphFlow()); + }; + + const onNavigateForward = () => { + history.replace(location.pathname); + dispatch(MoveForwardDataExplorerGraphFlow()); + }; + return { leftShrinked, rightShrinked, @@ -34,6 +61,10 @@ const useNavigationStackManager = () => { onLeftExpand, onRightShrink, onRightExpand, + onNavigateBack, + onNavigateForward, + backArrowVisible, + forwardArrowVisible, }; }; diff --git a/src/shared/store/reducers/data-explorer.ts b/src/shared/store/reducers/data-explorer.ts index 8856d169f..883d8eeb8 100644 --- a/src/shared/store/reducers/data-explorer.ts +++ b/src/shared/store/reducers/data-explorer.ts @@ -1,5 +1,14 @@ import { createSlice } from '@reduxjs/toolkit'; -import { slice, clone, dropRight, nth, last, concat } from 'lodash'; +import { + slice, + clone, + dropRight, + nth, + last, + concat, + first, + drop, +} from 'lodash'; type TProject = string; type TOrganization = string; @@ -45,7 +54,7 @@ export type TDataExplorerState = { search: string; state: Record; } | null; - limited: boolean; + fullscreen: boolean; }; export type TNavigationStackSide = 'left' | 'right'; @@ -58,7 +67,7 @@ const initialState: TDataExplorerState = { rightNodes: { links: [], shrinked: false }, current: null, referer: null, - limited: false, + fullscreen: false, }; const calculateNewDigest = (state: TDataExplorerState) => { @@ -99,13 +108,13 @@ export const dataExplorerSlice = createSlice({ }, InitNewVisitDataExplorerGraphView: ( state, - { payload: { source, current, limited, referer } } + { payload: { source, current, fullscreen, referer } } ) => { const newState = { ...state, referer, current, - limited, + fullscreen, leftNodes: { links: source && current @@ -223,22 +232,51 @@ export const dataExplorerSlice = createSlice({ return newState; }, ReturnBackDataExplorerGraphFlow: state => { - const current = last(state.leftNodes.links) as TDELink; - const newrightNodesLinks = state.rightNodes.links; - const newleftNodesLinks = dropRight(state.leftNodes.links) as TDELink[]; - insert(newrightNodesLinks, 0, state.current); + const newCurrent = last(state.leftNodes.links) as TDELink; + const current = state.current; + const newRightNodesLinks = concat( + current ? [current] : [], + state.rightNodes.links + ); + const newLeftNodesLinks = dropRight(state.leftNodes.links) as TDELink[]; const rightNodes = { - links: newrightNodesLinks, - shrinked: isShrinkable(newrightNodesLinks), + links: newRightNodesLinks, + shrinked: isShrinkable(newRightNodesLinks), + }; + const leftNodes = { + links: newLeftNodesLinks, + shrinked: isShrinkable(newLeftNodesLinks), }; const newState = { ...state, - current, rightNodes, - leftNodes: { - links: newleftNodesLinks, - shrinked: isShrinkable(newleftNodesLinks), - }, + leftNodes, + current: newCurrent, + }; + calculateNewDigest(newState); + return newState; + }, + MoveForwardDataExplorerGraphFlow: state => { + const newCurrent = first(state.rightNodes.links) as TDELink; + const current = state.current; + const newLeftNodesLinks = concat( + state.leftNodes.links, + current ? [current] : [] + ); + const newRightNodesLinks = drop(state.rightNodes.links) as TDELink[]; + const rightNodes = { + links: newRightNodesLinks, + shrinked: isShrinkable(newRightNodesLinks), + }; + const leftNodes = { + links: newLeftNodesLinks, + shrinked: isShrinkable(newLeftNodesLinks), + }; + const newState = { + ...state, + rightNodes, + leftNodes, + current: newCurrent, }; calculateNewDigest(newState); return newState; @@ -292,10 +330,13 @@ export const dataExplorerSlice = createSlice({ ResetDataExplorerGraphFlow: (_, action) => { return action.payload.initialState ?? initialState; }, - InitDataExplorerGraphFlowLimitedVersion: (state, action) => { + InitDataExplorerGraphFlowFullscreenVersion: ( + state, + { payload: { fullscreen } }: { payload: { fullscreen?: boolean } } + ) => { const newState = { ...state, - limited: action.payload ?? !state.limited, + fullscreen: fullscreen ?? !state.fullscreen, }; calculateNewDigest(newState); return newState; @@ -310,8 +351,9 @@ export const { ShrinkNavigationStackDataExplorerGraphFlow, JumpToNodeDataExplorerGraphFlow, ReturnBackDataExplorerGraphFlow, + MoveForwardDataExplorerGraphFlow, ResetDataExplorerGraphFlow, - InitDataExplorerGraphFlowLimitedVersion, + InitDataExplorerGraphFlowFullscreenVersion, } = dataExplorerSlice.actions; export default dataExplorerSlice.reducer; diff --git a/src/shared/views/ResourceView.tsx b/src/shared/views/ResourceView.tsx index f7aa9a1da..0e07ded51 100644 --- a/src/shared/views/ResourceView.tsx +++ b/src/shared/views/ResourceView.tsx @@ -1,9 +1,18 @@ import * as React from 'react'; +import { useHistory } from 'react-router'; +import { clsx } from 'clsx'; import ResourceViewContainer from '../containers/ResourceViewContainer'; -const ResourceView: React.FunctionComponent = props => { +const ResourceView: React.FunctionComponent = () => { + const { location } = useHistory(); + const background = !!(location.state as any)?.background; return ( -
+
); diff --git a/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx index 4db660ff9..f651285a6 100644 --- a/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx +++ b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx @@ -36,8 +36,8 @@ describe('DataExplorerSpec-Utils', () => { }, }, ]; + const actual = getAllPaths(resources); const expectedPaths = [ - '@id', 'contributors', 'contributors.name', 'contributors.name.firstname', @@ -50,8 +50,9 @@ describe('DataExplorerSpec-Utils', () => { 'distribution.filename', 'distribution.label', 'distribution.name', + '@id', ]; - expect(getAllPaths(resources)).toEqual(expectedPaths); + expect(actual).toEqual(expectedPaths); }); it('sorts path starting with underscore at the end of the list', () => { @@ -65,17 +66,18 @@ describe('DataExplorerSpec-Utils', () => { _updatedAt: 'some time ago', name: 'anotherNameValue', _createdAt: '12 September 2020', - project: 'secret project', + _project: 'secret project', }, ]; const expectedPaths = [ 'name', - 'project', + '_createdAt', + '_project', + '_updatedAt', + '_author', '_author.designation', '_author.name', - '_createdAt', - '_updatedAt', ]; const receivedPaths = getAllPaths(resources); @@ -627,4 +629,55 @@ describe('DataExplorerSpec-Utils', () => { ) ).toEqual(true); }); + + it('does not throw when checking for non existence on a path when resource has primitve value', () => { + const resource = { + '@context': [ + 'https://bluebrain.github.io/nexus/contexts/metadata.json', + { + '1Point': { + '@id': 'nsg:1Point', + }, + '2DContour': { + '@id': 'nsg:2DContour', + }, + '3DContour': { + '@id': 'nsg:3DContour', + }, + '3Point': { + '@id': 'nsg:3Point', + }, + '@vocab': + 'https://bbp-nexus.epfl.ch/vocabs/bbp/neurosciencegraph/core/v0.1.0/', + Derivation: { + '@id': 'prov:Derivation', + }, + xsd: 'http://www.w3.org/2001/XMLSchema#', + }, + ], + '@id': 'https://bbp.epfl.ch/nexus/search/neuroshapes', + _constrainedBy: + 'https://bluebrain.github.io/nexus/schemas/unconstrained.json', + _createdAt: '2019-02-11T14:15:14.020Z', + _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman', + _deprecated: false, + _incoming: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/incoming', + _outgoing: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/outgoing', + _project: + 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public', + _rev: 1, + _schemaProject: + 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public', + _self: + 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes', + _updatedAt: '2019-02-11T14:15:14.020Z', + _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman', + }; + + expect( + checkPathExistence(resource, '@context.@vocab', 'does-not-exist') + ).toEqual(true); + }); }); diff --git a/src/subapps/dataExplorer/DataExplorer.spec.tsx b/src/subapps/dataExplorer/DataExplorer.spec.tsx index a88c8a032..4c7d09950 100644 --- a/src/subapps/dataExplorer/DataExplorer.spec.tsx +++ b/src/subapps/dataExplorer/DataExplorer.spec.tsx @@ -6,9 +6,10 @@ import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { dataExplorerPageHandler, - defaultMockResult, filterByProjectHandler, + getCompleteResources, getMockResource, + sourceResourceHandler, } from '__mocks__/handlers/DataExplorer/handlers'; import { deltaPath } from '__mocks__/handlers/handlers'; import { setupServer } from 'msw/node'; @@ -19,7 +20,6 @@ import { AllProjects } from './ProjectSelector'; import { getColumnTitle } from './DataExplorerTable'; import { CONTAINS, - DEFAULT_OPTION, DOES_NOT_CONTAIN, DOES_NOT_EXIST, EXISTS, @@ -29,13 +29,21 @@ import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; import { Provider } from 'react-redux'; import configureStore from '../../shared/store'; +import { ALWAYS_DISPLAYED_COLUMNS, isNexusMetadata } from './DataExplorerUtils'; describe('DataExplorer', () => { const defaultTotalResults = 500_123; + const mockResourcesOnPage1: Resource[] = getCompleteResources(); + const mockResourcesForPage2: Resource[] = [ + getMockResource('self1', { author: 'piggy', edition: 1 }), + getMockResource('self2', { author: ['iggy', 'twinky'] }), + getMockResource('self3', { year: 2013 }), + ]; const server = setupServer( - dataExplorerPageHandler(defaultMockResult, defaultTotalResults), - filterByProjectHandler(defaultMockResult) + dataExplorerPageHandler(undefined, defaultTotalResults), + sourceResourceHandler(), + filterByProjectHandler() ); const history = createMemoryHistory({}); @@ -83,7 +91,7 @@ describe('DataExplorer', () => { const DropdownSelector = '.ant-select-dropdown'; const DropdownOptionSelector = 'div.ant-select-item-option-content'; - const TypeOptionSelector = 'div.ant-select-item-option-content > span'; + const CustomOptionSelector = 'div.ant-select-item-option-content > span'; const PathMenuLabel = 'path-selector'; const PredicateMenuLabel = 'predicate-selector'; @@ -101,22 +109,30 @@ describe('DataExplorer', () => { const expectColumHeaderToExist = async (name: string) => { const nameReg = new RegExp(getColumnTitle(name), 'i'); const header = await screen.getByText(nameReg, { - selector: 'th', + selector: 'th .ant-table-column-title', exact: false, }); expect(header).toBeInTheDocument(); return header; }; + const getColumnSorter = async (colName: string) => { + const column = await expectColumHeaderToExist(colName); + return column.closest('.ant-table-column-sorters'); + }; + + const getTotalColumns = () => { + return Array.from(container.querySelectorAll('th')); + }; + + const expectColumHeaderToNotExist = async (name: string) => { + expect(expectColumHeaderToExist(name)).rejects.toThrow(); + }; + const getTextForColumn = async (resource: Resource, colName: string) => { - const selfCell = await screen.getAllByText( - new RegExp(resource._self, 'i'), - { - selector: 'td', - } - ); + const row = await screen.getByTestId(resource._self); - const allCellsForRow = Array.from(selfCell[0].parentElement!.childNodes); + const allCellsForRow = Array.from(row.childNodes); const colIndex = Array.from( container.querySelectorAll('th') ).findIndex(header => @@ -126,13 +142,7 @@ describe('DataExplorer', () => { }; const getRowForResource = async (resource: Resource) => { - const selfCell = await screen.getAllByText( - new RegExp(resource._self, 'i'), - { - selector: 'td', - } - ); - const row = selfCell[0].parentElement; + const row = await screen.getByTestId(resource._self); expect(row).toBeInTheDocument(); return row!; }; @@ -168,6 +178,11 @@ describe('DataExplorer', () => { return typeColumn?.textContent; }; + const columnTextFromRow = (row: Element, colName: string) => { + const column = row.querySelector(`td.data-explorer-column-${colName}`); + return column?.textContent; + }; + const visibleTableRows = () => { return container.querySelectorAll('table tbody tr.data-explorer-row'); }; @@ -190,7 +205,10 @@ describe('DataExplorer', () => { resources: Resource[], total: number = 300 ) => { - server.use(dataExplorerPageHandler(resources, total)); + server.use( + sourceResourceHandler(resources), + dataExplorerPageHandler(resources, total) + ); const pageInput = await screen.getByRole('listitem', { name: '2' }); expect(pageInput).toBeInTheDocument(); @@ -200,10 +218,21 @@ describe('DataExplorer', () => { await expectRowCountToBe(3); }; - const openMenuFor = async (ariaLabel: string) => { - const menuInput = await screen.getByLabelText(ariaLabel, { + const getInputForLabel = async (label: string) => { + return (await screen.getByLabelText(label, { selector: 'input', - }); + })) as HTMLInputElement; + }; + + const getSelectedValueInMenu = async (menuLabel: string) => { + const input = await getInputForLabel(menuLabel); + return input + .closest('.ant-select-selector') + ?.querySelector('.ant-select-selection-item')?.innerHTML; + }; + + const openMenuFor = async (ariaLabel: string) => { + const menuInput = await getInputForLabel(ariaLabel); await userEvent.click(menuInput, { pointerEventsCheck: 0 }); await act(async () => { fireEvent.mouseDown(menuInput); @@ -213,6 +242,14 @@ describe('DataExplorer', () => { return menuDropdown; }; + const selectPath = async (path: string) => { + await selectOptionFromMenu(PathMenuLabel, path, CustomOptionSelector); + }; + + const selectPredicate = async (predicate: string) => { + await selectOptionFromMenu(PredicateMenuLabel, predicate); + }; + const selectOptionFromMenu = async ( menuAriaLabel: string, optionLabel: string, @@ -228,11 +265,11 @@ describe('DataExplorer', () => { * NOTE: Since antd menus use virtual scroll, not all options inside the menu are visible. * This function only returns those options that are visible. */ - const getVisibleOptionsFromMenu = () => { + const getVisibleOptionsFromMenu = ( + selector: string = DropdownOptionSelector + ) => { const menuDropdown = document.querySelector(DropdownSelector); - return Array.from( - menuDropdown?.querySelectorAll(DropdownOptionSelector) ?? [] - ); + return Array.from(menuDropdown?.querySelectorAll(selector) ?? []); }; const getTotalSizeOfDataset = async (expectedCount: string) => { @@ -264,7 +301,9 @@ describe('DataExplorer', () => { return filteredCount; }; - const updateResourcesShownInTable = async (resources: Resource[]) => { + const updateResourcesShownInTable = async ( + resources: Resource[] = mockResourcesForPage2 + ) => { await expectRowCountToBe(10); await getRowsForNextPage(resources); await expectRowCountToBe(resources.length); @@ -274,20 +313,77 @@ describe('DataExplorer', () => { return await screen.getByTestId('reset-project-button'); }; + const showMetadataSwitch = async () => + await screen.getByLabelText('Show metadata'); + + const showEmptyDataCellsSwitch = async () => + await screen.getByLabelText('Show empty data cells'); + + const resetPredicate = async () => { + const resetPredicateButton = await screen.getByRole('button', { + name: /reset predicate/i, + }); + await userEvent.click(resetPredicateButton); + }; + + const expectRowsInOrder = async (expectedOrder: Resource[]) => { + for await (const [index, row] of visibleTableRows().entries()) { + const text = await columnTextFromRow(row, 'author'); + if (expectedOrder[index].author) { + expect(text).toMatch(JSON.stringify(expectedOrder[index].author)); + } else { + expect(text).toMatch(/No data/i); + } + } + }; + + it('shows columns for fields that are only in source data', async () => { + await expectRowCountToBe(10); + const column = await expectColumHeaderToExist('userProperty1'); + expect(column).toBeInTheDocument(); + }); + it('shows rows for all fetched resources', async () => { await expectRowCountToBe(10); }); - it('shows columns for each top level property in resources', async () => { + it('shows only user columns for each top level property by default', async () => { await expectRowCountToBe(10); const seenColumns = new Set(); - for (const mockResource of defaultMockResult) { + for (const mockResource of mockResourcesOnPage1) { for (const topLevelProperty of Object.keys(mockResource)) { if (!seenColumns.has(topLevelProperty)) { seenColumns.add(topLevelProperty); - const header = getColumnTitle(topLevelProperty); - await expectColumHeaderToExist(header); + + if (ALWAYS_DISPLAYED_COLUMNS.has(topLevelProperty)) { + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); + } else if (isNexusMetadata(topLevelProperty)) { + expect( + expectColumHeaderToExist(getColumnTitle(topLevelProperty)) + ).rejects.toThrow(); + } else { + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); + } + } + } + } + + expect(seenColumns.size).toBeGreaterThan(1); + }); + + it('shows user columns for all top level properties when show user metadata clicked', async () => { + await expectRowCountToBe(10); + const showMetadataButton = await showMetadataSwitch(); + await userEvent.click(showMetadataButton); + + const seenColumns = new Set(); + + for (const mockResource of mockResourcesOnPage1) { + for (const topLevelProperty of Object.keys(mockResource)) { + if (!seenColumns.has(topLevelProperty)) { + seenColumns.add(topLevelProperty); + await expectColumHeaderToExist(getColumnTitle(topLevelProperty)); } } } @@ -343,7 +439,7 @@ describe('DataExplorer', () => { it('shows No data text when values are missing for a column', async () => { await expectRowCountToBe(10); - const resourceWithMissingProperty = defaultMockResult.find( + const resourceWithMissingProperty = mockResourcesOnPage1.find( res => !('specialProperty' in res) )!; const textForSpecialProperty = await getTextForColumn( @@ -355,7 +451,7 @@ describe('DataExplorer', () => { it('shows No data text when values is undefined', async () => { await expectRowCountToBe(10); - const resourceWithUndefinedProperty = defaultMockResult.find( + const resourceWithUndefinedProperty = mockResourcesOnPage1.find( res => res.specialProperty === undefined )!; const textForSpecialProperty = await getTextForColumn( @@ -367,7 +463,7 @@ describe('DataExplorer', () => { it('does not show No data text when values is null', async () => { await expectRowCountToBe(10); - const resourceWithUndefinedProperty = defaultMockResult.find( + const resourceWithUndefinedProperty = mockResourcesOnPage1.find( res => res.specialProperty === null )!; const textForSpecialProperty = await getTextForColumn( @@ -380,7 +476,7 @@ describe('DataExplorer', () => { it('does not show No data when value is empty string', async () => { await expectRowCountToBe(10); - const resourceWithEmptyString = defaultMockResult.find( + const resourceWithEmptyString = mockResourcesOnPage1.find( res => res.specialProperty === '' )!; @@ -394,7 +490,7 @@ describe('DataExplorer', () => { it('does not show No data when value is empty array', async () => { await expectRowCountToBe(10); - const resourceWithEmptyArray = defaultMockResult.find( + const resourceWithEmptyArray = mockResourcesOnPage1.find( res => Array.isArray(res.specialProperty) && res.specialProperty.length === 0 )!; @@ -409,7 +505,7 @@ describe('DataExplorer', () => { it('does not show No data when value is empty object', async () => { await expectRowCountToBe(10); - const resourceWithEmptyObject = defaultMockResult.find( + const resourceWithEmptyObject = mockResourcesOnPage1.find( res => typeof res.specialProperty === 'object' && res.specialProperty !== null && @@ -469,7 +565,7 @@ describe('DataExplorer', () => { it('shows resources filtered by the selected type', async () => { await expectRowCountToBe(10); - await selectOptionFromMenu(TypeMenuLabel, 'file', TypeOptionSelector); + await selectOptionFromMenu(TypeMenuLabel, 'file', CustomOptionSelector); visibleTableRows().forEach(row => expect(typeFromRow(row)).toMatch(/file/i) @@ -478,29 +574,32 @@ describe('DataExplorer', () => { it('only shows types that exist in selected project in type autocomplete', async () => { await openMenuFor(TypeMenuLabel); - const optionBefore = await getDropdownOption('Dataset', TypeOptionSelector); + const optionBefore = await getDropdownOption( + 'Dataset', + CustomOptionSelector + ); expect(optionBefore).toBeInTheDocument(); await selectOptionFromMenu(ProjectMenuLabel, 'unhcr'); await openMenuFor(TypeMenuLabel); expect( - getDropdownOption('Dataset', TypeOptionSelector) + getDropdownOption('Dataset', CustomOptionSelector) ).rejects.toThrowError(); }); it('shows paths as options in a select menu of path selector', async () => { + await expectRowCountToBe(10); await openMenuFor('path-selector'); - const pathOptions = getVisibleOptionsFromMenu(); + const pathOptions = getVisibleOptionsFromMenu(CustomOptionSelector); - const expectedPaths = getAllPaths(defaultMockResult); + const expectedPaths = getAllPaths(mockResourcesOnPage1); expect(expectedPaths.length).toBeGreaterThanOrEqual( - Object.keys(defaultMockResult[0]).length + Object.keys(mockResourcesOnPage1[0]).length ); - expect(pathOptions[0].innerHTML).toMatch(DEFAULT_OPTION); - pathOptions.slice(1).forEach((path, index) => { + pathOptions.forEach((path, index) => { expect(path.innerHTML).toMatch( new RegExp(`${expectedPaths[index]}$`, 'i') ); @@ -516,11 +615,11 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); await expectRowCountToBe(1); - await selectOptionFromMenu(PathMenuLabel, 'edition'); + await selectPath('edition'); await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST); await expectRowCountToBe(2); }); @@ -532,10 +631,10 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); - const valueInput = await screen.getByPlaceholderText('type the value...'); + const valueInput = await screen.getByPlaceholderText('Search for...'); await userEvent.type(valueInput, 'iggy'); await expectRowCountToBe(2); @@ -552,7 +651,7 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, CONTAINS); await expectRowCountToBe(3); @@ -565,7 +664,7 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, EXISTS); await expectRowCountToBe(2); @@ -578,10 +677,10 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_CONTAIN); - const valueInput = await screen.getByPlaceholderText('type the value...'); + const valueInput = await screen.getByPlaceholderText('Search for...'); await userEvent.type(valueInput, 'iggy'); await expectRowCountToBe(2); @@ -603,7 +702,7 @@ describe('DataExplorer', () => { expect(history.location.pathname).not.toContain('self1'); - const firstDataRow = await getRowForResource(defaultMockResult[0]); + const firstDataRow = await getRowForResource(mockResourcesOnPage1[0]); await userEvent.click(firstDataRow); expect(history.location.pathname).toContain('self1'); @@ -650,7 +749,7 @@ describe('DataExplorer', () => { getMockResource('self3', { year: 2013 }), ]); - await selectOptionFromMenu(PathMenuLabel, 'author'); + await selectPath('author'); await userEvent.click(container); await selectOptionFromMenu(PredicateMenuLabel, EXISTS); await expectRowCountToBe(2); @@ -658,4 +757,85 @@ describe('DataExplorer', () => { const totalFromFrontendAfter = await getFilteredResultsCount(2); expect(totalFromFrontendAfter).toBeVisible(); }); + + it('shows column for metadata path even if toggle for show metadata is off', async () => { + const metadataProperty = '_createdBy'; + await expectRowCountToBe(10); + + await expectColumHeaderToNotExist(metadataProperty); + + const originalColumns = getTotalColumns().length; + + await selectPath(metadataProperty); + await selectOptionFromMenu(PredicateMenuLabel, EXISTS); + + await expectColumHeaderToExist(metadataProperty); + expect(getTotalColumns().length).toEqual(originalColumns + 1); + + await resetPredicate(); + expect(getTotalColumns().length).toEqual(originalColumns); + }); + + it('resets predicate fields when reset predicate clicked', async () => { + await updateResourcesShownInTable(mockResourcesForPage2); + + await selectPath('author'); + await selectPredicate(EXISTS); + + const selectedPathBefore = await getSelectedValueInMenu(PathMenuLabel); + expect(selectedPathBefore).toMatch(/author/); + + await expectRowCountToBe(2); + + await resetPredicate(); + + await expectRowCountToBe(3); + + const selectedPathAfter = await getSelectedValueInMenu(PathMenuLabel); + expect(selectedPathAfter).toBeFalsy(); + }); + + it('only shows predicate menu if path is selected', async () => { + await expectRowCountToBe(10); + expect(openMenuFor(PredicateMenuLabel)).rejects.toThrow(); + await selectPath('@type'); + expect(openMenuFor(PredicateMenuLabel)).resolves.not.toThrow(); + }); + + it('sorts table columns', async () => { + const dataSource = [ + getMockResource('self1', { author: 'tweaty', edition: 1 }), + getMockResource('self2', { edition: 2001 }), + getMockResource('self3', { year: 2013, author: 'piggy' }), + ]; + await updateResourcesShownInTable(dataSource); + + await expectRowsInOrder(dataSource); + + const authorColumnSorter = await getColumnSorter('author'); + await userEvent.click(authorColumnSorter!); + + await expectRowsInOrder([dataSource[1], dataSource[2], dataSource[0]]); + }); + + it('does not show "No data" cell if "Show empty data cells" toggle is turned off', async () => { + await expectRowCountToBe(10); + const resourceWithMissingProperty = mockResourcesOnPage1.find( + res => !('specialProperty' in res) + )!; + const textForSpecialProperty = await getTextForColumn( + resourceWithMissingProperty, + 'specialProperty' + ); + expect(textForSpecialProperty).toMatch(/No data/i); + + const button = await showEmptyDataCellsSwitch(); + await userEvent.click(button); + + const textForSpecialPropertyAfter = await getTextForColumn( + resourceWithMissingProperty, + 'specialProperty' + ); + expect(textForSpecialPropertyAfter).toMatch(''); + }); }); diff --git a/src/subapps/dataExplorer/DataExplorer.tsx b/src/subapps/dataExplorer/DataExplorer.tsx index 9a001daa3..66a692e3a 100644 --- a/src/subapps/dataExplorer/DataExplorer.tsx +++ b/src/subapps/dataExplorer/DataExplorer.tsx @@ -1,29 +1,34 @@ import { Resource } from '@bbp/nexus-sdk'; -import { useNexusContext } from '@bbp/react-nexus'; -import { notification } from 'antd'; -import { isString } from 'lodash'; -import React, { useReducer } from 'react'; -import { useQuery } from 'react-query'; +import { Switch } from 'antd'; +import React, { useMemo, useReducer, useState } from 'react'; import { DataExplorerTable } from './DataExplorerTable'; -import './styles.less'; +import { + columnFromPath, + isUserColumn, + sortColumns, + usePaginatedExpandedResources, +} from './DataExplorerUtils'; import { ProjectSelector } from './ProjectSelector'; import { PredicateSelector } from './PredicateSelector'; import { DatasetCount } from './DatasetCount'; import { TypeSelector } from './TypeSelector'; +import './styles.less'; export interface DataExplorerConfiguration { pageSize: number; offset: number; orgAndProject?: [string, string]; type: string | undefined; - predicateFilter: ((resource: Resource) => boolean) | null; + predicate: ((resource: Resource) => boolean) | null; + selectedPath: string | null; } export const DataExplorer: React.FC<{}> = () => { - const nexus = useNexusContext(); + const [showMetadataColumns, setShowMetadataColumns] = useState(false); + const [showEmptyDataCells, setShowEmptyDataCells] = useState(true); const [ - { pageSize, offset, orgAndProject, predicateFilter, type }, + { pageSize, offset, orgAndProject, predicate, type, selectedPath }, updateTableConfiguration, ] = useReducer( ( @@ -35,45 +40,34 @@ export const DataExplorer: React.FC<{}> = () => { offset: 0, orgAndProject: undefined, type: undefined, - predicateFilter: null, + predicate: null, + selectedPath: null, } ); - const { data: resources, isLoading } = useQuery({ - queryKey: ['data-explorer', { pageSize, offset, orgAndProject, type }], - retry: false, - queryFn: () => { - return nexus.Resource.list(orgAndProject?.[0], orgAndProject?.[1], { - type, - from: offset, - size: pageSize, - }); - }, - onError: error => { - notification.error({ - message: 'Error loading data from the server', - description: isString(error) ? ( - error - ) : isObject(error) ? ( -
- {(error as any)['@type']} -
{(error as any)['details']}
-
- ) : ( - '' - ), - }); - }, + const { data: resources, isLoading } = usePaginatedExpandedResources({ + pageSize, + offset, + orgAndProject, + type, }); const currentPageDataSource: Resource[] = resources?._results || []; - const displayedDataSource = predicateFilter - ? currentPageDataSource.filter(resource => { - return predicateFilter(resource); - }) + const displayedDataSource = predicate + ? currentPageDataSource.filter(predicate) : currentPageDataSource; + const memoizedColumns = useMemo( + () => + columnsFromDataSource( + currentPageDataSource, + showMetadataColumns, + selectedPath + ), + [currentPageDataSource, showMetadataColumns, selectedPath] + ); + return (
@@ -101,38 +95,67 @@ export const DataExplorer: React.FC<{}> = () => {
{!isLoading && ( - +
+ +
+ setShowMetadataColumns(isChecked)} + id="show-metadata-columns" + className="data-explorer-toggle" + /> + + + setShowEmptyDataCells(isChecked)} + id="show-empty-data-cells" + className="data-explorer-toggle" + /> + +
+
)}
); }; -export const isObject = (value: any) => { - return typeof value === 'object' && value !== null && !Array.isArray(value); -}; - -export const columnsFromDataSource = (resources: Resource[]): string[] => { +export const columnsFromDataSource = ( + resources: Resource[], + showMetadataColumns: boolean, + selectedPath: string | null +): string[] => { const columnNames = new Set(); resources.forEach(resource => { Object.keys(resource).forEach(key => columnNames.add(key)); }); - return Array.from(columnNames); + if (showMetadataColumns) { + return Array.from(columnNames).sort(sortColumns); + } + + const selectedMetadataColumn = columnFromPath(selectedPath); + return Array.from(columnNames) + .filter( + colName => isUserColumn(colName) || colName === selectedMetadataColumn + ) + .sort(sortColumns); }; diff --git a/src/subapps/dataExplorer/DataExplorerTable.tsx b/src/subapps/dataExplorer/DataExplorerTable.tsx index c27dfce2f..c2141c4e7 100644 --- a/src/subapps/dataExplorer/DataExplorerTable.tsx +++ b/src/subapps/dataExplorer/DataExplorerTable.tsx @@ -19,6 +19,7 @@ interface TDataExplorerTable { offset: number; updateTableConfiguration: React.Dispatch>; columns: string[]; + showEmptyDataCells: boolean; } type TColumnNameToConfig = Map>; @@ -31,6 +32,7 @@ export const DataExplorerTable: React.FC = ({ pageSize, offset, updateTableConfiguration, + showEmptyDataCells, }: TDataExplorerTable) => { const history = useHistory(); const location = useLocation(); @@ -40,6 +42,7 @@ export const DataExplorerTable: React.FC = ({ const tablePaginationConfig: TablePaginationConfig = { pageSize, total: allowedTotal, + pageSizeOptions: [10, 20, 50], position: ['bottomLeft'], defaultPageSize: 50, defaultCurrent: 0, @@ -64,11 +67,12 @@ export const DataExplorerTable: React.FC = ({ return ( - columns={columnsConfig(columns)} + columns={columnsConfig(columns, showEmptyDataCells)} dataSource={dataSource} rowKey={record => record._self} onRow={resource => ({ onClick: _ => goToResource(resource), + 'data-testid': resource._self, })} loading={isLoading} bordered={false} @@ -81,6 +85,7 @@ export const DataExplorerTable: React.FC = ({ }, }} pagination={tablePaginationConfig} + sticky={{ offsetHeader: 52 }} /> ); }; @@ -89,15 +94,18 @@ export const DataExplorerTable: React.FC = ({ * For each resource in the resources array, it creates column configuration for all its keys (if the column config for that key does not already exist). */ export const columnsConfig = ( - columnNames: string[] + columnNames: string[], + showEmptyDataCells: boolean ): ColumnType[] => { const colNameToConfig = new Map( - columnNames.length === 0 ? [] : initialTableConfig() + columnNames.length === 0 ? [] : initialTableConfig(showEmptyDataCells) ); for (const columnName of columnNames) { if (!colNameToConfig.has(columnName)) { - colNameToConfig.set(columnName, { ...defaultColumnConfig(columnName) }); + colNameToConfig.set(columnName, { + ...defaultColumnConfig(columnName, showEmptyDataCells), + }); } } @@ -107,15 +115,22 @@ export const columnsConfig = ( export const getColumnTitle = (colName: string) => startCase(colName).toUpperCase(); -const defaultColumnConfig = (colName: string): ColumnType => { +const defaultColumnConfig = ( + colName: string, + showEmptyDataCells: boolean +): ColumnType => { return { key: colName, title: getColumnTitle(colName), dataIndex: colName, className: `data-explorer-column data-explorer-column-${colName}`, - sorter: false, + sorter: (a, b) => { + return JSON.stringify(a[colName] ?? '').localeCompare( + JSON.stringify(b[colName] ?? '') + ); + }, render: text => { - if (text === undefined) { + if (text === undefined && showEmptyDataCells) { // Text will also be undefined if a certain resource does not have `colName` as its property return ; } @@ -124,24 +139,30 @@ const defaultColumnConfig = (colName: string): ColumnType => { }; }; -const initialTableConfig = () => { +const initialTableConfig = (showEmptyDataCells: boolean) => { const colNameToConfig: TColumnNameToConfig = new Map(); const projectKey = '_project'; const typeKey = '@type'; const projectConfig: ColumnType = { - ...defaultColumnConfig(projectKey), + ...defaultColumnConfig(projectKey, showEmptyDataCells), title: 'PROJECT', render: text => { if (text) { const { org, project } = makeOrgProjectTuple(text); return `${org}/${project}`; } - return ; + return showEmptyDataCells && ; + }, + sorter: (a, b) => { + const tupleA = makeOrgProjectTuple(a[projectKey] ?? ''); + const tupleB = makeOrgProjectTuple(b[projectKey] ?? ''); + + return (tupleA.project ?? '').localeCompare(tupleB.project); }, }; const typeConfig: ColumnType = { - ...defaultColumnConfig(typeKey), + ...defaultColumnConfig(typeKey, showEmptyDataCells), title: 'TYPE', render: text => { let types = ''; @@ -161,7 +182,7 @@ const initialTableConfig = () => {
{types}
) : ( - + showEmptyDataCells && ); }, }; diff --git a/src/subapps/dataExplorer/DataExplorerUtils.tsx b/src/subapps/dataExplorer/DataExplorerUtils.tsx index e48b91df1..73fe2562f 100644 --- a/src/subapps/dataExplorer/DataExplorerUtils.tsx +++ b/src/subapps/dataExplorer/DataExplorerUtils.tsx @@ -1,6 +1,86 @@ +import { Resource } from '@bbp/nexus-sdk'; import { useNexusContext } from '@bbp/react-nexus'; +import PromisePool from '@supercharge/promise-pool'; import { notification } from 'antd'; import { useQuery } from 'react-query'; +import { makeOrgProjectTuple } from '../../shared/molecules/MyDataTable/MyDataTable'; +import { isString } from 'lodash'; + +export const usePaginatedExpandedResources = ({ + pageSize, + offset, + orgAndProject, + type, +}: PaginatedResourcesParams) => { + const nexus = useNexusContext(); + + return useQuery({ + queryKey: ['data-explorer', { pageSize, offset, orgAndProject, type }], + retry: false, + queryFn: async () => { + const resultWithPartialResources = await nexus.Resource.list( + orgAndProject?.[0], + orgAndProject?.[1], + { + type, + from: offset, + size: pageSize, + } + ); + + // If we failed to fetch the expanded source for some resources, we can use the compact/partial resource as a fallback. + const fallbackResources: Resource[] = []; + const { results: expandedResources } = await PromisePool.withConcurrency( + 4 + ) + .for(resultWithPartialResources._results) + .handleError(async (err, partialResource) => { + console.log( + `@@error in fetching resource with id: ${partialResource['@id']}`, + err + ); + fallbackResources.push(partialResource); + return; + }) + .process(async partialResource => { + if (partialResource._project) { + const { org, project } = makeOrgProjectTuple( + partialResource._project + ); + + return (await nexus.Resource.get( + org, + project, + encodeURIComponent(partialResource['@id']), + { annotate: true } + )) as Resource; + } + + return partialResource; + }); + return { + ...resultWithPartialResources, + _results: [...expandedResources, ...fallbackResources], + }; + }, + onError: error => { + notification.error({ + message: 'Error loading data from the server', + description: isString(error) ? ( + error + ) : isObject(error) ? ( +
+ {(error as any)['@type']} +
{(error as any)['details']}
+
+ ) : ( + '' + ), + }); + }, + staleTime: Infinity, + }); +}; export const useAggregations = ( bucketName: 'projects' | 'types', @@ -24,9 +104,61 @@ export const useAggregations = ( onError: error => { notification.error({ message: 'Aggregations could not be fetched' }); }, + staleTime: Infinity, }); }; +export const sortColumns = (a: string, b: string) => { + // Sorts paths alphabetically. Additionally all paths starting with an underscore are sorted at the end of the list (because they represent metadata). + const columnA = columnFromPath(a); + const columnB = columnFromPath(b); + + if (!isUserColumn(columnA) && !isUserColumn(columnB)) { + return a.localeCompare(b); + } + if (!isUserColumn(columnA)) { + return 1; + } + if (!isUserColumn(columnB)) { + return -1; + } + // Neither a, nor b are userColumns. Now, we want to "ALWAYS_SORTED_COLUMNS" to appear below other user defined columns like "contributions" + if (ALWAYS_DISPLAYED_COLUMNS.has(a) && ALWAYS_DISPLAYED_COLUMNS.has(b)) { + return a.localeCompare(b); + } + if (ALWAYS_DISPLAYED_COLUMNS.has(a)) { + return 1; + } + if (ALWAYS_DISPLAYED_COLUMNS.has(b)) { + return -1; + } + return a.localeCompare(b); +}; + +export const columnFromPath = (path: string | null) => + path?.split('.')[0] ?? ''; + +export const isUserColumn = (colName: string) => { + return ALWAYS_DISPLAYED_COLUMNS.has(colName) || !isNexusMetadata(colName); +}; + +export const ALWAYS_DISPLAYED_COLUMNS = new Set([ + '_project', + '_createdAt', + '_updatedAt', +]); + +const UNDERSCORE = '_'; + +const METADATA_COLUMNS = new Set(['@id', '@context']); + +export const isNexusMetadata = (colName: string) => + METADATA_COLUMNS.has(colName) || colName.startsWith(UNDERSCORE); + +export const isObject = (value: any) => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + export interface AggregationsResult { '@context': string; total: number; @@ -43,3 +175,10 @@ export type AggregatedProperty = { }; export type AggregatedBucket = { key: string; doc_count: number }; + +interface PaginatedResourcesParams { + pageSize: number; + offset: number; + orgAndProject?: string[]; + type?: string; +} diff --git a/src/subapps/dataExplorer/PredicateSelector.tsx b/src/subapps/dataExplorer/PredicateSelector.tsx index 5d8b9cc47..6208c688c 100644 --- a/src/subapps/dataExplorer/PredicateSelector.tsx +++ b/src/subapps/dataExplorer/PredicateSelector.tsx @@ -1,10 +1,18 @@ -import { Input, Select } from 'antd'; -import React, { useState } from 'react'; -import { DataExplorerConfiguration, isObject } from './DataExplorer'; -import './styles.less'; +import { UndoOutlined } from '@ant-design/icons'; import { Resource } from '@bbp/nexus-sdk'; +import { Button, Form, Input, Select } from 'antd'; +import { FormInstance } from 'antd/es/form'; +import { DefaultOptionType } from 'antd/lib/cascader'; +import React, { useMemo, useRef } from 'react'; import { normalizeString } from '../../utils/stringUtils'; -import { clsx } from 'clsx'; +import { DataExplorerConfiguration } from './DataExplorer'; +import { + columnFromPath, + isObject, + isUserColumn, + sortColumns, +} from './DataExplorerUtils'; +import './styles.less'; interface Props { dataSource: Resource[]; @@ -15,138 +23,204 @@ export const PredicateSelector: React.FC = ({ dataSource, onPredicateChange, }: Props) => { - const [path, setPath] = useState(DEFAULT_OPTION); - - const [predicate, setPredicate] = useState( - DEFAULT_OPTION - ); - const [searchTerm, setSearchTerm] = useState(null); + const formRef = useRef(null); - const pathOptions = [ - { value: DEFAULT_OPTION }, - ...getAllPaths(dataSource).map(path => ({ value: path })), - ]; const predicateFilterOptions: PredicateFilterOptions[] = [ - { value: DEFAULT_OPTION }, { value: EXISTS }, { value: DOES_NOT_EXIST }, { value: CONTAINS }, { value: DOES_NOT_CONTAIN }, ]; + const allPathOptions = useMemo( + () => pathOptions([...getAllPaths(dataSource)]), + [dataSource] + ); + const predicateSelected = ( path: string, - predicate: PredicateFilterOptions['value'], + predicate: PredicateFilterOptions['value'] | null, searchTerm: string | null ) => { - if (path === DEFAULT_OPTION || predicate === DEFAULT_OPTION) { - onPredicateChange({ predicateFilter: null }); + if (!path || !predicate) { + onPredicateChange({ predicate: null, selectedPath: null }); } switch (predicate) { case EXISTS: onPredicateChange({ - predicateFilter: (resource: Resource) => + predicate: (resource: Resource) => checkPathExistence(resource, path, 'exists'), + selectedPath: path, }); break; case DOES_NOT_EXIST: onPredicateChange({ - predicateFilter: (resource: Resource) => + predicate: (resource: Resource) => checkPathExistence(resource, path, 'does-not-exist'), + selectedPath: path, }); break; case CONTAINS: if (searchTerm) { onPredicateChange({ - predicateFilter: (resource: Resource) => + predicate: (resource: Resource) => doesResourceContain(resource, path, searchTerm, 'contains'), + selectedPath: path, }); } else { - onPredicateChange({ predicateFilter: null }); + onPredicateChange({ predicate: null, selectedPath: null }); } break; case DOES_NOT_CONTAIN: if (searchTerm) { onPredicateChange({ - predicateFilter: (resource: Resource) => + predicate: (resource: Resource) => doesResourceContain( resource, path, searchTerm, 'does-not-contain' ), + selectedPath: path, }); } else { - onPredicateChange({ predicateFilter: null }); + onPredicateChange({ predicate: null, selectedPath: null }); } break; default: - onPredicateChange({ predicateFilter: null }); + onPredicateChange({ predicate: null, selectedPath: null }); + } + }; + + const getFormFieldValue = (fieldName: string) => { + return formRef.current?.getFieldValue(fieldName) ?? ''; + }; + + const setFormField = (fieldName: string, fieldValue: string) => { + if (formRef.current) { + formRef.current.setFieldValue(fieldName, fieldValue); } }; + const onReset = () => { + const form = formRef.current; + if (form) { + form.resetFields(); + } + + onPredicateChange({ predicate: null, selectedPath: null }); + }; + const shouldShowValueInput = - predicate === CONTAINS || predicate === DOES_NOT_CONTAIN; + getFormFieldValue(PREDICATE_FIELD) === CONTAINS || + getFormFieldValue(PREDICATE_FIELD) === DOES_NOT_CONTAIN; return ( -
+
with - { - setPredicate(predicateLabel); - predicateSelected(path, predicateLabel, searchTerm); - }} - aria-label="predicate-selector" - className={clsx('select-menu', shouldShowValueInput && 'greyed-out')} - popupClassName="search-menu" - allowClear={true} - onClear={() => { - setPredicate(DEFAULT_OPTION); - predicateSelected(path, DEFAULT_OPTION, searchTerm); - }} - /> - - {shouldShowValueInput && ( - { - setSearchTerm(event.target.value); - predicateSelected(path, predicate, event.target.value); + + { + setFormField(PREDICATE_FIELD, predicateLabel); + predicateSelected( + getFormFieldValue(PATH_FIELD), + predicateLabel, + getFormFieldValue(SEARCH_TERM_FIELD) + ); + }} + aria-label="predicate-selector" + className="select-menu reduced-width" + popupClassName="search-menu" + autoFocus={true} + allowClear={true} + onClear={() => { + predicateSelected( + getFormFieldValue(PATH_FIELD), + null, + getFormFieldValue(SEARCH_TERM_FIELD) + ); + }} + /> + + + )} + + {shouldShowValueInput && ( + + { + const term = event.target.value; + setFormField(SEARCH_TERM_FIELD, term); + predicateSelected( + getFormFieldValue(PATH_FIELD), + getFormFieldValue(PREDICATE_FIELD), + term + ); + }} + /> + )} -
+ + + ); }; -export const DEFAULT_OPTION = '-'; export const DOES_NOT_EXIST = 'Does not exist'; export const EXISTS = 'Exists'; export const CONTAINS = 'Contains'; export const DOES_NOT_CONTAIN = 'Does not contain'; +const PATH_FIELD = 'path'; +const PREDICATE_FIELD = 'predicate'; +const SEARCH_TERM_FIELD = 'searchTerm'; + export type PredicateFilterT = | typeof DOES_NOT_EXIST | typeof EXISTS @@ -155,32 +229,39 @@ export type PredicateFilterT = | null; type PredicateFilterOptions = { - value: Exclude | typeof DEFAULT_OPTION; + value: Exclude; }; -export const pathOptions = (paths: string[]) => [ - { value: DEFAULT_OPTION }, - paths.map(path => ({ value: path })), -]; +// Creates