diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx index a5790cd9e35..c0e4946dd0c 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -13,6 +13,7 @@ import { toast, } from '@sim/emcn' import { PanelLeft, Send, Settings } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { getOrganizationSettingsHref } from '@/components/settings/navigation' @@ -20,7 +21,10 @@ import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' +import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { holdRowFocus } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' +import { useRenameOrganization } from '@/hooks/queries/organization' import { useUploadOrganizationLogo } from '@/hooks/queries/organization-logo' function getOrganizationInitial(name: string): string { @@ -29,7 +33,8 @@ function getOrganizationInitial(name: string): string { interface OrganizationHeaderProps { organization: OrganizationSurfaceOrganization - canEditLogo: boolean + /** Organization admins may change its logo and name. */ + canEditIdentity: boolean canInviteMembers: boolean isCollapsed: boolean /** Expands the rail; the collapsed header is itself the expand control. */ @@ -42,20 +47,25 @@ interface OrganizationHeaderProps { * into its settings; collapsed, it becomes the rail's expand control, swapping * the mark for a panel glyph on hover exactly as the workspace header does. The * mark is the organization's uploaded logo or its initial on the neutral tile. + * Admins change the logo and rename the organization from the card. */ export function OrganizationHeader({ organization, - canEditLogo, + canEditIdentity, canInviteMembers, isCollapsed, onExpandSidebar, }: OrganizationHeaderProps) { const fileInputRef = useRef(null) + const nameInputRef = useRef(null) const router = useRouter() const { mutate: uploadLogo, isPending: isUploadingLogo } = useUploadOrganizationLogo( organization.id ) + const renameOrganization = useRenameOrganization() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [draftName, setDraftName] = useState(null) const initial = getOrganizationInitial(organization.name) if (isCollapsed) { @@ -85,13 +95,34 @@ export function OrganizationHeader({ } const { memberCount } = organization + const stopRenaming = () => { + setDraftName(null) + renameOrganization.reset() + } + + const submitRename = () => { + if (draftName === null || renameOrganization.isPending) return + const name = draftName.trim() + if (!name || name === organization.name) { + stopRenaming() + return + } + renameOrganization.mutate( + { orgId: organization.id, name }, + { + onSuccess: stopRenaming, + onError: () => requestAnimationFrame(() => nameInputRef.current?.focus()), + } + ) + } + const logo = ( ) return (
- {canEditLogo && ( + {canEditIdentity && ( )} - + { + // The input's blur saves the draft; closing now would unmount it and hide a failure. + if (!open && draftName !== null) return + setIsMenuOpen(open) + }} + >
- {canEditLogo ? ( + {canEditIdentity ? ( - +
+ {draftName !== null ? ( + event.currentTarget.select()} + onChange={(event) => { + setDraftName(event.target.value) + if (renameOrganization.isError) renameOrganization.reset() + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitRename() + } else if (event.key === 'Escape') { + event.preventDefault() + stopRenaming() + } + }} + onBlur={submitRename} + /> + ) : canEditIdentity ? ( + { + event.preventDefault() + setDraftName(organization.name) + }} + > + + + ) : ( + + )} + {renameOrganization.isError && ( + + {getErrorMessage(renameOrganization.error, 'Failed to rename organization')} + + )} {memberCount} {memberCount === 1 ? 'member' : 'members'} @@ -176,7 +259,7 @@ export function OrganizationHeader({ open={isInviteModalOpen} onOpenChange={setIsInviteModalOpen} organizationId={organization.id} - isOrganizationAdmin={canEditLogo} + isOrganizationAdmin={canEditIdentity} canInvite={canInviteMembers} /> )} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index e465ddbc458..5d7d19dc225 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -201,7 +201,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({ > { - if (e.pointerType === 'mouse') e.preventDefault() -} - const EDIT_ROW_CLASS = cn( chipVariants({ active: true, fullWidth: true }), 'min-w-0 cursor-default select-none text-small' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts index 62f818b88bb..a6ed4725eb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -1,10 +1,19 @@ -import type { MouseEvent } from 'react' +import type { MouseEvent, PointerEvent } from 'react' import type { MothershipResource } from '@/lib/mothership/resource-types' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +/** + * Prevents Radix's mouse pointer handlers from transferring focus. Row handlers keep + * actions submenus open; content capture handlers preserve inline rename focus, + * including inside portaled folders. Radix skips handlers for defaulted events. + */ +export function holdRowFocus(event: PointerEvent) { + if (event.pointerType === 'mouse') event.preventDefault() +} + /** Distinguishes sidebar background clicks from controls and portaled overlays. */ export function isSidebarBackgroundClick(event: MouseEvent): boolean { return ( diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 6d58a60e748..4f33b0bc293 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -23,6 +23,7 @@ import { type RosterWorkspaceAccess, removeOrganizationMemberContract, transferOwnershipContract, + updateOrganizationContract, updateOrganizationMemberRoleContract, updateOrganizationMemberUsageLimitContract, updateOrganizationUsageLimitContract, @@ -167,6 +168,30 @@ export function useOrganization(orgId: string) { }) } +interface RenameOrganizationParams { + orgId: string + name: string +} + +/** + * Renames an organization. The organization surface reads its identity from the + * server layout, so a successful rename also refreshes the route. + */ +export function useRenameOrganization() { + const queryClient = useQueryClient() + const router = useRouter() + + return useMutation({ + mutationFn: ({ orgId, name }: RenameOrganizationParams) => + requestJson(updateOrganizationContract, { params: { id: orgId }, body: { name } }), + onSuccess: () => router.refresh(), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) + queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + }, + }) +} + /** * Update organization usage limit mutation with optimistic updates */