({
value,
onChange,
placeholder,
disabled,
className,
excludeDefault = true,
emptyContent,
portal,
}: ProjectSelectProps)
| 27 | } |
| 28 | |
| 29 | export function ProjectSelect({ |
| 30 | value, |
| 31 | onChange, |
| 32 | placeholder, |
| 33 | disabled, |
| 34 | className, |
| 35 | excludeDefault = true, |
| 36 | emptyContent, |
| 37 | portal, |
| 38 | }: ProjectSelectProps) { |
| 39 | const { t } = useTranslation(); |
| 40 | const [projects, setProjects] = useState<Project[]>([]); |
| 41 | // Projects referenced by `value` that aren't in the paged fetch. |
| 42 | // Mirrors Vue's `additionalOptions` so deep-linked URLs (e.g. |
| 43 | // `/sql-editor/projects/foo/...`) show the correct project label |
| 44 | // when the user lands on the page before the matching page-1 fetch |
| 45 | // returns it. |
| 46 | const [additionalProjects, setAdditionalProjects] = useState<Project[]>([]); |
| 47 | |
| 48 | // Read `excludeDefault` from a ref inside the callback so the |
| 49 | // callback identity stays stable across re-renders. Without this, any |
| 50 | // parent re-render that re-evaluates the `excludeDefault` prop |
| 51 | // expression (e.g. the SQL Editor's `AsidePanel` re-rendering on |
| 52 | // tab.connection mutations) recreates the callback, which causes the |
| 53 | // mount-`useEffect` below to re-fire and hit `ListProjects` on every |
| 54 | // re-render. The boolean value itself is read live each call, so |
| 55 | // updates still take effect — just without re-running the effect. |
| 56 | const excludeDefaultRef = useRef(excludeDefault); |
| 57 | excludeDefaultRef.current = excludeDefault; |
| 58 | |
| 59 | const fetchProjects = useCallback((query: string) => { |
| 60 | useAppStore |
| 61 | .getState() |
| 62 | .fetchProjectList({ |
| 63 | filter: { query, excludeDefault: excludeDefaultRef.current }, |
| 64 | pageSize: getDefaultPagination(), |
| 65 | }) |
| 66 | .then(({ projects: result }) => setProjects(result)); |
| 67 | }, []); |
| 68 | |
| 69 | // Fetch the first page on mount, and re-fetch only when |
| 70 | // `excludeDefault` actually flips (rare — typically once after |
| 71 | // permissions hydrate). `fetchProjects` is stable so this effect |
| 72 | // never re-fires from parent re-renders alone. |
| 73 | useEffect(() => { |
| 74 | fetchProjects(""); |
| 75 | }, [excludeDefault, fetchProjects]); |
| 76 | |
| 77 | // Hydrate the selected project so the trigger always renders its |
| 78 | // label, even before the paged list loads or when the project is |
| 79 | // outside the first page window. |
| 80 | useEffect(() => { |
| 81 | if (!isValidProjectName(value)) return; |
| 82 | let cancelled = false; |
| 83 | void useAppStore |
| 84 | .getState() |
| 85 | .getOrFetchProjectByName(value) |
| 86 | .then((p) => { |
nothing calls this directly
no test coverage detected