diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8384bfe6cc..2ee7dc093c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -222,8 +222,8 @@ importers: specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.71.2(react@19.2.4)) '@raystack/proton': - specifier: 0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60 - version: 0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 0.1.0-94668c362cad03165629c14c7b1abf753e885496 + version: 0.1.0-94668c362cad03165629c14c7b1abf753e885496(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.21(react@19.2.4) @@ -1595,8 +1595,8 @@ packages: '@tanstack/react-query': optional: true - '@raystack/proton@0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60': - resolution: {integrity: sha512-KRHFs7XiBEyoihmpVLxdWMwOOsqT3PBN7usRpXTicBUVkE6YwSemS48IC1g1irpi9iNDhVSXBOXhCN9DXvYp7Q==} + '@raystack/proton@0.1.0-94668c362cad03165629c14c7b1abf753e885496': + resolution: {integrity: sha512-C1dqPzOnF0Pb1Pyx5kKUPFtclNFravCNVM1kpfvgk2dUgkNlYd7kDCQpma0LOhs/gkddt2usu+U4qRjhykgyQQ==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: @@ -8090,7 +8090,7 @@ snapshots: - react - react-dom - '@raystack/proton@0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@raystack/proton@0.1.0-94668c362cad03165629c14c7b1abf753e885496(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@bufbuild/protobuf': 2.11.0 '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css new file mode 100644 index 0000000000..dde5c86382 --- /dev/null +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css @@ -0,0 +1,9 @@ +.content { + width: 480px !important; +} + +.list { + margin: 0; + padding-left: var(--rs-space-5); + list-style-type: disc; +} diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx new file mode 100644 index 0000000000..89781913b6 --- /dev/null +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import { AlertDialog, Button, Flex, Text } from '@raystack/apsara'; +import type { + DeleteBlocker, + DeleteBlockedContext, + DeleteBlockedPayload +} from '../../../shared/types'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { useTerminology } from '../../hooks/useTerminology'; +import { blockerLines } from '../../utils/delete-blockers'; +import styles from './delete-blocked-dialog.module.css'; + +export type DeleteBlockedDialogHandle = ReturnType< + typeof AlertDialog.createHandle +>; + +const DEFAULT_COPY: Record< + DeleteBlockedPayload['entity'], + (entityLabel: string) => { title: string; description: string } +> = { + organization: entityLabel => ({ + title: `Unable to delete ${entityLabel}`, + description: `Please resolve the following issues before deleting this ${entityLabel}:` + }) +}; + +const DeleteBlockedDialogContext = createContext( + null +); + +export interface DeleteBlockedDialogRootProps { + handle: DeleteBlockedDialogHandle; +} + +export function DeleteBlockedDialogRoot({ + handle +}: DeleteBlockedDialogRootProps) { + return ( + + {({ payload }) => + payload ? ( + handle.close()} + /> + ) : null + } + + ); +} + +function DeleteBlockedContent({ + payload, + close +}: { + payload: DeleteBlockedPayload; + close: () => void; +}) { + const t = useTerminology(); + const { slots } = useFrontier(); + const context: DeleteBlockedContext = { + ...payload, + entityLabel: t[payload.entity]({ case: 'lower' }), + close + }; + const body = slots.deleteBlockedDialog?.(context); + const isEmpty = body == null || typeof body === 'boolean'; + return ( + + {isEmpty ? : body} + + ); +} + +export interface DeleteBlockedDialogProps { + title?: string; + description?: string; + blockers?: DeleteBlocker[]; + messages?: Record; +} + +export function DeleteBlockedDialog({ + title, + description, + blockers, + messages = {} +}: DeleteBlockedDialogProps) { + const context = useContext(DeleteBlockedDialogContext); + const t = useTerminology(); + const { basePlan } = useFrontier(); + if (!context) { + throw new Error( + 'DeleteBlockedDialog must be returned from the deleteBlockedDialog slot on FrontierProvider.' + ); + } + + const copy = DEFAULT_COPY[context.entity](context.entityLabel); + const lines = blockerLines(blockers ?? context.blockers, messages, { + organizationLabel: t.organization({ case: 'lower' }), + basePlanTitle: basePlan?.title + }); + + return ( + + + {title ?? copy.title} + + + + + {description ?? copy.description} + + {lines.length > 0 && ( + } + > + {lines.map(line => ( +
  • {line}
  • + ))} +
    + )} +
    +
    + + + Close + + } + /> + +
    + ); +} diff --git a/web/sdk/client/components/delete-blocked-dialog/index.ts b/web/sdk/client/components/delete-blocked-dialog/index.ts new file mode 100644 index 0000000000..741193bd62 --- /dev/null +++ b/web/sdk/client/components/delete-blocked-dialog/index.ts @@ -0,0 +1,8 @@ +export { + DeleteBlockedDialog, + DeleteBlockedDialogRoot +} from './delete-blocked-dialog'; +export type { + DeleteBlockedDialogProps, + DeleteBlockedDialogHandle +} from './delete-blocked-dialog'; diff --git a/web/sdk/client/contexts/FrontierContext.tsx b/web/sdk/client/contexts/FrontierContext.tsx index 6574e71c44..9b9a0293d6 100644 --- a/web/sdk/client/contexts/FrontierContext.tsx +++ b/web/sdk/client/contexts/FrontierContext.tsx @@ -12,7 +12,8 @@ import { FrontierServiceQueries } from '~hooks'; import { FrontierClientOptions, - FrontierProviderProps + FrontierProviderProps, + FrontierProviderSlots } from '../../shared/types'; import { @@ -47,6 +48,8 @@ import { useLastActiveTracker } from '../hooks/useLastActiveTracker'; interface FrontierContextProviderProps { config: FrontierClientOptions; + slots: FrontierProviderSlots; + organizations: Organization[]; groups: Group[]; @@ -123,6 +126,8 @@ const defaultConfig: FrontierClientOptions = { const initialValues: FrontierContextProviderProps = { config: defaultConfig, + slots: {}, + organizations: [], groups: [], @@ -175,7 +180,8 @@ FrontierContext.displayName = 'FrontierContext '; export const FrontierContextProvider = ({ children, - config + config, + slots = {} }: FrontierProviderProps) => { const [activeOrganization, setActiveOrganization] = useState(); const [isActiveOrganizationLoading, setIsActiveOrganizationLoading] = @@ -322,6 +328,7 @@ export const FrontierContextProvider = ({ ...config, billing: { ...defaultConfig.billing, ...config.billing } }, + slots, organizations, groups, user, diff --git a/web/sdk/client/hooks/useDeleteCheck.ts b/web/sdk/client/hooks/useDeleteCheck.ts new file mode 100644 index 0000000000..4919a75ce5 --- /dev/null +++ b/web/sdk/client/hooks/useDeleteCheck.ts @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { AlertDialog, toastManager } from '@raystack/apsara'; +import type { Blocker } from '@raystack/proton/frontier'; +import type { DeleteBlocker, DeleteBlockedPayload } from '../../shared/types'; +import type { DeleteBlockedDialogHandle } from '../components/delete-blocked-dialog'; +import { + GENERIC_DELETE_BLOCKED_MESSAGE, + blockersFromError, + isOrganizationDisabled, + toDeleteBlockers +} from '../utils/delete-blockers'; +import { useTerminology } from './useTerminology'; +import { handleConnectError } from '~/utils/error'; + +export interface UseDeleteCheckOptions { + entity: DeleteBlockedPayload['entity']; + check: ( + payload: TPayload + ) => Promise<{ canDelete: boolean; blockers: Blocker[] }>; + confirmDialogHandle: ReturnType>; + blockedDialogHandle: DeleteBlockedDialogHandle; +} + +export function useDeleteCheck({ + entity, + check, + confirmDialogHandle, + blockedDialogHandle +}: UseDeleteCheckOptions) { + const t = useTerminology(); + const [isChecking, setIsChecking] = useState(false); + + function showBlocked(blockers: DeleteBlocker[]) { + confirmDialogHandle.close(); + blockedDialogHandle.openWithPayload({ entity, blockers }); + } + + function handleDeleteError(error: unknown) { + handleConnectError(error, { + FailedPrecondition: err => { + if (isOrganizationDisabled(err)) { + const org = t.organization({ case: 'lower' }); + toastManager.add({ + title: `Unable to delete ${t[entity]({ case: 'lower' })}`, + description: `This ${org} is disabled. Contact your administrator to enable it, then try again.`, + type: 'error' + }); + return; + } + const blockers = blockersFromError(err); + if (blockers.length > 0) { + showBlocked(blockers); + } else { + toastManager.add({ + title: `Unable to delete ${t[entity]({ case: 'lower' })}`, + description: GENERIC_DELETE_BLOCKED_MESSAGE, + type: 'error' + }); + } + }, + PermissionDenied: () => + toastManager.add({ + title: "You don't have permission to perform this action", + type: 'error' + }), + NotFound: () => + toastManager.add({ + title: 'Not found', + description: `This ${t[entity]({ case: 'lower' })} no longer exists.`, + type: 'error' + }), + Default: () => + toastManager.add({ + title: 'Something went wrong', + description: 'Please try again later or contact support.', + type: 'error' + }) + }); + } + + async function startDelete(payload: TPayload) { + setIsChecking(true); + try { + const { canDelete, blockers } = await check(payload); + if (canDelete) { + confirmDialogHandle.openWithPayload(payload); + } else { + showBlocked(toDeleteBlockers(blockers)); + } + } catch (error) { + handleDeleteError(error); + } finally { + setIsChecking(false); + } + } + + return { startDelete, isChecking, handleDeleteError }; +} diff --git a/web/sdk/client/hooks/useTokens.ts b/web/sdk/client/hooks/useTokens.ts index 2414d05773..8e253d2bbe 100644 --- a/web/sdk/client/hooks/useTokens.ts +++ b/web/sdk/client/hooks/useTokens.ts @@ -14,13 +14,7 @@ interface UseTokensReturn { fetchTokenBalance: () => Promise; } -export interface UseTokensOptions { - // Set this to false to skip fetching the balance. The delete dialog uses - // it so the balance is only fetched while the dialog is open. - enabled?: boolean; -} - -export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => { +export const useTokens = (): UseTokensReturn => { const { billingAccount } = useFrontier(); const { @@ -35,7 +29,7 @@ export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => { id: billingAccount?.id ?? '' }), { - enabled: !!billingAccount?.id && (options.enabled ?? true), + enabled: !!billingAccount?.id, retry: false } ); diff --git a/web/sdk/client/index.ts b/web/sdk/client/index.ts index 443fb2b056..778ced3715 100644 --- a/web/sdk/client/index.ts +++ b/web/sdk/client/index.ts @@ -3,6 +3,8 @@ export { ViewContainer } from './components/view-container'; export { ViewHeader } from './components/view-header'; export { AuthContainer } from './components/auth-container'; export { AuthHeader } from './components/auth-header'; +export { DeleteBlockedDialog } from './components/delete-blocked-dialog'; +export type { DeleteBlockedDialogProps } from './components/delete-blocked-dialog'; export { describeAuthError } from './utils/auth-error'; export type { AuthRejection } from './utils/auth-error'; @@ -44,7 +46,10 @@ export { usePreferences } from './hooks/usePreferences'; export type { FrontierClientOptions, FrontierClientBillingOptions, - FrontierClientCustomizationOptions + FrontierClientCustomizationOptions, + FrontierProviderSlots, + DeleteBlocker, + DeleteBlockedContext } from '../shared/types'; export { PREFERENCE_OPTIONS } from './utils/constants'; diff --git a/web/sdk/client/utils/delete-blockers.test.ts b/web/sdk/client/utils/delete-blockers.test.ts deleted file mode 100644 index 47cb2bb22f..0000000000 --- a/web/sdk/client/utils/delete-blockers.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Code, ConnectError } from '@connectrpc/connect'; -import { - GENERIC_DELETE_BLOCKED_MESSAGE, - deleteBlockedDescription, - instructionLines -} from './delete-blockers'; - -const wording = { organizationLabel: 'workspace', basePlanTitle: 'Standard Plan' }; - -describe('instructionLines', () => { - it('writes one full sentence per kind of blocker', () => { - const lines = instructionLines( - [ - { type: 'ACTIVE_SUBSCRIPTION' }, - { type: 'UNPAID_INVOICE' }, - { type: 'NEGATIVE_TOKEN_BALANCE' } - ], - wording - ); - expect(lines).toEqual([ - 'Downgrade your current subscription and switch to the Standard Plan before deleting this workspace.', - 'Please pay your outstanding invoice before deleting this workspace.', - 'Please purchase enough tokens to clear your outstanding token balance before deleting this workspace.' - ]); - }); - - it('counts blockers of the same kind', () => { - const lines = instructionLines( - [{ type: 'UNPAID_INVOICE' }, { type: 'UNPAID_INVOICE' }], - wording - ); - expect(lines).toEqual([ - 'Please pay your 2 outstanding invoices before deleting this workspace.' - ]); - }); - - it('falls back to plain words when the host app set no terminology', () => { - const lines = instructionLines([{ type: 'ACTIVE_SUBSCRIPTION' }]); - expect(lines).toEqual([ - 'Downgrade your current subscription and switch to the standard plan before deleting this organization.' - ]); - }); - - it('shows the generic message once for unknown kinds', () => { - const lines = instructionLines( - [{ type: 'SOMETHING_NEW' }, { type: 'SOMETHING_ELSE' }], - wording - ); - expect(lines).toEqual([GENERIC_DELETE_BLOCKED_MESSAGE]); - }); -}); - -describe('deleteBlockedDescription', () => { - it('reads the blockers out of the precondition failure detail', () => { - const err = new ConnectError('blocked', Code.FailedPrecondition); - err.details.push({ - type: 'google.rpc.PreconditionFailure', - value: new Uint8Array(), - debug: { - violations: [ - { type: 'ACTIVE_SUBSCRIPTION', subject: 'sub-1' }, - { type: 'NEGATIVE_TOKEN_BALANCE', subject: 'acct-1' } - ] - } - }); - expect(deleteBlockedDescription(err, wording)).toBe( - 'Downgrade your current subscription and switch to the Standard Plan before deleting this workspace. ' + - 'Please purchase enough tokens to clear your outstanding token balance before deleting this workspace.' - ); - }); - - it('returns the generic message when the error carries no blockers', () => { - const err = new ConnectError('blocked', Code.FailedPrecondition); - expect(deleteBlockedDescription(err, wording)).toBe( - GENERIC_DELETE_BLOCKED_MESSAGE - ); - }); -}); diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index dd5b658988..1adff5d34e 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -1,4 +1,9 @@ import { ConnectError } from '@connectrpc/connect'; +import type { Blocker } from '@raystack/proton/frontier'; +import type { DeleteBlocker } from '../../shared/types'; + +export const GENERIC_DELETE_BLOCKED_MESSAGE = + 'Something is blocking the delete right now. Please try again later or contact support.'; // Words that come from the host app: what it calls an organization // and what it calls its base plan. @@ -7,87 +12,75 @@ export interface BlockerWording { basePlanTitle?: string; } -const DEFAULT_WORDING: BlockerWording = { - organizationLabel: 'organization' -}; - -// One sentence per blocker type the server can return. -const BLOCKER_INSTRUCTIONS: Record< - string, - (count: number, wording: BlockerWording) => string -> = { - ACTIVE_SUBSCRIPTION: (_, { organizationLabel, basePlanTitle }) => - `Downgrade your current subscription and switch to the ${ +const DEFAULT_LINES: Record string> = { + ACTIVE_SUBSCRIPTION: ({ organizationLabel, basePlanTitle }) => + `Active subscription: Downgrade your subscription to the ${ basePlanTitle || 'standard plan' } before deleting this ${organizationLabel}.`, - UNPAID_INVOICE: (count, { organizationLabel }) => - count > 1 - ? `Please pay your ${count} outstanding invoices before deleting this ${organizationLabel}.` - : `Please pay your outstanding invoice before deleting this ${organizationLabel}.`, - NEGATIVE_TOKEN_BALANCE: (_, { organizationLabel }) => - `Please purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.` + UNPAID_INVOICE: ({ organizationLabel }) => + `Outstanding invoice: Pay all outstanding invoices before deleting this ${organizationLabel}.`, + NEGATIVE_TOKEN_BALANCE: ({ organizationLabel }) => + `Negative token balance: Purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.`, + RESOURCES_EXIST: () => + 'Existing resources: Delete the remaining resources, then try again.' }; -// Used for blocker types this version does not know. -export const GENERIC_DELETE_BLOCKED_MESSAGE = - 'Something is blocking the delete right now. Please try again later or contact support.'; - -// Returns one sentence per blocker type. Same-type blockers are counted. -export function instructionLines( - blockers: { type: string }[], - wording: BlockerWording = DEFAULT_WORDING +export function blockerLines( + blockers: DeleteBlocker[], + messages: Record, + wording: BlockerWording ): string[] { - const counts = new Map(); - for (const blocker of blockers) { - counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1); - } - const lines: string[] = []; + const lines = new Set(); let hasUnknown = false; - for (const [type, count] of counts) { - const instruction = BLOCKER_INSTRUCTIONS[type]; - if (instruction) { - lines.push(instruction(count, wording)); + for (const { type, subject } of blockers) { + const line = + messages[subject] ?? messages[type] ?? DEFAULT_LINES[type]?.(wording); + if (line) { + lines.add(line); } else { hasUnknown = true; } } if (hasUnknown) { - lines.push(GENERIC_DELETE_BLOCKED_MESSAGE); + lines.add(GENERIC_DELETE_BLOCKED_MESSAGE); } - return lines; + return [...lines]; } -// Reads the blockers from a failed_precondition error and returns the -// sentences as one string. The server sends them as a -// google.rpc.PreconditionFailure detail, which Connect exposes in the -// detail's debug field. -export function deleteBlockedDescription( - err: ConnectError, - wording: BlockerWording = DEFAULT_WORDING -): string { - for (const detail of err.details) { - if (!('type' in detail) || detail.type !== 'google.rpc.PreconditionFailure') { - continue; - } - const debug = (detail as { debug?: unknown }).debug; - if (typeof debug !== 'object' || debug === null) { - continue; - } - const violations = (debug as { violations?: unknown }).violations; - if (!Array.isArray(violations)) { - continue; - } - const blockers = violations - .filter( - (violation): violation is { type: string } => - typeof violation === 'object' && - violation !== null && - typeof (violation as { type?: unknown }).type === 'string' - ); - const lines = instructionLines(blockers, wording); - if (lines.length > 0) { - return lines.join(' '); - } - } - return GENERIC_DELETE_BLOCKED_MESSAGE; +export function toDeleteBlockers(blockers: Blocker[]): DeleteBlocker[] { + return blockers.map(({ type, subject, count }) => ({ type, subject, count })); +} + +function preconditionFailures(err: ConnectError) { + return err.details.filter( + detail => + 'type' in detail && detail.type === 'google.rpc.PreconditionFailure' + ); +} + +export function isOrganizationDisabled(err: ConnectError): boolean { + return preconditionFailures(err).length === 0; +} + +export function blockersFromError(err: ConnectError): DeleteBlocker[] { + return preconditionFailures(err).flatMap(detail => { + const debug = (detail as { debug?: { violations?: unknown } }).debug; + const violations = debug?.violations; + return Array.isArray(violations) + ? violations.filter(isViolation).map(({ type, subject }) => ({ + type, + subject: subject ?? '' + })) + : []; + }); +} + +function isViolation( + violation: unknown +): violation is { type: string; subject?: string } { + return ( + typeof violation === 'object' && + violation !== null && + typeof (violation as { type?: unknown }).type === 'string' + ); } diff --git a/web/sdk/client/views/general/components/delete-organization-dialog.tsx b/web/sdk/client/views/general/components/delete-organization-dialog.tsx index 206adcef99..76761bdd55 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -11,6 +11,7 @@ import { import { AlertDialog, Button, + Callout, Checkbox, Field, Flex, @@ -21,8 +22,7 @@ import { import { useFrontier } from '../../../contexts/FrontierContext'; import { useTerminology } from '../../../hooks/useTerminology'; import { useTokens } from '../../../hooks/useTokens'; -import { deleteBlockedDescription } from '../../../utils/delete-blockers'; -import { handleConnectError } from '~/utils/error'; +import styles from '../general-view.module.css'; const deleteOrgSchema = yup .object({ @@ -31,25 +31,39 @@ const deleteOrgSchema = yup .required(); export interface DeleteOrganizationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + handle: ReturnType>; onDeleteSuccess?: () => void; + onDeleteError: (error: unknown) => void; } export const DeleteOrganizationDialog = ({ - open, - onOpenChange, - onDeleteSuccess -}: DeleteOrganizationDialogProps) => { + handle, + onDeleteSuccess, + onDeleteError +}: DeleteOrganizationDialogProps) => ( + + + { + handle.close(); + onDeleteSuccess?.(); + }} + onDeleteError={onDeleteError} + /> + + +); + +function DeleteOrganizationForm({ + onDeleteSuccess, + onDeleteError +}: Omit) { const t = useTerminology(); - const { activeOrganization: organization, basePlan } = useFrontier(); + const { activeOrganization: organization } = useFrontier(); const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); const [isAcknowledged, setIsAcknowledged] = useState(false); - // The balance is fetched only while the dialog is open. The confirm - // button below stays disabled until the fetch finishes, so the user - // cannot confirm before the token warning had a chance to appear. - const { tokenBalance, isTokensFetching } = useTokens({ enabled: open }); + const { tokenBalance, isTokensFetching } = useTokens(); const { mutateAsync: deleteOrganization } = useMutation( FrontierServiceQueries.deleteOrganization @@ -87,102 +101,78 @@ export const DeleteOrganizationDialog = ({ }); onDeleteSuccess?.(); } catch (error) { - handleConnectError(error, { - PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }), - FailedPrecondition: (err) => - toastManager.add({ - title: `Cannot delete this ${orgLabelLower} yet`, - description: deleteBlockedDescription(err, { - organizationLabel: orgLabelLower, - basePlanTitle: basePlan?.title - }), - type: 'error' - }), - NotFound: () => toastManager.add({ title: 'Not found', description: `This ${orgLabelLower} no longer exists.`, type: 'error' }), - Default: () => toastManager.add({ title: 'Something went wrong', description: 'Please try again later or contact support.', type: 'error' }), - }); + onDeleteError(error); } } return ( - - -
    - - Delete {orgLabel} - - - + + + Delete {orgLabel} + + + + + + This action cannot be undone. All projects and resources in{' '} + {organization?.title} will be permanently deleted. + + + + + + + setIsAcknowledged(checked) + } + data-test-id="frontier-sdk-delete-organization-checkbox" + /> - This action can not be undone. This will permanently - delete all the projects and resources in {organization?.title}. + I understand that all data and resources in this {orgLabelLower}{' '} + will be permanently deleted, and I want to proceed. - {tokenBalance > 0 ? ( - - This {orgLabelLower} still has unused tokens, and deleting - it forfeits them. If any of them were purchased, our - support team will reach out to you to settle them. - - ) : null} - - - - - setIsAcknowledged(checked)} - data-test-id="frontier-sdk-delete-organization-checkbox" - /> - - I acknowledge I understand that all of the{' '} - {orgLabel} data will be deleted and want to proceed. - - - - - - Cancel - - } - /> + + {tokenBalance > 0 ? ( + + Any unused complimentary tokens will be forfeited. Purchased but + unused tokens will be refunded to your original payment method + within 15–30 days after {orgLabelLower} deletion. + + ) : null} + + + + - Delete + Cancel - -
    -
    -
    + } + /> + + + ); -}; +} diff --git a/web/sdk/client/views/general/general-view.module.css b/web/sdk/client/views/general/general-view.module.css index 7a62f2d2d9..e38a7e71e9 100644 --- a/web/sdk/client/views/general/general-view.module.css +++ b/web/sdk/client/views/general/general-view.module.css @@ -14,3 +14,16 @@ .fitContent { width: fit-content; } + +.deleteDialog { + width: 480px !important; +} + +.callout { + width: 100%; + box-sizing: border-box; +} + +.callout > :first-child > :last-child:empty { + display: none; +} diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index b6d6b4eefc..8deb293659 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -8,7 +8,6 @@ import { create } from '@bufbuild/protobuf'; import { createConnectQueryKey, useMutation, - useQuery, useTransport } from '@connectrpc/connect-query'; import { useQueryClient } from '@tanstack/react-query'; @@ -18,6 +17,7 @@ import { CheckOrganizationDeleteRequestSchema } from '@raystack/proton/frontier'; import { + AlertDialog, Button, Tooltip, Skeleton, @@ -30,12 +30,14 @@ import { import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; -import { instructionLines } from '../../utils/delete-blockers'; +import { useDeleteCheck } from '../../hooks/useDeleteCheck'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { AuthTooltipMessage } from '../../utils'; import { ViewContainer } from '../../components/view-container'; import { ViewHeader } from '../../components/view-header'; import { ImageUpload } from '../../components/image-upload'; +import { DeleteBlockedDialogRoot } from '../../components/delete-blocked-dialog'; +import type { DeleteBlockedPayload } from '../../../shared/types'; import { DeleteOrganizationDialog } from './components/delete-organization-dialog'; import styles from './general-view.module.css'; import { handleConnectError } from '~/utils/error'; @@ -60,12 +62,21 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const { activeOrganization: organization, isActiveOrganizationLoading, - setActiveOrganization, - basePlan + setActiveOrganization } = useFrontier(); const queryClient = useQueryClient(); const transport = useTransport(); + // Created per mount: a module-level handle keeps a dialog open across + // remounts (https://github.com/mui/base-ui/issues/4951). + // TODO @rohanchkrabrty: This is fixed in Base UI v1.7.0. We can update this after Apsara updates + const [deleteOrganizationDialogHandle] = useState(() => + AlertDialog.createHandle() + ); + const [deleteBlockedDialogHandle] = useState(() => + AlertDialog.createHandle() + ); + const resource = `app/organization:${organization?.id}`; const listOfPermissionsToCheck = useMemo(() => { @@ -101,29 +112,24 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; - // Ask the server whether a delete would go through right now. While the - // answer is loading, the delete button stays disabled. If this check - // itself fails, the button stays enabled: the server still refuses a - // blocked delete when the user actually clicks. - const { data: deleteCheck, isLoading: isDeleteCheckLoading } = useQuery( - FrontierServiceQueries.checkOrganizationDelete, - create(CheckOrganizationDeleteRequestSchema, { - id: organization?.id ?? '' - }), - { - enabled: canDeleteWorkspace && !!organization?.id, - retry: false - } - ); - const isDeleteBlocked = !!deleteCheck && !deleteCheck.canDelete; - const blockerLines = useMemo( - () => - instructionLines(deleteCheck?.blockers ?? [], { - organizationLabel: t.organization({ case: 'lower' }), - basePlanTitle: basePlan?.title - }), - [deleteCheck, t, basePlan] + const { mutateAsync: checkOrganizationDelete } = useMutation( + FrontierServiceQueries.checkOrganizationDelete ); + const { + startDelete, + isChecking: isCheckingDelete, + handleDeleteError + } = useDeleteCheck({ + entity: 'organization', + check: () => + checkOrganizationDelete( + create(CheckOrganizationDeleteRequestSchema, { + id: organization?.id ?? '' + }) + ), + confirmDialogHandle: deleteOrganizationDialogHandle, + blockedDialogHandle: deleteBlockedDialogHandle + }); // Update organization form const { mutateAsync: updateOrganization } = useMutation( @@ -190,8 +196,6 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } } - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); @@ -308,34 +312,24 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } > - {!canDeleteWorkspace ? ( + {!canDeleteWorkspace && ( {AuthTooltipMessage} - ) : isDeleteBlocked ? ( - - - {blockerLines.map(line => ( - {line} - ))} - - - ) : null} + )} )} @@ -343,11 +337,14 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { {canDeleteWorkspace && ( - + <> + + + )} ); diff --git a/web/sdk/package.json b/web/sdk/package.json index 55bd1c5649..b953f9274e 100644 --- a/web/sdk/package.json +++ b/web/sdk/package.json @@ -101,7 +101,7 @@ "@connectrpc/connect-query": "2.1.1", "@connectrpc/connect-web": "2.1.1", "@hookform/resolvers": "^3.10.0", - "@raystack/proton": "0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60", + "@raystack/proton": "0.1.0-94668c362cad03165629c14c7b1abf753e885496", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.168.3", "axios": "^1.9.0", diff --git a/web/sdk/shared/types.ts b/web/sdk/shared/types.ts index b73af19579..e190512ada 100644 --- a/web/sdk/shared/types.ts +++ b/web/sdk/shared/types.ts @@ -66,6 +66,26 @@ export interface InitialState { export type CustomHeaderValue = string | (() => string); +export interface DeleteBlocker { + type: string; + subject: string; + count?: number; +} + +export type DeleteBlockedPayload = { + entity: 'organization'; + blockers: DeleteBlocker[]; +}; + +export type DeleteBlockedContext = DeleteBlockedPayload & { + entityLabel: string; + close: () => void; +}; + +export interface FrontierProviderSlots { + deleteBlockedDialog?: Slot; +} + export interface FrontierProviderProps { config: FrontierClientOptions; children: React.ReactNode; @@ -74,4 +94,5 @@ export interface FrontierProviderProps { theme?: ThemeProviderProps; renderToastProvider?: boolean; renderThemeProvider?: boolean; + slots?: FrontierProviderSlots; }