|
2 | 2 | import { Button } from '$lib/elements/forms'; |
3 | 3 | import InputSelect from '$lib/elements/forms/inputSelect.svelte'; |
4 | 4 | import { projectRoles } from '$lib/stores/billing'; |
5 | | - import type { Models } from '@appwrite.io/console'; |
6 | | - import { Icon, Layout } from '@appwrite.io/pink-svelte'; |
| 5 | + import { sdk } from '$lib/stores/sdk'; |
| 6 | + import { organization } from '$lib/stores/organization'; |
| 7 | + import { Query } from '@appwrite.io/console'; |
| 8 | + import { Icon, Layout, Input } from '@appwrite.io/pink-svelte'; |
7 | 9 | import { IconPlus, IconTrash } from '@appwrite.io/pink-icons-svelte'; |
| 10 | + import { debounce } from '$lib/helpers/debounce'; |
| 11 | + import { onMount } from 'svelte'; |
8 | 12 |
|
9 | 13 | type ProjectAccess = { projectId: string; roleName: string }; |
| 14 | + type Option = { value: string; label: string }; |
10 | 15 |
|
11 | 16 | let { |
12 | | - projectAccess = $bindable<ProjectAccess[]>([]), |
13 | | - projects = [] |
| 17 | + projectAccess = $bindable<ProjectAccess[]>([]) |
14 | 18 | }: { |
15 | 19 | projectAccess: ProjectAccess[]; |
16 | | - projects: Models.Project[]; |
17 | 20 | } = $props(); |
18 | 21 |
|
19 | | - function availableProjects(currentId: string) { |
20 | | - const taken = new Set( |
21 | | - projectAccess.map((a) => a.projectId).filter((id) => id !== currentId) |
| 22 | + // Per-row options, loading state, and request-generation counters |
| 23 | + let rowOptions = $state<Option[][]>([]); |
| 24 | + let rowSearching = $state<boolean[]>([]); |
| 25 | + // Incremented on every loadProjects call for a row; the response is |
| 26 | + // discarded if a newer request has already been dispatched. |
| 27 | + let rowGeneration = $state<number[]>([]); |
| 28 | +
|
| 29 | + // Prefetch the first page the moment this component mounts (i.e. when the |
| 30 | + // user switches to "Specific projects") so results are ready or in-flight |
| 31 | + // before any row is opened. All rows share this single Promise for |
| 32 | + // unfiltered loads; typed searches always hit the API separately. |
| 33 | + let prefetchPromise: Promise<Option[]> | null = null; |
| 34 | +
|
| 35 | + onMount(() => { |
| 36 | + prefetchPromise = sdk.forConsole |
| 37 | + .organization($organization.$id) |
| 38 | + .listProjects({ |
| 39 | + queries: [ |
| 40 | + Query.limit(25), |
| 41 | + Query.orderDesc(''), |
| 42 | + Query.equal('teamId', $organization.$id) |
| 43 | + ] |
| 44 | + }) |
| 45 | + .then((r) => r.projects.map((p) => ({ value: p.$id, label: p.name }))) |
| 46 | + .catch(() => []); |
| 47 | + }); |
| 48 | +
|
| 49 | + function takenIds(excludeIndex: number): Set<string> { |
| 50 | + return new Set( |
| 51 | + projectAccess |
| 52 | + .filter((_, i) => i !== excludeIndex) |
| 53 | + .map((a) => a.projectId) |
| 54 | + .filter(Boolean) |
22 | 55 | ); |
23 | | - return projects |
24 | | - .filter((p) => !taken.has(p.$id)) |
25 | | - .map((p) => ({ label: p.name, value: p.$id })); |
26 | 56 | } |
27 | 57 |
|
28 | | - const allSelected = $derived(projects.length === 0 || projectAccess.length >= projects.length); |
| 58 | + async function loadProjects(index: number, search = '') { |
| 59 | + const gen = (rowGeneration[index] ?? 0) + 1; |
| 60 | + rowGeneration[index] = gen; |
| 61 | + rowSearching[index] = true; |
| 62 | +
|
| 63 | + let allOptions: Option[]; |
| 64 | + if (!search && prefetchPromise) { |
| 65 | + // Re-use the shared prefetch — avoids a redundant network request |
| 66 | + allOptions = await prefetchPromise; |
| 67 | + } else { |
| 68 | + const queries: string[] = [ |
| 69 | + Query.limit(25), |
| 70 | + Query.orderDesc(''), |
| 71 | + Query.equal('teamId', $organization.$id) |
| 72 | + ]; |
| 73 | + if (search) queries.push(Query.search('name', search)); |
| 74 | + const result = await sdk.forConsole |
| 75 | + .organization($organization.$id) |
| 76 | + .listProjects({ queries }) |
| 77 | + .catch(() => null); |
| 78 | + allOptions = (result?.projects ?? []).map((p) => ({ value: p.$id, label: p.name })); |
| 79 | + } |
| 80 | +
|
| 81 | + // Discard stale responses — a newer request has already been dispatched |
| 82 | + if (rowGeneration[index] !== gen) return; |
| 83 | +
|
| 84 | + const taken = takenIds(index); |
| 85 | + rowOptions[index] = allOptions.filter((p) => !taken.has(p.value)); |
| 86 | + rowSearching[index] = false; |
| 87 | + } |
| 88 | +
|
| 89 | + // When the component is initialised with pre-existing rows (edit mode), |
| 90 | + // options must be loaded eagerly so the ComboBox can resolve the project |
| 91 | + // name from the stored projectId instead of showing the raw UUID. |
| 92 | + $effect(() => { |
| 93 | + projectAccess.forEach((access, i) => { |
| 94 | + if (access.projectId && !rowOptions[i]?.length && !rowSearching[i]) { |
| 95 | + loadProjects(i); |
| 96 | + } |
| 97 | + }); |
| 98 | + }); |
| 99 | +
|
| 100 | + // One debounced searcher per row — created on demand |
| 101 | + const debouncedSearchers = new Map<number, (search: string) => void>(); |
| 102 | + function getDebouncedSearch(index: number) { |
| 103 | + if (!debouncedSearchers.has(index)) { |
| 104 | + debouncedSearchers.set( |
| 105 | + index, |
| 106 | + debounce((search: string) => loadProjects(index, search), 300) |
| 107 | + ); |
| 108 | + } |
| 109 | + return debouncedSearchers.get(index)!; |
| 110 | + } |
29 | 111 |
|
30 | 112 | function addRow() { |
| 113 | + const index = projectAccess.length; |
31 | 114 | projectAccess = [...projectAccess, { projectId: '', roleName: 'developer' }]; |
| 115 | + loadProjects(index); |
32 | 116 | } |
33 | 117 |
|
34 | 118 | function removeRow(i: number) { |
35 | 119 | projectAccess = projectAccess.filter((_, idx) => idx !== i); |
| 120 | + rowOptions = rowOptions.filter((_, idx) => idx !== i); |
| 121 | + rowSearching = rowSearching.filter((_, idx) => idx !== i); |
| 122 | + rowGeneration = rowGeneration.filter((_, idx) => idx !== i); |
| 123 | + debouncedSearchers.delete(i); |
| 124 | + // The removed row's project is no longer taken — invalidate sibling caches |
| 125 | + // so they reload with the freed-up project available again. |
| 126 | + rowOptions = rowOptions.map(() => []); |
| 127 | + } |
| 128 | +
|
| 129 | + // When a project is selected in one row, the other rows' cached option lists |
| 130 | + // are now stale (they still include the chosen project). Clear them so each |
| 131 | + // row reloads fresh options (filtered via takenIds) on next focus. |
| 132 | + function onProjectSelected(selectedIndex: number) { |
| 133 | + rowOptions = rowOptions.map((opts, i) => (i === selectedIndex ? opts : [])); |
36 | 134 | } |
37 | 135 | </script> |
38 | 136 |
|
39 | 137 | <Layout.Stack gap="s"> |
40 | 138 | {#each projectAccess as access, i} |
41 | 139 | <Layout.Stack direction="row" gap="s" alignItems="flex-end"> |
42 | | - <div style:flex="1"> |
43 | | - <InputSelect |
| 140 | + <div |
| 141 | + style:flex="1" |
| 142 | + onfocusin={() => { |
| 143 | + if (!rowOptions[i]?.length) loadProjects(i); |
| 144 | + }} |
| 145 | + oninput={(e) => { |
| 146 | + if (e.target instanceof HTMLInputElement) { |
| 147 | + getDebouncedSearch(i)(e.target.value); |
| 148 | + } |
| 149 | + }}> |
| 150 | + <Input.ComboBox |
44 | 151 | id="project-{i}" |
45 | 152 | label={i === 0 ? 'Project' : ''} |
46 | 153 | required |
47 | | - options={availableProjects(access.projectId)} |
| 154 | + placeholder="Search projects" |
48 | 155 | bind:value={access.projectId} |
49 | | - placeholder="Select project" /> |
| 156 | + options={rowOptions[i] ?? []} |
| 157 | + noResultsOption={rowSearching[i] |
| 158 | + ? { disabled: true, message: 'Searching...' } |
| 159 | + : undefined} |
| 160 | + on:change={() => onProjectSelected(i)} /> |
50 | 161 | </div> |
51 | 162 | <div style:width="140px"> |
52 | 163 | <InputSelect |
|
64 | 175 | </Layout.Stack> |
65 | 176 | {/each} |
66 | 177 | <div> |
67 | | - <Button secondary size="s" on:click={addRow} disabled={allSelected}> |
| 178 | + <Button secondary size="s" on:click={addRow}> |
68 | 179 | <Icon size="s" icon={IconPlus} slot="start" /> |
69 | 180 | Add project |
70 | 181 | </Button> |
|
0 commit comments