@squiz/resource-browser 1.68.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @squiz/resource-browser
2
2
 
3
+ ## 1.69.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b642152: updates to source list node selection
8
+
9
+ ## 1.68.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 7c324d7: Fixed useChildResources to ignore previous data (race condition bug)
14
+ - Updated dependencies [7c324d7]
15
+ - @squiz/generic-browser-lib@1.67.0
16
+
3
17
  ## 1.68.0
4
18
 
5
19
  ### Minor Changes
@@ -8,5 +8,6 @@ const generic_browser_lib_1 = require("@squiz/generic-browser-lib");
8
8
  const useChildResources = ({ source, currentResource, onRequestChildren }) => (0, generic_browser_lib_1.useAsync)({
9
9
  callback: () => (source ? onRequestChildren(source.source, currentResource || source.resource) : []),
10
10
  defaultValue: [],
11
+ ignorePrevious: true,
11
12
  }, [source, currentResource]);
12
13
  exports.useChildResources = useChildResources;
@@ -14,9 +14,14 @@ const usePreselectedResourcePath = ({ sourceId, resource, onRequestResource, onR
14
14
  }
15
15
  if (sourceId && source && resource) {
16
16
  const bestMatchLineage = (0, findBestMatchLineage_1.findBestMatchLineage)(source, resource);
17
- path = await Promise.all(bestMatchLineage.map(async (resourceId) => {
18
- return onRequestResource({ source: sourceId, resource: resourceId });
19
- }));
17
+ if (Array.isArray(bestMatchLineage) && bestMatchLineage.length > 0) {
18
+ path = await Promise.all(bestMatchLineage.map(async (resourceId) => {
19
+ return onRequestResource({ source: sourceId, resource: resourceId });
20
+ }));
21
+ }
22
+ else {
23
+ path = [resource];
24
+ }
20
25
  }
21
26
  return { source, path };
22
27
  },
@@ -40,6 +40,7 @@ const usePreselectedResourcePath_1 = require("../Hooks/usePreselectedResourcePat
40
40
  const useRecentLocations_1 = require("../Hooks/useRecentLocations");
41
41
  function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onRequestSources, onRequestResource, onRequestChildren, onChange, onClose, preselectedSourceId, preselectedResource, }) {
42
42
  const previewModalState = (0, react_stately_1.useOverlayTriggerState)({});
43
+ const [selectedSource, setSelectedSource] = (0, react_1.useState)(null);
43
44
  const [selectedResourceId, setSelectedResourceId] = (0, react_1.useState)(null);
44
45
  const [previewModalOverlayProps, setPreviewModalOverlayProps] = (0, react_1.useState)({});
45
46
  const { source, currentResource, hierarchy, setSource, push, popUntil } = (0, useResourcePath_1.useResourcePath)();
@@ -52,43 +53,63 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
52
53
  onRequestResource,
53
54
  onRequestSources,
54
55
  });
55
- const selectedResource = (0, react_1.useMemo)(() => resources.find((resource) => resource.id === selectedResourceId) || null, [selectedResourceId, resources]);
56
+ const selectedResource = (0, react_1.useMemo)(() => {
57
+ if (selectedSource) {
58
+ return selectedSource?.nodes.find((resource) => resource.id === selectedResourceId) || null;
59
+ }
60
+ return resources.find((resource) => resource.id === selectedResourceId) || null;
61
+ }, [selectedResourceId, resources, selectedSource]);
56
62
  const handleResourceDrillDown = (0, react_1.useCallback)((resource) => {
57
63
  push(resource);
58
64
  }, [push]);
59
65
  const handleResourceSelected = (0, react_1.useCallback)((resource, overlayProps) => {
60
66
  setPreviewModalOverlayProps(overlayProps);
67
+ setSelectedSource(null);
61
68
  setSelectedResourceId(resource.id);
62
69
  }, []);
63
70
  const handleSourceDrilldown = (0, react_1.useCallback)((source) => {
71
+ setSelectedSource(null);
72
+ setSelectedResourceId(null);
64
73
  setSource(source);
65
74
  }, [setSource]);
75
+ const handleSourceSelected = (0, react_1.useCallback)((node, overlayProps) => {
76
+ const { source, resource } = node;
77
+ setPreviewModalOverlayProps(overlayProps);
78
+ setSelectedSource(source || null);
79
+ setSelectedResourceId(resource?.id || null);
80
+ }, []);
66
81
  const handleReturnToRoot = (0, react_1.useCallback)(() => {
82
+ setSelectedSource(null);
83
+ setSelectedResourceId(null);
67
84
  setSource(null);
68
85
  }, [setSource]);
69
86
  const handleDetailSelect = (0, react_1.useCallback)((resource) => {
70
- onChange({ resource, source: source?.source });
87
+ onChange({ resource, source: selectedSource ?? source?.source });
71
88
  // Find the path that got them to where they are
72
89
  const selectedPath = hierarchy.map((path) => {
73
90
  const pathNode = path.node;
74
91
  return 'resource' in pathNode ? pathNode.resource : pathNode;
75
92
  });
76
93
  const [rootNode, ...path] = selectedPath;
77
- // Update the recent locations in local storage
78
- addRecentLocation({
79
- rootNode,
80
- path: path,
81
- source: source?.source,
82
- });
94
+ if (rootNode) {
95
+ // Update the recent locations in local storage
96
+ addRecentLocation({
97
+ rootNode,
98
+ path: path,
99
+ source: selectedSource ?? source?.source,
100
+ });
101
+ }
83
102
  onClose();
84
- }, [source, currentResource]);
103
+ }, [selectedSource, source, currentResource]);
85
104
  const handleDetailClose = (0, react_1.useCallback)(() => {
105
+ setSelectedSource(null);
86
106
  setSelectedResourceId(null);
87
107
  }, []);
88
108
  // Clear the selected resource if it no longer exists in the list of resources
89
109
  // (eg. due to navigating up/down the tree).
90
110
  (0, react_1.useEffect)(() => {
91
111
  if (resources.length > 0 && selectedResourceId && !selectedResource) {
112
+ setSelectedSource(null);
92
113
  setSelectedResourceId(null);
93
114
  }
94
115
  }, [resources, selectedResourceId, selectedResource]);
@@ -96,13 +117,17 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
96
117
  if (preselectedSource && preselectedPath?.length) {
97
118
  const [rootNode, ...path] = preselectedPath;
98
119
  const leaf = path.pop();
99
- setSource({
100
- source: preselectedSource,
101
- resource: rootNode,
102
- }, path);
103
120
  if (leaf) {
121
+ setSource({
122
+ source: preselectedSource,
123
+ resource: rootNode,
124
+ }, path);
104
125
  setSelectedResourceId(leaf.id);
105
126
  }
127
+ else {
128
+ setSelectedSource(preselectedSource);
129
+ setSelectedResourceId(rootNode.id);
130
+ }
106
131
  }
107
132
  }, [preselectedSource, preselectedSource]);
108
133
  return (react_1.default.createElement("div", { className: "relative flex flex-col h-full text-gray-800" },
@@ -117,7 +142,7 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
117
142
  react_1.default.createElement("div", { className: "overflow-y-scroll flex-1 grow-[3] border-r border-gray-300" },
118
143
  react_1.default.createElement("h3", { className: "sr-only" }, "Resource List"),
119
144
  hierarchy.length > 0 && (react_1.default.createElement(ResourceBreadcrumb_1.default, { hierarchy: hierarchy, onBreadcrumbSelect: popUntil, onReturnToRoot: handleReturnToRoot })),
120
- !source && (react_1.default.createElement(SourceList_1.default, { sources: sources, previewModalState: previewModalState, isLoading: isSourceLoading || isPreselectedResourcePathLoading, onSourceSelect: handleSourceDrilldown, handleReload: handleSourceReload, setSource: setSource, error: sourceError })),
145
+ !source && (react_1.default.createElement(SourceList_1.default, { sources: sources, selectedResource: selectedResource, previewModalState: previewModalState, isLoading: isSourceLoading || isPreselectedResourcePathLoading, onSourceSelect: handleSourceSelected, onSourceDrilldown: handleSourceDrilldown, handleReload: handleSourceReload, setSource: setSource, error: sourceError })),
121
146
  source && (react_1.default.createElement(ResourceList_1.default, { previewModalState: previewModalState, resources: resources, selectedResource: selectedResource, isLoading: isResourcesLoading, onResourceSelect: handleResourceSelected, onResourceDrillDown: handleResourceDrillDown, allowedTypes: allowedTypes, handleReturnToRoot: handleReturnToRoot, handleReload: handleResourceReload, error: resourceError }))),
122
147
  react_1.default.createElement("div", { className: "sm:overflow-y-scroll sm:flex-1 sm:grow-[2] bg-white" },
123
148
  react_1.default.createElement(PreviewPanel_1.default, { resource: isResourcesLoading ? null : selectedResource, modalState: previewModalState, previewModalOverlayProps: previewModalOverlayProps, allowedTypes: allowedTypes, onSelect: handleDetailSelect, onClose: handleDetailClose })))));
@@ -4,12 +4,14 @@ import { DOMAttributes, FocusableElement } from '@react-types/shared';
4
4
  import { Source, ScopedSource, Resource } from '../types';
5
5
  export interface SourceListProps {
6
6
  sources: Source[];
7
+ selectedResource?: Resource | null;
7
8
  previewModalState: OverlayTriggerState;
8
9
  isLoading: boolean;
9
10
  onSourceSelect: (node: ScopedSource, overlayProps: DOMAttributes<FocusableElement>) => void;
11
+ onSourceDrilldown: (source: ScopedSource) => void;
10
12
  handleReload: () => void;
11
13
  setSource: (source: ScopedSource | null, path?: Resource[]) => void;
12
14
  error: Error | null;
13
15
  }
14
- declare const SourceList: ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, setSource, error, }: SourceListProps) => React.JSX.Element;
16
+ declare const SourceList: ({ sources, selectedResource, previewModalState, isLoading, onSourceSelect, onSourceDrilldown, handleReload, setSource, error, }: SourceListProps) => React.JSX.Element;
15
17
  export default SourceList;
@@ -32,7 +32,7 @@ const clsx_1 = __importDefault(require("clsx"));
32
32
  const useCategorisedSources_1 = require("../Hooks/useCategorisedSources");
33
33
  const useRecentLocations_1 = require("../Hooks/useRecentLocations");
34
34
  const HistoryIcon_1 = require("../Icons/HistoryIcon");
35
- const SourceList = function ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, setSource, error, }) {
35
+ const SourceList = function ({ sources, selectedResource, previewModalState, isLoading, onSourceSelect, onSourceDrilldown, handleReload, setSource, error, }) {
36
36
  const categorisedSources = (0, useCategorisedSources_1.useCategorisedSources)(sources);
37
37
  const listRef = (0, react_1.useRef)(null);
38
38
  const { recentLocations } = (0, useRecentLocations_1.useRecentLocations)();
@@ -70,7 +70,10 @@ const SourceList = function ({ sources, previewModalState, isLoading, onSourceSe
70
70
  react_1.default.createElement("div", { className: "relative flex justify-center before:w-full before:h-px before:bg-gray-300 before:absolute before:top-2/4 before:z-0" },
71
71
  react_1.default.createElement("span", { className: "z-10 bg-gray-100 px-2.5" }, label)),
72
72
  sources.length > 0 && (react_1.default.createElement("ul", { "aria-label": `${label} nodes`, className: "flex flex-col" }, sources.map(({ source, resource }) => {
73
- return (react_1.default.createElement(generic_browser_lib_1.ResourceItem, { key: `${source.id}-${resource?.id}`, item: { source, resource }, label: resource?.name || source.name, type: resource?.type.code || 'folder', previewModalState: previewModalState, onSelect: onSourceSelect, className: "mt-3 rounded-lg", showChevron: true }));
73
+ if (!resource || resource.childCount === 0) {
74
+ return (react_1.default.createElement(generic_browser_lib_1.ResourceItem, { key: `${source.id}-${resource?.id}`, item: { source, resource }, label: resource?.name || source.name, type: resource?.type.code || 'folder', previewModalState: previewModalState, onSelect: onSourceDrilldown, className: "mt-3 rounded-lg", showChevron: true }));
75
+ }
76
+ return (react_1.default.createElement(generic_browser_lib_1.ResourceItem, { key: `${source.id}-${resource?.id}`, item: { source, resource }, selected: resource?.id == selectedResource?.id && resource != null, label: resource?.name || source.name, type: resource?.type.code || 'folder', childCount: resource?.childCount || undefined, previewModalState: previewModalState, onSelect: onSourceSelect, onDrillDown: onSourceDrilldown, className: "mt-3 rounded-lg", showChevron: true }));
74
77
  })))));
75
78
  })));
76
79
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/resource-browser",
3
- "version": "1.68.0",
3
+ "version": "1.69.0",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "private": false,
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@mui/icons-material": "5.11.16",
22
22
  "@squiz/dx-json-schema-lib": "^1.67.0",
23
- "@squiz/generic-browser-lib": "^1.66.0",
23
+ "@squiz/generic-browser-lib": "^1.67.0",
24
24
  "expiry-map": "^2.0.0",
25
25
  "p-memoize": "^4.0.4",
26
26
  "pretty-bytes": "5.6.0",
@@ -15,6 +15,7 @@ export const useChildResources = ({ source, currentResource, onRequestChildren }
15
15
  {
16
16
  callback: () => (source ? onRequestChildren(source.source, currentResource || source.resource) : []),
17
17
  defaultValue: [],
18
+ ignorePrevious: true,
18
19
  },
19
20
  [source, currentResource],
20
21
  );
@@ -34,11 +34,15 @@ export const usePreselectedResourcePath = ({
34
34
  if (sourceId && source && resource) {
35
35
  const bestMatchLineage = findBestMatchLineage(source, resource);
36
36
 
37
- path = await Promise.all(
38
- bestMatchLineage.map(async (resourceId) => {
39
- return onRequestResource({ source: sourceId, resource: resourceId });
40
- }),
41
- );
37
+ if (Array.isArray(bestMatchLineage) && bestMatchLineage.length > 0) {
38
+ path = await Promise.all(
39
+ bestMatchLineage.map(async (resourceId) => {
40
+ return onRequestResource({ source: sourceId, resource: resourceId });
41
+ }),
42
+ );
43
+ } else {
44
+ path = [resource];
45
+ }
42
46
  }
43
47
 
44
48
  return { source, path };
@@ -708,31 +708,58 @@ describe('ResourcePickerContainer', () => {
708
708
  ]);
709
709
  });
710
710
 
711
- it('handleDetailSelect() works', async () => {
712
- const onChangeMock = jest.fn();
713
- const onCloseMock = jest.fn();
714
- const { getAllByText } = render(
715
- <ResourcePickerContainer {...baseProps} onChange={onChangeMock} onClose={onCloseMock} />,
716
- );
711
+ describe('handleDetailSelect() tests', () => {
712
+ it('Source select works', async () => {
713
+ const onChangeMock = jest.fn();
714
+ const onCloseMock = jest.fn();
715
+ const { getAllByText } = render(
716
+ <ResourcePickerContainer {...baseProps} onChange={onChangeMock} onClose={onCloseMock} />,
717
+ );
717
718
 
718
- await waitFor(() => {
719
- expect(getAllByText('Test system')[0]).toBeInTheDocument();
719
+ await waitFor(() => {
720
+ expect(getAllByText('Test system')[0]).toBeInTheDocument();
721
+ });
722
+
723
+ const user = userEvent.setup();
724
+
725
+ // Select the resource
726
+ user.click(screen.getByRole('button', { name: 'site Test Website' }));
727
+
728
+ // Wait for the preview panel to open
729
+ await waitFor(() => expect(screen.getByText('Site')).toBeInTheDocument());
730
+ await waitFor(() => expect(screen.getByText('#1')).toBeInTheDocument());
731
+
732
+ user.click(screen.getByRole('button', { name: 'Select' }));
733
+
734
+ await waitFor(() => expect(onChangeMock).toHaveBeenCalled());
735
+ await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
720
736
  });
737
+ it('Resource select works', async () => {
738
+ const onChangeMock = jest.fn();
739
+ const onCloseMock = jest.fn();
740
+ const { getAllByText } = render(
741
+ <ResourcePickerContainer {...baseProps} onChange={onChangeMock} onClose={onCloseMock} />,
742
+ );
721
743
 
722
- const user = userEvent.setup();
723
- user.click(screen.getByRole('button', { name: 'Drill down to Test Website children' }));
724
- await waitFor(() => expect(screen.getByRole('button', { name: 'page Test Page' })).toBeInTheDocument());
744
+ await waitFor(() => {
745
+ expect(getAllByText('Test system')[0]).toBeInTheDocument();
746
+ });
725
747
 
726
- // Select the resource
727
- user.click(screen.getByRole('button', { name: 'page Test Page' }));
748
+ const user = userEvent.setup();
749
+ user.click(screen.getByRole('button', { name: 'Drill down to Test Website children' }));
750
+ await waitFor(() => expect(screen.getByRole('button', { name: 'page Test Page' })).toBeInTheDocument());
728
751
 
729
- // Wait for the preview panel to open
730
- await waitFor(() => expect(screen.getByText('Mocked Page')).toBeInTheDocument());
731
- await waitFor(() => expect(screen.getByText('#123')).toBeInTheDocument());
752
+ // Select the resource
753
+ user.click(screen.getByRole('button', { name: 'page Test Page' }));
754
+
755
+ // Wait for the preview panel to open
756
+ await waitFor(() => expect(screen.getByText('Mocked Page')).toBeInTheDocument());
757
+ await waitFor(() => expect(screen.getByText('#123')).toBeInTheDocument());
732
758
 
733
- user.click(screen.getByRole('button', { name: 'Select' }));
759
+ user.click(screen.getByRole('button', { name: 'Select' }));
734
760
 
735
- await waitFor(() => expect(onChangeMock).toHaveBeenCalled());
736
- await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
761
+ await waitFor(() => expect(onChangeMock).toHaveBeenCalled());
762
+ await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
763
+ });
737
764
  });
738
765
  });
@@ -2,13 +2,14 @@ import React from 'react';
2
2
  import { StoryFn, Meta } from '@storybook/react';
3
3
  import ResourcePickerContainer from './ResourcePickerContainer';
4
4
  import { createResourceBrowserCallbacks } from '../__mocks__/StorybookHelpers';
5
+ import sampleSources from '../SourceList/sample-sources.json';
5
6
 
6
7
  export default {
7
8
  title: 'Resource Picker container',
8
9
  component: ResourcePickerContainer,
9
10
  } as Meta<typeof ResourcePickerContainer>;
10
11
 
11
- const Template: StoryFn<typeof ResourcePickerContainer> = ({ title }) => {
12
+ const Template: StoryFn<typeof ResourcePickerContainer> = ({ title, preselectedSourceId, preselectedResource }) => {
12
13
  const { onRequestSources, onRequestChildren, onRequestResource, onChange } = createResourceBrowserCallbacks();
13
14
 
14
15
  return (
@@ -19,6 +20,8 @@ const Template: StoryFn<typeof ResourcePickerContainer> = ({ title }) => {
19
20
  onRequestSources={onRequestSources}
20
21
  onRequestChildren={onRequestChildren}
21
22
  onRequestResource={onRequestResource}
23
+ preselectedSourceId={preselectedSourceId}
24
+ preselectedResource={preselectedResource}
22
25
  onChange={onChange}
23
26
  onClose={() => {
24
27
  alert('Resource Browser closed');
@@ -32,3 +35,11 @@ export const Primary = Template.bind({});
32
35
  Primary.args = {
33
36
  title: 'Asset Picker',
34
37
  };
38
+
39
+ export const SourceListResourceSelected = Template.bind({});
40
+ // SourceList root node is selected
41
+ SourceListResourceSelected.args = {
42
+ title: 'Asset Picker',
43
+ preselectedSourceId: sampleSources[0].id,
44
+ preselectedResource: sampleSources[0].nodes[1],
45
+ };
@@ -47,6 +47,7 @@ function ResourcePickerContainer({
47
47
  preselectedResource,
48
48
  }: ResourcePickerContainerProps) {
49
49
  const previewModalState = useOverlayTriggerState({});
50
+ const [selectedSource, setSelectedSource] = useState<Source | null>(null);
50
51
  const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
51
52
  const [previewModalOverlayProps, setPreviewModalOverlayProps] = useState<DOMAttributes>({});
52
53
  const { source, currentResource, hierarchy, setSource, push, popUntil } = useResourcePath();
@@ -73,10 +74,12 @@ function ResourcePickerContainer({
73
74
  onRequestResource,
74
75
  onRequestSources,
75
76
  });
76
- const selectedResource = useMemo(
77
- () => resources.find((resource) => resource.id === selectedResourceId) || null,
78
- [selectedResourceId, resources],
79
- );
77
+ const selectedResource = useMemo(() => {
78
+ if (selectedSource) {
79
+ return selectedSource?.nodes.find((resource: Resource) => resource.id === selectedResourceId) || null;
80
+ }
81
+ return resources.find((resource: Resource) => resource.id === selectedResourceId) || null;
82
+ }, [selectedResourceId, resources, selectedSource]);
80
83
 
81
84
  const handleResourceDrillDown = useCallback(
82
85
  (resource: Resource) => {
@@ -87,23 +90,35 @@ function ResourcePickerContainer({
87
90
 
88
91
  const handleResourceSelected = useCallback((resource: Resource, overlayProps: DOMAttributes) => {
89
92
  setPreviewModalOverlayProps(overlayProps);
93
+ setSelectedSource(null);
90
94
  setSelectedResourceId(resource.id);
91
95
  }, []);
92
96
 
93
97
  const handleSourceDrilldown = useCallback(
94
98
  (source: ScopedSource) => {
99
+ setSelectedSource(null);
100
+ setSelectedResourceId(null);
95
101
  setSource(source);
96
102
  },
97
103
  [setSource],
98
104
  );
99
105
 
106
+ const handleSourceSelected = useCallback((node: ScopedSource, overlayProps: DOMAttributes) => {
107
+ const { source, resource } = node;
108
+ setPreviewModalOverlayProps(overlayProps);
109
+ setSelectedSource(source || null);
110
+ setSelectedResourceId(resource?.id || null);
111
+ }, []);
112
+
100
113
  const handleReturnToRoot = useCallback(() => {
114
+ setSelectedSource(null);
115
+ setSelectedResourceId(null);
101
116
  setSource(null);
102
117
  }, [setSource]);
103
118
 
104
119
  const handleDetailSelect = useCallback(
105
120
  (resource: Resource) => {
106
- onChange({ resource, source: source?.source as Source });
121
+ onChange({ resource, source: selectedSource ?? (source?.source as Source) });
107
122
 
108
123
  // Find the path that got them to where they are
109
124
  const selectedPath = hierarchy.map((path) => {
@@ -113,19 +128,22 @@ function ResourcePickerContainer({
113
128
 
114
129
  const [rootNode, ...path] = selectedPath;
115
130
 
116
- // Update the recent locations in local storage
117
- addRecentLocation({
118
- rootNode,
119
- path: path as Resource[],
120
- source: source?.source as Source,
121
- });
131
+ if (rootNode) {
132
+ // Update the recent locations in local storage
133
+ addRecentLocation({
134
+ rootNode,
135
+ path: path as Resource[],
136
+ source: selectedSource ?? (source?.source as Source),
137
+ });
138
+ }
122
139
 
123
140
  onClose();
124
141
  },
125
- [source, currentResource],
142
+ [selectedSource, source, currentResource],
126
143
  );
127
144
 
128
145
  const handleDetailClose = useCallback(() => {
146
+ setSelectedSource(null);
129
147
  setSelectedResourceId(null);
130
148
  }, []);
131
149
 
@@ -133,6 +151,7 @@ function ResourcePickerContainer({
133
151
  // (eg. due to navigating up/down the tree).
134
152
  useEffect(() => {
135
153
  if (resources.length > 0 && selectedResourceId && !selectedResource) {
154
+ setSelectedSource(null);
136
155
  setSelectedResourceId(null);
137
156
  }
138
157
  }, [resources, selectedResourceId, selectedResource]);
@@ -142,16 +161,19 @@ function ResourcePickerContainer({
142
161
  const [rootNode, ...path] = preselectedPath;
143
162
  const leaf = path.pop();
144
163
 
145
- setSource(
146
- {
147
- source: preselectedSource,
148
- resource: rootNode,
149
- },
150
- path,
151
- );
152
-
153
164
  if (leaf) {
165
+ setSource(
166
+ {
167
+ source: preselectedSource,
168
+ resource: rootNode,
169
+ },
170
+ path,
171
+ );
172
+
154
173
  setSelectedResourceId(leaf.id);
174
+ } else {
175
+ setSelectedSource(preselectedSource);
176
+ setSelectedResourceId(rootNode.id);
155
177
  }
156
178
  }
157
179
  }, [preselectedSource, preselectedSource]);
@@ -200,9 +222,11 @@ function ResourcePickerContainer({
200
222
  {!source && (
201
223
  <SourceList
202
224
  sources={sources}
225
+ selectedResource={selectedResource}
203
226
  previewModalState={previewModalState}
204
227
  isLoading={isSourceLoading || isPreselectedResourcePathLoading}
205
- onSourceSelect={handleSourceDrilldown}
228
+ onSourceSelect={handleSourceSelected}
229
+ onSourceDrilldown={handleSourceDrilldown}
206
230
  handleReload={handleSourceReload}
207
231
  setSource={setSource}
208
232
  error={sourceError}
@@ -107,6 +107,7 @@ describe('SourceList', () => {
107
107
  previewModalState={previewModalState}
108
108
  isLoading={true}
109
109
  onSourceSelect={() => {}}
110
+ onSourceDrilldown={() => {}}
110
111
  error={null}
111
112
  handleReload={reload}
112
113
  setSource={() => {}}
@@ -133,6 +134,7 @@ describe('SourceList', () => {
133
134
  previewModalState={previewModalState}
134
135
  isLoading={false}
135
136
  onSourceSelect={() => {}}
137
+ onSourceDrilldown={() => {}}
136
138
  error={null}
137
139
  handleReload={reload}
138
140
  setSource={() => {}}
@@ -160,6 +162,7 @@ describe('SourceList', () => {
160
162
  previewModalState={previewModalState}
161
163
  isLoading={false}
162
164
  onSourceSelect={() => {}}
165
+ onSourceDrilldown={() => {}}
163
166
  error={null}
164
167
  handleReload={reload}
165
168
  setSource={() => {}}
@@ -186,6 +189,7 @@ describe('SourceList', () => {
186
189
  previewModalState={previewModalState}
187
190
  isLoading={false}
188
191
  onSourceSelect={() => {}}
192
+ onSourceDrilldown={() => {}}
189
193
  error={null}
190
194
  handleReload={reload}
191
195
  setSource={() => {}}
@@ -219,6 +223,7 @@ describe('SourceList', () => {
219
223
  previewModalState={previewModalState}
220
224
  isLoading={false}
221
225
  onSourceSelect={onSourceSelect}
226
+ onSourceDrilldown={() => {}}
222
227
  error={null}
223
228
  handleReload={reload}
224
229
  setSource={() => {}}
@@ -229,7 +234,7 @@ describe('SourceList', () => {
229
234
  );
230
235
 
231
236
  const user = userEvent.setup();
232
- const itemButton = screen.getByRole('button', { name: 'Drill down to Node 1 children' });
237
+ const itemButton = screen.getByRole('button', { name: 'site Node 1' });
233
238
  user.click(itemButton);
234
239
 
235
240
  await waitFor(() => {
@@ -244,6 +249,42 @@ describe('SourceList', () => {
244
249
  });
245
250
  });
246
251
 
252
+ it('Clicking node child count button triggers correct onSourceDrilldown', async () => {
253
+ const onSourceDrilldown = jest.fn();
254
+ const reload = jest.fn();
255
+
256
+ render(
257
+ <SourceListTestWrapper
258
+ constructFunction={(previewModalState) => {
259
+ return (
260
+ <SourceList
261
+ sources={sources}
262
+ previewModalState={previewModalState}
263
+ isLoading={false}
264
+ onSourceSelect={() => {}}
265
+ onSourceDrilldown={onSourceDrilldown}
266
+ error={null}
267
+ handleReload={reload}
268
+ setSource={() => {}}
269
+ />
270
+ );
271
+ }}
272
+ />,
273
+ );
274
+
275
+ const user = userEvent.setup();
276
+ const itemButton = screen.getByRole('button', { name: 'Drill down to Node 1 children' });
277
+ user.click(itemButton);
278
+
279
+ await waitFor(() => {
280
+ // Provides the item that was clicked and an id reference to the button that was clicked
281
+ expect(onSourceDrilldown).toHaveBeenCalledWith({
282
+ source: sources[0],
283
+ resource: sources[0].nodes[0],
284
+ });
285
+ });
286
+ });
287
+
247
288
  it('Renders error state when an error occurs loading source list', async () => {
248
289
  const reload = jest.fn();
249
290
 
@@ -256,6 +297,7 @@ describe('SourceList', () => {
256
297
  previewModalState={previewModalState}
257
298
  isLoading={false}
258
299
  onSourceSelect={() => {}}
300
+ onSourceDrilldown={() => {}}
259
301
  error={new Error('Source list error!')}
260
302
  handleReload={reload}
261
303
  setSource={() => {}}
@@ -286,6 +328,7 @@ describe('SourceList', () => {
286
328
  previewModalState={previewModalState}
287
329
  isLoading={false}
288
330
  onSourceSelect={() => {}}
331
+ onSourceDrilldown={() => {}}
289
332
  error={null}
290
333
  handleReload={reload}
291
334
  setSource={setSource}
@@ -316,6 +359,7 @@ describe('SourceList', () => {
316
359
  previewModalState={previewModalState}
317
360
  isLoading={false}
318
361
  onSourceSelect={() => {}}
362
+ onSourceDrilldown={() => {}}
319
363
  error={null}
320
364
  handleReload={reload}
321
365
  setSource={setSource}
@@ -369,6 +413,7 @@ describe('SourceList', () => {
369
413
  previewModalState={previewModalState}
370
414
  isLoading={false}
371
415
  onSourceSelect={() => {}}
416
+ onSourceDrilldown={() => {}}
372
417
  error={null}
373
418
  handleReload={reload}
374
419
  setSource={setSource}
@@ -10,7 +10,7 @@ export default {
10
10
  component: SourceList,
11
11
  } as Meta<typeof SourceList>;
12
12
 
13
- const Template: StoryFn<typeof SourceList> = ({ sources, isLoading, allowedTypes }) => {
13
+ const Template: StoryFn<typeof SourceList> = ({ sources, isLoading }) => {
14
14
  const previewModalState = useOverlayTriggerState({});
15
15
 
16
16
  return (
@@ -18,9 +18,19 @@ const Template: StoryFn<typeof SourceList> = ({ sources, isLoading, allowedTypes
18
18
  sources={sources}
19
19
  previewModalState={previewModalState}
20
20
  isLoading={isLoading}
21
- onSourceSelect={({ source, id }) => alert(`Source Select: ${source} - ${id}`)}
22
- onSourceDrillDown={({ source, id }) => alert(`Child Drill Down: ${source} - ${id}`)}
23
- allowedTypes={allowedTypes}
21
+ onSourceSelect={({ source, resource }) =>
22
+ alert(`Resource Select: ${source.name} - ${resource?.name}[${resource?.id}]`)
23
+ }
24
+ onSourceDrilldown={({ source, resource }) =>
25
+ alert(`Child Drill Down: ${source.name} - ${resource?.name}[${resource?.id}]`)
26
+ }
27
+ handleReload={() => {
28
+ console.log('Handle reload');
29
+ }}
30
+ setSource={() => {
31
+ alert(`Recent Location Selected`);
32
+ }}
33
+ error={null}
24
34
  />
25
35
  );
26
36
  };
@@ -29,12 +39,10 @@ export const Primary = Template.bind({});
29
39
  Primary.args = {
30
40
  sources: sampleSources,
31
41
  isLoading: false,
32
- allowedTypes: ['site', 'image'],
33
42
  };
34
43
 
35
44
  export const Loading = Template.bind({});
36
45
  Loading.args = {
37
46
  ...Primary.args,
38
47
  isLoading: true,
39
- allowedTypes: ['site', 'image'],
40
48
  };
@@ -11,9 +11,11 @@ import { HistoryIcon } from '../Icons/HistoryIcon';
11
11
 
12
12
  export interface SourceListProps {
13
13
  sources: Source[];
14
+ selectedResource?: Resource | null;
14
15
  previewModalState: OverlayTriggerState;
15
16
  isLoading: boolean;
16
17
  onSourceSelect: (node: ScopedSource, overlayProps: DOMAttributes<FocusableElement>) => void;
18
+ onSourceDrilldown: (source: ScopedSource) => void;
17
19
  handleReload: () => void;
18
20
  setSource: (source: ScopedSource | null, path?: Resource[]) => void;
19
21
  error: Error | null;
@@ -21,9 +23,11 @@ export interface SourceListProps {
21
23
 
22
24
  const SourceList = function ({
23
25
  sources,
26
+ selectedResource,
24
27
  previewModalState,
25
28
  isLoading,
26
29
  onSourceSelect,
30
+ onSourceDrilldown,
27
31
  handleReload,
28
32
  setSource,
29
33
  error,
@@ -112,14 +116,31 @@ const SourceList = function ({
112
116
  {sources.length > 0 && (
113
117
  <ul aria-label={`${label} nodes`} className="flex flex-col">
114
118
  {sources.map(({ source, resource }) => {
119
+ if (!resource || resource.childCount === 0) {
120
+ return (
121
+ <ResourceItem
122
+ key={`${source.id}-${resource?.id}`}
123
+ item={{ source, resource }}
124
+ label={resource?.name || source.name}
125
+ type={resource?.type.code || 'folder'}
126
+ previewModalState={previewModalState}
127
+ onSelect={onSourceDrilldown}
128
+ className="mt-3 rounded-lg"
129
+ showChevron
130
+ />
131
+ );
132
+ }
115
133
  return (
116
134
  <ResourceItem
117
135
  key={`${source.id}-${resource?.id}`}
118
136
  item={{ source, resource }}
137
+ selected={resource?.id == selectedResource?.id && resource != null}
119
138
  label={resource?.name || source.name}
120
139
  type={resource?.type.code || 'folder'}
140
+ childCount={resource?.childCount || undefined}
121
141
  previewModalState={previewModalState}
122
142
  onSelect={onSourceSelect}
143
+ onDrillDown={onSourceDrilldown}
123
144
  className="mt-3 rounded-lg"
124
145
  showChevron
125
146
  />
@@ -41,6 +41,19 @@
41
41
  },
42
42
  "name": "Images",
43
43
  "childCount": 100
44
+ },
45
+ {
46
+ "id": "9999",
47
+ "type": {
48
+ "code": "site",
49
+ "name": "Site"
50
+ },
51
+ "status": {
52
+ "code": "live",
53
+ "name": "Live"
54
+ },
55
+ "name": "Another very unique id site",
56
+ "childCount": 100
44
57
  }
45
58
  ]
46
59
  },
@@ -49,7 +62,7 @@
49
62
  "name": "Acme internal system",
50
63
  "nodes": [
51
64
  {
52
- "id": "30",
65
+ "id": "32",
53
66
  "type": {
54
67
  "code": "site",
55
68
  "name": "Site"
@@ -62,7 +75,7 @@
62
75
  "childCount": 15
63
76
  },
64
77
  {
65
- "id": "1",
78
+ "id": "33",
66
79
  "type": {
67
80
  "code": "site",
68
81
  "name": "Site"
@@ -215,5 +228,24 @@
215
228
  "id": "30",
216
229
  "name": "Unsplash image library 27",
217
230
  "nodes": []
231
+ },
232
+ {
233
+ "id": "31",
234
+ "name": "Acme no children",
235
+ "nodes": [
236
+ {
237
+ "id": "3",
238
+ "type": {
239
+ "code": "site",
240
+ "name": "Site"
241
+ },
242
+ "status": {
243
+ "code": "live",
244
+ "name": "Live"
245
+ },
246
+ "name": "What Children?",
247
+ "childCount": 0
248
+ }
249
+ ]
218
250
  }
219
251
  ]
@@ -9,6 +9,29 @@ type CreateCallbacksProps = Partial<{
9
9
  error?: string;
10
10
  }>;
11
11
 
12
+ const indexSources = (sources: Source[]) => {
13
+ const indexed: Record<string, Resource> = {};
14
+ const pending: Resource[] = [];
15
+
16
+ sources.forEach((source) => {
17
+ if (source && 'nodes' in source) {
18
+ pending.push(...source.nodes);
19
+ }
20
+ });
21
+
22
+ while (pending.length > 0) {
23
+ const resource = pending.shift();
24
+
25
+ if (!resource) {
26
+ continue;
27
+ }
28
+
29
+ indexed[resource.id] = resource;
30
+ }
31
+
32
+ return indexed;
33
+ };
34
+
12
35
  const indexResources = (resources: Resource[]) => {
13
36
  const indexed: Record<string, Resource> = {};
14
37
  const pending = [...resources];
@@ -36,6 +59,7 @@ export const createResourceBrowserCallbacks = ({
36
59
  resourceIsLoading = false,
37
60
  error,
38
61
  }: CreateCallbacksProps = {}) => {
62
+ const indexSourceResources = indexSources(sampleSources as Source[]);
39
63
  const indexedResources = indexResources(sampleResources as Resource[]);
40
64
 
41
65
  return {
@@ -78,7 +102,12 @@ export const createResourceBrowserCallbacks = ({
78
102
  if (error && Math.random() > 0.5) {
79
103
  reject(new Error(error));
80
104
  } else {
81
- resolve(indexedResources[reference.resource]);
105
+ const foundResource = indexedResources[reference.resource];
106
+ const foundSourceResource = indexSourceResources[reference.resource];
107
+ if (foundResource) {
108
+ resolve(foundResource);
109
+ }
110
+ resolve(foundSourceResource);
82
111
  }
83
112
  }, delay);
84
113
  }
@@ -33,8 +33,14 @@ Primary.args = {
33
33
  error: '',
34
34
  };
35
35
 
36
- export const Selected = Template.bind({});
37
- Selected.args = {
36
+ export const SourceSelected = Template.bind({});
37
+ SourceSelected.args = {
38
+ ...Primary.args,
39
+ value: { resource: '9999', source: '1' },
40
+ };
41
+
42
+ export const ResourceSelected = Template.bind({});
43
+ ResourceSelected.args = {
38
44
  ...Primary.args,
39
45
  value: { resource: '33', source: '1' },
40
46
  };