diff --git a/src/lib/components/modal.svelte b/src/lib/components/modal.svelte index 0626393404..ace5aa01c9 100644 --- a/src/lib/components/modal.svelte +++ b/src/lib/components/modal.svelte @@ -27,6 +27,16 @@ $: if (error) { alert?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); } + + // Melt UI's ComboBox (and other listbox-based components) call removeScroll() + // when they open, which adds overflow:hidden + compensatory padding-right + $: if (typeof document !== 'undefined') { + if (show) { + document.body.setAttribute('data-melt-scroll-lock', ''); + } else { + document.body.removeAttribute('data-melt-scroll-lock'); + } + }
diff --git a/src/lib/profiles/css/base.css b/src/lib/profiles/css/base.css index ae0218c273..b88c0e1c15 100644 --- a/src/lib/profiles/css/base.css +++ b/src/lib/profiles/css/base.css @@ -4,3 +4,15 @@ scrollbar-width: thin; position: relative; } + +/* + * Melt UI applies `position: absolute` to the combobox menu via floating-ui, + * but only after `tick()` — a one-frame delay. During that initial frame the + * menu is in the normal document flow, momentarily expanding any ancestor with + * `overflow: visible` (e.g. the element) and causing a visible layout + * shift. Pre-setting `position: absolute` here removes the element from flow + * immediately at mount, so there is no reflow before floating-ui takes over. + */ +[data-melt-combobox-menu] { + position: absolute; +} diff --git a/src/routes/(console)/organization-[organization]/createMember.svelte b/src/routes/(console)/organization-[organization]/createMember.svelte index 3f6d8fd440..32f480d7f5 100644 --- a/src/routes/(console)/organization-[organization]/createMember.svelte +++ b/src/routes/(console)/organization-[organization]/createMember.svelte @@ -15,7 +15,7 @@ import Roles from '$lib/components/roles/roles.svelte'; import { Icon, Popover, Layout } from '@appwrite.io/pink-svelte'; import { IconInfo } from '@appwrite.io/pink-icons-svelte'; - import { Query, type Models } from '@appwrite.io/console'; + import { type Models } from '@appwrite.io/console'; import ProjectAccessSelector from './projectAccessSelector.svelte'; let { @@ -34,24 +34,6 @@ let role = $state(isSelfHosted ? 'owner' : 'developer'); let accessType = $state<'all' | 'specific'>('all'); let projectAccess = $state>([]); - let orgProjects = $state([]); - let hasFetchedProjects = $state(false); - - $effect(() => { - if (showCreate && supportsProjectRoles && !hasFetchedProjects) { - hasFetchedProjects = true; - sdk.forConsole - .organization($organization.$id) - .listProjects({ - queries: [Query.limit(100), Query.equal('teamId', $organization.$id)] - }) - .then((res) => { - orgProjects = res.projects; - }) - .catch(() => {}); - } - }); - $effect(() => { if (!showCreate) { error = null; @@ -60,8 +42,6 @@ role = isSelfHosted ? 'owner' : 'developer'; accessType = 'all'; projectAccess = []; - orgProjects = []; - hasFetchedProjects = false; } }); @@ -148,7 +128,7 @@ {:else} - + {/if} {/if} diff --git a/src/routes/(console)/organization-[organization]/members/+page.svelte b/src/routes/(console)/organization-[organization]/members/+page.svelte index 5d3777cead..67bbce0d9e 100644 --- a/src/routes/(console)/organization-[organization]/members/+page.svelte +++ b/src/routes/(console)/organization-[organization]/members/+page.svelte @@ -261,4 +261,4 @@ - + diff --git a/src/routes/(console)/organization-[organization]/members/edit.svelte b/src/routes/(console)/organization-[organization]/members/edit.svelte index 1cc83974c0..38e01c30b2 100644 --- a/src/routes/(console)/organization-[organization]/members/edit.svelte +++ b/src/routes/(console)/organization-[organization]/members/edit.svelte @@ -24,12 +24,10 @@ let { showEdit = $bindable(false), selectedMember, - projects = [], onupdated }: { showEdit: boolean; selectedMember: Models.Membership; - projects?: Models.Project[]; onupdated?: (membership: Models.Membership) => void; } = $props(); @@ -138,7 +136,7 @@ {:else} - + {/if} diff --git a/src/routes/(console)/organization-[organization]/projectAccessSelector.svelte b/src/routes/(console)/organization-[organization]/projectAccessSelector.svelte index a26b841ea2..17e4013899 100644 --- a/src/routes/(console)/organization-[organization]/projectAccessSelector.svelte +++ b/src/routes/(console)/organization-[organization]/projectAccessSelector.svelte @@ -2,51 +2,162 @@ import { Button } from '$lib/elements/forms'; import InputSelect from '$lib/elements/forms/inputSelect.svelte'; import { projectRoles } from '$lib/stores/billing'; - import type { Models } from '@appwrite.io/console'; - import { Icon, Layout } from '@appwrite.io/pink-svelte'; + import { sdk } from '$lib/stores/sdk'; + import { organization } from '$lib/stores/organization'; + import { Query } from '@appwrite.io/console'; + import { Icon, Layout, Input } from '@appwrite.io/pink-svelte'; import { IconPlus, IconTrash } from '@appwrite.io/pink-icons-svelte'; + import { debounce } from '$lib/helpers/debounce'; + import { onMount } from 'svelte'; type ProjectAccess = { projectId: string; roleName: string }; + type Option = { value: string; label: string }; let { - projectAccess = $bindable([]), - projects = [] + projectAccess = $bindable([]) }: { projectAccess: ProjectAccess[]; - projects: Models.Project[]; } = $props(); - function availableProjects(currentId: string) { - const taken = new Set( - projectAccess.map((a) => a.projectId).filter((id) => id !== currentId) + // Per-row options, loading state, and request-generation counters + let rowOptions = $state([]); + let rowSearching = $state([]); + // Incremented on every loadProjects call for a row; the response is + // discarded if a newer request has already been dispatched. + let rowGeneration = $state([]); + + // Prefetch the first page the moment this component mounts (i.e. when the + // user switches to "Specific projects") so results are ready or in-flight + // before any row is opened. All rows share this single Promise for + // unfiltered loads; typed searches always hit the API separately. + let prefetchPromise: Promise | null = null; + + onMount(() => { + prefetchPromise = sdk.forConsole + .organization($organization.$id) + .listProjects({ + queries: [ + Query.limit(25), + Query.orderDesc(''), + Query.equal('teamId', $organization.$id) + ] + }) + .then((r) => r.projects.map((p) => ({ value: p.$id, label: p.name }))) + .catch(() => []); + }); + + function takenIds(excludeIndex: number): Set { + return new Set( + projectAccess + .filter((_, i) => i !== excludeIndex) + .map((a) => a.projectId) + .filter(Boolean) ); - return projects - .filter((p) => !taken.has(p.$id)) - .map((p) => ({ label: p.name, value: p.$id })); } - const allSelected = $derived(projects.length === 0 || projectAccess.length >= projects.length); + async function loadProjects(index: number, search = '') { + const gen = (rowGeneration[index] ?? 0) + 1; + rowGeneration[index] = gen; + rowSearching[index] = true; + + let allOptions: Option[]; + if (!search && prefetchPromise) { + // Re-use the shared prefetch — avoids a redundant network request + allOptions = await prefetchPromise; + } else { + const queries: string[] = [ + Query.limit(25), + Query.orderDesc(''), + Query.equal('teamId', $organization.$id) + ]; + if (search) queries.push(Query.search('name', search)); + const result = await sdk.forConsole + .organization($organization.$id) + .listProjects({ queries }) + .catch(() => null); + allOptions = (result?.projects ?? []).map((p) => ({ value: p.$id, label: p.name })); + } + + // Discard stale responses — a newer request has already been dispatched + if (rowGeneration[index] !== gen) return; + + const taken = takenIds(index); + rowOptions[index] = allOptions.filter((p) => !taken.has(p.value)); + rowSearching[index] = false; + } + + // When the component is initialised with pre-existing rows (edit mode), + // options must be loaded eagerly so the ComboBox can resolve the project + // name from the stored projectId instead of showing the raw UUID. + $effect(() => { + projectAccess.forEach((access, i) => { + if (access.projectId && !rowOptions[i]?.length && !rowSearching[i]) { + loadProjects(i); + } + }); + }); + + // One debounced searcher per row — created on demand + const debouncedSearchers = new Map void>(); + function getDebouncedSearch(index: number) { + if (!debouncedSearchers.has(index)) { + debouncedSearchers.set( + index, + debounce((search: string) => loadProjects(index, search), 300) + ); + } + return debouncedSearchers.get(index)!; + } function addRow() { + const index = projectAccess.length; projectAccess = [...projectAccess, { projectId: '', roleName: 'developer' }]; + loadProjects(index); } function removeRow(i: number) { projectAccess = projectAccess.filter((_, idx) => idx !== i); + rowOptions = rowOptions.filter((_, idx) => idx !== i); + rowSearching = rowSearching.filter((_, idx) => idx !== i); + rowGeneration = rowGeneration.filter((_, idx) => idx !== i); + debouncedSearchers.delete(i); + // The removed row's project is no longer taken — invalidate sibling caches + // so they reload with the freed-up project available again. + rowOptions = rowOptions.map(() => []); + } + + // When a project is selected in one row, the other rows' cached option lists + // are now stale (they still include the chosen project). Clear them so each + // row reloads fresh options (filtered via takenIds) on next focus. + function onProjectSelected(selectedIndex: number) { + rowOptions = rowOptions.map((opts, i) => (i === selectedIndex ? opts : [])); } {#each projectAccess as access, i} -
- { + if (!rowOptions[i]?.length) loadProjects(i); + }} + oninput={(e) => { + if (e.target instanceof HTMLInputElement) { + getDebouncedSearch(i)(e.target.value); + } + }}> + + options={rowOptions[i] ?? []} + noResultsOption={rowSearching[i] + ? { disabled: true, message: 'Searching...' } + : undefined} + on:change={() => onProjectSelected(i)} />
{/each}
-