@squiz/resource-browser 1.67.0 → 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 +12 -0
- package/lib/Hooks/useRecentLocations.d.ts +10 -0
- package/lib/Hooks/useRecentLocations.js +34 -0
- package/lib/Icons/HistoryIcon.d.ts +4 -0
- package/lib/Icons/HistoryIcon.js +13 -0
- package/lib/ResourcePickerContainer/ResourcePickerContainer.js +17 -3
- package/lib/SourceDropdown/SourceDropdown.d.ts +4 -2
- package/lib/SourceDropdown/SourceDropdown.js +57 -5
- package/lib/SourceList/SourceList.d.ts +3 -2
- package/lib/SourceList/SourceList.js +19 -2
- package/lib/index.css +8 -0
- package/lib/utils/findBestMatchLineage.js +1 -1
- package/package.json +1 -1
- package/src/Hooks/useRecentLocations.spec.ts +85 -0
- package/src/Hooks/useRecentLocations.ts +45 -0
- package/src/Icons/HistoryIcon.tsx +17 -0
- package/src/ResourcePickerContainer/ResourcePickerContainer.tsx +23 -1
- package/src/SourceDropdown/SourceDropdown.spec.tsx +99 -1
- package/src/SourceDropdown/SourceDropdown.tsx +108 -6
- package/src/SourceList/SourceList.spec.tsx +146 -0
- package/src/SourceList/SourceList.tsx +51 -2
- package/src/utils/findBestMatchLineage.ts +1 -1
package/CHANGELOG.md
CHANGED
@@ -1,5 +1,17 @@
|
|
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
|
+
|
9
|
+
## 1.67.1
|
10
|
+
|
11
|
+
### Patch Changes
|
12
|
+
|
13
|
+
- 9e4cad8: Fixed minor issue with finding best matched lineage in resource browser
|
14
|
+
|
3
15
|
## 1.67.0
|
4
16
|
|
5
17
|
### Minor 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,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
|
-
|
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:
|
69
|
-
|
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 &&
|
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
|
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
|
}
|
@@ -19,7 +19,7 @@ const findBestMatchLineage = (source, resource) => {
|
|
19
19
|
// * Full lineage is: 1 > 10 > 100 > 1000 > 10000
|
20
20
|
// * The source has a node with an ID of: 100
|
21
21
|
// * The returned lineage will be: 100 > 1000 > 10000
|
22
|
-
return lineage.resourceIds.slice(rootNodeIndex
|
22
|
+
return lineage.resourceIds.slice(rootNodeIndex);
|
23
23
|
}
|
24
24
|
}
|
25
25
|
}
|
package/package.json
CHANGED
@@ -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={
|
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]">
|
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
|
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 &&
|
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
|
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>
|
@@ -21,7 +21,7 @@ export const findBestMatchLineage = (source: Source, resource: Resource): string
|
|
21
21
|
// * Full lineage is: 1 > 10 > 100 > 1000 > 10000
|
22
22
|
// * The source has a node with an ID of: 100
|
23
23
|
// * The returned lineage will be: 100 > 1000 > 10000
|
24
|
-
return lineage.resourceIds.slice(rootNodeIndex
|
24
|
+
return lineage.resourceIds.slice(rootNodeIndex);
|
25
25
|
}
|
26
26
|
}
|
27
27
|
}
|