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
5 changes: 4 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
Expand Down Expand Up @@ -56,6 +57,7 @@ export default async function WorkspaceLayout({
tableRowTtlEnabled,
modelSelectorEnabled,
planModeEnabled,
organizationHref,
] = await Promise.all([
cookies(),
hostContext.hostOrganizationId
Expand All @@ -71,6 +73,7 @@ export default async function WorkspaceLayout({
isTableRowTtlEnabled(),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
resolveOrganizationEntryPath(session),
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
prefetchWorkspaceAccess(queryClient, workspaceId, {
kind: 'session',
userId: session.user.id,
Expand Down Expand Up @@ -106,7 +109,7 @@ export default async function WorkspaceLayout({
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<WorkspaceChrome
sidebar={<Sidebar />}
sidebar={<Sidebar organizationHref={organizationHref} />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,11 @@ import { createRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockNavigateToSettings, mockWorkspacePermissions, hostContext } = vi.hoisted(() => ({
const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({
mockNavigateToSettings: vi.fn(),
hostContext: {
hostOrganizationId: null as string | null,
viewer: { isHostOrganizationMember: false },
features: { organizationSearch: false as boolean | undefined },
},
mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true },
}))

vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
useWorkspaceHostContext: () => hostContext,
}))

const onWorkspaceSwitch = vi.fn()

vi.mock('@tanstack/react-query', () => ({
Expand All @@ -29,7 +20,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/workspace/ws-emir/home',
}))
vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) }))
vi.mock('@/hooks/use-settings-navigation', () => ({
useSettingsNavigation: () => ({
navigateToSettings: mockNavigateToSettings,
Expand Down Expand Up @@ -112,6 +102,7 @@ function render(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {})
function header(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {}) {
return (
<WorkspaceHeader
organizationHref={null}
activeWorkspace={{ name: "Emir's Workspace" }}
workspaceId='ws-emir'
workspaces={WORKSPACES}
Expand Down Expand Up @@ -170,9 +161,6 @@ function typeInto(input: HTMLInputElement, value: string) {

beforeEach(() => {
vi.clearAllMocks()
hostContext.hostOrganizationId = null
hostContext.viewer.isHostOrganizationMember = false
hostContext.features.organizationSearch = false
Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true })
// jsdom implements neither; the component scrolls the active row into view.
Element.prototype.scrollIntoView = vi.fn()
Expand All @@ -187,8 +175,7 @@ describe('WorkspaceHeader workspace switcher highlight', () => {
it.each([null, 'organization'])(
'keeps access requests out of the workspace switcher (%s)',
(organizationId) => {
hostContext.hostOrganizationId = organizationId
render()
render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) })
expect(document.body).not.toHaveTextContent('My access requests')
expect(document.body).not.toHaveTextContent('Review access requests')
}
Expand Down Expand Up @@ -414,27 +401,22 @@ describe('WorkspaceHeader workspace switcher highlight', () => {
})

describe('WorkspaceHeader context navigation', () => {
it('links to the current host organization for enrolled members', () => {
hostContext.hostOrganizationId = 'host-org'
hostContext.viewer.isHostOrganizationMember = true
hostContext.features.organizationSearch = true
render()
expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent(
'Back to organization'
)
})
it.each([null, 'another-organization', 'viewer-organization'])(
'links to the viewer organization landing independently of workspace host %s',
(organizationId) => {
render({
organizationHref: '/o/viewer-organization/home',
workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })),
})
expect(document.querySelector('a[href="/o/viewer-organization/home"]')).toHaveTextContent(
'Back to organization'
)
}
)

it.each([
{ org: null, member: true, enabled: true },
{ org: 'host-org', member: false, enabled: true },
{ org: 'host-org', member: true, enabled: false },
{ org: 'host-org', member: true, enabled: undefined },
])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => {
hostContext.hostOrganizationId = org
hostContext.viewer.isHostOrganizationMember = member
hostContext.features.organizationSearch = enabled
render()
expect(document.querySelector('a[href^="/o/"]')).toBeNull()
it('hides organization navigation without an eligible destination', () => {
render({ organizationHref: null })
expect(document.body).not.toHaveTextContent('Back to organization')
})

it('keeps settings in the profile menu instead of duplicating it in the switcher', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
Expand Down Expand Up @@ -79,6 +77,7 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps)
}

interface WorkspaceHeaderProps {
organizationHref: string | null
/** The active workspace object */
activeWorkspace?: { name: string } | null
/** Current workspace ID */
Expand Down Expand Up @@ -127,6 +126,7 @@ interface WorkspaceHeaderProps {
* Workspace header component that displays workspace name and switcher.
*/
function WorkspaceHeaderImpl({
organizationHref,
activeWorkspace,
workspaceId,
workspaces,
Expand Down Expand Up @@ -273,8 +273,6 @@ function WorkspaceHeaderImpl({

const { navigateToSettings } = useSettingsNavigation()
const queryClient = useQueryClient()
const hostContext = useWorkspaceHostContext()
const organizationHref = getWorkspaceOrganizationHref(hostContext)

const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
import { captureEvent } from '@/lib/posthog/client'
import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file'
import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation'
import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
Expand Down Expand Up @@ -333,6 +332,10 @@ const HIDDEN_STYLE = { display: 'none' } as const
*/
const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'

interface SidebarProps {
organizationHref: string | null
}

/**
* Sidebar component with resizable width that persists across page refreshes.
*
Expand All @@ -349,7 +352,7 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
*
* @returns Sidebar with workflows panel
*/
export const Sidebar = memo(function Sidebar() {
export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) {
const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome()
const isCollapsed = isCollapsedProp && !isPeeking
const params = useParams()
Expand Down Expand Up @@ -820,7 +823,6 @@ export const Sidebar = memo(function Sidebar() {
onNavigate: () => handleOpenSettings(id),
}))

const organizationHref = getWorkspaceOrganizationHref(hostContext)
if (organizationHref) {
profileNavigationLinks.push({
label: 'Organization',
Expand Down Expand Up @@ -1305,6 +1307,7 @@ export const Sidebar = memo(function Sidebar() {
)}
>
<WorkspaceHeader
organizationHref={organizationHref}
activeWorkspace={activeWorkspace ?? routeWorkspace}
workspaceId={workspaceId}
workspaces={workspaces}
Expand Down
70 changes: 63 additions & 7 deletions apps/sim/hooks/queries/organization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiClientError } from '@/lib/api/client/errors'

const { mockGetFullOrganization, mockListOrganizations, mockRequestJson, featureFlags } =
vi.hoisted(() => ({
mockGetFullOrganization: vi.fn(),
mockListOrganizations: vi.fn(),
mockRequestJson: vi.fn(),
featureFlags: { organizations: true },
}))
const {
mockGetFullOrganization,
mockListOrganizations,
mockSetActiveOrganization,
mockRefresh,
mockRequestJson,
featureFlags,
} = vi.hoisted(() => ({
mockGetFullOrganization: vi.fn(),
mockListOrganizations: vi.fn(),
mockSetActiveOrganization: vi.fn(),
mockRefresh: vi.fn(),
mockRequestJson: vi.fn(),
featureFlags: { organizations: true },
}))

vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))

vi.mock('@/lib/core/config/env-flags', () => ({
get isOrganizationsEnabled() {
Expand All @@ -31,6 +43,7 @@ vi.mock('@/lib/auth/auth-client', () => ({
organization: {
getFullOrganization: mockGetFullOrganization,
list: mockListOrganizations,
setActive: mockSetActiveOrganization,
},
subscription: {
list: vi.fn(),
Expand All @@ -49,6 +62,7 @@ import {
} from '@/lib/api/contracts/subscription'
import {
organizationKeys,
useCreateOrganization,
useOrganization,
useOrganizationBilling,
useOrganizationList,
Expand Down Expand Up @@ -199,6 +213,48 @@ describe('organization identity transitions', () => {
expect(signal).toBeInstanceOf(AbortSignal)
})

it.each([true, false])(
'refreshes the server layout after organization activation settles (success=%s)',
async (success) => {
mockRequestJson.mockResolvedValue({ organizationId: 'new-organization' })
const activation = createDeferred<{ error: { message: string } | null }>()
mockSetActiveOrganization.mockReturnValue(activation.promise)
let mutation: ReturnType<typeof useCreateOrganization>
function CreationProbe() {
mutation = useCreateOrganization()
return null
}

await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CreationProbe />
</QueryClientProvider>
)
})
let pending: Promise<unknown>
await act(async () => {
pending = mutation.mutateAsync({ name: 'New organization' })
})
expect(mockSetActiveOrganization).toHaveBeenCalledWith({
organizationId: 'new-organization',
})
expect(mockRefresh).not.toHaveBeenCalled()

await act(async () => {
if (success) {
activation.resolve({ error: null })
await pending
} else {
const rejection = expect(pending).rejects.toThrow('Activation failed')
activation.resolve({ error: { message: 'Activation failed' } })
await rejection
}
})
expect(mockRefresh).toHaveBeenCalledOnce()
}
)

it('does not call the organization plugin when organizations are disabled', async () => {
featureFlags.organizations = false
await act(async () =>
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/hooks/queries/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput } from '@/lib/api/contracts'
Expand Down Expand Up @@ -605,6 +606,7 @@ type CreateOrganizationParams = Pick<

export function useCreateOrganization() {
const queryClient = useQueryClient()
const router = useRouter()

return useMutation({
mutationFn: async ({ name, slug }: CreateOrganizationParams) => {
Expand All @@ -615,15 +617,17 @@ export function useCreateOrganization() {
},
})

await client.organization.setActive({
const { error } = await client.organization.setActive({
organizationId: data.organizationId,
})
if (error) throw new Error(error.message || 'Failed to activate organization')

return data
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
router.refresh()
},
})
}
Loading
Loading