@squiz/resource-browser 1.67.1 → 1.68.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,11 @@
1
1
  # @squiz/resource-browser
2
2
 
3
+ ## 1.68.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8c2491b: Added recent locations section to resource browser
8
+
3
9
  ## 1.67.1
4
10
 
5
11
  ### Patch Changes
@@ -0,0 +1,10 @@
1
+ import { Resource, Source } from '../types';
2
+ export interface RecentLocation {
3
+ rootNode: Resource | null;
4
+ source: Source;
5
+ path: Array<Resource>;
6
+ }
7
+ export declare const useRecentLocations: (maxLocations?: number, storageKey?: string) => {
8
+ recentLocations: RecentLocation[];
9
+ addRecentLocation: (newLocation: RecentLocation) => void;
10
+ };
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useRecentLocations = void 0;
4
+ const react_1 = require("react");
5
+ const useRecentLocations = (maxLocations = 3, storageKey = 'rb_recent_locations') => {
6
+ let initialRecentLocations = [];
7
+ try {
8
+ initialRecentLocations = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
9
+ }
10
+ catch (_ignored) {
11
+ // Ignore this error (edge case someone messing with the local storage...)
12
+ }
13
+ // We just have an extra safety check here in case local storage has been messed with and set as {} for example
14
+ if (!Array.isArray(initialRecentLocations)) {
15
+ initialRecentLocations = [];
16
+ }
17
+ const [recentLocations, setRecentLocations] = (0, react_1.useState)(initialRecentLocations);
18
+ const addRecentLocation = (newLocation) => {
19
+ // Check if the new location to make sure we don't already have a recent location for this
20
+ if (JSON.stringify(recentLocations).indexOf(JSON.stringify(newLocation)) > -1) {
21
+ return;
22
+ }
23
+ const updatedLocations = [newLocation, ...recentLocations.slice(0, maxLocations - 1)];
24
+ // Set state
25
+ setRecentLocations(updatedLocations);
26
+ // Update local storage
27
+ localStorage.setItem(storageKey, JSON.stringify(updatedLocations));
28
+ };
29
+ return {
30
+ recentLocations,
31
+ addRecentLocation,
32
+ };
33
+ };
34
+ exports.useRecentLocations = useRecentLocations;
@@ -0,0 +1,4 @@
1
+ import React, { SVGAttributes } from 'react';
2
+ type HistoryIconProps = SVGAttributes<SVGElement>;
3
+ export declare const HistoryIcon: (props: HistoryIconProps) => React.JSX.Element;
4
+ export {};
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HistoryIcon = void 0;
7
+ const react_1 = __importDefault(require("react"));
8
+ const HistoryIcon = (props) => {
9
+ return (react_1.default.createElement("svg", { width: "19", height: "18", viewBox: "0 0 19 18", fill: "none", xmlns: "http://www.w3.org/2000/svg", ...props },
10
+ react_1.default.createElement("g", { id: "icon" },
11
+ react_1.default.createElement("path", { id: "Vector", d: "M10.5531 2.25131C6.73555 2.14631 3.60805 5.21381 3.60805 9.00131H2.26556C1.92806 9.00131 1.76306 9.40631 2.00306 9.63881L4.09556 11.7388C4.24555 11.8888 4.47806 11.8888 4.62806 11.7388L6.72055 9.63881C6.95305 9.40631 6.78805 9.00131 6.45055 9.00131H5.10805C5.10805 6.07631 7.49305 3.71381 10.4331 3.75131C13.2231 3.78881 15.5706 6.1363 15.6081 8.9263C15.6456 11.8588 13.2831 14.2513 10.3581 14.2513C9.15055 14.2513 8.03305 13.8388 7.14806 13.1413C6.84805 12.9088 6.42805 12.9313 6.15805 13.2013C5.84305 13.5163 5.86555 14.0488 6.21805 14.3188C7.35805 15.2188 8.79055 15.7513 10.3581 15.7513C14.1456 15.7513 17.2131 12.6238 17.1081 8.8063C17.0106 5.28881 14.0706 2.34881 10.5531 2.25131ZM10.1706 6.0013C9.86305 6.0013 9.60805 6.2563 9.60805 6.56381V9.32381C9.60805 9.58631 9.75055 9.83381 9.97555 9.9688L12.3156 11.3563C12.5856 11.5138 12.9306 11.4238 13.0881 11.1613C13.2456 10.8913 13.1556 10.5463 12.8931 10.3888L10.7331 9.10631V6.5563C10.7331 6.2563 10.4781 6.0013 10.1706 6.0013Z", fill: "#4F4F4F" }))));
12
+ };
13
+ exports.HistoryIcon = HistoryIcon;
@@ -37,11 +37,13 @@ const useResourcePath_1 = require("../Hooks/useResourcePath");
37
37
  const useChildResources_1 = require("../Hooks/useChildResources");
38
38
  const useSources_1 = require("../Hooks/useSources");
39
39
  const usePreselectedResourcePath_1 = require("../Hooks/usePreselectedResourcePath");
40
+ const useRecentLocations_1 = require("../Hooks/useRecentLocations");
40
41
  function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onRequestSources, onRequestResource, onRequestChildren, onChange, onClose, preselectedSourceId, preselectedResource, }) {
41
42
  const previewModalState = (0, react_stately_1.useOverlayTriggerState)({});
42
43
  const [selectedResourceId, setSelectedResourceId] = (0, react_1.useState)(null);
43
44
  const [previewModalOverlayProps, setPreviewModalOverlayProps] = (0, react_1.useState)({});
44
45
  const { source, currentResource, hierarchy, setSource, push, popUntil } = (0, useResourcePath_1.useResourcePath)();
46
+ const { addRecentLocation } = (0, useRecentLocations_1.useRecentLocations)();
45
47
  const { data: sources, isLoading: isSourceLoading, reload: handleSourceReload, error: sourceError, } = (0, useSources_1.useSources)({ onRequestSources });
46
48
  const { data: resources, isLoading: isResourcesLoading, reload: handleResourceReload, error: resourceError, } = (0, useChildResources_1.useChildResources)({ source, currentResource, onRequestChildren });
47
49
  const { data: { source: preselectedSource, path: preselectedPath }, isLoading: isPreselectedResourcePathLoading, } = (0, usePreselectedResourcePath_1.usePreselectedResourcePath)({
@@ -66,8 +68,20 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
66
68
  }, [setSource]);
67
69
  const handleDetailSelect = (0, react_1.useCallback)((resource) => {
68
70
  onChange({ resource, source: source?.source });
71
+ // Find the path that got them to where they are
72
+ const selectedPath = hierarchy.map((path) => {
73
+ const pathNode = path.node;
74
+ return 'resource' in pathNode ? pathNode.resource : pathNode;
75
+ });
76
+ const [rootNode, ...path] = selectedPath;
77
+ // Update the recent locations in local storage
78
+ addRecentLocation({
79
+ rootNode,
80
+ path: path,
81
+ source: source?.source,
82
+ });
69
83
  onClose();
70
- }, [source]);
84
+ }, [source, currentResource]);
71
85
  const handleDetailClose = (0, react_1.useCallback)(() => {
72
86
  setSelectedResourceId(null);
73
87
  }, []);
@@ -95,7 +109,7 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
95
109
  react_1.default.createElement("div", { className: "flex items-center p-4.5" },
96
110
  react_1.default.createElement("h2", { ...titleAriaProps, className: "text-xl leading-6 text-gray-800 font-semibold mr-6" }, title),
97
111
  react_1.default.createElement("div", { className: "px-3 border-l border-gray-300 w-300px" },
98
- react_1.default.createElement(SourceDropdown_1.default, { sources: sources, selectedSource: source, isLoading: isSourceLoading, onSourceSelect: handleSourceDrilldown, onRootSelect: handleReturnToRoot })),
112
+ react_1.default.createElement(SourceDropdown_1.default, { sources: sources, selectedSource: source, isLoading: isSourceLoading, onSourceSelect: handleSourceDrilldown, onRootSelect: handleReturnToRoot, setSource: setSource, currentResource: currentResource })),
99
113
  react_1.default.createElement("button", { type: "button", "aria-label": `Close ${title} dialog`, onClick: onClose, className: "absolute top-2 right-2 p-2.5 rounded hover:bg-blue-100 focus:bg-blue-100" },
100
114
  react_1.default.createElement("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg" },
101
115
  react_1.default.createElement("path", { d: "M13.3 0.710017C13.1131 0.522765 12.8595 0.417532 12.595 0.417532C12.3305 0.417532 12.0768 0.522765 11.89 0.710017L6.99997 5.59002L2.10997 0.700017C1.92314 0.512765 1.66949 0.407532 1.40497 0.407532C1.14045 0.407532 0.886802 0.512765 0.699971 0.700017C0.309971 1.09002 0.309971 1.72002 0.699971 2.11002L5.58997 7.00002L0.699971 11.89C0.309971 12.28 0.309971 12.91 0.699971 13.3C1.08997 13.69 1.71997 13.69 2.10997 13.3L6.99997 8.41002L11.89 13.3C12.28 13.69 12.91 13.69 13.3 13.3C13.69 12.91 13.69 12.28 13.3 11.89L8.40997 7.00002L13.3 2.11002C13.68 1.73002 13.68 1.09002 13.3 0.710017Z", fill: "currentColor" })))),
@@ -103,7 +117,7 @@ function ResourcePickerContainer({ title, titleAriaProps, allowedTypes, onReques
103
117
  react_1.default.createElement("div", { className: "overflow-y-scroll flex-1 grow-[3] border-r border-gray-300" },
104
118
  react_1.default.createElement("h3", { className: "sr-only" }, "Resource List"),
105
119
  hierarchy.length > 0 && (react_1.default.createElement(ResourceBreadcrumb_1.default, { hierarchy: hierarchy, onBreadcrumbSelect: popUntil, onReturnToRoot: handleReturnToRoot })),
106
- !source && (react_1.default.createElement(SourceList_1.default, { sources: sources, previewModalState: previewModalState, isLoading: isSourceLoading || isPreselectedResourcePathLoading, onSourceSelect: handleSourceDrilldown, handleReload: handleSourceReload, error: sourceError })),
120
+ !source && (react_1.default.createElement(SourceList_1.default, { sources: sources, previewModalState: previewModalState, isLoading: isSourceLoading || isPreselectedResourcePathLoading, onSourceSelect: handleSourceDrilldown, handleReload: handleSourceReload, setSource: setSource, error: sourceError })),
107
121
  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 }))),
108
122
  react_1.default.createElement("div", { className: "sm:overflow-y-scroll sm:flex-1 sm:grow-[2] bg-white" },
109
123
  react_1.default.createElement(PreviewPanel_1.default, { resource: isResourcesLoading ? null : selectedResource, modalState: previewModalState, previewModalOverlayProps: previewModalOverlayProps, allowedTypes: allowedTypes, onSelect: handleDetailSelect, onClose: handleDetailClose })))));
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
- import type { Source, ScopedSource } from '../types';
3
- export default function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSourceSelect, }: {
2
+ import type { Source, ScopedSource, Resource } from '../types';
3
+ export default function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSourceSelect, setSource, currentResource, }: {
4
4
  sources: Source[];
5
5
  selectedSource: ScopedSource | null;
6
6
  isLoading: boolean;
7
7
  onRootSelect: () => void;
8
8
  onSourceSelect: (source: ScopedSource) => void;
9
+ setSource: (source: ScopedSource | null, path?: Resource[]) => void;
10
+ currentResource: Resource | null;
9
11
  }): React.JSX.Element;
@@ -31,8 +31,12 @@ const interactions_1 = require("@react-aria/interactions");
31
31
  const generic_browser_lib_1 = require("@squiz/generic-browser-lib");
32
32
  const uuid_1 = __importDefault(require("../utils/uuid"));
33
33
  const useCategorisedSources_1 = require("../Hooks/useCategorisedSources");
34
- function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSourceSelect, }) {
34
+ const useRecentLocations_1 = require("../Hooks/useRecentLocations");
35
+ const HistoryIcon_1 = require("../Icons/HistoryIcon");
36
+ function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSourceSelect, setSource, currentResource, }) {
35
37
  const categorisedSources = (0, useCategorisedSources_1.useCategorisedSources)(sources);
38
+ const { recentLocations } = (0, useRecentLocations_1.useRecentLocations)();
39
+ const [recentLocationSelection, setRecentLocationSelection] = (0, react_1.useState)();
36
40
  const [uniqueId] = (0, react_1.useState)((0, uuid_1.default)());
37
41
  const buttonRef = (0, react_1.useRef)(null);
38
42
  const [isOpen, setIsOpen] = (0, react_1.useState)(false);
@@ -53,20 +57,49 @@ function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSo
53
57
  });
54
58
  const handleSourceClick = (source) => {
55
59
  setIsOpen(false);
60
+ setRecentLocationSelection(null);
56
61
  buttonRef.current?.focus();
57
62
  onSourceSelect(source);
58
63
  };
59
64
  const handleRootSelect = () => {
60
65
  setIsOpen(false);
66
+ setRecentLocationSelection(null);
61
67
  buttonRef.current?.focus();
62
68
  onRootSelect();
63
69
  };
70
+ const handleRecentLocationClick = (location) => {
71
+ setIsOpen(false);
72
+ setRecentLocationSelection(location);
73
+ buttonRef.current?.focus();
74
+ setSource({
75
+ source: location.source,
76
+ resource: location.rootNode,
77
+ }, location.path);
78
+ };
79
+ (0, react_1.useEffect)(() => {
80
+ const lastResource = recentLocationSelection?.path[recentLocationSelection.path.length - 1];
81
+ // If the current resource selected in the resource browser is no longer the item selected in the
82
+ // recent locations section dropdown then we set the selection to null to prevent active statuses.
83
+ if (currentResource?.id !== lastResource?.id) {
84
+ setRecentLocationSelection(null);
85
+ }
86
+ }, [recentLocationSelection, currentResource]);
64
87
  return (react_1.default.createElement("div", { ...focusWithinProps, ...keyboardProps, className: "relative w-72 border-2 rounded border-gray-300" },
65
88
  react_1.default.createElement("button", { ref: buttonRef, type: "button", "aria-label": "Source quick select", "aria-expanded": isOpen, "aria-controls": `${uniqueId}-button-menu`, onClick: () => setIsOpen(!isOpen), className: "relative flex items-center text-sm font-semibold p-1.5 w-full" },
66
89
  selectedSource && (react_1.default.createElement(react_1.default.Fragment, null,
67
90
  react_1.default.createElement("span", { className: "sr-only" }, "current source "),
68
- react_1.default.createElement(generic_browser_lib_1.Icon, { icon: selectedSource.resource?.type.code, resourceSource: "matrix", "aria-hidden": true, className: "mr-2.5 h-[20px] w-[20px]" }),
69
- react_1.default.createElement("div", { className: "truncate max-w-[200px]" }, selectedSource.resource?.name || selectedSource.source.name))),
91
+ react_1.default.createElement(generic_browser_lib_1.Icon, { icon:
92
+ // Ignoring this specific line in test coverage because its a super niche issue that I could only replicate in Matrix
93
+ /* istanbul ignore next */
94
+ recentLocationSelection
95
+ ? currentResource?.type.code || selectedSource.resource?.type.code
96
+ : selectedSource.resource?.type.code, resourceSource: "matrix", "aria-hidden": true, className: "mr-2.5 h-[20px] w-[20px]" }),
97
+ react_1.default.createElement("div", { className: "truncate max-w-[200px]" },
98
+ // Ignoring this specific line in test coverage because its a super niche issue that I could only replicate in Matrix
99
+ /* istanbul ignore next */
100
+ recentLocationSelection
101
+ ? currentResource?.name || selectedSource.resource?.name || selectedSource.source.name
102
+ : selectedSource.resource?.name || selectedSource.source.name))),
70
103
  !selectedSource && (react_1.default.createElement(react_1.default.Fragment, null,
71
104
  react_1.default.createElement("span", { className: "sr-only" }, "view "),
72
105
  react_1.default.createElement(generic_browser_lib_1.Icon, { icon: 'root', "aria-hidden": true, className: "mr-2.5 h-[20px] w-[20px]" }),
@@ -79,13 +112,32 @@ function SourceDropdown({ sources, selectedSource, isLoading, onRootSelect, onSo
79
112
  "All available sources")),
80
113
  isLoading && (react_1.default.createElement("li", { className: "mt-2" },
81
114
  react_1.default.createElement(generic_browser_lib_1.Spinner, { size: "sm", label: "Loading sources", className: "m-3" }))),
115
+ !isLoading && recentLocations.length > 0 && (react_1.default.createElement("li", { className: `flex flex-col text-sm font-semibold text-grey-800` },
116
+ 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" },
117
+ react_1.default.createElement("span", { className: "z-10 bg-gray-100 px-2.5 flex gap-1 items-center" },
118
+ react_1.default.createElement(HistoryIcon_1.HistoryIcon, null),
119
+ "Recent locations")),
120
+ react_1.default.createElement("ul", { "aria-label": "recent location nodes", className: "flex flex-col mt-2" }, recentLocations.map((item, index) => {
121
+ const lastResource = item.path[item.path.length - 1];
122
+ const isSelectedSource = item.source?.id === selectedSource?.source.id &&
123
+ item.rootNode?.id === selectedSource?.resource?.id &&
124
+ lastResource?.id === currentResource?.id &&
125
+ recentLocationSelection;
126
+ return (react_1.default.createElement("li", { key: `${index}-${item.source?.id}-${item.rootNode?.id}`, className: "flex items-center bg-white border border-b-0 last:border-b border-grey-200 first:rounded-t last:rounded-b" },
127
+ react_1.default.createElement("button", { type: "button", onClick: () => handleRecentLocationClick(item), className: `relative grow flex items-center p-2.5 hover:bg-gray-100 focus:bg-gray-100` },
128
+ react_1.default.createElement(generic_browser_lib_1.Icon, { icon: (lastResource?.type.code || item.rootNode?.type.code || 'folder'), resourceSource: "matrix", "aria-label": lastResource?.name || item.rootNode?.name || item.source?.name, className: "shrink-0 mr-2.5" }),
129
+ react_1.default.createElement("span", { className: "text-left mr-7" }, lastResource?.name || item.rootNode?.name || item.source?.name),
130
+ isSelectedSource && (react_1.default.createElement(generic_browser_lib_1.Icon, { icon: 'selected', "aria-label": "selected", className: "absolute right-4" })))));
131
+ })))),
82
132
  !isLoading &&
83
133
  categorisedSources.map(({ key, label, sources }, index) => {
84
- return (react_1.default.createElement("li", { key: key, className: `flex flex-col text-sm font-semibold text-grey-800 ${index > 0 ? 'mt-3' : ''}` },
134
+ return (react_1.default.createElement("li", { key: key, className: `flex flex-col text-sm font-semibold text-grey-800 ${index > 0 || recentLocations.length > 0 ? 'mt-3' : ''}` },
85
135
  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" },
86
136
  react_1.default.createElement("span", { className: "z-10 bg-gray-100 px-2.5" }, label)),
87
137
  sources?.length > 0 && (react_1.default.createElement("ul", { "aria-label": `${label} nodes`, className: "flex flex-col mt-2" }, sources.map(({ source, resource }) => {
88
- const isSelectedSource = source.id === selectedSource?.source.id && resource?.id === selectedSource?.resource?.id;
138
+ const isSelectedSource = source.id === selectedSource?.source.id &&
139
+ resource?.id === selectedSource?.resource?.id &&
140
+ !recentLocationSelection;
89
141
  return (react_1.default.createElement("li", { key: `${source.id}-${resource?.id}`, className: "flex items-center bg-white border border-b-0 last:border-b border-grey-200 first:rounded-t last:rounded-b" },
90
142
  react_1.default.createElement("button", { type: "button", onClick: () => handleSourceClick({ source, resource }), className: `relative grow flex items-center p-2.5 hover:bg-gray-100 focus:bg-gray-100` },
91
143
  react_1.default.createElement(generic_browser_lib_1.Icon, { icon: resource?.type.code, resourceSource: "matrix", "aria-label": resource?.type.name, className: "shrink-0 mr-2.5" }),
@@ -1,14 +1,15 @@
1
1
  import React from 'react';
2
2
  import { OverlayTriggerState } from 'react-stately';
3
3
  import { DOMAttributes, FocusableElement } from '@react-types/shared';
4
- import { Source, ScopedSource } from '../types';
4
+ import { Source, ScopedSource, Resource } from '../types';
5
5
  export interface SourceListProps {
6
6
  sources: Source[];
7
7
  previewModalState: OverlayTriggerState;
8
8
  isLoading: boolean;
9
9
  onSourceSelect: (node: ScopedSource, overlayProps: DOMAttributes<FocusableElement>) => void;
10
10
  handleReload: () => void;
11
+ setSource: (source: ScopedSource | null, path?: Resource[]) => void;
11
12
  error: Error | null;
12
13
  }
13
- declare const SourceList: ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, error, }: SourceListProps) => React.JSX.Element;
14
+ declare const SourceList: ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, setSource, error, }: SourceListProps) => React.JSX.Element;
14
15
  export default SourceList;
@@ -30,9 +30,12 @@ const react_1 = __importStar(require("react"));
30
30
  const generic_browser_lib_1 = require("@squiz/generic-browser-lib");
31
31
  const clsx_1 = __importDefault(require("clsx"));
32
32
  const useCategorisedSources_1 = require("../Hooks/useCategorisedSources");
33
- const SourceList = function ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, error, }) {
33
+ const useRecentLocations_1 = require("../Hooks/useRecentLocations");
34
+ const HistoryIcon_1 = require("../Icons/HistoryIcon");
35
+ const SourceList = function ({ sources, previewModalState, isLoading, onSourceSelect, handleReload, setSource, error, }) {
34
36
  const categorisedSources = (0, useCategorisedSources_1.useCategorisedSources)(sources);
35
37
  const listRef = (0, react_1.useRef)(null);
38
+ const { recentLocations } = (0, useRecentLocations_1.useRecentLocations)();
36
39
  (0, react_1.useEffect)(() => {
37
40
  if (listRef.current) {
38
41
  listRef.current?.focus({
@@ -47,9 +50,23 @@ const SourceList = function ({ sources, previewModalState, isLoading, onSourceSe
47
50
  }
48
51
  return (react_1.default.createElement("ul", { ref: listRef, tabIndex: -1, "aria-label": `Source list`, className: (0, clsx_1.default)('flex flex-col bg-gray-100 min-h-full focus-visible:outline-0 px-7 py-4') },
49
52
  error && react_1.default.createElement(generic_browser_lib_1.ResourceState, { state: "error", message: error.message, handleReload: handleReload }),
53
+ !error && recentLocations.length > 0 && (react_1.default.createElement("li", { className: `flex flex-col text-sm font-semibold text-grey-800` },
54
+ 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" },
55
+ react_1.default.createElement("span", { className: "z-10 bg-gray-100 px-2.5 flex gap-1 items-center" },
56
+ react_1.default.createElement(HistoryIcon_1.HistoryIcon, null),
57
+ "Recent locations")),
58
+ react_1.default.createElement("ul", { "aria-label": `recent location nodes`, className: "flex flex-col" }, recentLocations.map((item, index) => {
59
+ const lastResource = item.path[item.path.length - 1];
60
+ return (react_1.default.createElement(generic_browser_lib_1.ResourceItem, { key: `${index}-${item.source?.id}-${item.rootNode?.id}`, item: { source: item.source, resource: item.rootNode }, label: lastResource?.name || item.rootNode?.name || item?.source.name, type: lastResource?.type?.code || item.rootNode?.type?.code || 'folder', previewModalState: previewModalState, onSelect: () => {
61
+ setSource({
62
+ source: item.source,
63
+ resource: item.rootNode,
64
+ }, item.path);
65
+ }, className: (0, clsx_1.default)(index === 0 && 'rounded-t-lg mt-3', index === recentLocations.length - 1 && 'rounded-b-lg'), showChevron: true }));
66
+ })))),
50
67
  !error &&
51
68
  categorisedSources.map(({ key, label, sources }, index) => {
52
- return (react_1.default.createElement("li", { key: key, className: `flex flex-col text-sm font-semibold text-grey-800 ${index > 0 ? 'mt-3' : ''}` },
69
+ return (react_1.default.createElement("li", { key: key, className: `flex flex-col text-sm font-semibold text-grey-800 ${index > 0 || recentLocations.length > 0 ? 'mt-3' : ''}` },
53
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" },
54
71
  react_1.default.createElement("span", { className: "z-10 bg-gray-100 px-2.5" }, label)),
55
72
  sources.length > 0 && (react_1.default.createElement("ul", { "aria-label": `${label} nodes`, className: "flex flex-col" }, sources.map(({ source, resource }) => {
package/lib/index.css CHANGED
@@ -749,6 +749,14 @@
749
749
  .squiz-rb-scope .rounded-lg {
750
750
  border-radius: 0.5rem;
751
751
  }
752
+ .squiz-rb-scope .rounded-b-lg {
753
+ border-bottom-right-radius: 0.5rem;
754
+ border-bottom-left-radius: 0.5rem;
755
+ }
756
+ .squiz-rb-scope .rounded-t-lg {
757
+ border-top-left-radius: 0.5rem;
758
+ border-top-right-radius: 0.5rem;
759
+ }
752
760
  .squiz-rb-scope .border {
753
761
  border-width: 1px;
754
762
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/resource-browser",
3
- "version": "1.67.1",
3
+ "version": "1.68.0",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "private": false,
@@ -0,0 +1,85 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useRecentLocations } from './useRecentLocations';
3
+
4
+ import { mockResource, mockSource } from '../__mocks__/MockModels';
5
+
6
+ describe('useRecentLocations', () => {
7
+ const mockLocalStorageData = [
8
+ {
9
+ path: [],
10
+ source: {
11
+ id: '1',
12
+ name: 'Test source',
13
+ nodes: [],
14
+ },
15
+ rootNode: {
16
+ childCount: 0,
17
+ id: '1',
18
+ lineages: [],
19
+ name: 'Test resource',
20
+ status: {
21
+ code: 'live',
22
+ name: 'Live',
23
+ },
24
+ type: {
25
+ code: 'folder',
26
+ name: 'Folder',
27
+ },
28
+ url: 'https://no-where.com',
29
+ urls: [],
30
+ },
31
+ },
32
+ ];
33
+
34
+ beforeEach(() => {
35
+ localStorage.clear();
36
+ });
37
+
38
+ it('should initialize with empty array if no recent locations in storage', () => {
39
+ const { result } = renderHook(() => useRecentLocations());
40
+ expect(result.current.recentLocations).toEqual([]);
41
+ });
42
+
43
+ it('should add a new recent location', () => {
44
+ const { result } = renderHook(() => useRecentLocations());
45
+
46
+ act(() => {
47
+ result.current.addRecentLocation({
48
+ path: [],
49
+ source: mockSource(),
50
+ rootNode: mockResource(),
51
+ });
52
+ });
53
+
54
+ expect(result.current.recentLocations).toEqual(mockLocalStorageData);
55
+ });
56
+
57
+ it('should not add duplicate recent locations', () => {
58
+ const { result } = renderHook(() => useRecentLocations());
59
+
60
+ act(() => {
61
+ result.current.addRecentLocation({
62
+ path: [],
63
+ source: mockSource(),
64
+ rootNode: mockResource(),
65
+ });
66
+
67
+ // Add duplicate
68
+ result.current.addRecentLocation({
69
+ path: [],
70
+ source: mockSource(),
71
+ rootNode: mockResource(),
72
+ });
73
+ });
74
+
75
+ expect(result.current.recentLocations).toEqual(mockLocalStorageData);
76
+ });
77
+
78
+ it('should load recent locations from local storage on mount', () => {
79
+ localStorage.setItem('rb_recent_locations', JSON.stringify(mockLocalStorageData));
80
+
81
+ const { result } = renderHook(() => useRecentLocations());
82
+
83
+ expect(result.current.recentLocations).toEqual(mockLocalStorageData);
84
+ });
85
+ });
@@ -0,0 +1,45 @@
1
+ import { useState } from 'react';
2
+ import { Resource, Source } from '../types';
3
+
4
+ export interface RecentLocation {
5
+ rootNode: Resource | null;
6
+ source: Source;
7
+ path: Array<Resource>;
8
+ }
9
+
10
+ export const useRecentLocations = (maxLocations = 3, storageKey = 'rb_recent_locations') => {
11
+ let initialRecentLocations = [];
12
+
13
+ try {
14
+ initialRecentLocations = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
15
+ } catch (_ignored) {
16
+ // Ignore this error (edge case someone messing with the local storage...)
17
+ }
18
+
19
+ // We just have an extra safety check here in case local storage has been messed with and set as {} for example
20
+ if (!Array.isArray(initialRecentLocations)) {
21
+ initialRecentLocations = [];
22
+ }
23
+
24
+ const [recentLocations, setRecentLocations] = useState<Array<RecentLocation>>(initialRecentLocations);
25
+
26
+ const addRecentLocation = (newLocation: RecentLocation) => {
27
+ // Check if the new location to make sure we don't already have a recent location for this
28
+ if (JSON.stringify(recentLocations).indexOf(JSON.stringify(newLocation)) > -1) {
29
+ return;
30
+ }
31
+
32
+ const updatedLocations = [newLocation, ...recentLocations.slice(0, maxLocations - 1)];
33
+
34
+ // Set state
35
+ setRecentLocations(updatedLocations);
36
+
37
+ // Update local storage
38
+ localStorage.setItem(storageKey, JSON.stringify(updatedLocations));
39
+ };
40
+
41
+ return {
42
+ recentLocations,
43
+ addRecentLocation,
44
+ };
45
+ };
@@ -0,0 +1,17 @@
1
+ import React, { SVGAttributes } from 'react';
2
+
3
+ type HistoryIconProps = SVGAttributes<SVGElement>;
4
+
5
+ export const HistoryIcon = (props: HistoryIconProps) => {
6
+ return (
7
+ <svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
8
+ <g id="icon">
9
+ <path
10
+ id="Vector"
11
+ d="M10.5531 2.25131C6.73555 2.14631 3.60805 5.21381 3.60805 9.00131H2.26556C1.92806 9.00131 1.76306 9.40631 2.00306 9.63881L4.09556 11.7388C4.24555 11.8888 4.47806 11.8888 4.62806 11.7388L6.72055 9.63881C6.95305 9.40631 6.78805 9.00131 6.45055 9.00131H5.10805C5.10805 6.07631 7.49305 3.71381 10.4331 3.75131C13.2231 3.78881 15.5706 6.1363 15.6081 8.9263C15.6456 11.8588 13.2831 14.2513 10.3581 14.2513C9.15055 14.2513 8.03305 13.8388 7.14806 13.1413C6.84805 12.9088 6.42805 12.9313 6.15805 13.2013C5.84305 13.5163 5.86555 14.0488 6.21805 14.3188C7.35805 15.2188 8.79055 15.7513 10.3581 15.7513C14.1456 15.7513 17.2131 12.6238 17.1081 8.8063C17.0106 5.28881 14.0706 2.34881 10.5531 2.25131ZM10.1706 6.0013C9.86305 6.0013 9.60805 6.2563 9.60805 6.56381V9.32381C9.60805 9.58631 9.75055 9.83381 9.97555 9.9688L12.3156 11.3563C12.5856 11.5138 12.9306 11.4238 13.0881 11.1613C13.2456 10.8913 13.1556 10.5463 12.8931 10.3888L10.7331 9.10631V6.5563C10.7331 6.2563 10.4781 6.0013 10.1706 6.0013Z"
12
+ fill="#4F4F4F"
13
+ />
14
+ </g>
15
+ </svg>
16
+ );
17
+ };
@@ -19,6 +19,7 @@ import { useResourcePath } from '../Hooks/useResourcePath';
19
19
  import { useChildResources } from '../Hooks/useChildResources';
20
20
  import { useSources } from '../Hooks/useSources';
21
21
  import { usePreselectedResourcePath } from '../Hooks/usePreselectedResourcePath';
22
+ import { useRecentLocations } from '../Hooks/useRecentLocations';
22
23
 
23
24
  interface ResourcePickerContainerProps {
24
25
  title: string;
@@ -49,6 +50,8 @@ function ResourcePickerContainer({
49
50
  const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
50
51
  const [previewModalOverlayProps, setPreviewModalOverlayProps] = useState<DOMAttributes>({});
51
52
  const { source, currentResource, hierarchy, setSource, push, popUntil } = useResourcePath();
53
+ const { addRecentLocation } = useRecentLocations();
54
+
52
55
  const {
53
56
  data: sources,
54
57
  isLoading: isSourceLoading,
@@ -101,9 +104,25 @@ function ResourcePickerContainer({
101
104
  const handleDetailSelect = useCallback(
102
105
  (resource: Resource) => {
103
106
  onChange({ resource, source: source?.source as Source });
107
+
108
+ // Find the path that got them to where they are
109
+ const selectedPath = hierarchy.map((path) => {
110
+ const pathNode = path.node as ScopedSource;
111
+ return 'resource' in pathNode ? pathNode.resource : pathNode;
112
+ });
113
+
114
+ const [rootNode, ...path] = selectedPath;
115
+
116
+ // Update the recent locations in local storage
117
+ addRecentLocation({
118
+ rootNode,
119
+ path: path as Resource[],
120
+ source: source?.source as Source,
121
+ });
122
+
104
123
  onClose();
105
124
  },
106
- [source],
125
+ [source, currentResource],
107
126
  );
108
127
 
109
128
  const handleDetailClose = useCallback(() => {
@@ -150,6 +169,8 @@ function ResourcePickerContainer({
150
169
  isLoading={isSourceLoading}
151
170
  onSourceSelect={handleSourceDrilldown}
152
171
  onRootSelect={handleReturnToRoot}
172
+ setSource={setSource}
173
+ currentResource={currentResource}
153
174
  />
154
175
  </div>
155
176
  <button
@@ -183,6 +204,7 @@ function ResourcePickerContainer({
183
204
  isLoading={isSourceLoading || isPreselectedResourcePathLoading}
184
205
  onSourceSelect={handleSourceDrilldown}
185
206
  handleReload={handleSourceReload}
207
+ setSource={setSource}
186
208
  error={sourceError}
187
209
  />
188
210
  )}
@@ -4,7 +4,34 @@ import { screen, render, waitFor } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import SourceDropdown from './SourceDropdown';
6
6
  import { Source } from '../types';
7
- import { mockScopedSource, mockSource } from '../__mocks__/MockModels';
7
+ import { mockScopedSource, mockSource, mockResource } from '../__mocks__/MockModels';
8
+
9
+ const mockLocalStorageData = [
10
+ {
11
+ path: [],
12
+ source: {
13
+ id: '1',
14
+ name: 'Test source',
15
+ nodes: [],
16
+ },
17
+ rootNode: {
18
+ childCount: 0,
19
+ id: '1',
20
+ lineages: [],
21
+ name: 'Test resource',
22
+ status: {
23
+ code: 'live',
24
+ name: 'Live',
25
+ },
26
+ type: {
27
+ code: 'folder',
28
+ name: 'Folder',
29
+ },
30
+ url: 'https://no-where.com',
31
+ urls: [],
32
+ },
33
+ },
34
+ ];
8
35
 
9
36
  const sources: Source[] = [
10
37
  mockSource({
@@ -66,6 +93,8 @@ describe('SourceDropdown', () => {
66
93
  isLoading={false}
67
94
  onRootSelect={() => {}}
68
95
  onSourceSelect={() => {}}
96
+ setSource={() => {}}
97
+ currentResource={mockResource()}
69
98
  />,
70
99
  );
71
100
 
@@ -84,6 +113,8 @@ describe('SourceDropdown', () => {
84
113
  isLoading={true}
85
114
  onRootSelect={() => {}}
86
115
  onSourceSelect={() => {}}
116
+ setSource={() => {}}
117
+ currentResource={mockResource()}
87
118
  />,
88
119
  );
89
120
 
@@ -107,6 +138,8 @@ describe('SourceDropdown', () => {
107
138
  isLoading={false}
108
139
  onRootSelect={() => {}}
109
140
  onSourceSelect={() => {}}
141
+ setSource={() => {}}
142
+ currentResource={mockResource()}
110
143
  />,
111
144
  );
112
145
 
@@ -125,6 +158,8 @@ describe('SourceDropdown', () => {
125
158
  isLoading={false}
126
159
  onRootSelect={() => {}}
127
160
  onSourceSelect={() => {}}
161
+ setSource={() => {}}
162
+ currentResource={mockResource()}
128
163
  />,
129
164
  );
130
165
 
@@ -149,6 +184,8 @@ describe('SourceDropdown', () => {
149
184
  isLoading={false}
150
185
  onRootSelect={() => {}}
151
186
  onSourceSelect={() => {}}
187
+ setSource={() => {}}
188
+ currentResource={mockResource()}
152
189
  />
153
190
  <input />
154
191
  </div>,
@@ -190,6 +227,8 @@ describe('SourceDropdown', () => {
190
227
  isLoading={false}
191
228
  onRootSelect={() => {}}
192
229
  onSourceSelect={() => {}}
230
+ setSource={() => {}}
231
+ currentResource={mockResource()}
193
232
  />
194
233
  <input />
195
234
  </div>,
@@ -232,6 +271,8 @@ describe('SourceDropdown', () => {
232
271
  isLoading={false}
233
272
  onRootSelect={onRootSelect}
234
273
  onSourceSelect={() => {}}
274
+ setSource={() => {}}
275
+ currentResource={mockResource()}
235
276
  />,
236
277
  );
237
278
 
@@ -255,6 +296,8 @@ describe('SourceDropdown', () => {
255
296
  isLoading={false}
256
297
  onRootSelect={() => {}}
257
298
  onSourceSelect={onSourceSelect}
299
+ setSource={() => {}}
300
+ currentResource={mockResource()}
258
301
  />,
259
302
  );
260
303
 
@@ -267,4 +310,59 @@ describe('SourceDropdown', () => {
267
310
  expect(screen.queryByRole('button', { name: 'All available sources' })).toBeFalsy();
268
311
  });
269
312
  });
313
+
314
+ it('Recent location sources are rendered when dropdown clicked', async () => {
315
+ localStorage.setItem('rb_recent_locations', JSON.stringify(mockLocalStorageData));
316
+
317
+ render(
318
+ <SourceDropdown
319
+ sources={sources}
320
+ selectedSource={null}
321
+ isLoading={false}
322
+ onRootSelect={() => {}}
323
+ onSourceSelect={() => {}}
324
+ setSource={() => {}}
325
+ currentResource={mockResource()}
326
+ />,
327
+ );
328
+
329
+ const user = userEvent.setup();
330
+ user.click(screen.getByRole('button', { name: 'Source quick select' }));
331
+
332
+ await waitFor(() => {
333
+ expect(screen.getByText('Test resource')).toBeInTheDocument();
334
+ });
335
+ });
336
+
337
+ it('Selecting recent location calls setSource ', async () => {
338
+ const onSourceSelect = jest.fn();
339
+ const setSource = jest.fn();
340
+
341
+ render(
342
+ <SourceDropdown
343
+ sources={sources}
344
+ selectedSource={null}
345
+ isLoading={false}
346
+ onRootSelect={() => {}}
347
+ onSourceSelect={onSourceSelect}
348
+ setSource={setSource}
349
+ currentResource={mockResource()}
350
+ />,
351
+ );
352
+
353
+ const user = userEvent.setup();
354
+ await user.click(screen.getByRole('button', { name: 'Source quick select' }));
355
+ await user.click(screen.getByText('Test resource'));
356
+
357
+ await waitFor(() => {
358
+ expect(setSource).toHaveBeenCalledWith(
359
+ {
360
+ source: mockLocalStorageData[0].source,
361
+ resource: mockLocalStorageData[0].rootNode,
362
+ },
363
+ mockLocalStorageData[0].path,
364
+ );
365
+ expect(screen.queryByRole('button', { name: 'All available sources' })).toBeFalsy();
366
+ });
367
+ });
270
368
  });
@@ -1,11 +1,13 @@
1
- import React, { useState, useRef } from 'react';
1
+ import React, { useState, useRef, useEffect } from 'react';
2
2
  import { useFocusWithin, useKeyboard } from '@react-aria/interactions';
3
3
  import { Icon, IconOptions, Spinner } from '@squiz/generic-browser-lib';
4
4
 
5
- import type { Source, ScopedSource } from '../types';
5
+ import type { Source, ScopedSource, Resource } from '../types';
6
6
 
7
7
  import uuid from '../utils/uuid';
8
8
  import { useCategorisedSources } from '../Hooks/useCategorisedSources';
9
+ import { useRecentLocations, RecentLocation } from '../Hooks/useRecentLocations';
10
+ import { HistoryIcon } from '../Icons/HistoryIcon';
9
11
 
10
12
  export default function SourceDropdown({
11
13
  sources,
@@ -13,14 +15,21 @@ export default function SourceDropdown({
13
15
  isLoading,
14
16
  onRootSelect,
15
17
  onSourceSelect,
18
+ setSource,
19
+ currentResource,
16
20
  }: {
17
21
  sources: Source[];
18
22
  selectedSource: ScopedSource | null;
19
23
  isLoading: boolean;
20
24
  onRootSelect: () => void;
21
25
  onSourceSelect: (source: ScopedSource) => void;
26
+ setSource: (source: ScopedSource | null, path?: Resource[]) => void;
27
+ currentResource: Resource | null;
22
28
  }) {
23
29
  const categorisedSources = useCategorisedSources(sources);
30
+ const { recentLocations } = useRecentLocations();
31
+ const [recentLocationSelection, setRecentLocationSelection] = useState<RecentLocation | null>();
32
+
24
33
  const [uniqueId] = useState(uuid());
25
34
  const buttonRef = useRef<HTMLButtonElement>(null);
26
35
  const [isOpen, setIsOpen] = useState(false);
@@ -44,16 +53,40 @@ export default function SourceDropdown({
44
53
 
45
54
  const handleSourceClick = (source: ScopedSource) => {
46
55
  setIsOpen(false);
56
+ setRecentLocationSelection(null);
47
57
  buttonRef.current?.focus();
48
58
  onSourceSelect(source);
49
59
  };
50
60
 
51
61
  const handleRootSelect = () => {
52
62
  setIsOpen(false);
63
+ setRecentLocationSelection(null);
53
64
  buttonRef.current?.focus();
54
65
  onRootSelect();
55
66
  };
56
67
 
68
+ const handleRecentLocationClick = (location: RecentLocation) => {
69
+ setIsOpen(false);
70
+ setRecentLocationSelection(location);
71
+ buttonRef.current?.focus();
72
+ setSource(
73
+ {
74
+ source: location.source as Source,
75
+ resource: location.rootNode,
76
+ },
77
+ location.path,
78
+ );
79
+ };
80
+
81
+ useEffect(() => {
82
+ const lastResource = recentLocationSelection?.path[recentLocationSelection.path.length - 1];
83
+ // If the current resource selected in the resource browser is no longer the item selected in the
84
+ // recent locations section dropdown then we set the selection to null to prevent active statuses.
85
+ if (currentResource?.id !== lastResource?.id) {
86
+ setRecentLocationSelection(null);
87
+ }
88
+ }, [recentLocationSelection, currentResource]);
89
+
57
90
  return (
58
91
  <div {...focusWithinProps} {...keyboardProps} className="relative w-72 border-2 rounded border-gray-300">
59
92
  <button
@@ -69,12 +102,26 @@ export default function SourceDropdown({
69
102
  <>
70
103
  <span className="sr-only">current source </span>
71
104
  <Icon
72
- icon={selectedSource.resource?.type.code as IconOptions}
105
+ icon={
106
+ // Ignoring this specific line in test coverage because its a super niche issue that I could only replicate in Matrix
107
+ /* istanbul ignore next */
108
+ recentLocationSelection
109
+ ? currentResource?.type.code || selectedSource.resource?.type.code
110
+ : selectedSource.resource?.type.code
111
+ }
73
112
  resourceSource="matrix"
74
113
  aria-hidden
75
114
  className="mr-2.5 h-[20px] w-[20px]"
76
115
  />
77
- <div className="truncate max-w-[200px]">{selectedSource.resource?.name || selectedSource.source.name}</div>
116
+ <div className="truncate max-w-[200px]">
117
+ {
118
+ // Ignoring this specific line in test coverage because its a super niche issue that I could only replicate in Matrix
119
+ /* istanbul ignore next */
120
+ recentLocationSelection
121
+ ? currentResource?.name || selectedSource.resource?.name || selectedSource.source.name
122
+ : selectedSource.resource?.name || selectedSource.source.name
123
+ }
124
+ </div>
78
125
  </>
79
126
  )}
80
127
 
@@ -113,10 +160,63 @@ export default function SourceDropdown({
113
160
  <Spinner size="sm" label="Loading sources" className="m-3" />
114
161
  </li>
115
162
  )}
163
+
164
+ {!isLoading && recentLocations.length > 0 && (
165
+ <li className={`flex flex-col text-sm font-semibold text-grey-800`}>
166
+ <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">
167
+ <span className="z-10 bg-gray-100 px-2.5 flex gap-1 items-center">
168
+ <HistoryIcon />
169
+ Recent locations
170
+ </span>
171
+ </div>
172
+ <ul aria-label="recent location nodes" className="flex flex-col mt-2">
173
+ {recentLocations.map((item, index) => {
174
+ const lastResource = item.path[item.path.length - 1];
175
+ const isSelectedSource =
176
+ item.source?.id === selectedSource?.source.id &&
177
+ item.rootNode?.id === selectedSource?.resource?.id &&
178
+ lastResource?.id === currentResource?.id &&
179
+ recentLocationSelection;
180
+
181
+ return (
182
+ <li
183
+ key={`${index}-${item.source?.id}-${item.rootNode?.id}`}
184
+ className="flex items-center bg-white border border-b-0 last:border-b border-grey-200 first:rounded-t last:rounded-b"
185
+ >
186
+ <button
187
+ type="button"
188
+ onClick={() => handleRecentLocationClick(item)}
189
+ className={`relative grow flex items-center p-2.5 hover:bg-gray-100 focus:bg-gray-100`}
190
+ >
191
+ <Icon
192
+ icon={(lastResource?.type.code || item.rootNode?.type.code || 'folder') as IconOptions}
193
+ resourceSource="matrix"
194
+ aria-label={lastResource?.name || item.rootNode?.name || item.source?.name}
195
+ className="shrink-0 mr-2.5"
196
+ />
197
+ <span className="text-left mr-7">
198
+ {lastResource?.name || item.rootNode?.name || item.source?.name}
199
+ </span>
200
+ {isSelectedSource && (
201
+ <Icon icon={'selected' as IconOptions} aria-label="selected" className="absolute right-4" />
202
+ )}
203
+ </button>
204
+ </li>
205
+ );
206
+ })}
207
+ </ul>
208
+ </li>
209
+ )}
210
+
116
211
  {!isLoading &&
117
212
  categorisedSources.map(({ key, label, sources }, index) => {
118
213
  return (
119
- <li key={key} className={`flex flex-col text-sm font-semibold text-grey-800 ${index > 0 ? 'mt-3' : ''}`}>
214
+ <li
215
+ key={key}
216
+ className={`flex flex-col text-sm font-semibold text-grey-800 ${
217
+ index > 0 || recentLocations.length > 0 ? 'mt-3' : ''
218
+ }`}
219
+ >
120
220
  <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">
121
221
  <span className="z-10 bg-gray-100 px-2.5">{label}</span>
122
222
  </div>
@@ -124,7 +224,9 @@ export default function SourceDropdown({
124
224
  <ul aria-label={`${label} nodes`} className="flex flex-col mt-2">
125
225
  {sources.map(({ source, resource }) => {
126
226
  const isSelectedSource =
127
- source.id === selectedSource?.source.id && resource?.id === selectedSource?.resource?.id;
227
+ source.id === selectedSource?.source.id &&
228
+ resource?.id === selectedSource?.resource?.id &&
229
+ !recentLocationSelection;
128
230
 
129
231
  return (
130
232
  <li
@@ -7,6 +7,33 @@ import { useOverlayTriggerState, OverlayTriggerState } from 'react-stately';
7
7
  import SourceList from './SourceList';
8
8
  import { Source } from '../types';
9
9
 
10
+ const mockLocalStorageData = [
11
+ {
12
+ path: [],
13
+ source: {
14
+ id: '1',
15
+ name: 'Test source',
16
+ nodes: [],
17
+ },
18
+ rootNode: {
19
+ childCount: 0,
20
+ id: '1',
21
+ lineages: [],
22
+ name: 'Test resource',
23
+ status: {
24
+ code: 'live',
25
+ name: 'Live',
26
+ },
27
+ type: {
28
+ code: 'folder',
29
+ name: 'Folder',
30
+ },
31
+ url: 'https://no-where.com',
32
+ urls: [],
33
+ },
34
+ },
35
+ ];
36
+
10
37
  const sources: Source[] = [
11
38
  mockSource({
12
39
  id: '1',
@@ -82,6 +109,7 @@ describe('SourceList', () => {
82
109
  onSourceSelect={() => {}}
83
110
  error={null}
84
111
  handleReload={reload}
112
+ setSource={() => {}}
85
113
  />
86
114
  );
87
115
  }}
@@ -107,6 +135,7 @@ describe('SourceList', () => {
107
135
  onSourceSelect={() => {}}
108
136
  error={null}
109
137
  handleReload={reload}
138
+ setSource={() => {}}
110
139
  />
111
140
  );
112
141
  }}
@@ -133,6 +162,7 @@ describe('SourceList', () => {
133
162
  onSourceSelect={() => {}}
134
163
  error={null}
135
164
  handleReload={reload}
165
+ setSource={() => {}}
136
166
  />
137
167
  );
138
168
  }}
@@ -158,6 +188,7 @@ describe('SourceList', () => {
158
188
  onSourceSelect={() => {}}
159
189
  error={null}
160
190
  handleReload={reload}
191
+ setSource={() => {}}
161
192
  />
162
193
  );
163
194
  }}
@@ -190,6 +221,7 @@ describe('SourceList', () => {
190
221
  onSourceSelect={onSourceSelect}
191
222
  error={null}
192
223
  handleReload={reload}
224
+ setSource={() => {}}
193
225
  />
194
226
  );
195
227
  }}
@@ -226,6 +258,7 @@ describe('SourceList', () => {
226
258
  onSourceSelect={() => {}}
227
259
  error={new Error('Source list error!')}
228
260
  handleReload={reload}
261
+ setSource={() => {}}
229
262
  />
230
263
  );
231
264
  }}
@@ -237,4 +270,117 @@ describe('SourceList', () => {
237
270
  expect(errorMessage).toBeInTheDocument();
238
271
  });
239
272
  });
273
+
274
+ it('Renders recent locations section when local storage has data present', async () => {
275
+ const reload = jest.fn();
276
+ const setSource = jest.fn();
277
+
278
+ localStorage.setItem('rb_recent_locations', JSON.stringify(mockLocalStorageData));
279
+
280
+ render(
281
+ <SourceListTestWrapper
282
+ constructFunction={(previewModalState) => {
283
+ return (
284
+ <SourceList
285
+ sources={sources}
286
+ previewModalState={previewModalState}
287
+ isLoading={false}
288
+ onSourceSelect={() => {}}
289
+ error={null}
290
+ handleReload={reload}
291
+ setSource={setSource}
292
+ />
293
+ );
294
+ }}
295
+ />,
296
+ );
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByText('Recent locations')).toBeInTheDocument();
300
+ expect(screen.getByText('Test resource')).toBeInTheDocument();
301
+ });
302
+ });
303
+
304
+ it('Handles setSource on click of recent locations item', async () => {
305
+ const reload = jest.fn();
306
+ const setSource = jest.fn();
307
+
308
+ localStorage.setItem('rb_recent_locations', JSON.stringify(mockLocalStorageData));
309
+
310
+ render(
311
+ <SourceListTestWrapper
312
+ constructFunction={(previewModalState) => {
313
+ return (
314
+ <SourceList
315
+ sources={sources}
316
+ previewModalState={previewModalState}
317
+ isLoading={false}
318
+ onSourceSelect={() => {}}
319
+ error={null}
320
+ handleReload={reload}
321
+ setSource={setSource}
322
+ />
323
+ );
324
+ }}
325
+ />,
326
+ );
327
+
328
+ const user = userEvent.setup();
329
+ const itemButton = screen.getByRole('button', { name: 'Drill down to Test resource children' });
330
+ user.click(itemButton);
331
+
332
+ await waitFor(() => {
333
+ // Provides the item that was clicked and an id reference to the button that was clicked
334
+ expect(setSource).toHaveBeenCalledWith(
335
+ {
336
+ source: mockLocalStorageData[0].source,
337
+ resource: mockLocalStorageData[0].rootNode,
338
+ },
339
+ mockLocalStorageData[0].path,
340
+ );
341
+ });
342
+ });
343
+
344
+ it('Uses source name if resource name is not available', async () => {
345
+ const reload = jest.fn();
346
+ const setSource = jest.fn();
347
+
348
+ localStorage.setItem(
349
+ 'rb_recent_locations',
350
+ JSON.stringify([
351
+ {
352
+ path: [],
353
+ source: {
354
+ id: '1',
355
+ name: 'Test source',
356
+ nodes: [],
357
+ },
358
+ rootNode: null,
359
+ },
360
+ ]),
361
+ );
362
+
363
+ render(
364
+ <SourceListTestWrapper
365
+ constructFunction={(previewModalState) => {
366
+ return (
367
+ <SourceList
368
+ sources={sources}
369
+ previewModalState={previewModalState}
370
+ isLoading={false}
371
+ onSourceSelect={() => {}}
372
+ error={null}
373
+ handleReload={reload}
374
+ setSource={setSource}
375
+ />
376
+ );
377
+ }}
378
+ />,
379
+ );
380
+
381
+ await waitFor(() => {
382
+ expect(screen.getByText('Recent locations')).toBeInTheDocument();
383
+ expect(screen.getByText('Test source')).toBeInTheDocument();
384
+ });
385
+ });
240
386
  });
@@ -4,8 +4,10 @@ import { DOMAttributes, FocusableElement } from '@react-types/shared';
4
4
  import { SkeletonList, ResourceItem, ResourceState } from '@squiz/generic-browser-lib';
5
5
  import clsx from 'clsx';
6
6
 
7
- import { Source, ScopedSource } from '../types';
7
+ import { Source, ScopedSource, Resource } from '../types';
8
8
  import { useCategorisedSources } from '../Hooks/useCategorisedSources';
9
+ import { useRecentLocations } from '../Hooks/useRecentLocations';
10
+ import { HistoryIcon } from '../Icons/HistoryIcon';
9
11
 
10
12
  export interface SourceListProps {
11
13
  sources: Source[];
@@ -13,6 +15,7 @@ export interface SourceListProps {
13
15
  isLoading: boolean;
14
16
  onSourceSelect: (node: ScopedSource, overlayProps: DOMAttributes<FocusableElement>) => void;
15
17
  handleReload: () => void;
18
+ setSource: (source: ScopedSource | null, path?: Resource[]) => void;
16
19
  error: Error | null;
17
20
  }
18
21
 
@@ -22,10 +25,12 @@ const SourceList = function ({
22
25
  isLoading,
23
26
  onSourceSelect,
24
27
  handleReload,
28
+ setSource,
25
29
  error,
26
30
  }: SourceListProps) {
27
31
  const categorisedSources = useCategorisedSources(sources);
28
32
  const listRef = useRef<HTMLUListElement>(null);
33
+ const { recentLocations } = useRecentLocations();
29
34
 
30
35
  useEffect(() => {
31
36
  if (listRef.current) {
@@ -53,10 +58,54 @@ const SourceList = function ({
53
58
  >
54
59
  {error && <ResourceState state="error" message={error.message} handleReload={handleReload} />}
55
60
 
61
+ {!error && recentLocations.length > 0 && (
62
+ <li className={`flex flex-col text-sm font-semibold text-grey-800`}>
63
+ <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">
64
+ <span className="z-10 bg-gray-100 px-2.5 flex gap-1 items-center">
65
+ <HistoryIcon />
66
+ Recent locations
67
+ </span>
68
+ </div>
69
+ <ul aria-label={`recent location nodes`} className="flex flex-col">
70
+ {recentLocations.map((item, index) => {
71
+ const lastResource = item.path[item.path.length - 1];
72
+ return (
73
+ <ResourceItem
74
+ key={`${index}-${item.source?.id}-${item.rootNode?.id}`}
75
+ item={{ source: item.source, resource: item.rootNode }}
76
+ label={lastResource?.name || item.rootNode?.name || item?.source.name}
77
+ type={lastResource?.type?.code || item.rootNode?.type?.code || 'folder'}
78
+ previewModalState={previewModalState}
79
+ onSelect={() => {
80
+ setSource(
81
+ {
82
+ source: item.source as Source,
83
+ resource: item.rootNode,
84
+ },
85
+ item.path,
86
+ );
87
+ }}
88
+ className={clsx(
89
+ index === 0 && 'rounded-t-lg mt-3',
90
+ index === recentLocations.length - 1 && 'rounded-b-lg',
91
+ )}
92
+ showChevron
93
+ />
94
+ );
95
+ })}
96
+ </ul>
97
+ </li>
98
+ )}
99
+
56
100
  {!error &&
57
101
  categorisedSources.map(({ key, label, sources }, index) => {
58
102
  return (
59
- <li key={key} className={`flex flex-col text-sm font-semibold text-grey-800 ${index > 0 ? 'mt-3' : ''}`}>
103
+ <li
104
+ key={key}
105
+ className={`flex flex-col text-sm font-semibold text-grey-800 ${
106
+ index > 0 || recentLocations.length > 0 ? 'mt-3' : ''
107
+ }`}
108
+ >
60
109
  <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">
61
110
  <span className="z-10 bg-gray-100 px-2.5">{label}</span>
62
111
  </div>