Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/lib/components/modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</script>

<Form isModal {onSubmit}>
Expand Down
12 changes: 12 additions & 0 deletions src/lib/profiles/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog> 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,24 +34,6 @@
let role = $state<string>(isSelfHosted ? 'owner' : 'developer');
let accessType = $state<'all' | 'specific'>('all');
let projectAccess = $state<Array<{ projectId: string; roleName: string }>>([]);
let orgProjects = $state<Models.Project[]>([]);
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;
Expand All @@ -60,8 +42,6 @@
role = isSelfHosted ? 'owner' : 'developer';
accessType = 'all';
projectAccess = [];
orgProjects = [];
hasFetchedProjects = false;
}
});

Expand Down Expand Up @@ -148,7 +128,7 @@
</Layout.Stack>
</InputSelect>
{:else}
<ProjectAccessSelector bind:projectAccess projects={orgProjects} />
<ProjectAccessSelector bind:projectAccess />
{/if}
{/if}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,4 @@
</Container>

<Delete {selectedMember} bind:showDelete />
<Edit {selectedMember} projects={data.orgProjects?.projects ?? []} bind:showEdit />
<Edit {selectedMember} bind:showEdit />
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -138,7 +136,7 @@
</Layout.Stack>
</InputSelect>
{:else}
<ProjectAccessSelector bind:projectAccess {projects} />
<ProjectAccessSelector bind:projectAccess />
{/if}
</Layout.Stack>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectAccess[]>([]),
projects = []
projectAccess = $bindable<ProjectAccess[]>([])
}: {
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<Option[][]>([]);
let rowSearching = $state<boolean[]>([]);
// Incremented on every loadProjects call for a row; the response is
// discarded if a newer request has already been dispatched.
let rowGeneration = $state<number[]>([]);

// 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<Option[]> | 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<string> {
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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 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<number, (search: string) => 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 : []));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</script>

<Layout.Stack gap="s">
{#each projectAccess as access, i}
<Layout.Stack direction="row" gap="s" alignItems="flex-end">
<div style:flex="1">
<InputSelect
<div
style:flex="1"
onfocusin={() => {
if (!rowOptions[i]?.length) loadProjects(i);
}}
oninput={(e) => {
if (e.target instanceof HTMLInputElement) {
getDebouncedSearch(i)(e.target.value);
}
}}>
<Input.ComboBox
id="project-{i}"
label={i === 0 ? 'Project' : ''}
required
options={availableProjects(access.projectId)}
placeholder="Search projects"
bind:value={access.projectId}
placeholder="Select project" />
options={rowOptions[i] ?? []}
noResultsOption={rowSearching[i]
? { disabled: true, message: 'Searching...' }
: undefined}
on:change={() => onProjectSelected(i)} />
</div>
<div style:width="140px">
<InputSelect
Expand All @@ -64,7 +175,7 @@
</Layout.Stack>
{/each}
<div>
<Button secondary size="s" on:click={addRow} disabled={allSelected}>
<Button secondary size="s" on:click={addRow}>
<Icon size="s" icon={IconPlus} slot="start" />
Add project
</Button>
Expand Down