@squiz/resource-browser 1.66.3 → 1.67.1

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 +19 -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
@@ -1,31 +1,40 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
3
- import { ResourceBrowserInput } from './index';
4
-
5
- const mockRequestSources = jest.fn();
6
- const mockRequestChildren = jest.fn();
7
- const mockRequestResource = jest.fn();
8
- const mockChange = jest.fn();
9
-
10
- const defaultProps: any = {
11
- modalTitle: 'Asset Picker',
12
- allowedTypes: ['site', 'image', 'physical_file'],
13
- onRequestSources: mockRequestSources,
14
- onRequestChildren: mockRequestChildren,
15
- onRequestResource: mockRequestResource,
16
- onChange: mockChange,
17
- };
18
-
19
- describe('Related Asset Picker', () => {
2
+ import { act, fireEvent, screen, waitFor } from '@testing-library/react';
3
+ import { ResourceBrowserInput, ResourceBrowserInputProps, ResourceReference } from './index';
4
+ import { mockResource } from './__mocks__/MockModels';
5
+ import { renderWithContext } from './__mocks__/renderWithContext';
6
+
7
+ describe('Resource browser input', () => {
8
+ const mockRequestSources = jest.fn().mockResolvedValue([]);
9
+ const mockRequestChildren = jest.fn().mockResolvedValue([]);
10
+ const mockRequestResource = jest.fn();
11
+ const mockChange = jest.fn();
12
+ const renderComponent = (props: Partial<ResourceBrowserInputProps> = {}) => {
13
+ return renderWithContext(
14
+ <ResourceBrowserInput
15
+ modalTitle="Asset picker"
16
+ allowedTypes={['site', 'image', 'physical_file']}
17
+ onChange={mockChange}
18
+ value={null}
19
+ {...props}
20
+ />,
21
+ {
22
+ onRequestSources: mockRequestSources,
23
+ onRequestChildren: mockRequestChildren,
24
+ onRequestResource: mockRequestResource,
25
+ },
26
+ );
27
+ };
28
+
20
29
  it('should render the related asset picker with the default label', () => {
21
- render(<ResourceBrowserInput {...defaultProps} />);
30
+ renderComponent();
22
31
  const pickerLabel = screen.getByText('Choose asset');
23
32
 
24
33
  expect(pickerLabel).toBeInTheDocument();
25
34
  });
26
35
 
27
36
  it('should display the generic asset picking icon', () => {
28
- render(<ResourceBrowserInput {...defaultProps} />);
37
+ renderComponent();
29
38
  const pickerLabel = screen.getByText('Choose asset');
30
39
  const pickerIcon = screen.getByTestId('AdsClickRoundedIcon');
31
40
 
@@ -34,11 +43,62 @@ describe('Related Asset Picker', () => {
34
43
  });
35
44
 
36
45
  it('should display the image icon when only images are allowed', () => {
37
- render(<ResourceBrowserInput {...defaultProps} allowedTypes={['image']} />);
46
+ renderComponent({ allowedTypes: ['image'] });
38
47
  const pickerLabel = screen.getByText('Choose image');
39
48
  const pickerIcon = screen.getByTestId('PhotoLibraryRoundedIcon');
40
49
 
41
50
  expect(pickerLabel).toBeInTheDocument();
42
51
  expect(pickerIcon).toBeInTheDocument();
43
52
  });
53
+
54
+ it('should disallow removing and replacing selection if the input is disabled', async () => {
55
+ mockRequestResource.mockResolvedValue(
56
+ mockResource({
57
+ id: '100',
58
+ name: 'Mocked resource',
59
+ }),
60
+ );
61
+ renderComponent({ value: { source: '1', resource: '100' }, isDisabled: true });
62
+
63
+ await waitFor(() => expect(screen.queryByLabelText('Loading selection')).not.toBeInTheDocument());
64
+
65
+ expect(screen.getByRole('button', { name: 'Remove selection' })).toBeDisabled();
66
+ expect(screen.getByRole('button', { name: 'Replace selection' })).toBeDisabled();
67
+ });
68
+
69
+ it('clicking the replace selection button should open the resource browser', async () => {
70
+ mockRequestResource.mockResolvedValue(
71
+ mockResource({
72
+ id: '100',
73
+ name: 'Mocked resource',
74
+ }),
75
+ );
76
+ renderComponent({ value: { source: '1', resource: '100' } });
77
+
78
+ await waitFor(() => expect(screen.queryByLabelText('Loading selection')).not.toBeInTheDocument());
79
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Replace selection' })));
80
+ expect(screen.getByRole('button', { name: 'Close Asset picker dialog' })).toBeInTheDocument();
81
+ });
82
+
83
+ it.each([
84
+ ['resource selected', { source: '1', resource: '100' }, 1],
85
+ ['resource not selected', null, 0],
86
+ ])(
87
+ 'should only display the "replace" button if a resource has already been selected - %s',
88
+ async (description: string, value: ResourceReference | null, expectedReplaceSelectionButtons: number) => {
89
+ mockRequestResource.mockResolvedValue(
90
+ mockResource({
91
+ id: '100',
92
+ name: 'Mocked resource',
93
+ }),
94
+ );
95
+ renderComponent({ value });
96
+
97
+ await waitFor(() => expect(screen.queryByLabelText('Loading selection')).not.toBeInTheDocument());
98
+
99
+ expect(screen.queryAllByRole('button', { name: 'Replace selection' })).toHaveLength(
100
+ expectedReplaceSelectionButtons,
101
+ );
102
+ },
103
+ );
44
104
  });
@@ -1,5 +1,5 @@
1
1
  import { StoryFn, Meta } from '@storybook/react';
2
- import { ResourceBrowserInput, ResourceBrowserContext } from './index';
2
+ import { ResourceBrowserInput, ResourceBrowserContextProvider } from './index';
3
3
  import { createResourceBrowserCallbacks } from './__mocks__/StorybookHelpers';
4
4
 
5
5
  export default {
@@ -17,9 +17,9 @@ const Template: StoryFn<typeof ResourceBrowserInput> = (props) => {
17
17
 
18
18
  return (
19
19
  <div className="w-[400px] m-3">
20
- <ResourceBrowserContext.Provider value={{ onRequestSources, onRequestChildren, onRequestResource }}>
20
+ <ResourceBrowserContextProvider value={{ onRequestSources, onRequestChildren, onRequestResource }}>
21
21
  <ResourceBrowserInput {...other} onChange={onChange} />
22
- </ResourceBrowserContext.Provider>
22
+ </ResourceBrowserContextProvider>
23
23
  </div>
24
24
  );
25
25
  };
@@ -36,7 +36,7 @@ Primary.args = {
36
36
  export const Selected = Template.bind({});
37
37
  Selected.args = {
38
38
  ...Primary.args,
39
- value: { resource: '1', source: '2' },
39
+ value: { resource: '33', source: '1' },
40
40
  };
41
41
 
42
42
  export const ImagesOnly = Template.bind({});
package/src/index.tsx CHANGED
@@ -1,12 +1,16 @@
1
1
  import React, { useContext } from 'react';
2
2
  import ResourcePickerContainer from './ResourcePickerContainer/ResourcePickerContainer';
3
3
  import { HydratedResourceReference, Resource, ResourceReference, Source } from './types';
4
- import { ResourceBrowserContext, ResourceBrowserContextProps } from './ResourceBrowserContext/ResourceBrowserContext';
4
+ import {
5
+ ResourceBrowserContext,
6
+ ResourceBrowserContextProvider,
7
+ ResourceBrowserContextProps,
8
+ } from './ResourceBrowserContext/ResourceBrowserContext';
5
9
  import ResourcePicker from './ResourcePicker/ResourcePicker';
6
10
  import { useResource } from './Hooks/useResource';
7
11
 
8
12
  export type { HydratedResourceReference, Resource, ResourceReference, Source, ResourceBrowserContextProps };
9
- export { ResourceBrowserContext };
13
+ export { ResourceBrowserContext, ResourceBrowserContextProvider };
10
14
 
11
15
  export type ResourceBrowserInputProps = {
12
16
  modalTitle: string;
@@ -29,6 +33,7 @@ export const ResourceBrowserInput = ({
29
33
  const { data: resource, error, isLoading } = useResource({ onRequestResource, reference: value });
30
34
  const defaultOnClear = () => onChange(null);
31
35
  const onClearFunction = onClear ?? defaultOnClear;
36
+
32
37
  return (
33
38
  <div className="squiz-rb-scope">
34
39
  <ResourcePicker
@@ -41,11 +46,14 @@ export const ResourceBrowserInput = ({
41
46
  >
42
47
  {(onClose, titleProps) => (
43
48
  <ResourcePickerContainer
49
+ preselectedSourceId={value?.source}
50
+ preselectedResource={resource}
44
51
  title={modalTitle}
45
52
  titleAriaProps={titleProps}
46
53
  allowedTypes={allowedTypes}
47
54
  onClose={onClose}
48
55
  onRequestSources={onRequestSources}
56
+ onRequestResource={onRequestResource}
49
57
  onRequestChildren={onRequestChildren}
50
58
  onChange={onChange}
51
59
  />
package/src/types.ts CHANGED
@@ -19,6 +19,11 @@ export type Resource = {
19
19
  urls: string[];
20
20
  childCount: number;
21
21
  squizImage?: SquizImageType['__shape__'];
22
+ lineages?: ResourceLineage[];
23
+ };
24
+
25
+ export type ResourceLineage = {
26
+ resourceIds: string[];
22
27
  };
23
28
 
24
29
  /**
@@ -70,3 +75,7 @@ export type DeepPartial<T> = {
70
75
  ? ReadonlyArray<DeepPartial<U>>
71
76
  : DeepPartial<T[P]>;
72
77
  };
78
+
79
+ export type OnRequestSources = () => Promise<Source[]>;
80
+ export type OnRequestResource = (reference: ResourceReference) => Promise<Resource>;
81
+ export type OnRequestChildren = (source: Source, resource: Resource | null) => Promise<Resource[]>;
@@ -0,0 +1,81 @@
1
+ import { Resource } from '../types';
2
+ import { findBestMatchLineage } from './findBestMatchLineage';
3
+ import { mockResource, mockSource } from '../__mocks__/MockModels';
4
+
5
+ describe('findBestMatchLineage', () => {
6
+ it.each([
7
+ [
8
+ 'Resource lineage is not returned',
9
+ mockResource({
10
+ lineages: undefined,
11
+ }),
12
+ [],
13
+ ],
14
+ [
15
+ 'Resource has no lineages',
16
+ mockResource({
17
+ lineages: [],
18
+ }),
19
+ [],
20
+ ],
21
+ [
22
+ 'Resource does not have lineage which is beneath a configured root node',
23
+ mockResource({
24
+ id: '3000',
25
+ lineages: [{ resourceIds: ['1', '30', '300', '3000'] }],
26
+ }),
27
+ [],
28
+ ],
29
+ [
30
+ 'Resource has a lineage under one of the root nodes',
31
+ mockResource({
32
+ id: '100',
33
+ lineages: [{ resourceIds: ['1', '20', '200', '2000'] }],
34
+ }),
35
+ ['20', '200', '2000'],
36
+ ],
37
+ [
38
+ 'Resource has a lineage beneath multiple of the root nodes',
39
+ mockResource({
40
+ id: '1000',
41
+ lineages: [{ resourceIds: ['1', '10', '100', '1000'] }, { resourceIds: ['1', '20', '200', '1000'] }],
42
+ }),
43
+ // first match is returned.
44
+ ['10', '100', '1000'],
45
+ ],
46
+ [
47
+ 'Resource has multiple lineages under a single configured root node',
48
+ mockResource({
49
+ id: '1000',
50
+ lineages: [{ resourceIds: ['1', '10', '100', '1000'] }, { resourceIds: ['1', '10', '101', '1000'] }],
51
+ }),
52
+ // first match is returned.
53
+ ['10', '100', '1000'],
54
+ ],
55
+ [
56
+ 'Resource ID is a configured root node',
57
+ mockResource({
58
+ id: '10',
59
+ lineages: [{ resourceIds: ['1', '10'] }],
60
+ }),
61
+ [],
62
+ ],
63
+ [
64
+ 'Resource ID is a configured root node and also nested under a root node',
65
+ mockResource({
66
+ id: '10',
67
+ lineages: [{ resourceIds: ['1', '10'] }, { resourceIds: ['1', '20', '10'] }],
68
+ }),
69
+ ['20', '10'],
70
+ ],
71
+ ])(
72
+ 'Returns best match lineage which can be identified from provided source - %s',
73
+ (description: string, resource: Resource, expected: string[]) => {
74
+ const source = mockSource({
75
+ nodes: [mockResource({ id: '10' }), mockResource({ id: '20' })],
76
+ });
77
+
78
+ expect(findBestMatchLineage(source, resource)).toEqual(expected);
79
+ },
80
+ );
81
+ });
@@ -0,0 +1,30 @@
1
+ import { Source, Resource } from '../types';
2
+
3
+ export const findBestMatchLineage = (source: Source, resource: Resource): string[] => {
4
+ if (resource.lineages) {
5
+ for (const lineage of resource.lineages) {
6
+ // Lineage must:
7
+ // 1. Appear beneath the root node.
8
+ // 2. Not be the root node itself (root nodes can't be selected, to be changed in FEAAS-760).
9
+ // TODO: FEAAS-760 update as necessary so the lineage will be returned even if it ends at the root node, eg:
10
+ // const rootNode = source.nodes.find(node => lineage.resourceIds.includes(node.id));
11
+ const rootNode = source.nodes.find((node) => {
12
+ const index = lineage.resourceIds.indexOf(node.id);
13
+
14
+ return index >= 0 && index < lineage.resourceIds.length - 1;
15
+ });
16
+
17
+ if (rootNode) {
18
+ const rootNodeIndex = lineage.resourceIds.indexOf(rootNode.id);
19
+
20
+ // Return the lineage starting from the root node. eg.
21
+ // * Full lineage is: 1 > 10 > 100 > 1000 > 10000
22
+ // * The source has a node with an ID of: 100
23
+ // * The returned lineage will be: 100 > 1000 > 10000
24
+ return lineage.resourceIds.slice(rootNodeIndex);
25
+ }
26
+ }
27
+ }
28
+
29
+ return [];
30
+ };
@@ -1,20 +0,0 @@
1
- import React from 'react';
2
- import { Resource, ResourceReference, Source } from '../types';
3
-
4
- export type ResourceBrowserContextProps = {
5
- onRequestSources: () => Promise<Source[]>;
6
- onRequestChildren(source: Source, resource: Resource | null): Promise<Resource[]>;
7
- onRequestResource(reference: ResourceReference): Promise<Resource | null>;
8
- };
9
-
10
- export const ResourceBrowserContext = React.createContext<ResourceBrowserContextProps>({
11
- onRequestSources: () => {
12
- throw new Error('onRequestSources has not been configured.');
13
- },
14
- onRequestChildren: () => {
15
- throw new Error('onRequestChildren has not been configured.');
16
- },
17
- onRequestResource: () => {
18
- throw new Error('onRequestResource has not been configured.');
19
- },
20
- });
File without changes
File without changes
File without changes