diff --git a/scripts/seed-stress-test.sql b/scripts/seed-stress-test.sql new file mode 100644 index 000000000000..7ab87c8e14a9 --- /dev/null +++ b/scripts/seed-stress-test.sql @@ -0,0 +1,103 @@ +-- Seed script for stress-testing the user group management UI. +-- Creates 25 fake users and 25 fake projects in the target org. +-- +-- Seed: +-- psql postgres://postgres:postgres@localhost:5432/postgres \ +-- -v org=rilldata -f scripts/seed-stress-test.sql +-- +-- Cleanup: +-- psql postgres://postgres:postgres@localhost:5432/postgres \ +-- -v org=rilldata -v cleanup=true -f scripts/seed-stress-test.sql + +\if :{?org} +\else + \set org 'rilldata' +\endif + +\if :{?cleanup} + SET app.seed_cleanup = :'cleanup'; +\else + SET app.seed_cleanup = 'false'; +\endif + +SET app.seed_org = :'org'; + +DO $$ +DECLARE + v_org_name TEXT := current_setting('app.seed_org'); + v_cleanup TEXT := current_setting('app.seed_cleanup'); + v_org_id UUID; + v_role_id UUID; + v_user_id UUID; + v_emails TEXT[] := ARRAY[ + 'alice.johnson@example.com', 'bob.chen@example.com', 'carol.martinez@example.com', + 'david.lee@example.com', 'emma.wilson@example.com', 'frank.garcia@example.com', + 'grace.kim@example.com', 'henry.patel@example.com', 'iris.rodriguez@example.com', + 'jack.thompson@example.com', 'kate.brown@example.com', 'liam.davis@example.com', + 'maya.anderson@example.com', 'noah.white@example.com', 'olivia.harris@example.com', + 'paul.jackson@example.com', 'quinn.taylor@example.com', 'rachel.moore@example.com', + 'sam.nguyen@example.com', 'tina.clark@example.com', 'uma.scott@example.com', + 'victor.lewis@example.com', 'wendy.hall@example.com', 'xavier.young@example.com', + 'yara.walker@example.com' + ]; + v_names TEXT[] := ARRAY[ + 'Alice Johnson', 'Bob Chen', 'Carol Martinez', + 'David Lee', 'Emma Wilson', 'Frank Garcia', + 'Grace Kim', 'Henry Patel', 'Iris Rodriguez', + 'Jack Thompson', 'Kate Brown', 'Liam Davis', + 'Maya Anderson', 'Noah White', 'Olivia Harris', + 'Paul Jackson', 'Quinn Taylor', 'Rachel Moore', + 'Sam Nguyen', 'Tina Clark', 'Uma Scott', + 'Victor Lewis', 'Wendy Hall', 'Xavier Young', + 'Yara Walker' + ]; + v_projects TEXT[] := ARRAY[ + 'analytics-dashboard', 'sales-pipeline', 'marketing-metrics', + 'product-analytics', 'revenue-tracking', 'user-behavior', + 'customer-insights', 'growth-metrics', 'inventory-analysis', + 'financial-reporting', 'ops-dashboard', 'support-metrics', + 'data-quality', 'campaign-performance', 'churn-analysis', + 'acquisition-funnel', 'retention-metrics', 'cohort-analysis', + 'ab-testing', 'ml-predictions', 'event-tracking', + 'session-analytics', 'geo-distribution', 'pricing-analysis', + 'engineering-metrics' + ]; +BEGIN + SELECT id INTO v_org_id FROM orgs WHERE lower(name) = lower(v_org_name); + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'Org "%" not found', v_org_name; + END IF; + + -- Cleanup mode: remove seeded data and exit + IF v_cleanup = 'true' THEN + DELETE FROM users WHERE email = ANY(v_emails); + DELETE FROM projects WHERE org_id = v_org_id AND name = ANY(v_projects); + RAISE NOTICE 'Cleaned up seeded users and projects from org "%"', v_org_name; + RETURN; + END IF; + + SELECT id INTO v_role_id FROM org_roles WHERE lower(name) = 'viewer'; + + -- Create users and add them to the org as viewers + FOR i IN 1..array_length(v_emails, 1) LOOP + INSERT INTO users (email, display_name) + VALUES (v_emails[i], v_names[i]) + ON CONFLICT (lower(email)) DO NOTHING; + + SELECT id INTO v_user_id FROM users WHERE lower(email) = lower(v_emails[i]); + + INSERT INTO users_orgs_roles (user_id, org_id, org_role_id) + VALUES (v_user_id, v_org_id, v_role_id) + ON CONFLICT (user_id, org_id) DO NOTHING; + END LOOP; + + -- Create stub projects + FOR i IN 1..array_length(v_projects, 1) LOOP + INSERT INTO projects (org_id, name, description, public, region, prod_branch, prod_olap_driver, prod_olap_dsn, prod_slots) + VALUES (v_org_id, v_projects[i], '', false, '', 'main', 'duckdb', '', 2) + ON CONFLICT DO NOTHING; + END LOOP; + + RAISE NOTICE 'Seeded % users and % projects into org "%"', + array_length(v_emails, 1), array_length(v_projects, 1), v_org_name; +END $$; diff --git a/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte b/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte index dcc4ca261329..4e46acdc3cdb 100644 --- a/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte +++ b/web-admin/src/features/organizations/user-management/dialogs/CreateUserGroupDialog.svelte @@ -1,12 +1,18 @@ @@ -283,6 +368,7 @@ use:enhance >
+ + +
+ + {#if $projectsQuery?.isLoading} +
Loading projects...
+ {:else if projects.length === 0} +
No projects available
+ {:else} + + + + {selectedProjectsLabel} + + {#if projectDropdownOpen} + + {:else} + + {/if} + + + {#each projects as p (p.id)} + toggleProjectSelection(p.name)} + > + {p.name} + + {/each} + + + {/if} +
+ + +
+ + + + {selectedRoleLabel} + {#if roleDropdownOpen} + + {:else} + + {/if} + + + {#each PROJECT_ROLES_OPTIONS as option} + (selectedRole = option.value)} + > + {option.label} + {option.description} + + {/each} + + +
+ +
- - -
- {#if selectedUsers.length > 0} -
-
- {selectedUsers.length} User{selectedUsers.length === 1 ? "" : "s"} + +
+
Projects
+
+ {#if projectsLoading} +
+ Loading projects… +
+ {:else} + {#if selectedProjects.length > 0} +
+ {#each selectedProjects as project (project.name)} +
+ {project.name} + + + {capitalize(project.role)} + {#if projectRoleDropdownOpen[project.name]} + + {:else} + + {/if} + + + {#each PROJECT_ROLES_OPTIONS as opt (opt.value)} + + handleProjectRoleChange( + project.name, + opt.value, + )} + > + {opt.label} + {opt.description} + + {/each} + + + +
+ {/each} +
+ {/if} + + +
0} + class:rounded-md={selectedProjects.length === 0} + > + {#if projectSearchFocused} +
+ {#if filteredProjectOptions.length > 0} + {#each filteredProjectOptions as name (name)} + + {/each} + {:else} +
+ No more projects found +
+ {/if} +
+ {/if} + (projectSearchFocused = true)} + onblur={() => + setTimeout(() => (projectSearchFocused = false), 150)} + placeholder="Search projects…" + class="w-full bg-transparent px-3 py-2 text-sm focus:outline-none placeholder:text-fg-muted" + /> +
+ {/if}
- {/if} -
-
- {#each selectedUsers as user (user.userEmail)} -
- +
+
Members
+
+ {#if selectedUsers.length > 0} +
+ {#each selectedUsers as user (user.userEmail)} +
+
+ +
+ +
+ {/each} +
+ {/if} + + +
0} + class:rounded-md={selectedUsers.length === 0} + > + {#if memberSearchFocused} +
+ {#if $organizationUsersQuery.isLoading} +
Loading…
+ {:else if filteredMemberOptions.length > 0} + {#each filteredMemberOptions as user (user.userEmail)} + + {/each} + {:else} +
+ No more members found +
+ {/if} +
+ {/if} + (memberSearchFocused = true)} + onblur={() => + setTimeout(() => (memberSearchFocused = false), 150)} + placeholder="Search members…" + class="w-full bg-transparent px-3 py-2 text-sm focus:outline-none placeholder:text-fg-muted" /> -
- {/each} +
-
+ diff --git a/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte b/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte new file mode 100644 index 000000000000..51474bc55505 --- /dev/null +++ b/web-admin/src/features/organizations/user-management/table/groups/GroupProjectsCell.svelte @@ -0,0 +1,133 @@ + + + + + + {#if hasProcessed} + {projectCount} Project{projectCount !== 1 ? "s" : ""} + {:else} + Projects + {/if} + + {#if isDropdownOpen} + + {:else} + + {/if} + + + {#if isPending} +
Loading...
+ {:else if !hasProjects} +
No projects
+ {:else} + {#each accessibleProjects as project (project.id)} + + {project.name} + {formatRoleName(project.roleName)} + + {/each} + {/if} +
+
diff --git a/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte b/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte index 6f27f9486fae..dad2e6d33e4c 100644 --- a/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte +++ b/web-admin/src/features/organizations/user-management/table/groups/OrgGroupsTable.svelte @@ -4,8 +4,10 @@ import type { ColumnDef } from "tanstack-table-8-svelte-5"; import GroupActionsCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupActionsCell.svelte"; import GroupCompositeCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupCompositeCell.svelte"; + import GroupProjectsCell from "@rilldata/web-admin/features/organizations/user-management/table/groups/GroupProjectsCell.svelte"; import InfiniteScrollTable from "@rilldata/web-common/components/table/InfiniteScrollTable.svelte"; + export let organization: string; export let data: V1MemberUsergroup[]; export let currentUserEmail: string; export let hasNextPage: boolean; @@ -34,23 +36,23 @@ usersCount: row.original.usersCount, }), meta: { - widthPercent: 95, + widthPercent: 55, + }, + }, + { + accessorKey: "projects", + header: "Projects", + enableSorting: false, + cell: ({ row }) => + renderComponent(GroupProjectsCell, { + organization, + groupName: row.original.groupName ?? "", + }), + meta: { + widthPercent: 40, + marginLeft: "8px", }, }, - // { - // accessorKey: "roleName", - // header: "Role", - // cell: ({ row }) => - // flexRender(OrgGroupsTableRoleCell, { - // name: row.original.groupName, - // role: row.original.roleName, - // manageOrgAdmins: manageOrgAdmins, - // }), - // meta: { - // widthPercent: 20, - // marginLeft: "8px", - // }, - // }, { accessorKey: "actions", header: "", diff --git a/web-admin/src/routes/[organization]/-/users/groups/+page.svelte b/web-admin/src/routes/[organization]/-/users/groups/+page.svelte index cee408d4876e..ffb8d6a1bfad 100644 --- a/web-admin/src/routes/[organization]/-/users/groups/+page.svelte +++ b/web-admin/src/routes/[organization]/-/users/groups/+page.svelte @@ -94,6 +94,7 @@