@squiz/resource-browser 1.66.3 → 1.67.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/Hooks/usePreselectedResourcePath.d.ts +20 -0
  3. package/lib/Hooks/usePreselectedResourcePath.js +26 -0
  4. package/lib/Hooks/useResourcePath.d.ts +1 -1
  5. package/lib/Hooks/useResourcePath.js +2 -2
  6. package/lib/Hooks/useSources.d.ts +14 -0
  7. package/lib/Hooks/useSources.js +9 -0
  8. package/lib/Icons/CircledLoopIcon.d.ts +4 -0
  9. package/lib/Icons/CircledLoopIcon.js +12 -0
  10. package/lib/ResourceBrowserContext/ResourceBrowserContext.d.ts +12 -5
  11. package/lib/ResourceBrowserContext/ResourceBrowserContext.js +52 -2
  12. package/lib/ResourcePicker/ResourcePicker.js +1 -1
  13. package/lib/ResourcePicker/States/Selected.d.ts +2 -1
  14. package/lib/ResourcePicker/States/Selected.js +6 -2
  15. package/lib/ResourcePickerContainer/ResourcePickerContainer.d.ts +7 -4
  16. package/lib/ResourcePickerContainer/ResourcePickerContainer.js +35 -11
  17. package/lib/SourceDropdown/SourceDropdown.js +1 -1
  18. package/lib/index.css +19 -2
  19. package/lib/index.d.ts +2 -2
  20. package/lib/index.js +3 -2
  21. package/lib/types.d.ts +7 -0
  22. package/lib/utils/findBestMatchLineage.d.ts +2 -0
  23. package/lib/utils/findBestMatchLineage.js +28 -0
  24. package/package.json +6 -4
  25. package/src/Hooks/usePreselectedResourcePath.ts +50 -0
  26. package/src/Hooks/useResourcePath.ts +2 -2
  27. package/src/Hooks/useSources.ts +1 -1
  28. package/src/Icons/CircledLoopIcon.tsx +14 -0
  29. package/src/ResourceBrowserContext/ResourceBrowserContext.spec.tsx +93 -3
  30. package/src/ResourceBrowserContext/ResourceBrowserContext.tsx +56 -0
  31. package/src/ResourceList/sample-resources.json +684 -439
  32. package/src/ResourcePicker/ResourcePicker.tsx +8 -1
  33. package/src/ResourcePicker/States/Selected.tsx +23 -3
  34. package/src/ResourcePicker/resource-picker.scss +1 -1
  35. package/src/ResourcePickerContainer/ResourcePickerContainer.spec.tsx +146 -32
  36. package/src/ResourcePickerContainer/ResourcePickerContainer.tsx +64 -18
  37. package/src/SourceDropdown/SourceDropdown.tsx +1 -1
  38. package/src/SourceList/sample-sources.json +4 -4
  39. package/src/__mocks__/MockModels.ts +1 -0
  40. package/src/__mocks__/StorybookHelpers.ts +33 -4
  41. package/src/__mocks__/renderWithContext.tsx +23 -0
  42. package/src/index.spec.tsx +81 -21
  43. package/src/index.stories.tsx +4 -4
  44. package/src/index.tsx +10 -2
  45. package/src/types.ts +9 -0
  46. package/src/utils/findBestMatchLineage.spec.ts +81 -0
  47. package/src/utils/findBestMatchLineage.ts +30 -0
  48. package/src/ResourceBrowserContext/ResourceBrowserContext.ts +0 -20
  49. /package/lib/{uuid.d.ts → utils/uuid.d.ts} +0 -0
  50. /package/lib/{uuid.js → utils/uuid.js} +0 -0
  51. /package/src/{uuid.ts → utils/uuid.ts} +0 -0
@@ -59,7 +59,14 @@ const ResourcePicker = ({
59
59
  <div className="resource-picker-info__layout">
60
60
  {isLoading && <LoadingState />}
61
61
  {error && <ErrorState error={error} isDisabled={isDisabled} onClear={onClear} />}
62
- {resource && <SelectedState resource={resource} isDisabled={isDisabled} onClear={onClear} />}
62
+ {resource && (
63
+ <SelectedState
64
+ resource={resource}
65
+ isDisabled={isDisabled}
66
+ onClear={onClear}
67
+ resourcePickerContainer={children}
68
+ />
69
+ )}
63
70
  </div>
64
71
  </div>
65
72
  )}
@@ -1,24 +1,41 @@
1
1
  import React from 'react';
2
2
  import prettyBytes from 'pretty-bytes';
3
- import { Icon, IconOptions, ResetButton } from '@squiz/generic-browser-lib';
3
+ import { Icon, IconOptions, ModalTrigger, ResetButton } from '@squiz/generic-browser-lib';
4
4
 
5
5
  import { Resource } from '../../types';
6
6
  import StatusIndicator from '../../StatusIndicator/StatusIndicator';
7
+ import { CircledLoopIcon } from '../../Icons/CircledLoopIcon';
7
8
 
8
9
  export type SelectedStateProps = {
9
10
  resource: Resource;
10
11
  isDisabled?: boolean;
11
12
  onClear: () => void;
13
+ resourcePickerContainer: any;
12
14
  };
13
15
 
14
16
  export const SelectedState = ({
15
17
  resource: { id, type, name, status, squizImage, url },
16
18
  isDisabled,
17
19
  onClear,
20
+ resourcePickerContainer,
18
21
  }: SelectedStateProps) => {
19
22
  const fileSize = squizImage?.imageVariations?.original?.byteSize;
20
23
  const fileWidth = squizImage?.imageVariations?.original?.width;
21
24
  const fileHeight = squizImage?.imageVariations?.original?.height;
25
+
26
+ const replaceAsset = (
27
+ <ModalTrigger
28
+ showLabel={false}
29
+ label="Replace selection"
30
+ containerClasses="text-gray-500 hover:text-gray-800 focus:text-gray-800 disabled:text-gray-500 disabled:cursor-not-allowed"
31
+ icon={<CircledLoopIcon aria-hidden className="m-1" />}
32
+ isDisabled={isDisabled}
33
+ scope="squiz-rb-scope"
34
+ >
35
+ {resourcePickerContainer}
36
+ </ModalTrigger>
37
+ );
38
+
22
39
  return (
23
40
  <>
24
41
  {/* Left column */}
@@ -29,7 +46,7 @@ export const SelectedState = ({
29
46
  ) : (
30
47
  <Icon icon={type.code as IconOptions} resourceSource="matrix" className="w-4 h-4 mt-1 flex self-start" />
31
48
  )}
32
- {/* Center column */}
49
+ {/* Center columns */}
33
50
  <div className="justify-self-start self-center w-full overflow-hidden break-words">
34
51
  <span>{name}</span>
35
52
  <dl className="col-start-2 col-end-2 flex flex-row gap-1 justify-self-start items-center font-normal text-sm">
@@ -58,7 +75,10 @@ export const SelectedState = ({
58
75
  )}
59
76
  </div>
60
77
  {/* End column */}
61
- <ResetButton isDisabled={isDisabled} onClick={onClear} />
78
+ <div className="flex">
79
+ {replaceAsset}
80
+ <ResetButton isDisabled={isDisabled} onClick={onClear} />
81
+ </div>
62
82
  </>
63
83
  );
64
84
  };
@@ -6,7 +6,7 @@
6
6
  @apply w-full p-2 bg-gray-100 rounded-md;
7
7
  @apply text-gray-900 text-base text-md font-semibold;
8
8
  &__layout {
9
- @apply grid grid-cols-[auto_1fr_24px] gap-2;
9
+ @apply grid grid-cols-[auto_1fr_auto] gap-2;
10
10
  @apply justify-items-center;
11
11
  }
12
12
  }
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { screen, render, waitFor, within, act } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { mockResource, mockSource } from '../__mocks__/MockModels';
6
- import { Resource, Source, Hierarchy } from '../types';
6
+ import { Resource, Source, Hierarchy, ResourceReference } from '../types';
7
7
  import { Context as ResponsiveContext } from 'react-responsive';
8
8
  import { OverlayTriggerState } from 'react-stately';
9
9
 
@@ -38,39 +38,36 @@ const baseProps = {
38
38
  titleAriaProps: {},
39
39
  allowedTypes: undefined,
40
40
  onClose: jest.fn(),
41
- onRequestSources: () => {
42
- return Promise.resolve([
43
- mockSource({
44
- id: '1',
45
- name: 'Test system',
46
- nodes: [
47
- {
48
- id: '1',
49
- type: {
50
- code: 'site',
51
- name: 'Site',
52
- },
53
- name: 'Test Website',
54
- childCount: 21,
41
+ onRequestSources: jest.fn().mockResolvedValue([
42
+ mockSource({
43
+ id: '1',
44
+ name: 'Test system',
45
+ nodes: [
46
+ {
47
+ id: '1',
48
+ type: {
49
+ code: 'site',
50
+ name: 'Site',
55
51
  },
56
- ],
57
- }),
58
- ]);
59
- },
60
- onRequestChildren: () => {
61
- return Promise.resolve([
62
- mockResource({
63
- id: '123',
64
- type: {
65
- code: 'page',
66
- name: 'Mocked Page',
52
+ name: 'Test Website',
53
+ childCount: 21,
67
54
  },
68
- name: 'Test Page',
69
- childCount: 0,
70
- }),
71
- ]);
72
- },
73
- onChange: () => {},
55
+ ],
56
+ }),
57
+ ]),
58
+ onRequestResource: jest.fn().mockRejectedValue(new Error('onRequestResource has not been mocked.')),
59
+ onRequestChildren: jest.fn().mockResolvedValue([
60
+ mockResource({
61
+ id: '123',
62
+ type: {
63
+ code: 'page',
64
+ name: 'Mocked Page',
65
+ },
66
+ name: 'Test Page',
67
+ childCount: 0,
68
+ }),
69
+ ]),
70
+ onChange: jest.fn(),
74
71
  };
75
72
 
76
73
  describe('ResourcePickerContainer', () => {
@@ -146,6 +143,123 @@ describe('ResourcePickerContainer', () => {
146
143
  });
147
144
  });
148
145
 
146
+ it('The preselected resource is selected', async () => {
147
+ const resources: Record<string, Resource> = {
148
+ 1: mockResource({ id: '1', name: 'Root Folder' }),
149
+ 100: mockResource({ id: '100', name: 'Source root node #1' }),
150
+ 200: mockResource({ id: '200', name: 'Source root node #2' }),
151
+ 201: mockResource({ id: '201', name: 'Child #1' }),
152
+ 202: mockResource({ id: '202', name: 'Child #2' }),
153
+ 203: mockResource({
154
+ id: '203',
155
+ name: 'Leaf',
156
+ lineages: [
157
+ {
158
+ resourceIds: ['1', '200', '201', '202', '203'],
159
+ },
160
+ ],
161
+ }),
162
+ 204: mockResource({ id: '204', name: 'Another leaf' }),
163
+ 300: mockResource({ id: '300', name: 'Source root node #3' }),
164
+ };
165
+ const sources: Record<string, Source> = {
166
+ 1: mockSource({
167
+ id: '1',
168
+ name: 'Test system 1',
169
+ nodes: [resources[100]],
170
+ }),
171
+ 2: mockSource({
172
+ id: '2',
173
+ name: 'Test system 2',
174
+ nodes: [resources[200], resources[300]],
175
+ }),
176
+ };
177
+
178
+ const onRequestSources = jest.fn().mockResolvedValue(Object.values(sources));
179
+ const onRequestResource = jest.fn((reference: ResourceReference) => Promise.resolve(resources[reference.resource]));
180
+ const onRequestChildren = jest.fn().mockResolvedValue([resources[203], resources[204]]);
181
+ const preselectedSourceId = '2';
182
+ const preselectedResource = resources[203];
183
+
184
+ render(
185
+ <ResourcePickerContainer
186
+ {...baseProps}
187
+ onRequestSources={onRequestSources}
188
+ onRequestResource={onRequestResource}
189
+ onRequestChildren={onRequestChildren}
190
+ preselectedSourceId={preselectedSourceId}
191
+ preselectedResource={preselectedResource}
192
+ />,
193
+ );
194
+
195
+ await waitFor(() => expect(screen.getByRole('button', { name: 'folder Leaf selected' })).toBeInTheDocument());
196
+
197
+ const breadcrumbs = within(screen.getByLabelText('Resource breadcrumb')).getAllByRole('listitem');
198
+
199
+ // Breadcrumbs should be populated, source should be populated.
200
+ // "Leaf" resource should be selected, "Another leaf" resource should not be selected.
201
+ expect(breadcrumbs.map((item) => item.textContent)).toEqual(['', 'Source root node #2', 'Child #1', 'Child #2']);
202
+ expect(screen.getByLabelText('Source quick select')).toHaveTextContent(/Source root node #2/);
203
+ expect(screen.getByRole('button', { name: 'folder Leaf selected' })).toBeInTheDocument();
204
+ expect(screen.getByRole('button', { name: 'folder Another leaf' })).toBeInTheDocument();
205
+ });
206
+
207
+ it.each([
208
+ ['the preselected resource is a root node', 10],
209
+ ['the preselected resource lineage does not exist under a root node', 100],
210
+ ['the preselected resource lineage does not appear under a root node', 200],
211
+ ])('The source list is displayed if %s', async (description: string, preselectedResourceId: number) => {
212
+ const resources: Record<string, Resource> = {
213
+ 10: mockResource({
214
+ id: '100',
215
+ name: 'Source root node #1',
216
+ lineages: [{ resourceIds: ['1', '10'] }],
217
+ }),
218
+ 100: mockResource({
219
+ id: '100',
220
+ name: 'Resource without lineages',
221
+ lineages: undefined,
222
+ }),
223
+ 200: mockResource({
224
+ id: '200',
225
+ name: 'Resource not available under a root node',
226
+ lineages: [{ resourceIds: ['1', '20', '200'] }],
227
+ }),
228
+ };
229
+ const sources: Record<string, Source> = {
230
+ 1: mockSource({
231
+ id: '1',
232
+ name: 'Test system 1',
233
+ nodes: [resources[10]],
234
+ }),
235
+ };
236
+
237
+ const onRequestSources = jest.fn().mockResolvedValue(Object.values(sources));
238
+ const onRequestResource = jest.fn((reference: ResourceReference) => Promise.resolve(resources[reference.resource]));
239
+ const onRequestChildren = jest.fn().mockResolvedValue([]);
240
+ const preselectedSourceId = '1';
241
+ const preselectedResource = resources[preselectedResourceId];
242
+
243
+ render(
244
+ <ResourcePickerContainer
245
+ {...baseProps}
246
+ onRequestSources={onRequestSources}
247
+ onRequestResource={onRequestResource}
248
+ onRequestChildren={onRequestChildren}
249
+ preselectedSourceId={preselectedSourceId}
250
+ preselectedResource={preselectedResource}
251
+ />,
252
+ );
253
+
254
+ await waitFor(() => expect(screen.queryByRole('list', { name: 'Source list' })).toBeInTheDocument());
255
+
256
+ // Breadcrumbs should not be displayed.
257
+ // Source list should be displayed.
258
+ // "Leaf" resource should be selected, "Another leaf" resource should not be selected.
259
+ expect(screen.queryByLabelText('Resource breadcrumb')).not.toBeInTheDocument();
260
+ expect(screen.getByRole('button', { name: 'Drill down to Source root node #1 children' })).toBeInTheDocument();
261
+ });
262
+
149
263
  it('Selecting a child count drills down', async () => {
150
264
  const onRequestChildren = jest.fn(() => {
151
265
  return Promise.resolve([]);
@@ -1,26 +1,36 @@
1
- import React, { useState, useCallback, useEffect } from 'react';
1
+ import React, { useState, useCallback, useMemo, useEffect } from 'react';
2
2
  import { DOMAttributes, FocusableElement } from '@react-types/shared';
3
3
  import { useOverlayTriggerState } from 'react-stately';
4
- import { useAsync } from '@squiz/generic-browser-lib';
5
-
6
4
  import SourceList from '../SourceList/SourceList';
7
5
  import ResourceList from '../ResourceList/ResourceList';
8
6
  import ResourceBreadcrumb from '../ResourceBreadcrumb/ResourceBreadcrumb';
9
7
  import PreviewPanel from '../PreviewPanel/PreviewPanel';
10
8
  import SourceDropdown from '../SourceDropdown/SourceDropdown';
11
-
12
- import { Source, Resource, HydratedResourceReference, ScopedSource } from '../types';
9
+ import {
10
+ Source,
11
+ Resource,
12
+ HydratedResourceReference,
13
+ ScopedSource,
14
+ OnRequestSources,
15
+ OnRequestChildren,
16
+ OnRequestResource,
17
+ } from '../types';
13
18
  import { useResourcePath } from '../Hooks/useResourcePath';
14
19
  import { useChildResources } from '../Hooks/useChildResources';
20
+ import { useSources } from '../Hooks/useSources';
21
+ import { usePreselectedResourcePath } from '../Hooks/usePreselectedResourcePath';
15
22
 
16
23
  interface ResourcePickerContainerProps {
17
24
  title: string;
18
25
  titleAriaProps: DOMAttributes<FocusableElement>;
19
26
  allowedTypes: string[] | undefined;
20
- onRequestSources: () => Promise<Source[]>;
21
- onRequestChildren(source: Source, resource: Resource | null): Promise<Resource[]>;
27
+ onRequestSources: OnRequestSources;
28
+ onRequestResource: OnRequestResource;
29
+ onRequestChildren: OnRequestChildren;
22
30
  onChange(resource: HydratedResourceReference | null): void;
23
31
  onClose: () => void;
32
+ preselectedSourceId?: string;
33
+ preselectedResource?: Resource | null;
24
34
  }
25
35
 
26
36
  function ResourcePickerContainer({
@@ -28,28 +38,42 @@ function ResourcePickerContainer({
28
38
  titleAriaProps,
29
39
  allowedTypes,
30
40
  onRequestSources,
41
+ onRequestResource,
31
42
  onRequestChildren,
32
43
  onChange,
33
44
  onClose,
45
+ preselectedSourceId,
46
+ preselectedResource,
34
47
  }: ResourcePickerContainerProps) {
35
48
  const previewModalState = useOverlayTriggerState({});
36
- const [selectedResource, setSelectedResource] = useState<Resource | null>(null);
49
+ const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
37
50
  const [previewModalOverlayProps, setPreviewModalOverlayProps] = useState<DOMAttributes>({});
38
51
  const { source, currentResource, hierarchy, setSource, push, popUntil } = useResourcePath();
39
-
40
52
  const {
41
53
  data: sources,
42
54
  isLoading: isSourceLoading,
43
55
  reload: handleSourceReload,
44
56
  error: sourceError,
45
- } = useAsync({ callback: onRequestSources, defaultValue: [] }, []);
46
-
57
+ } = useSources({ onRequestSources });
47
58
  const {
48
59
  data: resources,
49
60
  isLoading: isResourcesLoading,
50
61
  reload: handleResourceReload,
51
62
  error: resourceError,
52
63
  } = useChildResources({ source, currentResource, onRequestChildren });
64
+ const {
65
+ data: { source: preselectedSource, path: preselectedPath },
66
+ isLoading: isPreselectedResourcePathLoading,
67
+ } = usePreselectedResourcePath({
68
+ sourceId: preselectedSourceId,
69
+ resource: preselectedResource,
70
+ onRequestResource,
71
+ onRequestSources,
72
+ });
73
+ const selectedResource = useMemo(
74
+ () => resources.find((resource) => resource.id === selectedResourceId) || null,
75
+ [selectedResourceId, resources],
76
+ );
53
77
 
54
78
  const handleResourceDrillDown = useCallback(
55
79
  (resource: Resource) => {
@@ -60,7 +84,7 @@ function ResourcePickerContainer({
60
84
 
61
85
  const handleResourceSelected = useCallback((resource: Resource, overlayProps: DOMAttributes) => {
62
86
  setPreviewModalOverlayProps(overlayProps);
63
- setSelectedResource(resource);
87
+ setSelectedResourceId(resource.id);
64
88
  }, []);
65
89
 
66
90
  const handleSourceDrilldown = useCallback(
@@ -83,13 +107,35 @@ function ResourcePickerContainer({
83
107
  );
84
108
 
85
109
  const handleDetailClose = useCallback(() => {
86
- setSelectedResource(null);
110
+ setSelectedResourceId(null);
87
111
  }, []);
88
112
 
89
- // When the active node changes clear the selected resource
113
+ // Clear the selected resource if it no longer exists in the list of resources
114
+ // (eg. due to navigating up/down the tree).
115
+ useEffect(() => {
116
+ if (resources.length > 0 && selectedResourceId && !selectedResource) {
117
+ setSelectedResourceId(null);
118
+ }
119
+ }, [resources, selectedResourceId, selectedResource]);
120
+
90
121
  useEffect(() => {
91
- setSelectedResource(null);
92
- }, [hierarchy]);
122
+ if (preselectedSource && preselectedPath?.length) {
123
+ const [rootNode, ...path] = preselectedPath;
124
+ const leaf = path.pop();
125
+
126
+ setSource(
127
+ {
128
+ source: preselectedSource,
129
+ resource: rootNode,
130
+ },
131
+ path,
132
+ );
133
+
134
+ if (leaf) {
135
+ setSelectedResourceId(leaf.id);
136
+ }
137
+ }
138
+ }, [preselectedSource, preselectedSource]);
93
139
 
94
140
  return (
95
141
  <div className="relative flex flex-col h-full text-gray-800">
@@ -134,7 +180,7 @@ function ResourcePickerContainer({
134
180
  <SourceList
135
181
  sources={sources}
136
182
  previewModalState={previewModalState}
137
- isLoading={isSourceLoading}
183
+ isLoading={isSourceLoading || isPreselectedResourcePathLoading}
138
184
  onSourceSelect={handleSourceDrilldown}
139
185
  handleReload={handleSourceReload}
140
186
  error={sourceError}
@@ -157,7 +203,7 @@ function ResourcePickerContainer({
157
203
  </div>
158
204
  <div className="sm:overflow-y-scroll sm:flex-1 sm:grow-[2] bg-white">
159
205
  <PreviewPanel
160
- resource={selectedResource}
206
+ resource={isResourcesLoading ? null : selectedResource}
161
207
  modalState={previewModalState}
162
208
  previewModalOverlayProps={previewModalOverlayProps}
163
209
  allowedTypes={allowedTypes}
@@ -4,7 +4,7 @@ import { Icon, IconOptions, Spinner } from '@squiz/generic-browser-lib';
4
4
 
5
5
  import type { Source, ScopedSource } from '../types';
6
6
 
7
- import uuid from '../uuid';
7
+ import uuid from '../utils/uuid';
8
8
  import { useCategorisedSources } from '../Hooks/useCategorisedSources';
9
9
 
10
10
  export default function SourceDropdown({
@@ -17,7 +17,7 @@
17
17
  "childCount": 21
18
18
  },
19
19
  {
20
- "id": "2",
20
+ "id": "10",
21
21
  "type": {
22
22
  "code": "site",
23
23
  "name": "Site"
@@ -30,7 +30,7 @@
30
30
  "childCount": 135877
31
31
  },
32
32
  {
33
- "id": "3",
33
+ "id": "20",
34
34
  "type": {
35
35
  "code": "folder",
36
36
  "name": "Folder"
@@ -49,7 +49,7 @@
49
49
  "name": "Acme internal system",
50
50
  "nodes": [
51
51
  {
52
- "id": "4",
52
+ "id": "30",
53
53
  "type": {
54
54
  "code": "site",
55
55
  "name": "Site"
@@ -62,7 +62,7 @@
62
62
  "childCount": 15
63
63
  },
64
64
  {
65
- "id": "5",
65
+ "id": "1",
66
66
  "type": {
67
67
  "code": "site",
68
68
  "name": "Site"
@@ -18,6 +18,7 @@ export const mockResource = (properties: Partial<Resource> = {}): Resource => {
18
18
  url: 'https://no-where.com',
19
19
  urls: [],
20
20
  childCount: 0,
21
+ lineages: [],
21
22
  ...properties,
22
23
  };
23
24
  };
@@ -1,5 +1,5 @@
1
1
  import sampleSources from '../SourceList/sample-sources.json';
2
- import { HydratedResourceReference, Resource, Source } from '../types';
2
+ import { HydratedResourceReference, Resource, ResourceReference, Source } from '../types';
3
3
  import sampleResources from '../ResourceList/sample-resources.json';
4
4
 
5
5
  type CreateCallbacksProps = Partial<{
@@ -9,14 +9,39 @@ type CreateCallbacksProps = Partial<{
9
9
  error?: string;
10
10
  }>;
11
11
 
12
+ const indexResources = (resources: Resource[]) => {
13
+ const indexed: Record<string, Resource> = {};
14
+ const pending = [...resources];
15
+
16
+ while (pending.length > 0) {
17
+ const resource = pending.shift();
18
+
19
+ if (!resource) {
20
+ continue;
21
+ }
22
+
23
+ indexed[resource.id] = resource;
24
+
25
+ if (resource && '_children' in resource) {
26
+ pending.push(...(resource._children as Resource[]));
27
+ }
28
+ }
29
+
30
+ return indexed;
31
+ };
32
+
12
33
  export const createResourceBrowserCallbacks = ({
13
34
  delay = 500,
14
35
  sourceIsLoading = false,
15
36
  resourceIsLoading = false,
16
37
  error,
17
38
  }: CreateCallbacksProps = {}) => {
39
+ const indexedResources = indexResources(sampleResources as Resource[]);
40
+
18
41
  return {
19
42
  onRequestSources: () => {
43
+ console.log('onRequestSources');
44
+
20
45
  return new Promise((resolve, reject) => {
21
46
  if (!sourceIsLoading) {
22
47
  setTimeout(() => {
@@ -30,26 +55,30 @@ export const createResourceBrowserCallbacks = ({
30
55
  });
31
56
  },
32
57
  onRequestChildren: (source: Source, resource: Resource | null) => {
58
+ console.log('onRequestChildren', source, resource);
59
+
33
60
  return new Promise((resolve, reject) => {
34
61
  if (!resourceIsLoading) {
35
62
  setTimeout(() => {
36
63
  if (error && Math.random() > 0.5) {
37
64
  reject(new Error(error));
38
65
  } else {
39
- resolve(((resource as any)?._children || sampleResources) as Resource[] | undefined);
66
+ resolve(resource ? (indexedResources[resource.id] as any)?._children || [] : sampleResources);
40
67
  }
41
68
  }, delay);
42
69
  }
43
70
  });
44
71
  },
45
- onRequestResource: () => {
72
+ onRequestResource: (reference: ResourceReference) => {
73
+ console.log('onRequestResource', reference);
74
+
46
75
  return new Promise((resolve, reject) => {
47
76
  if (!resourceIsLoading) {
48
77
  setTimeout(() => {
49
78
  if (error && Math.random() > 0.5) {
50
79
  reject(new Error(error));
51
80
  } else {
52
- resolve(sampleResources[0]);
81
+ resolve(indexedResources[reference.resource]);
53
82
  }
54
83
  }, delay);
55
84
  }
@@ -0,0 +1,23 @@
1
+ import React, { ReactElement, ReactNode } from 'react';
2
+ import {
3
+ ResourceBrowserContextProps,
4
+ ResourceBrowserContextProvider,
5
+ } from '../ResourceBrowserContext/ResourceBrowserContext';
6
+ import { render } from '@testing-library/react';
7
+
8
+ export const renderWithContext = (ui: ReactElement, context: Partial<ResourceBrowserContextProps> = {}) => {
9
+ return render(ui, {
10
+ wrapper: ({ children }: { children: ReactNode }): ReactElement => (
11
+ <ResourceBrowserContextProvider
12
+ value={{
13
+ onRequestSources: jest.fn(),
14
+ onRequestChildren: jest.fn(),
15
+ onRequestResource: jest.fn(),
16
+ ...context,
17
+ }}
18
+ >
19
+ {children}
20
+ </ResourceBrowserContextProvider>
21
+ ),
22
+ });
23
+ };