8000 Bed 5985 by elikmiller · Pull Request #1664 · SpecterOps/BloodHound · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Bed 5985 #1664

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2025 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

import React, { ForwardedRef, useCallback, useMemo, useRef } from 'react';
import { UseInfiniteQueryResult } from 'react-query';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useMeasure } from '../../hooks/useMeasure';
import { PaginatedResult } from '../../utils/paginatedFetcher';

export type InfiniteQueryFixedListProps<T> = {
itemSize: number;
queryResult: UseInfiniteQueryResult<PaginatedResult<T>>;
renderRow: (item: T, index: number, style: React.CSSProperties, isScrolling?: boolean) => React.ReactNode;
overscanCount?: number;
thresholdCount?: number;
listRef?: ForwardedRef<FixedSizeList<T[]>>;
};

export const InfiniteQueryFixedList = <T,>({
itemSize,
queryResult,
renderRow,
overscanCount = 5,
thresholdCount = 5,
listRef,
}: InfiniteQueryFixedListProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);

const [width, height] = useMeasure(containerRef);

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = queryResult;

const items = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data]);

const isItemLoaded = useCallback((index: number) => index < items.length, [items.length]);

const loadMoreItems = useCallback(async () => {
if (!isFetchingNextPage) await fetchNextPage();
}, [isFetchingNextPage, fetchNextPage]);

const Row = useCallback(
({ index, style, data: itemData, isScrolling }: ListChildComponentProps<T[]>) => {
if (!isItemLoaded(index)) {
return <div style={style}>Loading...</div>;
}
return renderRow(itemData[index], index, style, isScrolling);
},
[isItemLoaded, renderRow]
);

return (
<div ref={containerRef} className='h-full w-full'>
{height > 0 && width > 0 && (
<FixedSizeList<T[]>
ref={listRef}
height={height}
itemSize={itemSize}
itemCount={hasNextPage ? items.length + 1 : items.length}
overscanCount={overscanCount}
width={width}
visibleStopIndex }) => {
if (hasNextPage && !isFetchingNextPage && visibleStopIndex >= items.length - thresholdCount) {
loadMoreItems();
}
}}
itemData={items}>
{Row}
</FixedSizeList>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2025 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

export * from './InfiniteQueryFixedList';
41 changes: 41 additions & 0 deletions packages/javascript/bh-shared-ui/src/hooks/useMeasure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2025 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

import { RefObject, useEffect, useState } from 'react';

export function useMeasure(ref: RefObject<HTMLElement>) {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

useEffect(() => {
if (!ref.current) return;

const updateMeasurements = () => {
if (ref.current) {
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight);
}
};

updateMeasurements();
const observer = new ResizeObserver(updateMeasurements);
observer.observe(ref.current);

return () => observer.disconnect();
}, [ref]);

return [width, height];
}
50 changes: 50 additions & 0 deletions packages/javascript/bh-shared-ui/src/utils/paginatedFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2025 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

export type PageParam = {
skip: number;
limit: number;
};

export type PaginatedResult<T> = {
items: T[];
nextPageParam?: PageParam;
};

export type PaginatedApiResponse<T> = {
data: {
data: { [key: string]: T[] };
count: number;
};
};

/**
* Generic helper to wrap paginated API responses into a React Query-friendly shape.
*/
export const createPaginatedFetcher = <T>(
fetchFn: () => Promise<PaginatedApiResponse<T>>,
key: string,
skip: number,
limit: number
): Promise<PaginatedResult<T>> =>
fetchFn().then((res) => {
const items = res.data.data[key];
const hasMore = skip + limit < res.data.count;
return {
items,
nextPageParam: hasMore ? { skip: skip + limit, limit } : undefined,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@
// SPDX-License-Identifier: Apache-2.0

import { Button } from '@bloodhoundenterprise/doodleui';
import { AssetGroupTag, AssetGroupTagSelector } from 'js-client-library';
import { FC, useContext } from 'react';
import { UseQueryResult, useQuery } from 'react-query';
import { AssetGroupTagTypeLabel, AssetGroupTagTypeOwned, AssetGroupTagTypeTier } from 'js-client-library';
import { FC, useContext, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Link, useLocation, useParams } from 'react-router-dom';
import { apiClient, useAppNavigate } from '../../../utils';
import { SortOrder } from '../../../types';
import { useAppNavigate } from '../../../utils';
import { ZoneManagementContext } from '../ZoneManagementContext';
import {
useSelectorMembersInfiniteQuery,
useSelectorsInfiniteQuery,
useTagMembersInfiniteQuery,
useTagsQuery,
} from '../hooks';
import { TIER_ZERO_ID, getTagUrlValue } from '../utils';
import { DetailsList } from './DetailsList';
import { MembersList } from './MembersList';
import { SelectedDetails } from './SelectedDetails';
import { SelectorsList } from './SelectorsList';

export const getSavePath = (
tierId: string | undefined,
Expand All @@ -42,39 +50,24 @@ export const getSavePath = (
return savePath;
};

const getItemCount = (
tagId: string | undefined,
tagsQuery: UseQueryResult<AssetGroupTag[]>,
selectorId: string | undefined,
selectorsQuery: UseQueryResult<AssetGroupTagSelector[]>
export const getEditButtonState = (
memberId?: string,
selectorsQuery?: UseQueryResult,
tiersQuery?: UseQueryResult,
labelsQuery?: UseQueryResult
) => {
if (selectorId !== undefined) {
const selectedSelector = selectorsQuery.data?.find((selector) => {
return selectorId === selector.id.toString();
});
return selectedSelector?.counts?.members || 0;
} else if (tagId !== undefined) {
const selectedTag = tagsQuery.data?.find((tag) => {
return tagId === tag.id.toString();
});
return selectedTag?.counts?.members || 0;
} else {
return 0;
}
};

export const getEditButtonState = (memberId?: string, selectorsQuery?: UseQueryResult, tagsQuery?: UseQueryResult) => {
return (
!!memberId ||
(selectorsQuery?.isLoading && tagsQuery?.isLoading) ||
(selectorsQuery?.isError && tagsQuery?.isError)
(selectorsQuery?.isLoading && tiersQuery?.isLoading && labelsQuery?.isLoading) ||
(selectorsQuery?.isError && tiersQuery?.isError && labelsQuery?.isError)
);
};
Comment on lines +53 to 64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Review the edit button state logic.

The current implementation only disables the edit button when ALL queries are loading or ALL queries have errors. This might not be the intended behavior - typically you'd want to disable the button if ANY query is loading or has an error.

 export const getEditButtonState = (
     memberId?: string,
     selectorsQuery?: UseQueryResult,
     tiersQuery?: UseQueryResult,
     labelsQuery?: UseQueryResult
 ) => {
     return (
         !!memberId ||
-        (selectorsQuery?.isLoading && tiersQuery?.isLoading && labelsQuery?.isLoading) ||
-        (selectorsQuery?.isError && tiersQuery?.isError && labelsQuery?.isError)
+        (selectorsQuery?.isLoading || tiersQuery?.isLoading || labelsQuery?.isLoading) ||
+        (selectorsQuery?.isError || tiersQuery?.isError || labelsQuery?.isError)
     );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getEditButtonState = (
memberId?: string,
selectorsQuery?: UseQueryResult,
tiersQuery?: UseQueryResult,
labelsQuery?: UseQueryResult
) => {
if (selectorId !== undefined) {
const selectedSelector = selectorsQuery.data?.find((selector) => {
return selectorId === selector.id.toString();
});
return selectedSelector?.counts?.members || 0;
} else if (tagId !== undefined) {
const selectedTag = tagsQuery.data?.find((tag) => {
return tagId === tag.id.toString();
});
return selectedTag?.counts?.members || 0;
} else {
return 0;
}
};
export const getEditButtonState = (memberId?: string, selectorsQuery?: UseQueryResult, tagsQuery?: UseQueryResult) => {
return (
!!memberId ||
(selectorsQuery?.isLoading && tagsQuery?.isLoading) ||
(selectorsQuery?.isError && tagsQuery?.isError)
(selectorsQuery?.isLoading && tiersQuery?.isLoading && labelsQuery?.isLoading) ||
(selectorsQuery?.isError && tiersQuery?.isError && labelsQuery?.isError)
);
};
export const getEditButtonState = (
memberId?: string,
selectorsQuery?: UseQueryResult,
tiersQuery?: UseQueryResult,
labelsQuery?: UseQueryResult
) => {
return (
!!memberId ||
(selectorsQuery?.isLoading || tiersQuery?.isLoading || labelsQuery?.isLoading) ||
(selectorsQuery?.isError || tiersQuery?.isError || labelsQuery?.isError)
);
};
🤖 Prompt for AI Agents
In packages/javascript/bh-shared-ui/src/views/ZoneManagement/Details/Details.tsx
around lines 53 to 64, the getEditButtonState function disables the edit button
only when all queries are loading or all have errors, but it should disable the
button if any query is loading or has an error. Update the logic to use OR
conditions for loading and error checks across selectorsQuery, tiersQuery, and
labelsQuery, so the button is disabled if any one of these queries is loading or
has an error.


const Details: FC = () => {
const navigate = useAppNavigate();
const location = useLocation();
const { tierId = TIER_ZERO_ID, labelId, selectorId, memberId } = useParams();
const [membersListSortOrder, setMembersListSortOrder] = useState<SortOrder>('asc');
const tagId = labelId === undefined ? tierId : labelId;

const context = useContext(ZoneManagementContext);
Expand All @@ -83,26 +76,19 @@ const Details: FC = () => {
}
const { InfoHeader } = context;

const tagsQuery = useQuery({
queryKey: ['zone-management', 'tags'],
queryFn: async () => {
return apiClient.getAssetGroupTags({ params: { counts: true } }).then((res) => {
return res.data.data['tags'];
});
},
});

const selectorsQuery = useQuery({
queryKey: ['zone-management', 'tags', tagId, 'selectors'],
queryFn: async () => {
if (!tagId) return [];
return apiClient.getAssetGroupTagSelectors(tagId, { params: { counts: true } }).then((res) => {
return res.data.data['selectors'];
});
},
});

const showEditButton = !getEditButtonState(memberId, selectorsQuery, tagsQuery);
const tiersQuery = useTagsQuery((tag) => tag.type === AssetGroupTagTypeTier);

const labelsQuery = useTagsQuery(
(tag) => tag.type === AssetGroupTagTypeLabel || tag.type === AssetGroupTagTypeOwned
);

const selectorsQuery = useSelectorsInfiniteQuery(tagId);

const selectorMembersQuery = useSelectorMembersInfiniteQuery(tagId, selectorId, membersListSortOrder);

const tagMembersQuery = useTagMembersInfiniteQuery(tagId, membersListSortOrder);

const showEditButton = !getEditButtonState(memberId, selectorsQuery, tiersQuery, labelsQuery);

return (
<div>
Expand All @@ -118,35 +104,56 @@ const Details: FC = () => {
</div>
<div className='flex gap-8 mt-4'>
<div className='flex basis-2/3 bg-neutral-light-2 dark:bg-neutral-dark-2 rounded-lg shadow-outer-1 *:w-1/3 h-full'>
<DetailsList
title={location.pathname.includes('label') ? 'Labels' : 'Tiers'}
listQuery={tagsQuery}
selected={tagId}
=> {
navigate(`/zone-management/details/${getTagUrlValue(labelId)}/${id}`);
}}
/>
<DetailsList
title='Selectors'
{location.pathname.includes('label') ? (
<DetailsList
title={'Labels'}
listQuery={labelsQuery}
selected={tagId}
=> {
navigate(`/zone-management/details/${getTagUrlValue(labelId)}/${id}`);
}}
/>
) : (
<DetailsList
title={'Tiers'}
listQuery={tiersQuery}
selected={tagId}
=> {
navigate(`/zone-management/details/${getTagUrlValue(labelId)}/${id}`);
}}
/>
)}

<SelectorsList
listQuery={selectorsQuery}
selected={selectorId}
=> {
navigate(`/zone-management/details/${getTagUrlValue(labelId)}/${tagId}/selector/${id}`);
}}
/>
<MembersList
itemCount={getItemCount(tagId, tagsQuery, selectorId, selectorsQuery)}
=> {
if (selectorId) {
{selectorId !== undefined ? (
<MembersList
listQuery={selectorMembersQuery}
selected={memberId}
=> {
navigate(
`/zone-management/details/${getTagUrlValue(labelId)}/${tagId}/selector/${selectorId}/member/${id}`
);
} else {
}}
sortOrder={membersListSortOrder}
>
/>
) : (
<MembersList
listQuery={tagMembersQuery}
selected={memberId}
=> {
navigate(`/zone-management/details/${getTagUrlValue(labelId)}/${tagId}/member/${id}`);
}
}}
selected={memberId}
5D96 />
}}
sortOrder={membersListSortOrder}
>
/>
)}
</div>
<div className='basis-1/3'>
<SelectedDetails />
Expand Down
Loading
0