From 0ce8ded1e7c84c4f4b225ffb0b629f6fd2309fea Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 14 Aug 2026 20:26:26 -0700 Subject: [PATCH] improvement(credential-groups): align settings surface with the shared page patterns (#6727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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. * fix(credential-groups): await the refetch before clearing the edit buffer 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. --- .../components/credential-group-detail.tsx | 126 +++++++- .../components/credential-group-details.tsx | 276 ++++++++---------- .../credential-group-invite-modal.tsx | 7 +- .../components/credential-groups-settings.tsx | 90 +++--- .../components/slack-managed-users-modal.tsx | 126 ++++---- apps/sim/hooks/queries/credential-groups.ts | 28 +- 6 files changed, 376 insertions(+), 277 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 3bab5af6bc..672ed564a6 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 501f0a7b36..9af6a3da77 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,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] - const handleDelete = async () => { - if (!deletingGroupId) return - try { - await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) - setDeletingGroupId(null) - } catch { - return - } - } + /** + * 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 ( void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroup} /> ) } @@ -97,18 +111,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 +136,6 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin Disabled ) : undefined } - trailing={ - setDeletingGroupId(group.id), - }, - ]} - /> - } /> ) })} @@ -137,25 +145,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 a6bdef02c8..34d3052565 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,59 +94,6 @@ export function SlackManagedUsersModal({ const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) - 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() - } - return () => channel.close() - }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) - - useEffect( - () => () => { - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popup.current?.close() - }, - [] - ) - const reset = () => { popup.current?.close() popup.current = null @@ -161,6 +108,73 @@ export function SlackManagedUsersModal({ 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 + messageHandler.current(event.data) + } + return () => channel.close() + }, [open]) + + useEffect( + () => () => { + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popup.current?.close() + }, + [] + ) + 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 d45dbed396..c231296b2c 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), + }) }, }) } @@ -98,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), + }), + ]), }) }