diff --git a/web/sdk/admin/utils/helper.ts b/web/sdk/admin/utils/helper.ts index c8b97df75..90bd9c197 100644 --- a/web/sdk/admin/utils/helper.ts +++ b/web/sdk/admin/utils/helper.ts @@ -41,6 +41,13 @@ export function randomSuffix(length = 4): string { return out; } +// Role titles come prefixed with the scope (e.g. "Organization Owner"). +// For display we only want the bare title: "Owner", "Member", "Admin". +export function formatRoleTitle(title?: string): string { + if (!title) return ''; + return title.replace(/^organization\s+/i, ''); +} + export function convertBillingAddressToString(address?: { line1?: string; line2?: string; diff --git a/web/sdk/admin/views/organizations/details/apis/apis.module.css b/web/sdk/admin/views/organizations/details/apis/apis.module.css index ecc31ad20..54401a3e1 100644 --- a/web/sdk/admin/views/organizations/details/apis/apis.module.css +++ b/web/sdk/admin/views/organizations/details/apis/apis.module.css @@ -7,6 +7,14 @@ text-wrap: auto; } +.zero-state-container { + padding: var(--rs-space-10); +} + +.zero-state { + padding-top: var(--rs-space-17); +} + .table { width: 100%; table-layout: fixed; diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 0d2b00738..bffbf467a 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -36,6 +36,20 @@ const NoCredentials = () => { ); }; +const ZeroState = () => { + return ( +
+ } + heading="API Credentials" + subHeading="Service accounts provide programmatic API access on behalf of this organization." + /> +
+ ); +}; + const ErrorState = () => { return ( : } + zeroState={} classNames={{ root: styles["table-wrapper"], table: styles["table"], diff --git a/web/sdk/admin/views/organizations/details/invoices/columns.tsx b/web/sdk/admin/views/organizations/details/invoices/columns.tsx index 47ec8f15a..9a47eb7b6 100644 --- a/web/sdk/admin/views/organizations/details/invoices/columns.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/columns.tsx @@ -75,7 +75,12 @@ export const getColumns = ({ cell: ({ getValue }) => { const value = getValue() as string; return ( - + View Invoice ); diff --git a/web/sdk/admin/views/organizations/details/invoices/index.tsx b/web/sdk/admin/views/organizations/details/invoices/index.tsx index 76eff1d09..24cc11bf2 100644 --- a/web/sdk/admin/views/organizations/details/invoices/index.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/index.tsx @@ -70,6 +70,20 @@ const NoInvoices = () => { ); }; +const ZeroState = () => { + return ( +
+ } + heading="Invoices" + subHeading="Invoices generated for this organization's billing activity will appear here." + /> +
+ ); +}; + const ErrorState = () => { const t = useTerminology(); return ( @@ -180,6 +194,7 @@ export function OrganizationInvoicesView() { : } + zeroState={} classNames={{ table: styles["table"], root: styles["table-wrapper"], diff --git a/web/sdk/admin/views/organizations/details/invoices/invoices.module.css b/web/sdk/admin/views/organizations/details/invoices/invoices.module.css index 3fc52a098..1ef5c9ce0 100644 --- a/web/sdk/admin/views/organizations/details/invoices/invoices.module.css +++ b/web/sdk/admin/views/organizations/details/invoices/invoices.module.css @@ -7,6 +7,14 @@ text-wrap: auto; } +.zero-state-container { + padding: var(--rs-space-10); +} + +.zero-state { + padding-top: var(--rs-space-17); +} + .table { width: 100%; table-layout: fixed; @@ -26,3 +34,7 @@ .first-column { padding-left: var(--rs-space-7); } + +.invoice-link { + text-decoration: none; +} diff --git a/web/sdk/admin/views/organizations/details/members/columns.tsx b/web/sdk/admin/views/organizations/details/members/columns.tsx index 50a2ce1ec..02ad05152 100644 --- a/web/sdk/admin/views/organizations/details/members/columns.tsx +++ b/web/sdk/admin/views/organizations/details/members/columns.tsx @@ -17,6 +17,7 @@ import styles from "./members.module.css"; import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; import { DeleteIcon } from "~/admin/assets/icons/DeleteIcon"; import { formatTimestamp, TimeStamp } from "~/admin/utils/connect-timestamp"; +import { formatRoleTitle } from "../../../../utils/helper"; const MemberStates = { enabled: "Active", @@ -46,7 +47,7 @@ export const getColumns = ({ const roleMap = roles.reduce( (acc, role) => { const id = role?.id ?? ""; - acc[id] = role.title || ""; + acc[id] = formatRoleTitle(role.title); return acc; }, {} as Record, diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index a706ebb3c..7f80fdadd 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -54,6 +54,20 @@ const NoMembers = () => { ); }; +const ZeroState = () => { + return ( +
+ } + heading="Members" + subHeading="Members are users who belong to this organization and can access its resources." + /> +
+ ); +}; + const ErrorState = () => { return ( : } + zeroState={} classNames={{ table: styles["table"], root: styles["table-wrapper"], diff --git a/web/sdk/admin/views/organizations/details/members/members.module.css b/web/sdk/admin/views/organizations/details/members/members.module.css index b74e1e389..b68df4bfe 100644 --- a/web/sdk/admin/views/organizations/details/members/members.module.css +++ b/web/sdk/admin/views/organizations/details/members/members.module.css @@ -15,6 +15,14 @@ text-wrap: auto; } +.zero-state-container { + padding: var(--rs-space-10); +} + +.zero-state { + padding-top: var(--rs-space-17); +} + .table { width: 100%; table-layout: fixed; @@ -34,3 +42,14 @@ .table-action-column { width: var(--rs-space-12); } + +.table-action-column > * { + opacity: 0; + transition: opacity 120ms ease-in-out; +} + +.table-wrapper tr:hover .table-action-column > *, +.table-action-column:focus-within > *, +.table-action-column:has([data-popup-open]) > * { + opacity: 1; +} diff --git a/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx index c4d66f0bf..8739d5447 100644 --- a/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx +++ b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx @@ -99,7 +99,6 @@ export function PatDetailsDialog({ {pat?.title || ""} - diff --git a/web/sdk/admin/views/organizations/details/projects/columns.tsx b/web/sdk/admin/views/organizations/details/projects/columns.tsx index 3a14a59d2..27d24895c 100644 --- a/web/sdk/admin/views/organizations/details/projects/columns.tsx +++ b/web/sdk/admin/views/organizations/details/projects/columns.tsx @@ -5,6 +5,7 @@ import { Flex, Text, Menu, + IconButton, } from "@raystack/apsara"; import type { DataTableColumnDef } from "@raystack/apsara"; import type { @@ -45,6 +46,7 @@ interface AddMemberDropdownProps { eligibleMembers: User[]; isLoading: boolean; setSearchQuery: (query: string) => void; + disabled?: boolean; } function AddMemberDropdown({ @@ -52,6 +54,7 @@ function AddMemberDropdown({ eligibleMembers, isLoading, setSearchQuery, + disabled, }: AddMemberDropdownProps) { const t = useTerminology(); return ( @@ -60,7 +63,7 @@ function AddMemberDropdown({ autocompleteMode="manual" onInputValueChange={setSearchQuery} > - + Add {t.member({ case: "lower" })} @@ -96,12 +99,14 @@ function ProjectActionsContent({ project, handleProjectUpdate, handleRenameOptionOpen, + canAddMember, }: { project: SearchOrganizationProjectsResponse_OrganizationProject; handleProjectUpdate: ( project: SearchOrganizationProjectsResponse_OrganizationProject, ) => void; handleRenameOptionOpen: () => void; + canAddMember: boolean; }) { const t = useTerminology(); const handleRenameOptionClick = (e: React.MouseEvent) => { @@ -130,6 +135,7 @@ function ProjectActionsContent({ eligibleMembers={eligibleMembers} isLoading={isLoading} setSearchQuery={setSearchQuery} + disabled={!canAddMember} /> void; + canAddMember: boolean; }) { const [open, setOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const preventClickBubbling = (e: React.MouseEvent) => { + const preventClickBubbling = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -182,10 +190,13 @@ function ProjectActions({ + > + + } /> @@ -193,6 +204,7 @@ function ProjectActions({ project={project} handleProjectUpdate={handleProjectUpdate} handleRenameOptionOpen={handleRenameOptionOpen} + canAddMember={canAddMember} /> @@ -216,6 +228,10 @@ export const getColumns = ({ SearchOrganizationProjectsResponse_OrganizationProject, unknown >[] => { + // Adding a project member requires searching org members. With a single org + // member there is no one to add, so the action is disabled until more members + // are invited. + const canAddMember = Object.keys(orgMembersMap).length > 1; return [ { accessorKey: "title", @@ -289,6 +305,7 @@ export const getColumns = ({ ); }, diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index 9cd2a9e86..a8847c3bb 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -51,6 +51,21 @@ const NoProjects = () => { ); }; +const ZeroState = () => { + const t = useTerminology(); + return ( +
+ } + heading={t.project({ plural: true, case: "capital" })} + subHeading={`${t.project({ plural: true, case: "capital" })} group resources and members within this ${t.organization({ case: "lower" })}.`} + /> +
+ ); +}; + const ErrorState = () => { const t = useTerminology(); return ( @@ -168,12 +183,15 @@ export function OrganizationProjectsView() { const columns = getColumns({ orgMembersMap, handleProjectUpdate, t }); + const canAddMember = Object.keys(orgMembersMap).length > 1; + return ( <> {memberDialogConfig.open && memberDialogConfig.projectId ? ( ) : null} @@ -192,6 +210,7 @@ export function OrganizationProjectsView() { : } + zeroState={} classNames={{ table: styles["table"], root: styles["table-wrapper"], diff --git a/web/sdk/admin/views/organizations/details/projects/members/add-members-dropdown.tsx b/web/sdk/admin/views/organizations/details/projects/members/add-members-dropdown.tsx index 457dd690c..184d5c3c3 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/add-members-dropdown.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/add-members-dropdown.tsx @@ -2,7 +2,6 @@ import { Avatar, Button, Menu, - Flex, getAvatarColor, Text, } from "@raystack/apsara"; @@ -14,6 +13,7 @@ import { useAddProjectMembers } from "../use-add-project-members"; interface AddMembersDropdownProps { projectId: string; refetchMembers: () => Promise; + disabled?: boolean; } function Loader() { @@ -31,6 +31,7 @@ function Loader() { export function AddMembersDropdown({ projectId, refetchMembers, + disabled, }: AddMembersDropdownProps) { const { eligibleMembers, isLoading, addMember, setSearchQuery } = useAddProjectMembers({ @@ -52,7 +53,11 @@ export function AddMembersDropdown({ autocompleteMode="manual" > Add member} + render={ + + } /> - } /> + + + + } + /> (); export const ProjectMembersDialog = ({ projectId, onClose, + canAddMember, }: { projectId: string; onClose: () => void; + canAddMember: boolean; }) => { + const initialFocusRef = useRef(null); + const [tableQuery, setTableQuery] = useDebouncedState<{ query: DataTableQuery; rqlRequest: RQLRequest; @@ -199,14 +203,16 @@ export const ProjectMembersDialog = ({ /> ) : null} - + initialFocusRef.current} + > {isProjectLoading ? ( ) : ( {project?.title ?? ""} )} - * { + opacity: 0; + transition: opacity 120ms ease-in-out; +} + +.table-wrapper tr:hover .table-action-column>*, +.table-action-column:focus-within>*, +.table-action-column:has([data-popup-open])>* { + opacity: 1; +} + +.table-wrapper tr:has([data-test-id="admin-project-member-action-menu"][data-popup-open]) { + background: var(--rs-color-background-base-primary-hover); } .table-action-dropdown { diff --git a/web/sdk/admin/views/organizations/details/projects/projects.module.css b/web/sdk/admin/views/organizations/details/projects/projects.module.css index 98114a4f8..c9415bbf1 100644 --- a/web/sdk/admin/views/organizations/details/projects/projects.module.css +++ b/web/sdk/admin/views/organizations/details/projects/projects.module.css @@ -15,6 +15,14 @@ text-wrap: auto; } +.zero-state-container { + padding: var(--rs-space-10); +} + +.zero-state { + padding-top: var(--rs-space-17); +} + .table { width: 100%; table-layout: fixed; @@ -35,6 +43,22 @@ width: var(--rs-space-12); } +.table-action-column > * { + opacity: 0; + transition: opacity 120ms ease-in-out; +} + +.table-wrapper tr:hover .table-action-column > *, +.table-action-column:focus-within > *, +.table-action-column:has([data-popup-open]) > * { + opacity: 1; +} + +/* Keep the row highlighted while its action menu is open. */ +.table-wrapper tr:has([data-test-id="admin-project-actions"][data-popup-open]) { + background: var(--rs-color-background-base-primary-hover); +} + .flex1 { flex: 1; } diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index 31f953bcd..a7ca7c424 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -45,6 +45,20 @@ const NoTokens = () => { ); }; +const ZeroState = () => { + return ( +
+ } + heading="Tokens" + subHeading="Token transactions for this organization's consumable balance will appear here." + /> +
+ ); +}; + const ErrorState = () => { const t = useTerminology(); return ( @@ -150,6 +164,7 @@ export function OrganizationTokensView() { : } + zeroState={} classNames={{ table: styles["table"], root: styles["table-wrapper"], diff --git a/web/sdk/admin/views/organizations/details/tokens/tokens.module.css b/web/sdk/admin/views/organizations/details/tokens/tokens.module.css index 11cd46935..0bfa4a70e 100644 --- a/web/sdk/admin/views/organizations/details/tokens/tokens.module.css +++ b/web/sdk/admin/views/organizations/details/tokens/tokens.module.css @@ -7,6 +7,14 @@ text-wrap: auto; } +.zero-state-container { + padding: var(--rs-space-10); +} + +.zero-state { + padding-top: var(--rs-space-17); +} + .table { width: 100%; table-layout: fixed; diff --git a/web/sdk/admin/views/organizations/list/list.module.css b/web/sdk/admin/views/organizations/list/list.module.css index 8306a41b5..086bc7d25 100644 --- a/web/sdk/admin/views/organizations/list/list.module.css +++ b/web/sdk/admin/views/organizations/list/list.module.css @@ -5,7 +5,7 @@ display: flex; align-items: center; justify-content: space-between; - min-height: var(--rs-space-12); + height: var(--rs-space-11); } .table { @@ -83,4 +83,4 @@ .select-content { max-height: 400px; -} +} \ No newline at end of file diff --git a/web/sdk/admin/views/organizations/list/navbar.tsx b/web/sdk/admin/views/organizations/list/navbar.tsx index 400aec865..b451f6869 100644 --- a/web/sdk/admin/views/organizations/list/navbar.tsx +++ b/web/sdk/admin/views/organizations/list/navbar.tsx @@ -64,16 +64,20 @@ export const OrganizationsNavabar = ({
- - + {!showSearch ? ( + <> + + + + ) : null} {showSearch ? (