Skip to content

Commit a7cb313

Browse files
author
bengotow
committed
[ui] Add a hierarchical sidebar to the asset catalog
1 parent 3254031 commit a7cb313

File tree

12 files changed

+1064
-203
lines changed

12 files changed

+1064
-203
lines changed

js_modules/dagster-ui/packages/ui-components/src/components/SplitPanelContainer.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export type SplitPanelContainerHandle = {
2020
changeSize: (value: number) => void;
2121
};
2222

23+
function getStorageKey(identifier: string) {
24+
return `dagster.panel-width.${identifier}`;
25+
}
26+
27+
export function getFirstPanelSizeFromStorage(identifier: string, firstInitialPercent: number) {
28+
const storedSize = window.localStorage.getItem(getStorageKey(identifier));
29+
const parsed = storedSize === null ? null : parseFloat(storedSize);
30+
return parsed === null || isNaN(parsed) ? firstInitialPercent : parsed;
31+
}
32+
2333
export const SplitPanelContainer = forwardRef<SplitPanelContainerHandle, SplitPanelContainerProps>(
2434
(props, ref) => {
2535
const {
@@ -34,23 +44,20 @@ export const SplitPanelContainer = forwardRef<SplitPanelContainerHandle, SplitPa
3444

3545
const [_, setTrigger] = useState(0);
3646
const [resizing, setResizing] = useState(false);
37-
const key = `dagster.panel-width.${identifier}`;
3847

3948
const getSize = useCallback(() => {
4049
if (!second) {
4150
return 100;
4251
}
43-
const storedSize = window.localStorage.getItem(key);
44-
const parsed = storedSize === null ? null : parseFloat(storedSize);
45-
return parsed === null || isNaN(parsed) ? firstInitialPercent : parsed;
46-
}, [firstInitialPercent, key, second]);
52+
return getFirstPanelSizeFromStorage(identifier, firstInitialPercent);
53+
}, [firstInitialPercent, identifier, second]);
4754

4855
const onChangeSize = useCallback(
4956
(newValue: number) => {
50-
window.localStorage.setItem(key, `${newValue}`);
57+
window.localStorage.setItem(getStorageKey(identifier), `${newValue}`);
5158
setTrigger((current) => (current ? 0 : 1));
5259
},
53-
[key],
60+
[identifier],
5461
);
5562

5663
useImperativeHandle(ref, () => ({getSize, changeSize: onChangeSize}), [onChangeSize, getSize]);

js_modules/dagster-ui/packages/ui-core/src/asset-graph/sidebar/AssetSidebarNode.tsx

Lines changed: 4 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Box, Colors, Icon, MiddleTruncate, UnstyledButton} from '@dagster-io/ui-components';
1+
import {Box, Colors, Icon, UnstyledButton} from '@dagster-io/ui-components';
22
import * as React from 'react';
33
import {observeEnabled} from 'shared/app/observeEnabled.oss';
44
import styled from 'styled-components';
@@ -12,6 +12,8 @@ import {
1212
} from './util';
1313
import {AssetHealthSummary} from '../../assets/AssetHealthSummary';
1414
import {ExplorerPath} from '../../pipelines/PipelinePathUtils';
15+
import {FocusableLabelContainer, GrayOnHoverBox} from '../../ui/Sidebar/FocusableLabelContainer';
16+
import {SidebarDisclosureTriangle} from '../../ui/Sidebar/SidebarDisclosureTriangle';
1517
import {AssetGroup} from '../AssetGraphExplorer';
1618
import {AssetNodeMenuProps, useAssetNodeMenu} from '../AssetNodeMenu';
1719
import {useGroupNodeContextMenu} from '../CollapsedGroupNode';
@@ -52,27 +54,7 @@ export const AssetSidebarNode = (props: AssetSidebarNodeProps) => {
5254
onDoubleClick={(e) => !e.metaKey && toggleOpen()}
5355
>
5456
{showArrow ? (
55-
<UnstyledButton
56-
onClick={(e) => {
57-
e.stopPropagation();
58-
toggleOpen();
59-
}}
60-
onDoubleClick={(e) => {
61-
e.stopPropagation();
62-
}}
63-
onKeyDown={(e) => {
64-
if (e.code === 'Space') {
65-
// Prevent the default scrolling behavior
66-
e.preventDefault();
67-
}
68-
}}
69-
style={{cursor: 'pointer', width: 18}}
70-
>
71-
<Icon
72-
name="arrow_drop_down"
73-
style={{transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}}
74-
/>
75-
</UnstyledButton>
57+
<SidebarDisclosureTriangle isOpen={isOpen} toggleOpen={toggleOpen} />
7658
) : level === 1 && isAssetNode ? (
7759
// Special case for when asset nodes are at the root (level = 1) due to their being only a single group.
7860
// In this case we don't need the spacer div to align nodes because none of the nodes will be collapsible/un-collapsible.
@@ -188,43 +170,6 @@ const AssetSidebarLocationLabel = ({
188170
);
189171
};
190172

191-
const FocusableLabelContainer = ({
192-
isSelected,
193-
isLastSelected,
194-
icon,
195-
text,
196-
}: {
197-
isSelected: boolean;
198-
isLastSelected: boolean;
199-
icon: React.ReactNode;
200-
text: string;
201-
}) => {
202-
const ref = React.useRef<HTMLButtonElement | null>(null);
203-
React.useLayoutEffect(() => {
204-
// When we click on a node in the graph it also changes "isSelected" in the sidebar.
205-
// We want to check if the focus is currently in the graph and if it is lets keep it there
206-
// Otherwise it means the click happened in the sidebar in which case we should move focus to the element
207-
// in the sidebar
208-
if (ref.current && isLastSelected && !isElementInsideSVGViewport(document.activeElement)) {
209-
ref.current.focus();
210-
}
211-
}, [isLastSelected]);
212-
213-
return (
214-
<GrayOnHoverBox
215-
ref={ref}
216-
style={{
217-
gridTemplateColumns: icon ? 'auto minmax(0, 1fr)' : 'minmax(0, 1fr)',
218-
gridTemplateRows: 'minmax(0, 1fr)',
219-
...(isSelected ? {background: Colors.backgroundBlue()} : {}),
220-
}}
221-
>
222-
{icon}
223-
<MiddleTruncate text={text} />
224-
</GrayOnHoverBox>
225-
);
226-
};
227-
228173
const BoxWrapper = ({level, children}: {level: number; children: React.ReactNode}) => {
229174
const wrapper = React.useMemo(() => {
230175
let sofar = children;
@@ -255,22 +200,6 @@ const ExpandMore = styled(UnstyledButton)`
255200
visibility: hidden;
256201
`;
257202

258-
const GrayOnHoverBox = styled(UnstyledButton)`
259-
border-radius: 8px;
260-
user-select: none;
261-
width: 100%;
262-
display: grid;
263-
flex-direction: row;
264-
height: 32px;
265-
align-items: center;
266-
padding: 5px 8px;
267-
justify-content: space-between;
268-
gap: 6px;
269-
flex-grow: 1;
270-
flex-shrink: 1;
271-
transition: background 100ms linear;
272-
`;
273-
274203
export const ItemContainer = styled(Box)`
275204
height: 32px;
276205
position: relative;
@@ -287,7 +216,3 @@ export const ItemContainer = styled(Box)`
287216
}
288217
}
289218
`;
290-
291-
function isElementInsideSVGViewport(element: Element | null) {
292-
return !!element?.closest('[data-svg-viewport]');
293-
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import invariant from 'invariant';
2+
import {useMemo} from 'react';
3+
4+
import {tokenForAssetKey} from '../../asset-graph/Utils';
5+
import {HierarchicalSidebar, buildHierarchyFromPaths} from '../../ui/Sidebar/HierarchicalSidebar';
6+
import {HierarchyNode} from '../../ui/Sidebar/types';
7+
import {AssetTableFragment} from '../types/AssetTableFragment.types';
8+
9+
export const AssetCatalogTableSidebar = ({
10+
assets,
11+
loading,
12+
selection,
13+
onChangeSelection,
14+
}: {
15+
assets: AssetTableFragment[] | undefined;
16+
loading: boolean;
17+
selection: string;
18+
onChangeSelection: (str: string) => void;
19+
}) => {
20+
const hierarchyData = useMemo(() => {
21+
const hierarchy = buildHierarchyFromPaths(
22+
(assets || []).map((a) => tokenForAssetKey(a.key)),
23+
false,
24+
);
25+
const root: HierarchyNode = {
26+
type: 'folder',
27+
name: 'Catalog',
28+
children: hierarchy,
29+
path: 'Catalog',
30+
icon: 'catalog_book',
31+
};
32+
return [root];
33+
}, [assets]);
34+
35+
const currentKeyPrefix = extractKeyPrefixFromSelection(selection);
36+
return (
37+
<HierarchicalSidebar
38+
loading={loading}
39+
hierarchyData={hierarchyData}
40+
selectedPaths={
41+
currentKeyPrefix?.key === '*'
42+
? ['Catalog']
43+
: currentKeyPrefix?.key
44+
? [`Catalog/${currentKeyPrefix.key}`]
45+
: []
46+
}
47+
onSelectPath={(e, path) => {
48+
onChangeSelection(selectionReplacingKeyPrefix(selection, path.replace(/^Catalog\/?/, '')));
49+
}}
50+
/>
51+
);
52+
};
53+
54+
/** Given `code_location:"dagster_open_platform"+ AND key:"aws/prod/*"`,
55+
* returns `aws/prod`. Returns null if there are multiple key prefixes.
56+
*/
57+
export function extractKeyPrefixFromSelection(selection: string) {
58+
const matches = Array.from(selection.matchAll(/(?<=^|\s)key:\"([^"]*\/?\*)\"/g));
59+
const first = matches[0];
60+
if (!first || matches.length !== 1) {
61+
return null;
62+
}
63+
invariant(first[1], 'Regexp match must contain first match group');
64+
return {text: first[0], key: first[1].replace(/\/\*$/, '')};
65+
}
66+
67+
/* Given an existing search selection, update the existing key prefix clause or add
68+
* a new one. Makes an attempt to preserve logical correctness by adding parens if necessary.
69+
*/
70+
export function selectionReplacingKeyPrefix(selection: string, nextKeyPrefix: string): string {
71+
const existing = extractKeyPrefixFromSelection(selection);
72+
const term = nextKeyPrefix.length ? `key:"${nextKeyPrefix}/*"` : 'key:"*"';
73+
if (existing !== null) {
74+
return selection.replace(existing.text, term);
75+
}
76+
if (selection.toLowerCase().includes(' or ') && !selection.startsWith('(')) {
77+
return `(${selection}) AND ${term}`;
78+
}
79+
return `${selection}${selection.length > 0 ? ' AND ' : ''}${term}`;
80+
}

0 commit comments

Comments
 (0)