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
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ 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'
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 {
Expand All @@ -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. */
Expand All @@ -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<HTMLInputElement>(null)
const nameInputRef = useRef<HTMLInputElement>(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<string | null>(null)
const initial = getOrganizationInitial(organization.name)

if (isCollapsed) {
Expand Down Expand Up @@ -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 = (
<IdentityTile size='lg' initial={initial} logoUrl={organization.logo} alt={organization.name} />
)

return (
<div className='min-w-0 flex-1'>
{canEditLogo && (
{canEditIdentity && (
<input
ref={fileInputRef}
type='file'
Expand All @@ -110,7 +141,14 @@ export function OrganizationHeader({
}}
/>
)}
<DropdownMenu>
<DropdownMenu
open={isMenuOpen}
onOpenChange={(open) => {
// The input's blur saves the draft; closing now would unmount it and hide a failure.
if (!open && draftName !== null) return
setIsMenuOpen(open)
}}
>
<DropdownMenuTrigger asChild>
<Chip
aria-label='Organization menu'
Expand All @@ -126,9 +164,11 @@ export function OrganizationHeader({
side='bottom'
sideOffset={8}
className='w-64 max-w-[calc(100vw-24px)]'
onPointerMoveCapture={draftName !== null ? holdRowFocus : undefined}
onPointerOutCapture={draftName !== null ? holdRowFocus : undefined}
>
<div className='flex items-center gap-2 px-2 py-1.5'>
{canEditLogo ? (
{canEditIdentity ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuItem
Expand All @@ -150,8 +190,51 @@ export function OrganizationHeader({
) : (
logo
)}
<div className='flex min-w-0 flex-col'>
<OverflowText label={organization.name} />
<div className='flex min-w-0 flex-1 flex-col'>
{draftName !== null ? (
<SidebarRenameRow
ref={nameInputRef}
aria-label='Organization name'
aria-invalid={renameOrganization.isError || undefined}
autoFocus
value={draftName}
disabled={renameOrganization.isPending}
onFocus={(event) => 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}
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
/>
) : canEditIdentity ? (
<DropdownMenuItem
aria-label='Rename organization'
textValue='Rename organization'
className='h-auto min-w-0 p-0 hover-hover:opacity-70 focus-visible:opacity-70'
onSelect={(event) => {
event.preventDefault()
setDraftName(organization.name)
}}
>
<OverflowText label={organization.name} />
</DropdownMenuItem>
) : (
<OverflowText label={organization.name} />
)}
{renameOrganization.isError && (
<span role='alert' className='text-[var(--text-error)] text-caption'>
{getErrorMessage(renameOrganization.error, 'Failed to rename organization')}
</span>
)}
<span className='text-[var(--text-muted)] text-caption'>
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</span>
Expand All @@ -176,7 +259,7 @@ export function OrganizationHeader({
open={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
organizationId={organization.id}
isOrganizationAdmin={canEditLogo}
isOrganizationAdmin={canEditIdentity}
canInvite={canInviteMembers}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({
>
<OrganizationHeader
organization={organization}
canEditLogo={viewer.isAdmin}
canEditIdentity={viewer.isAdmin}
canInviteMembers={viewer.canInviteMembers}
isCollapsed={isCollapsed}
onExpandSidebar={toggleCollapsed}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
SIDEBAR_RAIL_CHIP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
holdRowFocus,
interleaveSiblings,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'

Expand Down Expand Up @@ -217,15 +220,6 @@ interface CollapsedWorkflowFlyoutItemProps {
canRename?: boolean
}

/**
* 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.
*/
const holdRowFocus = (e: React.PointerEvent) => {
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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>): boolean {
return (
Expand Down
25 changes: 25 additions & 0 deletions apps/sim/hooks/queries/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type RosterWorkspaceAccess,
removeOrganizationMemberContract,
transferOwnershipContract,
updateOrganizationContract,
updateOrganizationMemberRoleContract,
updateOrganizationMemberUsageLimitContract,
updateOrganizationUsageLimitContract,
Expand Down Expand Up @@ -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
*/
Expand Down
Loading