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
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
Expand Down Expand Up @@ -77,29 +77,23 @@ export function Admin() {
setSearchInput((current) => (current === searchQuery ? current : searchQuery))
}, [searchQuery])

const totalPages = useMemo(
() => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE),
[usersData?.total]
)
const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset])
const totalPages = Math.ceil((usersData?.total ?? 0) / PAGE_SIZE)
const currentPage = Math.floor(usersOffset / PAGE_SIZE) + 1

const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}

const handleMothershipEnvironmentChange = useCallback(
async (nextEnvironment: MothershipEnvironment) => {
if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) {
await updateSetting.mutateAsync({
key: 'mothershipEnvironment',
value: nextEnvironment,
})
}
},
[settings?.mothershipEnvironment, updateSetting]
)
const handleMothershipEnvironmentChange = async (nextEnvironment: MothershipEnvironment) => {
if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) {
await updateSetting.mutateAsync({
key: 'mothershipEnvironment',
value: nextEnvironment,
})
}
}

const handleImport = () => {
if (!workflowId.trim()) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
const { isFree, isPro, isEnterprise, isPaid, isOrgScoped } = subscription
const { isTeamAdmin } = userRole

// Non-admin org members see the "team member" view: no edit / no cancel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
'use client'

import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn'
import { formatRelativeTime } from '@sim/utils/formatting'
import { ArrowRight, Paperclip } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import {
type InboxStatusFilter,
inboxTaskParsers,
inboxTaskUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/inbox/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { InboxTaskItem } from '@/hooks/queries/inbox'
import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox'
Expand All @@ -18,7 +24,10 @@ const STATUS_OPTIONS = [
{ value: 'rejected', label: 'Rejected' },
] as const

type StatusFilter = (typeof STATUS_OPTIONS)[number]['value']
type StatusFilter = InboxStatusFilter

/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const

const STATUS_BADGES: Record<
string,
Expand All @@ -36,8 +45,26 @@ export function InboxTaskList() {
const router = useRouter()
const workspaceId = params.workspaceId as string

const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [searchTerm, setSearchTerm] = useState('')
const [{ status: statusFilter, search: searchTerm }, setInboxFilters] = useQueryStates(
inboxTaskParsers,
inboxTaskUrlKeys
)

/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over the loaded
* tasks, so it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const next = value.length > 0 ? value : null
setInboxFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setInboxFilters]
)

const { data: config } = useInboxConfig(workspaceId)
const { data: tasksData, isLoading } = useInboxTasks(workspaceId, {
Expand Down Expand Up @@ -80,7 +107,7 @@ export function InboxTaskList() {
value={statusFilter}
onChange={(value) => {
if (STATUS_OPTIONS.some((option) => option.value === value)) {
setStatusFilter(value as StatusFilter)
setInboxFilters({ status: value as StatusFilter })
}
}}
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'

/** Selectable status filters for the inbox task list. */
export const INBOX_STATUS_FILTERS = [
'all',
'completed',
'processing',
'received',
'failed',
'rejected',
] as const

export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number]

/**
* Co-located, typed URL query-param definitions for the inbox task list.
*
* - `status` is the active status filter (feeds the tasks query key).
* - `search` is the subject/sender/body name filter. The input is controlled
* directly by the nuqs value; only its URL write is debounced via
* `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke.
*/
export const inboxTaskParsers = {
status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'),
search: parseAsString.withDefault(''),
} as const

/** Status/search view-state: clean URLs, no back-stack churn. */
export const inboxTaskUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ interface NoOrganizationViewProps {
hasTeamPlan: boolean
hasEnterprisePlan: boolean
orgName: string
setOrgName: (name: string) => void
orgSlug: string
setOrgSlug: (slug: string) => void
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
Expand All @@ -30,7 +29,6 @@ export function NoOrganizationView({
hasTeamPlan,
hasEnterprisePlan,
orgName,
setOrgName,
orgSlug,
setOrgSlug,
onOrgNameChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ interface OrganizationMemberListsProps {
roster: OrganizationRoster | null | undefined
isLoadingRoster: boolean
currentUserId: string
currentUserEmail: string
onRemoveMember: (member: Member) => void
onTransferOwnership?: () => void
}
Expand All @@ -87,7 +86,6 @@ export function OrganizationMemberLists({
roster,
isLoadingRoster,
currentUserId,
currentUserEmail,
onRemoveMember,
onTransferOwnership,
}: OrganizationMemberListsProps) {
Expand Down Expand Up @@ -376,6 +374,38 @@ export function OrganizationMemberLists({
const hasOrgMatches = filteredOrgMembers.length + filteredOrgPending.length > 0
const showMembersSection = !isActiveSearch || hasOrgMatches

/**
* Group each workspace's members and pending invites once per roster change.
* This is O(workspaces × members) and independent of the search query, so
* hoisting it out of render keeps keystroke filtering cheap on large orgs.
*/
const workspaceGroups = useMemo(
() =>
workspaces.map((workspace) => {
const workspaceMembers = members
.map((member) => ({
member,
access: member.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
const workspaceInvites = pendingInvitations
.map((invitation) => ({
invitation,
access: invitation.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter(
(
entry
): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
return { workspace, workspaceMembers, workspaceInvites }
}),
[workspaces, members, pendingInvitations]
)

return (
<>
<div className='flex items-center gap-2'>
Expand All @@ -399,27 +429,7 @@ export function OrganizationMemberLists({
</MemberSection>
)}

{workspaces.map((workspace) => {
const workspaceMembers = members
.map((member) => ({
member,
access: member.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
const workspaceInvites = pendingInvitations
.map((invitation) => ({
invitation,
access: invitation.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter(
(
entry
): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)

{workspaceGroups.map(({ workspace, workspaceMembers, workspaceInvites }) => {
const visibleMembers = workspaceMembers.filter(({ member }) =>
matches(member.name, member.email)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,6 @@ export function TeamManagement() {
hasTeamPlan={hasTeamPlan}
hasEnterprisePlan={hasEnterprisePlan}
orgName={orgName}
setOrgName={setOrgName}
orgSlug={orgSlug}
setOrgSlug={setOrgSlug}
onOrgNameChange={handleOrgNameChange}
Expand Down Expand Up @@ -337,7 +336,6 @@ export function TeamManagement() {
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
currentUserEmail={session?.user?.email ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { parseAsString } from 'nuqs/server'

/**
* Co-located, typed URL query-param definition for the Teammates view.
*
* `search` is the name/email filter. The input is controlled directly by the
* nuqs value; only its URL write is debounced via `limitUrlUpdates`.
*/
export const teammatesSearchParam = {
key: 'search',
parser: parseAsString.withDefault(''),
} as const

/** Search view-state: clean URLs, no back-stack churn. */
export const teammatesUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChipDropdown, Plus, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryState } from 'nuqs'
import {
RoleLockTooltip,
type WorkspaceRoleSource,
Expand All @@ -18,6 +19,10 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
teammatesSearchParam,
teammatesUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import {
Expand Down Expand Up @@ -71,7 +76,11 @@ export function Teammates() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''

const [searchTerm, setSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, {
...teammatesSearchParam.parser,
...teammatesUrlKeys,
limitUrlUpdates: debounce(300),
})
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)

const { data: permissions, isPending: permissionsLoading } =
Expand Down Expand Up @@ -167,7 +176,7 @@ export function Teammates() {
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
onChange: (value) => void setSearchTerm(value),
placeholder: 'Search teammates...',
}}
actions={[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,13 @@ interface CreateWorkflowMcpServerModalProps {
onOpenChange: (open: boolean) => void
workspaceId: string
workflowOptions?: ComboboxOption[]
isLoadingWorkflows?: boolean
}

export function CreateWorkflowMcpServerModal({
open,
onOpenChange,
workspaceId,
workflowOptions,
isLoadingWorkflows = false,
}: CreateWorkflowMcpServerModalProps) {
const createServerMutation = useCreateWorkflowMcpServer()

Expand Down
Loading
Loading