Skip to content
This repository was archived by the owner on Sep 21, 2026. It is now read-only.

Commit 80beffe

Browse files
authored
Merge pull request #3068 from appwrite/feat-ser542-searchable-project-selector
Feat: searchable project selector
2 parents ce9e596 + 26026dd commit 80beffe

6 files changed

Lines changed: 154 additions & 43 deletions

File tree

‎src/lib/components/modal.svelte‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@
2727
$: if (error) {
2828
alert?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
2929
}
30+
31+
// Melt UI's ComboBox (and other listbox-based components) call removeScroll()
32+
// when they open, which adds overflow:hidden + compensatory padding-right
33+
$: if (typeof document !== 'undefined') {
34+
if (show) {
35+
document.body.setAttribute('data-melt-scroll-lock', '');
36+
} else {
37+
document.body.removeAttribute('data-melt-scroll-lock');
38+
}
39+
}
3040
</script>
3141

3242
<Form isModal {onSubmit}>

‎src/lib/profiles/css/base.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,15 @@
44
scrollbar-width: thin;
55
position: relative;
66
}
7+
8+
/*
9+
* Melt UI applies `position: absolute` to the combobox menu via floating-ui,
10+
* but only after `tick()` — a one-frame delay. During that initial frame the
11+
* menu is in the normal document flow, momentarily expanding any ancestor with
12+
* `overflow: visible` (e.g. the <dialog> element) and causing a visible layout
13+
* shift. Pre-setting `position: absolute` here removes the element from flow
14+
* immediately at mount, so there is no reflow before floating-ui takes over.
15+
*/
16+
[data-melt-combobox-menu] {
17+
position: absolute;
18+
}

‎src/routes/(console)/organization-[organization]/createMember.svelte‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import Roles from '$lib/components/roles/roles.svelte';
1616
import { Icon, Popover, Layout } from '@appwrite.io/pink-svelte';
1717
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
18-
import { Query, type Models } from '@appwrite.io/console';
18+
import { type Models } from '@appwrite.io/console';
1919
import ProjectAccessSelector from './projectAccessSelector.svelte';
2020
2121
let {
@@ -34,24 +34,6 @@
3434
let role = $state<string>(isSelfHosted ? 'owner' : 'developer');
3535
let accessType = $state<'all' | 'specific'>('all');
3636
let projectAccess = $state<Array<{ projectId: string; roleName: string }>>([]);
37-
let orgProjects = $state<Models.Project[]>([]);
38-
let hasFetchedProjects = $state(false);
39-
40-
$effect(() => {
41-
if (showCreate && supportsProjectRoles && !hasFetchedProjects) {
42-
hasFetchedProjects = true;
43-
sdk.forConsole
44-
.organization($organization.$id)
45-
.listProjects({
46-
queries: [Query.limit(100), Query.equal('teamId', $organization.$id)]
47-
})
48-
.then((res) => {
49-
orgProjects = res.projects;
50-
})
51-
.catch(() => {});
52-
}
53-
});
54-
5537
$effect(() => {
5638
if (!showCreate) {
5739
error = null;
@@ -60,8 +42,6 @@
6042
role = isSelfHosted ? 'owner' : 'developer';
6143
accessType = 'all';
6244
projectAccess = [];
63-
orgProjects = [];
64-
hasFetchedProjects = false;
6545
}
6646
});
6747
@@ -148,7 +128,7 @@
148128
</Layout.Stack>
149129
</InputSelect>
150130
{:else}
151-
<ProjectAccessSelector bind:projectAccess projects={orgProjects} />
131+
<ProjectAccessSelector bind:projectAccess />
152132
{/if}
153133
{/if}
154134

‎src/routes/(console)/organization-[organization]/members/+page.svelte‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,4 @@
261261
</Container>
262262

263263
<Delete {selectedMember} bind:showDelete />
264-
<Edit {selectedMember} projects={data.orgProjects?.projects ?? []} bind:showEdit />
264+
<Edit {selectedMember} bind:showEdit />

‎src/routes/(console)/organization-[organization]/members/edit.svelte‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,10 @@
2424
let {
2525
showEdit = $bindable(false),
2626
selectedMember,
27-
projects = [],
2827
onupdated
2928
}: {
3029
showEdit: boolean;
3130
selectedMember: Models.Membership;
32-
projects?: Models.Project[];
3331
onupdated?: (membership: Models.Membership) => void;
3432
} = $props();
3533
@@ -138,7 +136,7 @@
138136
</Layout.Stack>
139137
</InputSelect>
140138
{:else}
141-
<ProjectAccessSelector bind:projectAccess {projects} />
139+
<ProjectAccessSelector bind:projectAccess />
142140
{/if}
143141
</Layout.Stack>
144142

‎src/routes/(console)/organization-[organization]/projectAccessSelector.svelte‎

Lines changed: 128 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,162 @@
22
import { Button } from '$lib/elements/forms';
33
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
44
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';
79
import { IconPlus, IconTrash } from '@appwrite.io/pink-icons-svelte';
10+
import { debounce } from '$lib/helpers/debounce';
11+
import { onMount } from 'svelte';
812
913
type ProjectAccess = { projectId: string; roleName: string };
14+
type Option = { value: string; label: string };
1015
1116
let {
12-
projectAccess = $bindable<ProjectAccess[]>([]),
13-
projects = []
17+
projectAccess = $bindable<ProjectAccess[]>([])
1418
}: {
1519
projectAccess: ProjectAccess[];
16-
projects: Models.Project[];
1720
} = $props();
1821
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)
2255
);
23-
return projects
24-
.filter((p) => !taken.has(p.$id))
25-
.map((p) => ({ label: p.name, value: p.$id }));
2656
}
2757
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+
}
29111
30112
function addRow() {
113+
const index = projectAccess.length;
31114
projectAccess = [...projectAccess, { projectId: '', roleName: 'developer' }];
115+
loadProjects(index);
32116
}
33117
34118
function removeRow(i: number) {
35119
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 : []));
36134
}
37135
</script>
38136

39137
<Layout.Stack gap="s">
40138
{#each projectAccess as access, i}
41139
<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
44151
id="project-{i}"
45152
label={i === 0 ? 'Project' : ''}
46153
required
47-
options={availableProjects(access.projectId)}
154+
placeholder="Search projects"
48155
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)} />
50161
</div>
51162
<div style:width="140px">
52163
<InputSelect
@@ -64,7 +175,7 @@
64175
</Layout.Stack>
65176
{/each}
66177
<div>
67-
<Button secondary size="s" on:click={addRow} disabled={allSelected}>
178+
<Button secondary size="s" on:click={addRow}>
68179
<Icon size="s" icon={IconPlus} slot="start" />
69180
Add project
70181
</Button>

0 commit comments

Comments
 (0)