Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d147506
feat(web-admin): Add Projects column to Groups management page
cursoragent Feb 18, 2026
8ab307c
fix(web-admin): Pre-fetch project count on component mount for GroupP…
cursoragent Feb 18, 2026
65211d5
fix(web-admin): Fix project listing and add 'Add Project' feature for…
cursoragent Feb 18, 2026
37c47af
feat(web-admin): Move Add Project feature to group dialogs
cursoragent Feb 19, 2026
72a8e8a
refactor(web-admin): Reorder fields and simplify group dialogs
cursoragent Feb 19, 2026
13fa798
fix(web-admin): Show role in table dropdown, not form dropdown
cursoragent Feb 19, 2026
608d4a9
fix(web-admin): Fix lint error - remove unused variable
cursoragent Feb 20, 2026
11b0e2e
chore: trigger CI re-run
cursoragent Feb 23, 2026
fc0203a
fix: form reset and project URL in Groups page
cursoragent Feb 23, 2026
9704472
fix(canvas): Suppress "Invalid Date/Interval" when metrics view is un…
ericokuma Feb 19, 2026
bdf69a4
Revert "fix(canvas): Suppress "Invalid Date/Interval" when metrics vi…
ericokuma Feb 19, 2026
0f8d16a
feat(web-admin): add project and member management to Edit Group dialog
ericokuma Feb 24, 2026
dea1a98
fix(web-admin): match mock design and show all items on empty search …
ericokuma Feb 24, 2026
7ee383f
fix(web-admin): open search dropdowns downward and show members on focus
ericokuma Feb 24, 2026
c468596
fix(web-admin): pre-fetch org members so dropdown is instant on focus
ericokuma Feb 24, 2026
482218b
fix(web-admin): load org members once and filter client-side
ericokuma Feb 24, 2026
e51e778
fix(web-admin): replace bordered select with borderless role dropdown…
ericokuma Feb 24, 2026
6a40817
fix(web-admin): remove fixed width from role dropdown trigger in Edit…
ericokuma Feb 24, 2026
62c6822
fix(web-admin): make role dropdown full width in Create Group dialog
ericokuma Feb 24, 2026
311d4eb
fix(web-admin): restore bordered full-width style for Create Group ro…
ericokuma Feb 24, 2026
c48ee92
fix(web-admin): widen Edit Group role selector and match Create Group…
ericokuma Feb 24, 2026
386877d
fix(web-admin): use sameWidth on Create Group dropdowns to match fiel…
ericokuma Feb 24, 2026
0b9e443
fix(web-admin): adjust dropdown widths in Create and Edit Group dialogs
ericokuma Feb 24, 2026
a4a913f
fix(web-admin): fix member/project search bars becoming unclickable a…
ericokuma Feb 24, 2026
48911b9
fix(web-admin): close member/project search dropdowns after selection…
ericokuma Feb 24, 2026
312def3
refactor: improve GroupProjectsCell and project access handling
cursoragent Mar 3, 2026
5d87609
fix: use theme-aware CSS variables in EditUserGroupDialog
cursoragent Mar 28, 2026
e9b78f6
fix: resolve merge conflicts with main branch
cursoragent Mar 28, 2026
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
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<script lang="ts">
import { page } from "$app/stores";
import type { V1OrganizationMemberUser } from "@rilldata/web-admin/client";
import type {
V1OrganizationMemberUser,
V1Project,
} from "@rilldata/web-admin/client";
import {
createAdminServiceAddProjectMemberUsergroup,
createAdminServiceAddUsergroupMemberUser,
createAdminServiceCreateUsergroup,
createAdminServiceListOrganizationMemberUsersInfinite,
createAdminServiceListProjectsForOrganization,
getAdminServiceListOrganizationMemberUsergroupsQueryKey,
getAdminServiceListOrganizationMemberUsersQueryKey,
getAdminServiceListProjectMemberUsergroupsQueryKey,
getAdminServiceListUsergroupMemberUsersQueryKey,
} from "@rilldata/web-admin/client";
import AvatarListItem from "@rilldata/web-common/components/avatar/AvatarListItem.svelte";
Expand All @@ -20,8 +26,13 @@
DialogTitle,
DialogTrigger,
} from "@rilldata/web-common/components/dialog";
import * as Dropdown from "@rilldata/web-common/components/dropdown-menu";
import Input from "@rilldata/web-common/components/forms/Input.svelte";
import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
import CaretUpIcon from "@rilldata/web-common/components/icons/CaretUpIcon.svelte";
import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts";
import { PROJECT_ROLES_OPTIONS } from "@rilldata/web-admin/features/projects/constants";
import { ProjectUserRoles } from "@rilldata/web-common/features/users/roles";
import { useQueryClient } from "@tanstack/svelte-query";
import { defaults, superForm } from "sveltekit-superforms";
import { yup } from "sveltekit-superforms/adapters";
Expand All @@ -39,6 +50,11 @@
let pendingAdditions: string[] = [];
let pendingRemovals: string[] = [];

let selectedProjects: string[] = [];
let projectDropdownOpen = false;
let selectedRole: ProjectUserRoles = ProjectUserRoles.Viewer;
let roleDropdownOpen = false;

// Debounce search input to avoid too many API calls.
// Use a standard Svelte reactive block: it re-runs whenever `searchInput` changes.
// We capture `searchInput` into a local constant to avoid race conditions in the timeout.
Expand All @@ -52,6 +68,40 @@

$: organization = $page.params.organization;

// Projects list
$: projectsQuery = createAdminServiceListProjectsForOrganization(
organization,
undefined,
{
query: {
enabled: open && !!organization,
},
},
);
$: projects = $projectsQuery?.data?.projects ?? ([] as V1Project[]);

$: selectedRoleLabel =
PROJECT_ROLES_OPTIONS.find((o) => o.value === selectedRole)?.label ??
"Viewer";

$: selectedProjectsLabel = (() => {
if (selectedProjects.length === 0) return "Select projects";
if (selectedProjects.length === 1) return selectedProjects[0];
return `${selectedProjects.length} Projects`;
})();

function toggleProjectSelection(projectName: string) {
const idx = selectedProjects.indexOf(projectName);
if (idx >= 0) {
selectedProjects = selectedProjects.filter(
(name) => name !== projectName,
);
} else {
selectedProjects = [...selectedProjects, projectName];
}
projectDropdownOpen = true;
}

// Infinite query for organization users (debounced by search)
$: organizationUsersInfiniteQuery =
createAdminServiceListOrganizationMemberUsersInfinite(
Expand Down Expand Up @@ -100,6 +150,8 @@
const queryClient = useQueryClient();
const createUserGroup = createAdminServiceCreateUsergroup();
const addUsergroupMemberUser = createAdminServiceAddUsergroupMemberUser();
const addProjectMemberUsergroup =
createAdminServiceAddProjectMemberUsergroup();

async function handleCreate(newName: string) {
try {
Expand All @@ -113,6 +165,9 @@
// Apply pending user changes after group creation
await applyPendingChanges(newName);

// Add group to selected projects
await applyProjectAccess(newName);

await queryClient.invalidateQueries({
queryKey: getAdminServiceListOrganizationMemberUsergroupsQueryKey(
organization,
Expand All @@ -126,6 +181,8 @@
selectedUsers = [];
pendingAdditions = [];
pendingRemovals = [];
selectedProjects = [];
selectedRole = ProjectUserRoles.Viewer;
open = false;

eventBus.emit("notification", { message: "User group created" });
Expand All @@ -137,6 +194,37 @@
}
}

async function applyProjectAccess(usergroup: string) {
if (selectedProjects.length === 0) return;

try {
await Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are adding all users in serial vs here where we are adding in parallel. We should do this in serial as well to keep things consistent. Also add a TODO to make sure we handle partial updates like group is created but only one project got added.

selectedProjects.map(async (projectName) => {
await $addProjectMemberUsergroup.mutateAsync({
org: organization,
project: projectName,
usergroup: usergroup,
data: {
role: selectedRole,
},
});

await queryClient.invalidateQueries({
queryKey: getAdminServiceListProjectMemberUsergroupsQueryKey(
organization,
projectName,
),
});
}),
);
} catch (error) {
eventBus.emit("notification", {
message: `Error adding group to projects: ${error.response?.data?.message || error.message}`,
type: "error",
});
}
}

async function applyPendingChanges(usergroup: string) {
try {
// Add pending users to the group
Expand Down Expand Up @@ -248,6 +336,10 @@
selectedUsers = [];
pendingAdditions = [];
pendingRemovals = [];
selectedProjects = [];
selectedRole = ProjectUserRoles.Viewer;
projectDropdownOpen = false;
roleDropdownOpen = false;
$errors = {};
// Only reset the form if it has been modified
if (hasFormChanges) {
Expand Down Expand Up @@ -281,6 +373,7 @@
use:enhance
>
<div class="flex flex-col gap-4 w-full">
<!-- Name -->
<Input
bind:value={$form.name}
id="create-user-group-name"
Expand All @@ -290,6 +383,92 @@
alwaysShowError={true}
/>

<!-- Project access multi-select -->
<div class="flex flex-col gap-y-1">
<label
for="project-access"
class="line-clamp-1 text-sm font-medium text-fg-primary"
>
Project access
</label>
{#if $projectsQuery?.isLoading}
<div class="text-sm text-fg-secondary">Loading projects...</div>
{:else if projects.length === 0}
<div class="text-sm text-fg-secondary">No projects available</div>
{:else}
<Dropdown.Root
bind:open={projectDropdownOpen}
closeOnItemClick={false}
>
<Dropdown.Trigger
class="w-full min-h-[36px] flex flex-row justify-between gap-1 items-center rounded-sm border border-gray-300 bg-surface-background text-sm px-3 {projectDropdownOpen
? 'bg-gray-200'
: 'hover:bg-surface-hover'}"
>
<span class="truncate">
{selectedProjectsLabel}
</span>
{#if projectDropdownOpen}
<CaretUpIcon size="12px" />
{:else}
<CaretDownIcon size="12px" />
{/if}
</Dropdown.Trigger>
<Dropdown.Content
align="start"
class="w-full max-h-60 overflow-y-auto"
>
{#each projects as p (p.id)}
<Dropdown.CheckboxItem
class="font-normal flex items-center overflow-hidden"
checked={selectedProjects.includes(p.name)}
onCheckedChange={() => toggleProjectSelection(p.name)}
>
<span class="truncate w-full" title={p.name}>{p.name}</span>
</Dropdown.CheckboxItem>
{/each}
</Dropdown.Content>
</Dropdown.Root>
{/if}
</div>

<!-- Access level selector -->
<div class="flex flex-col gap-y-1">
<label
for="access-level"
class="line-clamp-1 text-sm font-medium text-fg-primary"
>
Access level
</label>
<Dropdown.Root bind:open={roleDropdownOpen}>
<Dropdown.Trigger
class="w-full min-h-[36px] flex flex-row justify-between gap-1 items-center rounded-sm border border-gray-300 bg-surface-background text-sm px-3 {roleDropdownOpen
? 'bg-gray-200'
: 'hover:bg-surface-hover'}"
>
<span>{selectedRoleLabel}</span>
{#if roleDropdownOpen}
<CaretUpIcon size="12px" />
{:else}
<CaretDownIcon size="12px" />
{/if}
</Dropdown.Trigger>
<Dropdown.Content align="start" class="w-full">
{#each PROJECT_ROLES_OPTIONS as option}
<Dropdown.CheckboxItem
checked={selectedRole === option.value}
onCheckedChange={(checked) => {
if (checked) selectedRole = option.value;
}}
>
{option.label}
</Dropdown.CheckboxItem>
{/each}
</Dropdown.Content>
</Dropdown.Root>
</div>

<!-- Users -->
<div class="flex flex-col gap-y-1">
<label
for="user-group-users"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,6 @@
initialized = true;
}

// TODO: we need to get role from a separate query and fill in selectedUsers
// organizationUsers is not guaranteed to have the user present in the group.

const queryClient = useQueryClient();
const addUsergroupMemberUser = createAdminServiceAddUsergroupMemberUser();
const removeUserGroupMember = createAdminServiceRemoveUsergroupMemberUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from "svelte";
import * as Dropdown from "@rilldata/web-common/components/dropdown-menu";
import {
adminServiceListProjectsForOrganization,
adminServiceListProjectMemberUsergroups,
} from "@rilldata/web-admin/client";
import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
import CaretUpIcon from "@rilldata/web-common/components/icons/CaretUpIcon.svelte";

export let organization: string;
export let groupName: string;

interface ProjectWithRole {
id: string;
name: string;
roleName: string;
}

let isDropdownOpen = false;
let isPending = true;
let accessibleProjects: ProjectWithRole[] = [];
let hasLoaded = false;

async function loadProjectsForGroup() {
if (hasLoaded) return;

isPending = true;

try {
const projectsResponse =
await adminServiceListProjectsForOrganization(organization);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a tanstack query. This direct call will not cache the results.

const allProjects = projectsResponse.projects ?? [];

const projectAccessResults = await Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really the best way to get projects? We should add a new API that fetches all projects accessible by the user group.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@begelundmuller, another one here!

allProjects.map(async (project) => {
try {
const usergroupsResponse =
await adminServiceListProjectMemberUsergroups(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

organization,
project.name ?? "",
);
const members = usergroupsResponse.members ?? [];
const groupMember = members.find((m) => m.groupName === groupName);
if (groupMember) {
return {
project: {
id: project.id ?? "",
name: project.name ?? "",
roleName: groupMember.roleName ?? "",
},
hasAccess: true,
};
}
return { project: null, hasAccess: false };
} catch {
return { project: null, hasAccess: false };
}
}),
);

accessibleProjects = projectAccessResults
.filter((r) => r.hasAccess && r.project)
.map((r) => r.project as ProjectWithRole);
hasLoaded = true;
} catch {
// Ignore errors, will show "No projects"
} finally {
isPending = false;
}
}

onMount(() => {
void loadProjectsForGroup();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserProjectCell fetches the data only when the dropdown is open. The count is fetched with the list of groups.

});

$: projectCount = accessibleProjects.length;
$: hasProjects = projectCount > 0;

function getProjectUrl(projectName: string) {
return `/${organization}/${projectName}/-/share`;
}

function formatRoleName(roleName: string): string {
return roleName.charAt(0).toUpperCase() + roleName.slice(1).toLowerCase();
}
</script>

{#if hasLoaded && hasProjects}
<Dropdown.Root bind:open={isDropdownOpen}>
<Dropdown.Trigger
class="flex flex-row gap-1 items-center rounded-sm {isDropdownOpen
? 'bg-gray-200'
: 'hover:bg-surface-hover'} px-2 py-1"
>
<span class="capitalize">
{projectCount} Project{projectCount !== 1 ? "s" : ""}
</span>
{#if isDropdownOpen}
<CaretUpIcon size="12px" />
{:else}
<CaretDownIcon size="12px" />
{/if}
</Dropdown.Trigger>
<Dropdown.Content align="start">
{#each accessibleProjects as project (project.id)}
<Dropdown.Item
href={getProjectUrl(project.name)}
class="flex items-center justify-between gap-4"
>
<span class="truncate">{project.name}</span>
<span class="text-fg-secondary text-xs shrink-0"
>{formatRoleName(project.roleName)}</span
>
</Dropdown.Item>
{/each}
</Dropdown.Content>
</Dropdown.Root>
{:else if isPending}
<div class="rounded-sm px-2 py-1 text-fg-secondary">Loading...</div>
{:else}
<div class="rounded-sm px-2 py-1 text-fg-secondary">No projects</div>
{/if}
Loading
Loading