From ba5fec1cd33482a4011beb22168f3c9fe48420cc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 20:00:41 -0700 Subject: [PATCH 1/3] improvement(credential-groups): align settings surface with the shared page patterns - drop the row "..." menu; a row opening a detail page carries the chevron only, and Delete moves to the detail header behind a confirm modal - replace the hand-rolled Save chip with saveDiscardActions, and wire useSettingsUnsavedGuard so detail edits survive tab switches - fix swapped staleTime constants: the list carried Infinity, which combined with the app-wide retryOnMount:false to cache one transient failure until a full page reload - evict the detail query on delete, and keep the bots prop referentially stable so a refetch cannot drop a queued Slack authorization message - reset the detail tab param on open and close so a stale link cannot open the next group on the previous group's tab - match peer rows (iconFilled + --text-icon), drop a bespoke max-w and a duplicated gap-7, align no-results copy and the Slack modal field gutter --- .../components/credential-group-detail.tsx | 126 +++++++- .../components/credential-group-details.tsx | 276 ++++++++---------- .../credential-group-invite-modal.tsx | 7 +- .../components/credential-groups-settings.tsx | 84 +++--- .../components/slack-managed-users-modal.tsx | 122 ++++---- apps/sim/hooks/queries/credential-groups.ts | 10 +- 6 files changed, 355 insertions(+), 270 deletions(-) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 3bab5af6bc0..672ed564a66 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,6 +5,7 @@ import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, @@ -13,6 +14,7 @@ import type { import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { credentialGroupTabParam, credentialGroupTabUrlKeys, @@ -26,12 +28,15 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' import { useCredentialGroupDetail, + useDeleteCredentialGroup, useResendCredentialGroupEnrollment, useRevokeCredentialGroupEnrollment, + useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -120,12 +125,17 @@ export function CredentialGroupDetail({ }) const resend = useResendCredentialGroupEnrollment() const revoke = useRevokeCredentialGroupEnrollment() + const updateGroup = useUpdateCredentialGroup() + const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { ...credentialGroupTabParam.parser, ...credentialGroupTabUrlKeys, }) const [showInvite, setShowInvite] = useState(false) + const [showDelete, setShowDelete] = useState(false) const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [draftName, setDraftName] = useState(null) + const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] const revokingEnrollment = revokingEnrollmentId @@ -144,14 +154,65 @@ export function CredentialGroupDetail({ slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) ) + const name = draftName ?? credentialGroup?.name ?? '' + const description = draftDescription ?? credentialGroup?.description ?? '' + const normalizedDescription = description.trim() || null + const detailsDirty = Boolean( + credentialGroup && + (name.trim() !== credentialGroup.name || + normalizedDescription !== credentialGroup.description) + ) + const guard = useSettingsUnsavedGuard({ isDirty: detailsDirty }) + + const discardDetails = () => { + setDraftName(null) + setDraftDescription(null) + } + + const handleSaveDetails = async () => { + if (!credentialGroup || !name.trim()) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + discardDetails() + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + /** + * Each tab owns its own primary action: Details commits the edited name and + * description, People invites more users. Delete is available from both. + */ const actions: SettingsAction[] = credentialGroup ? [ + ...(activeTab === 'details' + ? saveDiscardActions({ + dirty: detailsDirty, + saving: updateGroup.isPending, + onSave: () => void handleSaveDetails(), + onDiscard: discardDetails, + saveDisabled: !name.trim(), + saveTooltip: name.trim() ? undefined : 'Name is required', + }) + : [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary' as const, + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ]), { - text: 'Invite users', - icon: Plus, - variant: 'primary', - onSelect: () => setShowInvite(true), - disabled: credentialGroup.status !== 'active' || !configurationReady, + id: 'delete', + text: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onSelect: () => setShowDelete(true), + disabled: deleteGroup.isPending, }, ] : [] @@ -180,15 +241,25 @@ export function CredentialGroupDetail({ } } - const handleBack = () => { - void setActiveTab(null, { history: 'replace' }) - onBack() + const handleDelete = async () => { + if (!credentialGroup) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId }) + setShowDelete(false) + onBack() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not delete credential group')) + } } return ( <> guard.guardBack(onBack), + }} title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} @@ -198,7 +269,7 @@ export function CredentialGroupDetail({ {getErrorMessage(detail.error, "Couldn't load credential group")} ) : detail.isPending || !credentialGroup ? null : ( -
+ <> {activeTab === 'details' && ( - + )} {activeTab === 'people' && ( @@ -233,7 +311,8 @@ export function CredentialGroupDetail({ return ( } + icon={} + iconFilled title={enrollment.email} description={ @@ -272,7 +351,7 @@ export function CredentialGroupDetail({ )} )} -
+ )}
{credentialGroup && ( @@ -296,6 +375,27 @@ export function CredentialGroupDetail({ disabled: revoke.isPending, }} /> + !open && !deleteGroup.isPending && setShowDelete(false)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${credentialGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + ) } diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 501f0a7b366..9af6a3da772 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts' import type { CredentialGroup, CredentialGroupOption, @@ -28,9 +29,17 @@ import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack- import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ +const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] + interface CredentialGroupDetailsProps { credentialGroup: CredentialGroup workspaceId: string + /** Edited name; committed by the panel header's Save action, which owns the dirty state. */ + name: string + onNameChange: (name: string) => void + description: string + onDescriptionChange: (description: string) => void } function toOptionUpdateInput( @@ -52,6 +61,10 @@ function toOptionUpdateInput( export function CredentialGroupDetails({ credentialGroup, workspaceId, + name, + onNameChange, + description, + onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() const slackBots = useWorkspaceCredentials({ @@ -59,15 +72,9 @@ export function CredentialGroupDetails({ type: 'service_account', providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) - const [name, setName] = useState(credentialGroup.name) - const [description, setDescription] = useState(credentialGroup.description ?? '') - const [slackSetupOpen, setSlackSetupOpen] = useState(false) - const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) - const normalizedDescription = description.trim() || null - const detailsDirty = - name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description const isUpdating = updateGroup.isPending const updateOptions = async ( @@ -100,8 +107,7 @@ export function CredentialGroupDetails({ } const openSlackSetup = (credentialId?: string) => { - setSlackSetupCredentialId(credentialId) - setSlackSetupOpen(true) + setSlackSetup({ credentialId }) } const handleProviderAction = (provider: CredentialGroupProvider) => { @@ -117,20 +123,6 @@ export function CredentialGroupDetails({ throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) } - const handleSaveDetails = async () => { - if (!detailsDirty || !name.trim() || isUpdating) return - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { name: name.trim(), description: normalizedDescription }, - }) - toast.success('Details saved') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not save details')) - } - } - const handleRemoveProvider = async () => { if (!removingProvider) return const service = getCredentialGroupProviderService(removingProvider) @@ -142,141 +134,129 @@ export function CredentialGroupDetails({ return ( <> -
- void handleSaveDetails()} - disabled={!name.trim() || isUpdating} - > - {isUpdating ? 'Saving...' : 'Save changes'} - - ) : undefined - } - > -
- - setName(event.target.value)} - error={!name.trim()} - /> - - - setDescription(event.target.value)} - placeholder='What these accounts will be used for' - rows={3} - /> - -
-
+ +
+ + onNameChange(event.target.value)} + error={!name.trim()} + /> + + + onDescriptionChange(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
- -
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { - const service = getCredentialGroupProviderService(provider) - const support = getCredentialGroupProviderSupport(provider) - const option = credentialGroup.options.find( - (candidate) => candidate.provider === provider - ) - const ProviderIcon = service.icon - const slackBot = - provider === 'slack' && option?.provider === 'slack' - ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) - : undefined - const slackNeedsSetup = - provider === 'slack' && - option?.provider === 'slack' && - (!slackBot || option.configurationStatus !== 'ready') - const descriptionText = - provider === 'slack' && option - ? slackBot - ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` - : slackBots.isPending - ? 'Loading custom Slack app...' - : 'Custom Slack app unavailable' - : support.description + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description - return ( - } - title={service.name} - description={descriptionText} - badge={ - option && !slackNeedsSetup ? ( - Connected - ) : undefined - } - trailing={ - option ? ( -
- {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( - openSlackSetup(slackBot.id)} disabled={isUpdating}> - Continue setup - - ) : null} - - openSlackSetup( - option?.provider === 'slack' - ? option.slackBotCredentialId - : undefined - ), - disabled: isUpdating, - }, - ] - : []), - { - label: 'Remove', - destructive: true, - onSelect: () => setRemovingProvider(provider), - disabled: isUpdating, - }, - ]} - /> -
- ) : ( - handleProviderAction(provider)} - disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} - > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} - - ) - } - /> - ) - })} -
-
-
+ return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+ { - setSlackSetupOpen(nextOpen) - if (!nextOpen) setSlackSetupCredentialId(undefined) + if (!nextOpen) setSlackSetup(null) }} - bots={slackBots.data ?? []} + bots={slackBots.data ?? EMPTY_SLACK_BOTS} isLoading={slackBots.isPending} error={slackBots.error} - initialCredentialId={slackSetupCredentialId} + initialCredentialId={slackSetup?.credentialId} /> item.email).join(', ')}` : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` ) - } catch (error) { - setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } catch { + return } } @@ -100,7 +100,8 @@ export function CredentialGroupInviteModal({ disabled={invite.isPending} /> - {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + {deliveryError ?? + (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} (null) const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { ...credentialGroupIdParam.parser, ...credentialGroupIdUrlKeys, }) - const deletingGroup = groups.find((group) => group.id === deletingGroupId) + /** + * The detail view's tab is scoped to one group, so both transitions reset it — + * otherwise a `credential-group-id` that never resolves leaves + * `credential-group-tab` behind and the next group opens on the previous + * group's tab. nuqs batches these same-tick writes into one URL update. + */ + const [, setSelectedTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const openGroup = (groupId: string) => { + void setSelectedGroupId(groupId) + void setSelectedTab(null) + } + const closeGroup = () => { + void setSelectedGroupId(null, { history: 'replace' }) + void setSelectedTab(null) + } const selectedGroup = selectedGroupId ? groups.find((group) => group.id === selectedGroupId) : undefined @@ -59,22 +75,12 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] - const handleDelete = async () => { - if (!deletingGroupId) return - try { - await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) - setDeletingGroupId(null) - } catch { - return - } - } - if (selectedGroup) { return ( void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroup} /> ) } @@ -97,18 +103,24 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin ) : isPending ? null : groups.length === 0 ? ( Click "Create group" above to get started ) : filtered.length === 0 ? ( - No groups match "{search}" + + No credential groups found matching "{search}" + ) : (
{filtered.map((group) => { const optionCount = group.options.length + const accountTypes = `${optionCount} account type${optionCount === 1 ? '' : 's'}` return ( } + icon={} + iconFilled title={group.name} - description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} - onClick={() => void setSelectedGroupId(group.id)} + description={ + group.description ? `${accountTypes} · ${group.description}` : accountTypes + } + onClick={() => openGroup(group.id)} clickLabel={`Open ${group.name}`} navigable badge={ @@ -116,18 +128,6 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin Disabled ) : undefined } - trailing={ - setDeletingGroupId(group.id), - }, - ]} - /> - } /> ) })} @@ -137,25 +137,9 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin void setSelectedGroupId(groupId)} + onCreated={openGroup} workspaceId={workspaceId} /> - !open && !deleteGroup.isPending && setDeletingGroupId(null)} - srTitle='Delete credential group' - title='Delete credential group' - text={[ - `Delete ${deletingGroup?.name ?? 'this credential group'}?`, - { text: ' This cannot be undone.', error: true }, - ]} - dismissLabel='Cancel' - confirm={{ - label: deleteGroup.isPending ? 'Deleting...' : 'Delete', - onClick: handleDelete, - disabled: deleteGroup.isPending, - }} - /> ) } diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index a6bdef02c80..34d3052565c 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -94,50 +94,78 @@ export function SlackManagedUsersModal({ const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleAuthorizationMessage = (message: SlackManagedUsersMessage) => { + if (!expectedState.current || message.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if (!message.ok) { + const notification = getSlackManagedUsersFailureNotification(message.reason) + if (notification.variant === 'warning') toast.warning(notification.message) + else toast.error(notification.message) + return + } + if ( + message.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + message.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + + /** + * The subscription's identity is `open` alone. Routing the handler through a + * ref keeps a `bots` refetch from closing and reopening the channel mid-flow, + * which would drop an already-queued authorization message from the popup. + */ + const messageHandler = useRef(handleAuthorizationMessage) + useEffect(() => { + messageHandler.current = handleAuthorizationMessage + }) + useEffect(() => { if (!open) return const channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { if (!isSlackManagedUsersMessage(event.data)) return - if (!expectedState.current || event.data.state !== expectedState.current) return - const verifiedCredentialId = expectedCredentialId.current - expectedState.current = null - expectedCredentialId.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - popup.current?.close() - popup.current = null - setPending(false) - if (!event.data.ok) { - const notification = getSlackManagedUsersFailureNotification(event.data.reason) - if (notification.variant === 'warning') toast.warning(notification.message) - else toast.error(notification.message) - return - } - if ( - event.data.credentialGroupId !== credentialGroupId || - !verifiedCredentialId || - event.data.slackBotCredentialId !== verifiedCredentialId - ) { - toast.error('Slack app verification failed. Please try again.') - return - } - if (!bots.some((bot) => bot.id === verifiedCredentialId)) { - toast.error('The verified Slack app is no longer available.') - return - } - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.list(workspaceId), - }) - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), - }) - toast.success('Slack configured') - onOpenChange(false) - reset() + messageHandler.current(event.data) } return () => channel.close() - }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + }, [open]) useEffect( () => () => { @@ -147,20 +175,6 @@ export function SlackManagedUsersModal({ [] ) - const reset = () => { - popup.current?.close() - popup.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - expectedState.current = null - expectedCredentialId.current = null - setSelectedCredentialId(null) - setClientId('') - setClientSecret('') - setPending(false) - startAuthorization.reset() - } - const handleOpenChange = (nextOpen: boolean) => { if (pending && !nextOpen) return onOpenChange(nextOpen) @@ -247,12 +261,12 @@ export function SlackManagedUsersModal({ {isLoading ? ( -
+
- +
) : noBots ? ( -

+

Add a custom Slack app from Integrations before adding Slack to this group.

) : ( diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index d45dbed396d..413a51945e5 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -29,7 +29,7 @@ export function useCredentialGroups(workspaceId?: string) { return fetchCredentialGroupList(workspaceId, signal) }, enabled: Boolean(workspaceId), - staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -49,7 +49,10 @@ export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) getNextPageParam: (lastPage: ContractJsonResponse) => lastPage.nextCursor ?? undefined, enabled: Boolean(workspaceId && groupId), - staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + // An infinite staleTime never goes stale, so the app-wide `retryOnMount: false` + // would cache one transient failure for the life of the QueryClient. + retryOnMount: true, }) } @@ -78,6 +81,9 @@ export function useDeleteCredentialGroup() { }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.removeQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) }, }) } From 522b3f5d8f76220b18d03a6397606556614bdaaa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 20:15:19 -0700 Subject: [PATCH 2/3] improvement(credential-groups): hold first paint for a deep-linked group Matches the data-drains list: a deep link whose id is still resolving no longer flashes the list chrome before jumping to the detail. Keys the detail by group id so lifted draft state can never carry across groups. --- .../components/credential-groups-settings.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx index db366c8826a..f6d81bf954f 100644 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -75,9 +75,17 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] + /** + * Hold the first paint while a deep-linked id could still resolve, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedGroupId !== null && isPending) return null + if (selectedGroup) { return ( Date: Fri, 14 Aug 2026 20:22:02 -0700 Subject: [PATCH 3/3] fix(credential-groups): await the refetch before clearing the edit buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update mutation fired its invalidations without returning them, so mutateAsync resolved before the refetch landed. Callers that clear their draft on success then fell back onto the pre-save cache and flashed the old name and description until the refetch completed — or kept showing them if it failed. --- apps/sim/hooks/queries/credential-groups.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 413a51945e5..c231296b2cc 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -104,12 +104,18 @@ export function useUpdateCredentialGroup() { params: { id: workspaceId, groupId }, body, }), - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }) - }, + // Returned so `mutateAsync` resolves only once the refetch has landed. Callers + // clear their edit buffer on success, which would otherwise fall back onto the + // pre-save cache and flash the old values. + onSettled: (_data, _error, variables) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(variables.workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }), + ]), }) }