From 02a23d0f9ae17ae2b812704476d7324b4a9c5cfa Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Thu, 30 Apr 2026 22:30:45 +0200 Subject: [PATCH 01/15] Migrate branch selector to design system components --- .../branches/ui/branch-create-form.tsx | 6 +- .../branch-list-item/branch-default-badge.tsx | 12 +- .../entities/branches/ui/branch-selector.tsx | 337 ++++++++---------- .../src/entities/navigation/ui/app-header.tsx | 2 +- .../shared/components/aria/autocomplete.tsx | 27 +- .../app/src/shared/hooks/useIsTruncated.ts | 17 - .../ui/src/components/button/button.tsx | 3 +- 7 files changed, 189 insertions(+), 215 deletions(-) delete mode 100644 frontend/app/src/shared/hooks/useIsTruncated.ts diff --git a/frontend/app/src/entities/branches/ui/branch-create-form.tsx b/frontend/app/src/entities/branches/ui/branch-create-form.tsx index 55edb2dca0c..d0a28e9b443 100644 --- a/frontend/app/src/entities/branches/ui/branch-create-form.tsx +++ b/frontend/app/src/entities/branches/ui/branch-create-form.tsx @@ -73,11 +73,13 @@ const BranchCreateForm = ({ defaultBranchName, onCancel, onSuccess }: BranchCrea - - Create a new branch + + Create a new branch + ); diff --git a/frontend/app/src/entities/branches/ui/branch-list-item/branch-default-badge.tsx b/frontend/app/src/entities/branches/ui/branch-list-item/branch-default-badge.tsx index fc6b7d5ddb1..4c18a73c41d 100644 --- a/frontend/app/src/entities/branches/ui/branch-list-item/branch-default-badge.tsx +++ b/frontend/app/src/entities/branches/ui/branch-list-item/branch-default-badge.tsx @@ -1,10 +1,16 @@ -import { Badge, type BadgeProps } from "@/shared/components/ui/badge"; +import type { BadgeProps } from "@/shared/components/ui/badge"; import { classNames } from "@/shared/utils/common"; export function BranchDefaultBadge({ className, ...props }: BadgeProps) { return ( - + default - + ); } diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 62e83c381c1..1ba34dfefea 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -1,236 +1,203 @@ import { Icon } from "@iconify-icon/react"; import { Button, LinkButton } from "@infrahub/ui"; -import { useCommandState } from "cmdk"; +import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"; import { useQueryState } from "nuqs"; -import { useRef, useState } from "react"; +import React from "react"; +import { + type ButtonProps as AriaButtonProps, + Collection, + ListLayout, + useFilter, + Virtualizer, +} from "react-aria-components"; -import type { Branch } from "@/shared/api/graphql/generated/types"; import { constructPath } from "@/shared/api/rest/fetch"; +import { Autocomplete } from "@/shared/components/aria/autocomplete"; +import { ListBox, ListBoxItem, ListBoxLoadMoreItem } from "@/shared/components/aria/list-box"; +import { Popover, PopoverDialog, PopoverTrigger } from "@/shared/components/aria/popover"; +import { Separator } from "@/shared/components/aria/separator"; import { Tooltip } from "@/shared/components/aria/tooltip"; -import { ComboboxItem } from "@/shared/components/ui/combobox"; -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, -} from "@/shared/components/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"; +import { Row } from "@/shared/components/container"; import { QSP } from "@/shared/config/qsp"; -import { useIsTruncated } from "@/shared/hooks/useIsTruncated"; import { useAuth } from "@/entities/authentication/ui/useAuth"; +import type { BranchListItem } from "@/entities/branches/domain/branch.mappers"; import BranchCreateForm from "@/entities/branches/ui/branch-create-form"; +import { BranchDefaultBadge } from "@/entities/branches/ui/branch-list-item/branch-default-badge"; import { BranchStatusBadge } from "@/entities/branches/ui/branch-list-item/branch-status-badge"; import { useCurrentBranch } from "@/entities/branches/ui/branches-provider"; -import { useGetBranches } from "@/entities/branches/ui/queries/get-branches.query"; -import { branchesToSelectOptions } from "@/entities/branches/utils"; +import { useGetBranchesPaginated } from "@/entities/branches/ui/queries/get-branches.query"; -type DisplayForm = { - open: boolean; - defaultBranchName?: string; -}; +// textValue for the "Create branch X" item. +// Whitelisted by the Autocomplete filter so it remains visible regardless of the current search input. +const CREATE_BRANCH_ITEM_VALUE = "__create_branch__"; -export default function BranchSelector() { +export function BranchSelector() { const { currentBranch } = useCurrentBranch(); - const [isOpen, setIsOpen] = useState(false); - const [displayForm, setDisplayForm] = useState({ open: false }); + const [isCreating, setIsCreating] = React.useState(false); + const [initialBranchName, setInitialBranchName] = React.useState(""); + + function openCreateForm(name: string) { + setInitialBranchName(name); + setIsCreating(true); + } + + function closeCreateForm() { + setIsCreating(false); + } return ( - { - setDisplayForm({ open: false }); - setIsOpen(open); + if (open) closeCreateForm(); }} > - - - - - - {displayForm.open ? ( - setDisplayForm({ open: false })} - onSuccess={() => { - setDisplayForm({ open: false }); - setIsOpen(false); - }} - defaultBranchName={displayForm.defaultBranchName} - data-testid="branch-create-form" - /> - ) : ( - - )} - - + + + + + {({ close }) => + isCreating ? ( + { + closeCreateForm(); + close(); + }} + defaultBranchName={initialBranchName} + data-testid="branch-create-form" + /> + ) : ( + + ) + } + + + ); } -function BranchSelect({ - setPopoverOpen, - setFormOpen, -}: { - setPopoverOpen: (open: boolean) => void; - setFormOpen: (displayForm: DisplayForm) => void; -}) { - const { data: branches, isPending } = useGetBranches(); +interface BranchListProps { + closePopover: () => void; + openCreateForm: (name: string) => void; +} + +function BranchList({ closePopover, openCreateForm }: BranchListProps) { + const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = useGetBranchesPaginated(); const { setCurrentBranch } = useCurrentBranch(); const [, setBranchInQueryString] = useQueryState(QSP.BRANCH); + const { contains } = useFilter({ sensitivity: "base" }); + const [search, setSearch] = React.useState(""); + const trimmedSearch = search.trim(); + const branches = data?.pages.flat() ?? []; - const handleBranchChange = (branch: Branch) => { + function handleBranchChange(branch: BranchListItem) { setBranchInQueryString(branch.is_default ? null : branch.name); setCurrentBranch(branch); - setPopoverOpen(false); - }; + closePopover(); + } return ( <> - -
- + textValue === CREATE_BRANCH_ITEM_VALUE || contains(textValue, input) + } + suffix={ + openCreateForm(trimmedSearch)} + className="mr-1 ml-auto" /> - - -
- - - setFormOpen({ open: true, defaultBranchName })} - /> - - {branches && - branchesToSelectOptions(branches).map((branch) => ( - handleBranchChange(branch)} - /> - ))} - - {isPending && ( - - Loading branches... - - )} - -
-
+ } + > + + + + {(branch) => ( + handleBranchChange(branch)}> + + {branch.name} + + + + {branch.is_default && } + + {branch.sync_with_git && } + + + )} + + + {trimmedSearch && ( + openCreateForm(trimmedSearch)} + > + Create branch {trimmedSearch} + + )} + + {hasNextPage && ( + + )} + + + + + + + setPopoverOpen(false)} + href={constructPath("/branches")} + className="grow justify-start text-sm" + onPress={closePopover} > View all branches -
+ ); } -function BranchOption({ branch, onChange }: { branch: Branch; onChange: () => void }) { - const { currentBranch } = useCurrentBranch(); - const nameRef = useRef(null); - const isTruncated = useIsTruncated(nameRef); - - return ( - -
- - {branch.name} - - -
- {branch.is_default && ( - - default - - )} - {branch.sync_with_git && ( - - )} - -
-
-
- ); -} - -export const BranchFormTriggerButton = ({ - setOpen, -}: { - setOpen: (displayForm: DisplayForm) => void; -}) => { +export function BranchFormTriggerButton({ ...props }: AriaButtonProps) { const { isAuthenticated } = useAuth(); - const handlePress = () => { - setOpen({ open: true }); - }; - return ( - + ); -}; - -const BranchNotFound = ({ onSelect }: { onSelect: (branchName: string) => void }) => { - const filteredCount = useCommandState((state) => state.filtered.count); - const search = useCommandState((state) => state.search); - const { isAuthenticated } = useAuth(); - - if (!isAuthenticated) return No branch found; - if (filteredCount !== 0) return null; - - return ( - onSelect(search)} - className="gap-1 truncate text-neutral-600" - > - Create branch {search} - - ); -}; +} diff --git a/frontend/app/src/entities/navigation/ui/app-header.tsx b/frontend/app/src/entities/navigation/ui/app-header.tsx index e09e61145ac..236fe56c07e 100644 --- a/frontend/app/src/entities/navigation/ui/app-header.tsx +++ b/frontend/app/src/entities/navigation/ui/app-header.tsx @@ -1,7 +1,7 @@ import { ScrollArea } from "@infrahub/ui"; import { Card } from "@infrahub/ui/card"; -import BranchSelector from "@/entities/branches/ui/branch-selector"; +import { BranchSelector } from "@/entities/branches/ui/branch-selector"; import { BreadcrumbNavigation } from "@/entities/navigation/ui/breadcrumbs/breadcrumb-navigation"; import { TimeFrameSelector } from "@/entities/navigation/ui/time-selector"; import { TaskStatus } from "@/entities/tasks/ui/task-status"; diff --git a/frontend/app/src/shared/components/aria/autocomplete.tsx b/frontend/app/src/shared/components/aria/autocomplete.tsx index e0cd3662e40..99e7be06772 100644 --- a/frontend/app/src/shared/components/aria/autocomplete.tsx +++ b/frontend/app/src/shared/components/aria/autocomplete.tsx @@ -1,4 +1,5 @@ import { SearchIcon, XIcon } from "lucide-react"; +import type React from "react"; import { Autocomplete as AriaAutocomplete, type AutocompleteProps as AriaAutocompleteProps, @@ -10,9 +11,20 @@ import { useFilter, } from "react-aria-components"; +import { Row } from "@/shared/components/container"; import { classNames } from "@/shared/utils/common"; -export function Autocomplete({ filter, onInputChange, children, ...props }: AriaAutocompleteProps) { +interface AutocompleteProps extends AriaAutocompleteProps { + suffix?: React.ReactNode; +} + +export function Autocomplete({ + filter, + onInputChange, + children, + suffix, + ...props +}: AutocompleteProps) { const { contains } = useFilter({ sensitivity: "base" }); // When onInputChange is provided, items are controlled externally (server-side search) — skip client-side filtering. const resolvedFilter = filter ?? (onInputChange ? undefined : contains); @@ -20,7 +32,10 @@ export function Autocomplete({ filter, onInputChange, children, ...props }: Aria return (
- + + + {suffix} + {children}
@@ -34,22 +49,22 @@ export interface SearchInputProps extends AriaSearchFieldProps { export function AutocompleteSearchField({ className, placeholder, ...props }: SearchInputProps) { return ( - + ) { - const [isTruncated, setIsTruncated] = useState(false); - - useEffect(() => { - const el = ref.current; - if (!el) return; - const observer = new ResizeObserver(() => { - setIsTruncated(el.scrollWidth > el.clientWidth); - }); - observer.observe(el); - return () => observer.disconnect(); - }, [ref]); - - return isTruncated; -} diff --git a/frontend/packages/ui/src/components/button/button.tsx b/frontend/packages/ui/src/components/button/button.tsx index 771bebd591f..4f841b7d5d2 100644 --- a/frontend/packages/ui/src/components/button/button.tsx +++ b/frontend/packages/ui/src/components/button/button.tsx @@ -55,11 +55,12 @@ const buttonVariants = tv({ ], ghost: [ "border-transparent text-stone-800 shadow-none", - "data-hovered:bg-neutral-200/50", + "data-hovered:bg-neutral-600/10", "data-pressed:bg-neutral-200", ], }, size: { + xxs: "h-6", xs: "h-7", sm: "h-8", md: "h-9", From 49ef34cfd28012f0699a1c436a7062fa2eae3998 Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Thu, 30 Apr 2026 22:50:29 +0200 Subject: [PATCH 02/15] more --- .../entities/branches/ui/branch-selector.tsx | 10 ++----- .../shared/components/aria/autocomplete.tsx | 30 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 1ba34dfefea..fb8f40a4de3 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -122,12 +122,7 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { filter={(textValue, input) => textValue === CREATE_BRANCH_ITEM_VALUE || contains(textValue, input) } - suffix={ - openCreateForm(trimmedSearch)} - className="mr-1 ml-auto" - /> - } + suffix={ openCreateForm(trimmedSearch)} />} > openCreateForm(trimmedSearch)} + className="gap-1 whitespace-nowrap" > - Create branch {trimmedSearch} + Create branch {trimmedSearch} )} diff --git a/frontend/app/src/shared/components/aria/autocomplete.tsx b/frontend/app/src/shared/components/aria/autocomplete.tsx index 99e7be06772..34d4eaf96e1 100644 --- a/frontend/app/src/shared/components/aria/autocomplete.tsx +++ b/frontend/app/src/shared/components/aria/autocomplete.tsx @@ -1,9 +1,9 @@ +import { Button } from "@infrahub/ui"; import { SearchIcon, XIcon } from "lucide-react"; import type React from "react"; import { Autocomplete as AriaAutocomplete, type AutocompleteProps as AriaAutocompleteProps, - Button as AriaButton, Input as AriaInput, type InputProps as AriaInputProps, SearchField as AriaSearchField, @@ -32,8 +32,8 @@ export function Autocomplete({ return (
- - + + {suffix} {children} @@ -49,29 +49,25 @@ export interface SearchInputProps extends AriaSearchFieldProps { export function AutocompleteSearchField({ className, placeholder, ...props }: SearchInputProps) { return ( - - - + + ); } From a60c3931d4d1782284101656fc156d182d8e34df Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Thu, 30 Apr 2026 23:00:18 +0200 Subject: [PATCH 03/15] better --- .../entities/branches/ui/branch-selector.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index fb8f40a4de3..5c75dc43505 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -1,6 +1,6 @@ import { Icon } from "@iconify-icon/react"; import { Button, LinkButton } from "@infrahub/ui"; -import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react"; import { useQueryState } from "nuqs"; import React from "react"; import { @@ -82,7 +82,6 @@ export function BranchSelector() { close(); }} defaultBranchName={initialBranchName} - data-testid="branch-create-form" /> ) : ( @@ -164,17 +163,16 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { - - - View all branches - - + + View all branches + + ); } From 87afa5b4ed7e8c9ff4ddc974709a338489e34774 Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Fri, 1 May 2026 01:08:40 +0200 Subject: [PATCH 04/15] more --- .../app/src/entities/branches/ui/branch-selector.tsx | 9 ++++++--- frontend/app/src/shared/components/aria/autocomplete.tsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 5c75dc43505..746ea8019e8 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -55,6 +55,7 @@ export function BranchSelector() { From bcadf5ffdd08ac597d647422c81a7821cb8bac0a Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Fri, 1 May 2026 01:33:37 +0200 Subject: [PATCH 05/15] fix --- .../entities/branches/ui/branch-selector.tsx | 2 +- .../e2e/branches/branch-selector.spec.ts | 44 ++++++++----------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 746ea8019e8..464df77a2e2 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -129,7 +129,7 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { layout={ListLayout} layoutOptions={{ rowHeight: 30, loaderHeight: 30, padding: 4 }} > - + {(branch) => ( handleBranchChange(branch)}> diff --git a/frontend/app/tests/e2e/branches/branch-selector.spec.ts b/frontend/app/tests/e2e/branches/branch-selector.spec.ts index 030a713b116..fadc1139205 100644 --- a/frontend/app/tests/e2e/branches/branch-selector.spec.ts +++ b/frontend/app/tests/e2e/branches/branch-selector.spec.ts @@ -6,12 +6,12 @@ test.describe("Branch selector", () => { test.describe("when not logged in", () => { test("should not be able to create a branch if not logged in", async ({ page }) => { await page.goto("/"); - await page.getByTestId("branch-selector-trigger").click(); - await expect(page.getByTestId("create-branch-button")).toBeDisabled(); + await page.getByRole("button", { name: "Branch selector" }).click(); + await expect(page.getByRole("button", { name: "Create branch" })).toBeDisabled(); await test.step("to go branch list view", async () => { await page.getByRole("link", { name: "View all branches" }).click(); - await expect(page.getByTestId("branches-table")).toContainText("main"); + await expect(page.getByTestId("branches-table")).toBeVisible(); }); }); @@ -19,12 +19,11 @@ test.describe("Branch selector", () => { page, }) => { await page.goto("/"); - await page.getByTestId("branch-selector-trigger").click(); + await page.getByRole("button", { name: "Branch selector" }).click(); const nonExistentBranchName = "non-existent-branch-123"; - await page.getByTestId("branch-search-input").fill(nonExistentBranchName); + await page.getByPlaceholder("Search...").fill(nonExistentBranchName); - await expect(page.getByText("No branch found")).toBeVisible(); await expect( page.getByRole("option", { name: `Create branch ${nonExistentBranchName}` }) ).not.toBeVisible(); @@ -32,24 +31,19 @@ test.describe("Branch selector", () => { test("should be able to search and switch branch", async ({ page }) => { await page.goto("/"); - await expect(page.getByTestId("branch-selector-trigger")).toContainText("main"); - await page.getByTestId("branch-selector-trigger").click(); - await expect( - page.getByTestId("branch-list").getByRole("option", { name: "main default" }) - ).toBeVisible(); - await page.getByTestId("branch-search-input").fill("atl1"); - await expect( - page.getByTestId("branch-list").getByRole("option", { name: "atl1-delete-upstream" }) - ).toBeVisible(); - await expect(page.getByTestId("branch-list").getByRole("option")).toHaveCount(1); - await page - .getByTestId("branch-list") - .getByRole("option", { name: "atl1-delete-upstream" }) - .click(); - await expect(page.getByTestId("branch-selector-trigger")).toContainText( - "atl1-delete-upstream" - ); + const branchSelectorTrigger = page.getByRole("button", { name: "Branch selector" }); + await expect(branchSelectorTrigger).toContainText("main"); + await branchSelectorTrigger.click(); + const branchList = page.getByLabel("branch list"); + await expect(branchList.getByRole("option", { name: "main default" })).toBeVisible(); + await expect(branchList.getByRole("option", { name: "atl1-delete-upstream" })).toBeVisible(); + + await page.getByPlaceholder("Search...").fill("atl1"); + await expect(branchList.getByRole("option", { name: "atl1-delete-upstream" })).toBeVisible(); + await expect(branchList.getByRole("option", { name: "main default" })).toBeHidden(); + await branchList.getByRole("option", { name: "atl1-delete-upstream" }).click(); + await expect(branchSelectorTrigger).toContainText("atl1-delete-upstream"); }); }); @@ -58,8 +52,8 @@ test.describe("Branch selector", () => { test("allow to create a branch with a name that does not exists", async ({ page }) => { await page.goto("/"); - await page.getByTestId("branch-selector-trigger").click(); - await page.getByTestId("branch-search-input").fill("quick-branch-form"); + await page.getByRole("button", { name: "Branch selector" }).click(); + await page.getByPlaceholder("Search...").fill("quick-branch-form"); await page.getByRole("option", { name: "Create branch quick-branch-form" }).click(); await expect(page.getByLabel("New branch name *")).toHaveValue("quick-branch-form"); }); From 0801801b0e2fca142eec0dbcf00235591314535a Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Fri, 1 May 2026 01:45:21 +0200 Subject: [PATCH 06/15] more --- .../entities/branches/ui/branch-selector.tsx | 8 +------- .../e2e/branches/branch-selector.spec.ts | 13 ++++++------ .../app/tests/e2e/branches/branches.spec.ts | 20 ++++++++++--------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 464df77a2e2..0fa66843df9 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -52,13 +52,7 @@ export function BranchSelector() { if (open) closeCreateForm(); }} > - From c98fa86453581cb898196b2c2d6dc56e5e8f4def Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Wed, 3 Jun 2026 11:05:31 +0200 Subject: [PATCH 13/15] add selection indicator --- .../src/entities/branches/ui/branch-selector.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 3c11bb9ee3b..2e272ab4d1b 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -1,6 +1,12 @@ import { Icon } from "@iconify-icon/react"; import { Button, LinkButton } from "@infrahub/ui"; -import { ArrowUpRightIcon, ChevronsUpDownIcon, LoaderIcon, PlusIcon } from "lucide-react"; +import { + ArrowUpRightIcon, + CheckIcon, + ChevronsUpDownIcon, + LoaderIcon, + PlusIcon, +} from "lucide-react"; import { useQueryState } from "nuqs"; import React from "react"; import { @@ -101,7 +107,7 @@ interface BranchListProps { function BranchList({ closePopover, openCreateForm }: BranchListProps) { const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isPending } = useGetBranchesPaginated(); - const { setCurrentBranch } = useCurrentBranch(); + const { currentBranch, setCurrentBranch } = useCurrentBranch(); const [, setBranchInQueryString] = useQueryState(QSP.BRANCH); const { contains } = useFilter({ sensitivity: "base" }); const { isAuthenticated } = useAuth(); @@ -150,6 +156,9 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { + {currentBranch.name === branch.name && ( + + )} {branch.is_default && } {branch.sync_with_git && } From 69fc44c7efdb81063ea8962ec6dfaad0a06eb06b Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Wed, 3 Jun 2026 11:15:40 +0200 Subject: [PATCH 14/15] async search branches --- .../entities/branches/ui/branch-selector.tsx | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/frontend/app/src/entities/branches/ui/branch-selector.tsx b/frontend/app/src/entities/branches/ui/branch-selector.tsx index 2e272ab4d1b..9e8a48aafeb 100644 --- a/frontend/app/src/entities/branches/ui/branch-selector.tsx +++ b/frontend/app/src/entities/branches/ui/branch-selector.tsx @@ -1,19 +1,12 @@ import { Icon } from "@iconify-icon/react"; import { Button, LinkButton } from "@infrahub/ui"; -import { - ArrowUpRightIcon, - CheckIcon, - ChevronsUpDownIcon, - LoaderIcon, - PlusIcon, -} from "lucide-react"; +import { ArrowUpRightIcon, CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react"; import { useQueryState } from "nuqs"; import React from "react"; import { type ButtonProps as AriaButtonProps, Collection, ListLayout, - useFilter, Virtualizer, } from "react-aria-components"; @@ -25,6 +18,7 @@ import { Separator } from "@/shared/components/aria/separator"; import { Tooltip } from "@/shared/components/aria/tooltip"; import { Row } from "@/shared/components/container"; import { QSP } from "@/shared/config/qsp"; +import { useDebounce } from "@/shared/hooks/useDebounce"; import { useAuth } from "@/entities/authentication/ui/useAuth"; import type { BranchListItem } from "@/entities/branches/domain/branch.mappers"; @@ -105,14 +99,15 @@ interface BranchListProps { } function BranchList({ closePopover, openCreateForm }: BranchListProps) { - const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isPending } = - useGetBranchesPaginated(); const { currentBranch, setCurrentBranch } = useCurrentBranch(); const [, setBranchInQueryString] = useQueryState(QSP.BRANCH); - const { contains } = useFilter({ sensitivity: "base" }); const { isAuthenticated } = useAuth(); const [search, setSearch] = React.useState(""); const trimmedSearch = search.trim(); + const debouncedSearch = useDebounce(trimmedSearch, 300); + const { data, fetchNextPage, isFetchingNextPage, isPending } = useGetBranchesPaginated({ + filters: debouncedSearch ? [{ name: "any__value", value: debouncedSearch }] : undefined, + }); const branches = data?.pages.flat() ?? []; function handleBranchChange(branch: BranchListItem) { @@ -126,9 +121,6 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { - textValue === CREATE_BRANCH_ITEM_VALUE || contains(textValue, input) - } suffix={ openCreateForm(trimmedSearch)} />} > - isPending ? ( - - Loading... - - ) : ( + !isPending && (
No branch found
) } @@ -167,6 +155,11 @@ function BranchList({ closePopover, openCreateForm }: BranchListProps) { )}
+ + {isAuthenticated && trimmedSearch && ( {trimmedSearch} )} - - {hasNextPage && ( - - )}
From 8c1d26296ac1e3868bbfa5d2c68be924fd021287 Mon Sep 17 00:00:00 2001 From: bilalabbad Date: Wed, 3 Jun 2026 11:17:16 +0200 Subject: [PATCH 15/15] update doc --- dev/knowledge/frontend/shared-components.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dev/knowledge/frontend/shared-components.md b/dev/knowledge/frontend/shared-components.md index 095dbd0f5c4..b19a378d39b 100644 --- a/dev/knowledge/frontend/shared-components.md +++ b/dev/knowledge/frontend/shared-components.md @@ -131,8 +131,7 @@ The `tab` argument on each helper is a string-literal union (e.g. `BranchDetails | Debounced value | `useDebounce` | `shared/hooks/useDebounce.ts` | | Pagination state | `usePagination` | `shared/hooks/usePagination.ts` | | Local-storage state | `useLocalStorage` | `shared/hooks/useLocalStorage.ts` | -| Copy to clipboard (returns timed `isCopied` flag for feedback) | `useCopyToClipboard` — never call `navigator.clipboard.*` directly | `shared/hooks/useCopyToClipboard.ts` | -| Detect truncation | `useIsTruncated` | `shared/hooks/useIsTruncated.ts` | +| Copy to clipboard | `useCopyToClipboard` | `shared/hooks/useCopyToClipboard.ts` | | Previous value | `usePrevious` | `shared/hooks/usePrevious.ts` | | Search input state | `useSearch` | `shared/hooks/useSearch.ts` | | Page title | `useTitle` | `shared/hooks/useTitle.ts` |