diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 56e5a9e33aa..f2fdfd98a44 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -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' @@ -77,11 +77,8 @@ 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) { @@ -89,17 +86,14 @@ export function Admin() { } } - 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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts index 681edb0490b..94e514d1112 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index f7d9280d2a4..3b52de1ea3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -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' @@ -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, @@ -36,8 +45,26 @@ export function InboxTaskList() { const router = useRouter() const workspaceId = params.workspaceId as string - const [statusFilter, setStatusFilter] = useState('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, { @@ -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 }))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts new file mode 100644 index 00000000000..f21a66f2d11 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index f51fe878a99..f3f30c7bca0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -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) => void @@ -30,7 +29,6 @@ export function NoOrganizationView({ hasTeamPlan, hasEnterprisePlan, orgName, - setOrgName, orgSlug, setOrgSlug, onOrgNameChange, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 1ae42a9543f..23582d801eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -71,7 +71,6 @@ interface OrganizationMemberListsProps { roster: OrganizationRoster | null | undefined isLoadingRoster: boolean currentUserId: string - currentUserEmail: string onRemoveMember: (member: Member) => void onTransferOwnership?: () => void } @@ -87,7 +86,6 @@ export function OrganizationMemberLists({ roster, isLoadingRoster, currentUserId, - currentUserEmail, onRemoveMember, onTransferOwnership, }: OrganizationMemberListsProps) { @@ -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 ( <>
@@ -399,27 +429,7 @@ export function OrganizationMemberLists({ )} - {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) ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 60776f5da79..334d9f32e37 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -293,7 +293,6 @@ export function TeamManagement() { hasTeamPlan={hasTeamPlan} hasEnterprisePlan={hasEnterprisePlan} orgName={orgName} - setOrgName={setOrgName} orgSlug={orgSlug} setOrgSlug={setOrgSlug} onOrgNameChange={handleOrgNameChange} @@ -337,7 +336,6 @@ export function TeamManagement() { roster={roster ?? null} isLoadingRoster={isLoadingRoster} currentUserId={session?.user?.id ?? ''} - currentUserEmail={session?.user?.email ?? ''} onRemoveMember={handleRemoveMember} onTransferOwnership={handleOpenTransferDialog} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts new file mode 100644 index 00000000000..3366760fbc4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index 5ecf4da0c52..404069d7829 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -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, @@ -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 { @@ -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 } = @@ -167,7 +176,7 @@ export function Teammates() { void setSearchTerm(value), placeholder: 'Search teammates...', }} actions={[ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index 59805c404b7..a130cb3c418 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -29,7 +29,6 @@ interface CreateWorkflowMcpServerModalProps { onOpenChange: (open: boolean) => void workspaceId: string workflowOptions?: ComboboxOption[] - isLoadingWorkflows?: boolean } export function CreateWorkflowMcpServerModal({ @@ -37,7 +36,6 @@ export function CreateWorkflowMcpServerModal({ onOpenChange, workspaceId, workflowOptions, - isLoadingWorkflows = false, }: CreateWorkflowMcpServerModalProps) { const createServerMutation = useCreateWorkflowMcpServer() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 60deec155c3..bd6121440b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -109,28 +109,28 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro const [editServerIsPublic, setEditServerIsPublic] = useState(false) const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') - useEffect(() => { - if (toolToView) { - setEditingDescription(toolToView.toolDescription || '') - const schema = toolToView.parameterSchema as - | { properties?: Record } - | undefined - const properties = schema?.properties - if (properties) { - const descriptions: Record = {} - for (const [name, prop] of Object.entries(properties)) { - descriptions[name] = prop.description || '' - } - setEditingParameterDescriptions(descriptions) - } else { - setEditingParameterDescriptions({}) - } - } - }, [toolToView]) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) const mcpServerUrl = `${getBaseUrl()}/api/mcp/serve/${serverId}` + const handleOpenToolEdit = (tool: WorkflowMcpTool) => { + setToolToView(tool) + setEditingDescription(tool.toolDescription || '') + const schema = tool.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + if (properties) { + const descriptions: Record = {} + for (const [name, prop] of Object.entries(properties)) { + descriptions[name] = prop.description || '' + } + setEditingParameterDescriptions(descriptions) + } else { + setEditingParameterDescriptions({}) + } + } + const handleDeleteTool = async () => { if (!toolToDelete) return try { @@ -425,7 +425,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro setToolToView(tool) }, + { label: 'Edit', onSelect: () => handleOpenToolEdit(tool) }, { label: 'Remove', destructive: true, @@ -860,8 +860,7 @@ export function WorkflowMcpServers() { const searchParams = useSearchParams() const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId) - const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = - useDeployedWorkflows(workspaceId) + const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId) const deleteServerMutation = useDeleteWorkflowMcpServer() const [searchTerm, setSearchTerm] = useState('') @@ -1004,7 +1003,6 @@ export function WorkflowMcpServers() { onOpenChange={setShowAddModal} workspaceId={workspaceId} workflowOptions={workflowOptions} - isLoadingWorkflows={isLoadingWorkflows} />