From a57a074d176fd42831777566bd857012a7aa944d Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Wed, 13 May 2026 17:49:27 -0300 Subject: [PATCH] feat: filter listing by git commit SHAs * Add optional gitCommitHashes query param (comma-separated full hashes). * Backend uses matching checkouts instead of tree heads when provided. * Dashboard parses commit hash tokens for hardware search. * Dashboard search bar simple intent detection to apply smart search. One-line variant: feat(hardware): add gitCommitHashes filter and search intent for commits Signed-off-by: Alan Peixinho --- .../kernelCI_app/constants/localization.py | 4 + backend/kernelCI_app/queries/hardware.py | 40 ++++-- .../queries/hardware_queries_test.py | 24 ++++ .../unitTests/views/hardwareView_test.py | 37 ++++- .../typeModels/hardwareListing.py | 17 +++ backend/kernelCI_app/views/hardwareView.py | 13 +- backend/requests/hardware-listing.sh | 3 + dashboard/src/api/hardware.ts | 5 + dashboard/src/lib/commits.ts | 12 ++ dashboard/src/lib/intent.ts | 22 +++ dashboard/src/pages/Hardware/Hardware.tsx | 6 +- .../pages/Hardware/HardwareListingPage.tsx | 3 + .../pages/hardwareDetails/HardwareDetails.tsx | 133 ++++++++++++++++-- .../HardwareDetailsHeaderTable.tsx | 89 +++++++++--- .../_main/hardware/$hardwareId/route.tsx | 2 + .../src/types/hardware/hardwareDetails.ts | 1 + 16 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 dashboard/src/lib/commits.ts create mode 100644 dashboard/src/lib/intent.ts diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index d0ebbc828..754d2704c 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -93,6 +93,10 @@ class DocStrings: ) HARDWARE_LISTING_ORIGIN_DESCRIPTION = "Origin of the hardware" + HARDWARE_LISTING_COMMITS_LIST_DESCRIPTION = ( + "Optional comma-separated git commit identifiers: full SHA(s) " + "and/or tag strings that appear in checkout.git_commit_tags." + ) ISSUE_DETAILS_VERSION_DESCRIPTION = "Issue version" diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 1bb410747..814ae822c 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -110,17 +110,24 @@ def _get_hardware_listing_count_clauses() -> str: def get_hardware_listing_data( - start_date: datetime, end_date: datetime, origin: str + *, + start_date: datetime, + end_date: datetime, + origin: str, + commits_list: Optional[list[str]] = None, ) -> list[dict]: """ Retrieves the listing of platform, compatibles, and the status counts of builds, boots and tests for the latest checkout of every tree. The selected checkouts and tests are limited to the start_date/end_date interval. + When commits_list is set, tree heads are not used: checkouts qualify if + git_commit_hash is in the token list or git_commit_tags overlaps it (comma- + separated request values can be full SHAs or tag strings stored on checkouts). + Still scoped by the test start_time and origin filters below. """ count_clauses = _get_hardware_listing_count_clauses() - tree_head_clause = _get_hardware_tree_heads_clause(id_only=True) params = { "start_date": start_date, @@ -128,6 +135,26 @@ def get_hardware_listing_data( "origin": origin, } + if commits_list: + params["commits_list"] = commits_list + checkout_ids_select = """ + SELECT C.id + FROM checkouts C + WHERE C.git_commit_hash = ANY(%(commits_list)s) + OR ( + C.git_commit_tags IS NOT NULL + AND C.git_commit_tags && %(commits_list)s::text[] + ) + """ + else: + checkout_ids_select = _get_hardware_tree_heads_clause(id_only=True) + + selected_checkouts_cte = f""" + selected_checkouts AS ( + {checkout_ids_select} + ), + """ + # The grouping by platform and compatibles is possible because a platform # can dictate the array of compatibles, meaning that if the array of compatibles # is different, then the platform should/must be different as well. @@ -137,12 +164,7 @@ def get_hardware_listing_data( # to the tests, not checkouts. There are no platforms being tested by multiple origins yet. query = f""" WITH - -- Selects the id of the latest checkout of all trees in the given period. - -- No checkout data is returned in the end. - tree_heads AS ( - {tree_head_clause} - ), - -- Selects all tests/builds related to those checkouts. + {selected_checkouts_cte} relevant_tests AS ( SELECT "tests"."environment_compatible" AS hardware, @@ -155,7 +177,7 @@ def get_hardware_listing_data( FROM tests INNER JOIN builds b ON tests.build_id = b.id - JOIN tree_heads TH ON b.checkout_id = TH.id + JOIN selected_checkouts AC ON b.checkout_id = AC.id WHERE "tests"."environment_misc" ->> 'platform' IS NOT NULL AND "tests"."origin" = %(origin)s diff --git a/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py b/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py index f3469d03f..67f16469a 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py @@ -36,6 +36,30 @@ def test_get_hardware_listing_data_success(self, mock_connection): assert result == expected_result mock_cursor.execute.assert_called_once() + sql, exec_params = mock_cursor.execute.call_args[0] + assert "selected_checkouts AS" in sql + assert "SELECT DISTINCT" in sql + assert "commits_list" not in exec_params + + @patch("kernelCI_app.queries.hardware.connection") + def test_get_hardware_listing_data_commit_filter_tokens(self, mock_connection): + mock_cursor = setup_mock_cursor(mock_connection) + mock_cursor.fetchall.return_value = [] + + get_hardware_listing_data( + start_date=datetime(2025, 11, 10), + end_date=datetime(2025, 11, 12), + origin="maestro", + commits_list=["a" * 40], + ) + + sql, exec_params = mock_cursor.execute.call_args[0] + assert "selected_checkouts AS" in sql + assert "JOIN selected_checkouts AC ON b.checkout_id = AC.id" in sql + assert "ANY(%(commits_list)s)" in sql + assert "git_commit_tags" in sql and "&&" in sql + assert "SELECT DISTINCT" not in sql + assert exec_params["commits_list"] == ["a" * 40] class TestGetHardwareDetailsData: diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py index 06ff2633e..b9f88564b 100644 --- a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, patch from django.test.testcases import SimpleTestCase from rest_framework.test import APIRequestFactory @@ -30,6 +30,41 @@ def test_get_hardware_listing_success(self, mock_get_hardware_listing_data): response = self.view.get(request) self.assertEqual(response.status_code, HTTPStatus.OK) + mock_get_hardware_listing_data.assert_called_once_with( + origin="origin1", + start_date=ANY, + end_date=ANY, + commits_list=None, + ) + + @patch("kernelCI_app.views.hardwareView.get_hardware_listing_data") + def test_get_hardware_listing_passes_commits_list( + self, mock_get_hardware_listing_data + ): + mock_get_hardware_listing_data.return_value = [ + ("platform1", "hardware1", *range(22)), + ] + h1 = "a" * 40 + h2 = "b" * 40 + + request = self.factory.get( + self.url, + { + "startTimestampInSeconds": "1741192200", + "endTimestampInSeconds": "1741624200", + "origin": "origin1", + "commitsList": f"{h1},{h2}", + }, + ) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + mock_get_hardware_listing_data.assert_called_once_with( + origin="origin1", + start_date=ANY, + end_date=ANY, + commits_list=[h1, h2], + ) def test_get_hardware_listing_invalid_query_params_returns_bad_request(self): query_params = {"origin": "origin1"} diff --git a/backend/kernelCI_app/typeModels/hardwareListing.py b/backend/kernelCI_app/typeModels/hardwareListing.py index 32858577e..c07aa603b 100644 --- a/backend/kernelCI_app/typeModels/hardwareListing.py +++ b/backend/kernelCI_app/typeModels/hardwareListing.py @@ -8,6 +8,15 @@ from kernelCI_app.typeModels.common import StatusCount +def _normalize_commits_list(value: object) -> Optional[list[str]]: + if value is None: + return None + if isinstance(value, str): + cleaned = [part.strip() for part in value.split(",") if part.strip()] + return cleaned if cleaned else None + return None + + class HardwareItem(BaseModel): hardware: Optional[Union[str, set[str]]] platform: str @@ -37,6 +46,10 @@ class HardwareQueryParamsDocumentationOnly(BaseModel): endTimestampInSeconds: str = Field( # noqa: N815 description=DocStrings.DEFAULT_END_TS_DESCRIPTION ) + commitsList: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_LISTING_COMMITS_LIST_DESCRIPTION, + ) class HardwareQueryParams(BaseModel): @@ -47,3 +60,7 @@ class HardwareQueryParams(BaseModel): ] start_date: datetime end_date: datetime + commits_list: Annotated[ + Optional[list[str]], + BeforeValidator(_normalize_commits_list), + ] = Field(default=None) diff --git a/backend/kernelCI_app/views/hardwareView.py b/backend/kernelCI_app/views/hardwareView.py index afc21cf63..7dfe21dfc 100644 --- a/backend/kernelCI_app/views/hardwareView.py +++ b/backend/kernelCI_app/views/hardwareView.py @@ -1,4 +1,3 @@ -from datetime import datetime from http import HTTPStatus from drf_spectacular.utils import extend_schema @@ -70,18 +69,16 @@ def get(self, request: Request): start_date=request.GET.get("startTimestampInSeconds"), end_date=request.GET.get("endTimestampInSeconds"), origin=request.GET.get("origin"), + commits_list=request.GET.get("commitsList"), ) - - start_date: datetime = query_params.start_date - end_date: datetime = query_params.end_date - origin = query_params.origin except ValidationError as e: return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) hardwares_raw = get_hardware_listing_data( - origin=origin, - start_date=start_date, - end_date=end_date, + origin=query_params.origin, + start_date=query_params.start_date, + end_date=query_params.end_date, + commits_list=query_params.commits_list, ) try: diff --git a/backend/requests/hardware-listing.sh b/backend/requests/hardware-listing.sh index e4d8503d3..a0bb42aab 100644 --- a/backend/requests/hardware-listing.sh +++ b/backend/requests/hardware-listing.sh @@ -1,5 +1,8 @@ http 'http://localhost:8000/api/hardware/?startTimestampInSeconds=1736510400&endTimestampInSeconds=1736942400&origin=maestro' +# Optional: comma-separated commit identifiers — full SHA and/or git tag strings (matches hash or overlaps git_commit_tags) +# http 'http://localhost:8000/api/hardware/?startTimestampInSeconds=1736510400&endTimestampInSeconds=1736942400&origin=maestro&commitsList=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + # HTTP/1.1 200 OK # Allow: GET, HEAD, OPTIONS # Cache-Control: max-age=0 diff --git a/dashboard/src/api/hardware.ts b/dashboard/src/api/hardware.ts index 0c3787dee..1f40d1731 100644 --- a/dashboard/src/api/hardware.ts +++ b/dashboard/src/api/hardware.ts @@ -16,6 +16,7 @@ const fetchHardwareListing = async ( origin: string, startTimestampInSeconds: number, endTimestampInSeconds: number, + commitsList?: string[], ): Promise => { const data = await RequestData.get( '/api/hardware/', @@ -24,6 +25,7 @@ const fetchHardwareListing = async ( startTimestampInSeconds, endTimestampInSeconds, origin, + ...(commitsList?.length ? { commitsList: commitsList.join(',') } : {}), }, }, ); @@ -35,6 +37,7 @@ export const useHardwareListing = ( startTimestampInSeconds: number, endTimestampInSeconds: number, searchFrom: HardwareListingRoutesMap['v1']['search'], + commitsList?: string[], ): UseQueryResult => { const { origin } = useSearch({ from: searchFrom }); @@ -43,6 +46,7 @@ export const useHardwareListing = ( startTimestampInSeconds, endTimestampInSeconds, origin, + commitsList ?? null, ]; return useQuery({ @@ -52,6 +56,7 @@ export const useHardwareListing = ( origin, startTimestampInSeconds, endTimestampInSeconds, + commitsList, ), refetchOnWindowFocus: false, }); diff --git a/dashboard/src/lib/commits.ts b/dashboard/src/lib/commits.ts new file mode 100644 index 000000000..763df1fdb --- /dev/null +++ b/dashboard/src/lib/commits.ts @@ -0,0 +1,12 @@ +const COMMIT_REGEX = [ + /\b[0-9a-f]{40}\b/g, + /\S*v?\d+\.\d+(?:\.\d+)?(?:-rc\d+)?\S*/gi, + /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-(\d{8})\S*/gi, +]; + +export const listCommits = (text: string): string[] => { + if (typeof text !== 'string') { + return []; + } + return COMMIT_REGEX.flatMap(r => text.match(r) || []); +}; diff --git a/dashboard/src/lib/intent.ts b/dashboard/src/lib/intent.ts new file mode 100644 index 000000000..f19d4f856 --- /dev/null +++ b/dashboard/src/lib/intent.ts @@ -0,0 +1,22 @@ +import { listCommits } from '@/lib/commits'; + +export type SearchIntent = + | { intent: 'commits'; commits: string[]; search: string } + | { intent: 'text'; search: string }; + +export const parseSearchIntent = (text: string): SearchIntent => { + const commitList = listCommits(text); + if (commitList.length > 0) { + return { + intent: 'commits', + commits: commitList, + search: commitList + .reduce((acc, word) => acc.replace(word, ''), text) + .trim(), + }; + } + return { + intent: 'text', + search: text, + }; +}; diff --git a/dashboard/src/pages/Hardware/Hardware.tsx b/dashboard/src/pages/Hardware/Hardware.tsx index a7d0a35ec..8dab45b6f 100644 --- a/dashboard/src/pages/Hardware/Hardware.tsx +++ b/dashboard/src/pages/Hardware/Hardware.tsx @@ -8,6 +8,7 @@ import { MemoizedListingOGTags } from '@/components/OpenGraphTags/ListingOGTags' import { OldPageBanner } from '@/components/Banner/PageBanner'; import { useFeatureFlag } from '@/hooks/useFeatureFlag'; import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; +import { parseSearchIntent } from '@/lib/intent'; const Hardware = ({ urlFromMap, @@ -19,6 +20,8 @@ const Hardware = ({ from: urlFromMap.search, }); + const intent = parseSearchIntent(hardwareSearch ?? ''); + return ( <> @@ -30,7 +33,8 @@ const Hardware = ({ )}
diff --git a/dashboard/src/pages/Hardware/HardwareListingPage.tsx b/dashboard/src/pages/Hardware/HardwareListingPage.tsx index 1bc4dd792..7fb80ec6b 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPage.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPage.tsx @@ -26,6 +26,7 @@ import { HardwareTable } from './HardwareTable'; interface HardwareListingPageProps { inputFilter: string; + commitsList?: string[]; urlFromMap: HardwareListingRoutesMap['v1']; } @@ -68,6 +69,7 @@ const useHardwareListingTime = ( const HardwareListingPage = ({ inputFilter, + commitsList, urlFromMap, }: HardwareListingPageProps): JSX.Element => { const { startTimestampInSeconds, endTimestampInSeconds } = @@ -78,6 +80,7 @@ const HardwareListingPage = ({ startTimestampInSeconds, endTimestampInSeconds, urlFromMap.search, + commitsList, ); const listItems: HardwareItem[] = useMemo(() => { diff --git a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx index 2c486038d..03a0f798f 100644 --- a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx +++ b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx @@ -24,11 +24,14 @@ import { useHardwareDetailsCommitHistory } from '@/api/hardwareDetails'; import type { CommitHead, + CommitHistory, CommitHistoryTable, PreparedTrees, HardwareTrees, } from '@/types/hardware/hardwareDetails'; +import { parseSearchIntent } from '@/lib/intent'; + import MemoizedCompatibleHardware from '@/components/Cards/CompatibleHardware'; import { GroupedTestStatus } from '@/components/Status/Status'; @@ -70,16 +73,44 @@ import { HardwareHeader } from './HardwareDetailsHeaderTable'; import HardwareDetailsTabs from './Tabs/HardwareDetailsTabs'; import HardwareDetailsFilter from './HardwareDetailsFilter'; +function matchesCommitToken( + hash: string, + tags: string[] | undefined, + selected: string, +): boolean { + return ( + hash.toLowerCase() === selected.toLowerCase() || + (tags?.includes(selected) ?? false) + ); +} + +function findMatchingCheckout( + rows: CommitHistory[], + tokens: string[], +): CommitHistory | undefined { + for (const token of tokens) { + const found = rows.find(r => + matchesCommitToken(r.git_commit_hash, r.git_commit_tags, token), + ); + if (found) { + return found; + } + } + return undefined; +} + const prepareTreeItems = ({ treeItems, commitHistoryData, isCommitHistoryDataLoading, isMainPageLoading, + commitIntentTokens, }: { treeItems?: HardwareTrees[]; commitHistoryData?: CommitHistoryTable; isCommitHistoryDataLoading: boolean; isMainPageLoading: boolean; + commitIntentTokens?: string[] | null; }): PreparedTrees[] | void => treeItems?.map(tree => { const treeIdentifier = makeTreeIdentifierKey({ @@ -88,27 +119,41 @@ const prepareTreeItems = ({ gitRepositoryUrl: tree.git_repository_url ?? '', }); - const result: PreparedTrees = { + const rows = commitHistoryData?.[treeIdentifier] ?? []; + const excludeFromCommitIntentDefaultSelection = + !!commitIntentTokens?.length && + !isCommitHistoryDataLoading && + !findMatchingCheckout(rows, commitIntentTokens) && + !commitIntentTokens.some(t => + matchesCommitToken( + tree.head_git_commit_hash ?? '', + tree.head_git_commit_tags, + t, + ), + ); + + return { tree_name: tree['tree_name'] ?? '-', origin: tree['origin'], git_repository_branch: tree['git_repository_branch'] ?? '-', head_git_commit_name: tree['head_git_commit_name'] ?? '-', head_git_commit_hash: tree['head_git_commit_hash'] ?? '-', + head_git_commit_tags: tree['head_git_commit_tags'], git_repository_url: tree['git_repository_url'] ?? '-', index: tree['index'], selected_commit_status: tree['selected_commit_status'], - selectableCommits: commitHistoryData?.[treeIdentifier] ?? [], + selectableCommits: rows, isCommitHistoryDataLoading, - isMainPageLoading: isMainPageLoading, + isMainPageLoading, + excludeFromCommitIntentDefaultSelection, }; - - return result; }); function HardwareDetails(): JSX.Element { - const { treeIndexes, treeCommits, diffFilter, origin } = useSearch({ - from: '/_main/hardware/$hardwareId', - }); + const { treeIndexes, treeCommits, diffFilter, origin, hardwareSearch } = + useSearch({ + from: '/_main/hardware/$hardwareId', + }); const { startTimestampInSeconds, endTimestampInSeconds } = useSearch({ from: '/_main/hardware/$hardwareId/', }); @@ -190,7 +235,10 @@ function HardwareDetails(): JSX.Element { const numIndexes = summaryResponse?.data?.common?.trees?.length || 0; const updateTreeFilters = useCallback( - (selectedIndexes: number[] | null) => { + ( + selectedIndexes: number[] | null, + { replace = false }: { replace?: boolean } = {}, + ) => { const numSelectedIndexes = selectedIndexes?.length || 0; const indexes = numSelectedIndexes === numIndexes ? null : selectedIndexes; @@ -200,6 +248,7 @@ function HardwareDetails(): JSX.Element { treeIndexes: indexes, }), state: s => s, + replace, }); }, [navigate, numIndexes], @@ -260,6 +309,61 @@ function HardwareDetails(): JSX.Element { { enabled: !fullResponse.isLoading && !!fullResponse.data }, ); + const commitHistoryTable = commitHistoryData?.commit_history_table; + + const commitIntent = useMemo( + () => parseSearchIntent(hardwareSearch ?? ''), + [hardwareSearch], + ); + const intentCommits = + commitIntent.intent === 'commits' ? commitIntent.commits : null; + + useEffect(() => { + if (!isEmptyObject(treeCommits) || !intentCommits?.length) { + return; + } + + const trees = summaryResponse.data?.common.trees; + if (!trees?.length || !commitHistoryTable || commitHistoryIsLoading) { + return; + } + + const newTreeCommits: Record = {}; + for (const tree of trees) { + const key = makeTreeIdentifierKey({ + treeName: tree.tree_name ?? '', + gitRepositoryBranch: tree.git_repository_branch ?? '', + gitRepositoryUrl: tree.git_repository_url ?? '', + }); + const match = findMatchingCheckout( + commitHistoryTable[key] ?? [], + intentCommits, + ); + if (match && match.git_commit_hash !== tree.head_git_commit_hash) { + newTreeCommits[tree.index] = match.git_commit_hash; + } + } + + if (!Object.keys(newTreeCommits).length) { + return; + } + + setTreeIndexesLength(trees.length); + navigate({ + search: prev => ({ ...prev, treeCommits: newTreeCommits }), + state: s => s, + replace: true, + }); + }, [ + treeCommits, + intentCommits, + summaryResponse.data?.common.trees, + commitHistoryTable, + commitHistoryIsLoading, + navigate, + setTreeIndexesLength, + ]); + const filterListElement = useMemo(() => { const flatFilter = createFlatFilter(diffFilter); if (flatFilter.length === 0) { @@ -360,15 +464,19 @@ function HardwareDetails(): JSX.Element { () => prepareTreeItems({ treeItems: summaryResponse.data?.common.trees, - commitHistoryData: commitHistoryData?.commit_history_table, - isCommitHistoryDataLoading: commitHistoryIsLoading, + commitHistoryData: commitHistoryTable, + isCommitHistoryDataLoading: + commitHistoryIsLoading || !commitHistoryData, isMainPageLoading: fullResponse.isLoading || fullResponse.isPlaceholderData, + commitIntentTokens: intentCommits, }), [ commitHistoryIsLoading, + commitHistoryData, + commitHistoryTable, + intentCommits, summaryResponse.data?.common.trees, - commitHistoryData?.commit_history_table, fullResponse.isLoading, fullResponse.isPlaceholderData, ], @@ -481,6 +589,7 @@ function HardwareDetails(): JSX.Element { selectedIndexes={treeIndexes} updateTreeFilters={updateTreeFilters} setTreeIndexesLength={setTreeIndexesLength} + selectionResetKey={`${hardwareId}\0${hardwareSearch ?? ''}`} /> {summaryResponse.data && summaryResponse.data.common.compatibles.length > 0 && ( diff --git a/dashboard/src/pages/hardwareDetails/HardwareDetailsHeaderTable.tsx b/dashboard/src/pages/hardwareDetails/HardwareDetailsHeaderTable.tsx index 51f098351..899b55629 100644 --- a/dashboard/src/pages/hardwareDetails/HardwareDetailsHeaderTable.tsx +++ b/dashboard/src/pages/hardwareDetails/HardwareDetailsHeaderTable.tsx @@ -2,6 +2,7 @@ import type { ColumnDef, RowSelectionState, SortingState, + Updater, } from '@tanstack/react-table'; import { flexRender, @@ -14,7 +15,7 @@ import { import type { SetStateAction, Dispatch, JSX } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -55,8 +56,12 @@ const DEBOUNCE_INTERVAL = 2000; interface IHardwareHeader { treeItems: PreparedTrees[]; selectedIndexes: number[] | null; - updateTreeFilters: (selectedIndexes: number[] | null) => void; + updateTreeFilters: ( + selectedIndexes: number[] | null, + options?: { replace?: boolean }, + ) => void; setTreeIndexesLength: Dispatch>; + selectionResetKey: string; } const CommitSelector = ({ @@ -329,23 +334,29 @@ const getColumns = ( const getInitialRowSelection = ( selectedIndexes: number[] | null, - treeItemsLength: number, + treeItems: PreparedTrees[], isInitialLoad = false, ): Record => { - if (selectedIndexes === null) { - return Object.fromEntries( - Array.from({ length: treeItemsLength }, (_, i) => [i.toString(), true]), - ); + if (treeItems.length === 0) { + return {}; } - if (selectedIndexes.length === 0 && isInitialLoad) { + if ( + selectedIndexes === null || + (selectedIndexes.length === 0 && isInitialLoad) + ) { return Object.fromEntries( - Array.from({ length: treeItemsLength }, (_, i) => [i.toString(), true]), + treeItems + .filter(t => !t.excludeFromCommitIntentDefaultSelection) + .map(t => [t.index, true]), ); } return Object.fromEntries( - Array.from(selectedIndexes, treeIndex => [treeIndex.toString(), true]), + selectedIndexes + .map(idx => treeItems.find(t => t.index === String(idx))) + .filter((t): t is PreparedTrees => !!t) + .map(t => [t.index, true]), ); }; @@ -369,6 +380,7 @@ export function HardwareHeader({ selectedIndexes = null, updateTreeFilters, setTreeIndexesLength, + selectionResetKey, }: IHardwareHeader): JSX.Element { const [sorting, setSorting] = useState([ { id: 'tree_name', desc: false }, @@ -377,25 +389,64 @@ export function HardwareHeader({ 'hardwareDetailsTrees', ); + const treeItemsRef = useRef(treeItems); + treeItemsRef.current = treeItems; + + /** After the user toggles row selection, do not re-apply commit-intent default selection. */ + const userAdjustedRowSelectionRef = useRef(false); + + useEffect(() => { + userAdjustedRowSelectionRef.current = false; + }, [selectionResetKey]); + + const commitHistoryReady = + treeItems.length > 0 && !treeItems[0]?.isCommitHistoryDataLoading; + // The initial assignment is useful to catch the initial indexes from URL const [rowSelection, setRowSelection] = useState(() => - getInitialRowSelection(selectedIndexes, treeItems.length, true), + getInitialRowSelection(selectedIndexes, treeItems, true), ); const rowSelectionDebounced = useDebounce(rowSelection, DEBOUNCE_INTERVAL); useEffect(() => { const updatedSelection = indexesFromRowSelection(rowSelectionDebounced); - updateTreeFilters(updatedSelection); + updateTreeFilters(updatedSelection, { + replace: !userAdjustedRowSelectionRef.current, + }); }, [rowSelectionDebounced, updateTreeFilters, treeItems.length]); - // This useEffect update the current row selection when the selectedIndexes change. - // Useful when the user select a tree by filter modal. useEffect(() => { - setRowSelection(() => - getInitialRowSelection(selectedIndexes, treeItems.length), - ); - }, [selectedIndexes, treeItems.length]); + if (treeItems.length === 0) { + return; + } + + if (selectedIndexes !== null) { + setRowSelection(() => + getInitialRowSelection(selectedIndexes, treeItemsRef.current), + ); + return; + } + + if (userAdjustedRowSelectionRef.current) { + return; + } + + setRowSelection(() => getInitialRowSelection(null, treeItemsRef.current)); + }, [ + selectedIndexes, + treeItems.length, + selectionResetKey, + commitHistoryReady, + ]); + + const onRowSelectionChange = useCallback( + (updater: Updater) => { + userAdjustedRowSelectionRef.current = true; + setRowSelection(updater); + }, + [], + ); const columns = useMemo( () => getColumns(setTreeIndexesLength), @@ -413,7 +464,7 @@ export function HardwareHeader({ getFilteredRowModel: getFilteredRowModel(), getRowId: originalRow => originalRow.index, enableRowSelection: true, - onRowSelectionChange: setRowSelection, + onRowSelectionChange, state: { sorting, pagination, diff --git a/dashboard/src/routes/_main/hardware/$hardwareId/route.tsx b/dashboard/src/routes/_main/hardware/$hardwareId/route.tsx index d7d23fe98..3d9f891e8 100644 --- a/dashboard/src/routes/_main/hardware/$hardwareId/route.tsx +++ b/dashboard/src/routes/_main/hardware/$hardwareId/route.tsx @@ -25,6 +25,7 @@ const defaultValues = { treeCommits: {}, tableFilter: zTableFilterInfoDefault, diffFilter: DEFAULT_DIFF_FILTER, + hardwareSearch: '', }; const hardwareDetailsSearchSchema = z.object({ origin: zOrigin, @@ -33,6 +34,7 @@ const hardwareDetailsSearchSchema = z.object({ treeCommits: zTreeCommits, tableFilter: zTableFilterInfoValidator, diffFilter: zDiffFilter, + hardwareSearch: z.string().catch(''), } satisfies SearchSchema); export const Route = createFileRoute('/_main/hardware/$hardwareId')({ diff --git a/dashboard/src/types/hardware/hardwareDetails.ts b/dashboard/src/types/hardware/hardwareDetails.ts index 1c9ff4290..9ab78840a 100644 --- a/dashboard/src/types/hardware/hardwareDetails.ts +++ b/dashboard/src/types/hardware/hardwareDetails.ts @@ -30,6 +30,7 @@ export type PreparedTrees = HardwareTrees & { selectableCommits: CommitHistory[]; isCommitHistoryDataLoading: boolean; isMainPageLoading: boolean; + excludeFromCommitIntentDefaultSelection?: boolean; }; export type HardwareCommon = {