From 574ad14a4f94b676db9cbce7b8d2230c90a45ca8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 22 Oct 2026 20:40:33 +0530 Subject: [PATCH 01/18] feat(sdk): check delete blockers on click and show them in a dialog The delete organization button no longer checks the blockers on page load and stays disabled with a tooltip. On click it runs checkOrganizationDelete with a loading state. If the delete is blocked, a dialog lists the blockers; otherwise the delete dialog opens. --- web/sdk/client/utils/delete-blockers.test.ts | 66 ++++++++++--- web/sdk/client/utils/delete-blockers.ts | 81 +++++++++------- .../delete-blockers-dialog.module.css | 9 ++ .../components/delete-blockers-dialog.tsx | 70 ++++++++++++++ web/sdk/client/views/general/general-view.tsx | 94 +++++++++---------- 5 files changed, 225 insertions(+), 95 deletions(-) create mode 100644 web/sdk/client/views/general/components/delete-blockers-dialog.module.css create mode 100644 web/sdk/client/views/general/components/delete-blockers-dialog.tsx diff --git a/web/sdk/client/utils/delete-blockers.test.ts b/web/sdk/client/utils/delete-blockers.test.ts index 47cb2bb22..c0f90af70 100644 --- a/web/sdk/client/utils/delete-blockers.test.ts +++ b/web/sdk/client/utils/delete-blockers.test.ts @@ -1,15 +1,16 @@ import { Code, ConnectError } from '@connectrpc/connect'; import { GENERIC_DELETE_BLOCKED_MESSAGE, + blockerItems, 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( +describe('blockerItems', () => { + it('gives a label and one full sentence per kind of blocker', () => { + const items = blockerItems( [ { type: 'ACTIVE_SUBSCRIPTION' }, { type: 'UNPAID_INVOICE' }, @@ -17,27 +18,64 @@ describe('instructionLines', () => { ], 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.' + expect(items).toEqual([ + { + label: 'Active subscription', + instruction: + 'Downgrade your subscription to the Standard Plan before deleting this workspace.' + }, + { + label: 'Outstanding invoice', + instruction: + 'Pay all outstanding invoices before deleting this workspace.' + }, + { + label: 'Negative token balance', + instruction: + 'Purchase enough tokens to clear your outstanding token balance before deleting this workspace.' + } ]); }); - it('counts blockers of the same kind', () => { - const lines = instructionLines( + it('shows blockers of the same kind once', () => { + const items = blockerItems( [{ type: 'UNPAID_INVOICE' }, { type: 'UNPAID_INVOICE' }], wording ); - expect(lines).toEqual([ - 'Please pay your 2 outstanding invoices before deleting this workspace.' + expect(items).toHaveLength(1); + }); + + it('adds the generic message once, with no label, for unknown kinds', () => { + const items = blockerItems( + [ + { type: 'UNPAID_INVOICE' }, + { type: 'SOMETHING_NEW' }, + { type: 'SOMETHING_ELSE' } + ], + wording + ); + expect(items).toEqual([ + { + label: 'Outstanding invoice', + instruction: + 'Pay all outstanding invoices before deleting this workspace.' + }, + { instruction: GENERIC_DELETE_BLOCKED_MESSAGE } ]); }); + it('gives the generic message when there are no blockers', () => { + expect(blockerItems([], wording)).toEqual([ + { instruction: GENERIC_DELETE_BLOCKED_MESSAGE } + ]); + }); +}); + +describe('instructionLines', () => { 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.' + 'Downgrade your subscription to the standard plan before deleting this organization.' ]); }); @@ -64,8 +102,8 @@ describe('deleteBlockedDescription', () => { } }); 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.' + 'Downgrade your subscription to the Standard Plan before deleting this workspace. ' + + 'Purchase enough tokens to clear your outstanding token balance before deleting this workspace.' ); }); diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index dd5b65898..b84aeab27 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -11,50 +11,70 @@ const DEFAULT_WORDING: BlockerWording = { organizationLabel: 'organization' }; -// One sentence per blocker type the server can return. -const BLOCKER_INSTRUCTIONS: Record< +// A short label and one sentence per blocker type the server can return. +const BLOCKERS: Record< string, - (count: number, wording: BlockerWording) => string + { label: string; instruction: (wording: BlockerWording) => string } > = { - ACTIVE_SUBSCRIPTION: (_, { organizationLabel, basePlanTitle }) => - `Downgrade your current subscription and switch 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}.` + ACTIVE_SUBSCRIPTION: { + label: 'Active subscription', + instruction: ({ organizationLabel, basePlanTitle }) => + `Downgrade your subscription to the ${ + basePlanTitle || 'standard plan' + } before deleting this ${organizationLabel}.` + }, + UNPAID_INVOICE: { + label: 'Outstanding invoice', + instruction: ({ organizationLabel }) => + `Pay all outstanding invoices before deleting this ${organizationLabel}.` + }, + NEGATIVE_TOKEN_BALANCE: { + label: 'Negative token balance', + instruction: ({ organizationLabel }) => + `Purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.` + } }; // 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( +export interface BlockerItem { + label?: string; + instruction: string; +} + +// Returns one item per blocker type. Unknown types share one generic +// item with no label. Empty blockers also give the generic item. +export function blockerItems( blockers: { type: string }[], wording: BlockerWording = DEFAULT_WORDING -): string[] { - const counts = new Map(); - for (const blocker of blockers) { - counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1); - } - const lines: string[] = []; +): BlockerItem[] { + const items: BlockerItem[] = []; let hasUnknown = false; - for (const [type, count] of counts) { - const instruction = BLOCKER_INSTRUCTIONS[type]; - if (instruction) { - lines.push(instruction(count, wording)); + for (const type of new Set(blockers.map(blocker => blocker.type))) { + const blocker = BLOCKERS[type]; + if (blocker) { + items.push({ + label: blocker.label, + instruction: blocker.instruction(wording) + }); } else { hasUnknown = true; } } - if (hasUnknown) { - lines.push(GENERIC_DELETE_BLOCKED_MESSAGE); + if (hasUnknown || items.length === 0) { + items.push({ instruction: GENERIC_DELETE_BLOCKED_MESSAGE }); } - return lines; + return items; +} + +// Returns one sentence per blocker type. +export function instructionLines( + blockers: { type: string }[], + wording: BlockerWording = DEFAULT_WORDING +): string[] { + return blockerItems(blockers, wording).map(item => item.instruction); } // Reads the blockers from a failed_precondition error and returns the @@ -84,10 +104,7 @@ export function deleteBlockedDescription( violation !== null && typeof (violation as { type?: unknown }).type === 'string' ); - const lines = instructionLines(blockers, wording); - if (lines.length > 0) { - return lines.join(' '); - } + return instructionLines(blockers, wording).join(' '); } return GENERIC_DELETE_BLOCKED_MESSAGE; } diff --git a/web/sdk/client/views/general/components/delete-blockers-dialog.module.css b/web/sdk/client/views/general/components/delete-blockers-dialog.module.css new file mode 100644 index 000000000..dde5c8638 --- /dev/null +++ b/web/sdk/client/views/general/components/delete-blockers-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/views/general/components/delete-blockers-dialog.tsx b/web/sdk/client/views/general/components/delete-blockers-dialog.tsx new file mode 100644 index 000000000..df7070238 --- /dev/null +++ b/web/sdk/client/views/general/components/delete-blockers-dialog.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { AlertDialog, Button, Flex, Text } from '@raystack/apsara'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useTerminology } from '../../../hooks/useTerminology'; +import { blockerItems } from '../../../utils/delete-blockers'; +import styles from './delete-blockers-dialog.module.css'; + +export type DeleteBlockersPayload = { blockers: { type: string }[] }; + +export interface DeleteBlockersDialogProps { + handle: ReturnType>; +} + +export function DeleteBlockersDialog({ handle }: DeleteBlockersDialogProps) { + const t = useTerminology(); + const { basePlan } = useFrontier(); + const orgLabelLower = t.organization({ case: 'lower' }); + + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as DeleteBlockersPayload | undefined; + const items = blockerItems(payload?.blockers ?? [], { + organizationLabel: orgLabelLower, + basePlanTitle: basePlan?.title + }); + return ( + + + Unable to delete {orgLabelLower} + + + + + Please resolve the following issues before deleting this{' '} + {orgLabelLower}: + + } + > + {items.map(({ label, instruction }) => ( +
  • + {label ? `${label}: ${instruction}` : instruction} +
  • + ))} +
    +
    +
    + + + Close + + } + /> + +
    + ); + }} +
    + ); +} diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index b6d6b4eef..ead2210bb 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,13 +30,16 @@ import { import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; -import { instructionLines } from '../../utils/delete-blockers'; 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 { DeleteOrganizationDialog } from './components/delete-organization-dialog'; +import { + DeleteBlockersDialog, + type DeleteBlockersPayload +} from './components/delete-blockers-dialog'; import styles from './general-view.module.css'; import { handleConnectError } from '~/utils/error'; @@ -50,6 +53,9 @@ const generalSchema = yup type FormData = yup.InferType; +const deleteBlockersDialogHandle = + AlertDialog.createHandle(); + export interface GeneralViewProps { onDeleteSuccess?: () => void; urlPrefix?: string; @@ -60,8 +66,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const { activeOrganization: organization, isActiveOrganizationLoading, - setActiveOrganization, - basePlan + setActiveOrganization } = useFrontier(); const queryClient = useQueryClient(); const transport = useTransport(); @@ -101,29 +106,10 @@ 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] - ); + // Asks the server on each click, so the answer is fresh after the user + // fixes a blocker somewhere else. + const { mutateAsync: checkOrganizationDelete, isPending: isCheckingDelete } = + useMutation(FrontierServiceQueries.checkOrganizationDelete); // Update organization form const { mutateAsync: updateOrganization } = useMutation( @@ -192,6 +178,23 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + async function onDeleteClick() { + if (!organization?.id) return; + try { + const { canDelete, blockers } = await checkOrganizationDelete( + create(CheckOrganizationDeleteRequestSchema, { id: organization.id }) + ); + if (!canDelete) { + deleteBlockersDialogHandle.openWithPayload({ blockers }); + return; + } + } catch { + // If the check itself fails, go on to the delete dialog: the server + // still refuses a blocked delete when the user confirms. + } + setShowDeleteDialog(true); + } + const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); @@ -308,34 +311,24 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } > - {!canDeleteWorkspace ? ( + {!canDeleteWorkspace && ( {AuthTooltipMessage} - ) : isDeleteBlocked ? ( - - - {blockerLines.map(line => ( - {line} - ))} - - - ) : null} + )} )} @@ -343,11 +336,14 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { {canDeleteWorkspace && ( - + <> + + + )} ); From 2ad36cce822c8dde962f1595dfa6476b7fe012b8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 22 Oct 2026 21:07:08 +0530 Subject: [PATCH 02/18] chore(sdk): update proton to 680ef693 Brings in CheckProjectDelete, CheckCurrentUserDelete, DeleteCurrentUser, the top-level Blocker message with count, and the org resource RPCs. --- web/pnpm-lock.yaml | 16 ++++++++-------- web/sdk/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8384bfe6c..4278b5664 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-680ef693ec1059e996f6a92e6936a56d1a8ac1cd + version: 0.1.0-680ef693ec1059e996f6a92e6936a56d1a8ac1cd(@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) @@ -1587,16 +1587,16 @@ packages: '@types/react': optional: true - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': - resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} + '@raystack/proton@0.1.0-680ef693ec1059e996f6a92e6936a56d1a8ac1cd': + resolution: {integrity: sha512-1+ImQj+EuRWRrYBA8oBafM+eSQv/DSVdJG8uFqLJGxiJjJint0FhVWANPRkXKPz4UpaPEsGEnbxkkFnK4jMXCg==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: '@tanstack/react-query': optional: true - '@raystack/proton@0.1.0-a9c52ee5b8a499de1e1ea042046b93c26f65de60': - resolution: {integrity: sha512-KRHFs7XiBEyoihmpVLxdWMwOOsqT3PBN7usRpXTicBUVkE6YwSemS48IC1g1irpi9iNDhVSXBOXhCN9DXvYp7Q==} + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': + resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: @@ -8076,7 +8076,7 @@ snapshots: - '@date-fns/tz' - date-fns - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@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-680ef693ec1059e996f6a92e6936a56d1a8ac1cd(@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) @@ -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-859ba765e6cfd44736ddcf42664b742fe7fd916e(@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/package.json b/web/sdk/package.json index 55bd1c564..2e7538931 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-680ef693ec1059e996f6a92e6936a56d1a8ac1cd", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.168.3", "axios": "^1.9.0", From dbbfb7937190a12909e9fd283e68ac6c10fab99d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 13:20:49 +0530 Subject: [PATCH 03/18] fix(sdk): open the delete dialog only when the delete check passes A disabled organization fails the check with FAILED_PRECONDITION, so it shows as a blocker in the same dialog. Any other check error shows a toast. The delete button keeps its label while the check runs. --- web/sdk/client/utils/delete-blockers.test.ts | 11 +++++++ web/sdk/client/utils/delete-blockers.ts | 25 ++++++++++++---- web/sdk/client/views/general/general-view.tsx | 29 +++++++++++++------ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/web/sdk/client/utils/delete-blockers.test.ts b/web/sdk/client/utils/delete-blockers.test.ts index c0f90af70..feddc5a24 100644 --- a/web/sdk/client/utils/delete-blockers.test.ts +++ b/web/sdk/client/utils/delete-blockers.test.ts @@ -1,6 +1,7 @@ import { Code, ConnectError } from '@connectrpc/connect'; import { GENERIC_DELETE_BLOCKED_MESSAGE, + ORGANIZATION_DISABLED, blockerItems, deleteBlockedDescription, instructionLines @@ -37,6 +38,16 @@ describe('blockerItems', () => { ]); }); + it('names the host app terminology for a disabled organization', () => { + expect(blockerItems([{ type: ORGANIZATION_DISABLED }], wording)).toEqual([ + { + label: 'Disabled workspace', + instruction: + 'Contact your administrator to enable this workspace before deleting it.' + } + ]); + }); + it('shows blockers of the same kind once', () => { const items = blockerItems( [{ type: 'UNPAID_INVOICE' }, { type: 'UNPAID_INVOICE' }], diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index b84aeab27..a33335d1c 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -11,25 +11,38 @@ const DEFAULT_WORDING: BlockerWording = { organizationLabel: 'organization' }; -// A short label and one sentence per blocker type the server can return. +// Not a server blocker type. The server refuses the check for a disabled +// organization with FAILED_PRECONDITION before it runs, and the caller turns +// that into this blocker so it shows with the others. +export const ORGANIZATION_DISABLED = 'ORGANIZATION_DISABLED'; + +// A short label and one sentence per blocker type. const BLOCKERS: Record< string, - { label: string; instruction: (wording: BlockerWording) => string } + { + label: (wording: BlockerWording) => string; + instruction: (wording: BlockerWording) => string; + } > = { + ORGANIZATION_DISABLED: { + label: ({ organizationLabel }) => `Disabled ${organizationLabel}`, + instruction: ({ organizationLabel }) => + `Contact your administrator to enable this ${organizationLabel} before deleting it.` + }, ACTIVE_SUBSCRIPTION: { - label: 'Active subscription', + label: () => 'Active subscription', instruction: ({ organizationLabel, basePlanTitle }) => `Downgrade your subscription to the ${ basePlanTitle || 'standard plan' } before deleting this ${organizationLabel}.` }, UNPAID_INVOICE: { - label: 'Outstanding invoice', + label: () => 'Outstanding invoice', instruction: ({ organizationLabel }) => `Pay all outstanding invoices before deleting this ${organizationLabel}.` }, NEGATIVE_TOKEN_BALANCE: { - label: 'Negative token balance', + label: () => 'Negative token balance', instruction: ({ organizationLabel }) => `Purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.` } @@ -56,7 +69,7 @@ export function blockerItems( const blocker = BLOCKERS[type]; if (blocker) { items.push({ - label: blocker.label, + label: blocker.label(wording), instruction: blocker.instruction(wording) }); } else { diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index ead2210bb..962db6ed8 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -30,6 +30,7 @@ import { import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; +import { ORGANIZATION_DISABLED } from '../../utils/delete-blockers'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { AuthTooltipMessage } from '../../utils'; import { ViewContainer } from '../../components/view-container'; @@ -180,17 +181,27 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { async function onDeleteClick() { if (!organization?.id) return; + let check; try { - const { canDelete, blockers } = await checkOrganizationDelete( + check = await checkOrganizationDelete( create(CheckOrganizationDeleteRequestSchema, { id: organization.id }) ); - if (!canDelete) { - deleteBlockersDialogHandle.openWithPayload({ blockers }); - return; - } - } catch { - // If the check itself fails, go on to the delete dialog: the server - // still refuses a blocked delete when the user confirms. + } catch (error) { + handleConnectError(error, { + // The check returns this only when the organization is disabled. + FailedPrecondition: () => + deleteBlockersDialogHandle.openWithPayload({ + blockers: [{ type: ORGANIZATION_DISABLED }] + }), + PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", 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' }), + }); + return; + } + if (!check.canDelete) { + deleteBlockersDialogHandle.openWithPayload({ blockers: check.blockers }); + return; } setShowDeleteDialog(true); } @@ -320,7 +331,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { onClick={onDeleteClick} disabled={!canDeleteWorkspace || isCheckingDelete} loading={isCheckingDelete} - loaderText="Checking..." + loaderText={`Delete ${orgLabelLower}`} data-test-id="frontier-sdk-delete-organization-btn" > Delete {orgLabelLower} From 47706036d60949ca17d15c848897d4f62859e75b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 13:38:33 +0530 Subject: [PATCH 04/18] refactor(sdk): open the delete organization dialog with a handle The form now mounts only while the dialog is open, so it starts empty on every open and fetches the token balance then, without an open prop. --- .../components/delete-organization-dialog.tsx | 177 +++++++++--------- web/sdk/client/views/general/general-view.tsx | 10 +- 2 files changed, 96 insertions(+), 91 deletions(-) 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 206adcef9..61d4f427f 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -31,25 +31,35 @@ const deleteOrgSchema = yup .required(); export interface DeleteOrganizationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + handle: ReturnType; onDeleteSuccess?: () => void; } export const DeleteOrganizationDialog = ({ - open, - onOpenChange, + handle, onDeleteSuccess -}: DeleteOrganizationDialogProps) => { +}: DeleteOrganizationDialogProps) => ( + + + + + +); + +// Mounted only while the dialog is open, so each open starts with an empty +// form and fetches the token balance again. +function DeleteOrganizationForm({ + onDeleteSuccess +}: Pick) { const t = useTerminology(); const { activeOrganization: organization, basePlan } = 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 }); + // The confirm button below stays disabled until the balance fetch + // finishes, so the user cannot confirm before the token warning had a + // chance to appear. + const { tokenBalance, isTokensFetching } = useTokens(); const { mutateAsync: deleteOrganization } = useMutation( FrontierServiceQueries.deleteOrganization @@ -104,85 +114,82 @@ export const DeleteOrganizationDialog = ({ } } + return ( - - -
    - - Delete {orgLabel} - - - - - This action can not be undone. This will permanently - delete all the projects and resources in {organization?.title}. - - {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 - - } + + + Delete {orgLabel} + + + + + This action can not be undone. This will permanently + delete all the projects and resources in {organization?.title}. + + {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. + + + + + + - Delete + Cancel - - -
    -
    + } + /> + + + ); -}; +} diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 962db6ed8..18d4d4b84 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import { useForm } from 'react-hook-form'; import * as yup from 'yup'; @@ -56,6 +56,7 @@ type FormData = yup.InferType; const deleteBlockersDialogHandle = AlertDialog.createHandle(); +const deleteOrganizationDialogHandle = AlertDialog.createHandle(); export interface GeneralViewProps { onDeleteSuccess?: () => void; @@ -177,8 +178,6 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } } - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - async function onDeleteClick() { if (!organization?.id) return; let check; @@ -203,7 +202,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { deleteBlockersDialogHandle.openWithPayload({ blockers: check.blockers }); return; } - setShowDeleteDialog(true); + deleteOrganizationDialogHandle.open(null); } const orgLabel = t.organization({ case: 'capital' }); @@ -350,8 +349,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { <> From 488d0ca93960b4635939a19c452121c180ec5d7f Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 13:39:20 +0530 Subject: [PATCH 05/18] chore(sdk): drop comments from the delete flow changes --- web/sdk/client/utils/delete-blockers.ts | 7 ------- .../general/components/delete-organization-dialog.tsx | 5 ----- web/sdk/client/views/general/general-view.tsx | 3 --- 3 files changed, 15 deletions(-) diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index a33335d1c..0d792fa19 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -11,12 +11,8 @@ const DEFAULT_WORDING: BlockerWording = { organizationLabel: 'organization' }; -// Not a server blocker type. The server refuses the check for a disabled -// organization with FAILED_PRECONDITION before it runs, and the caller turns -// that into this blocker so it shows with the others. export const ORGANIZATION_DISABLED = 'ORGANIZATION_DISABLED'; -// A short label and one sentence per blocker type. const BLOCKERS: Record< string, { @@ -57,8 +53,6 @@ export interface BlockerItem { instruction: string; } -// Returns one item per blocker type. Unknown types share one generic -// item with no label. Empty blockers also give the generic item. export function blockerItems( blockers: { type: string }[], wording: BlockerWording = DEFAULT_WORDING @@ -82,7 +76,6 @@ export function blockerItems( return items; } -// Returns one sentence per blocker type. export function instructionLines( blockers: { type: string }[], wording: BlockerWording = DEFAULT_WORDING 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 61d4f427f..6ca4eb2db 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -46,8 +46,6 @@ export const DeleteOrganizationDialog = ({ ); -// Mounted only while the dialog is open, so each open starts with an empty -// form and fetches the token balance again. function DeleteOrganizationForm({ onDeleteSuccess }: Pick) { @@ -56,9 +54,6 @@ function DeleteOrganizationForm({ const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); const [isAcknowledged, setIsAcknowledged] = useState(false); - // The confirm button below stays disabled until the balance fetch - // finishes, so the user cannot confirm before the token warning had a - // chance to appear. const { tokenBalance, isTokensFetching } = useTokens(); const { mutateAsync: deleteOrganization } = useMutation( diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 18d4d4b84..3b011f2e2 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -108,8 +108,6 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; - // Asks the server on each click, so the answer is fresh after the user - // fixes a blocker somewhere else. const { mutateAsync: checkOrganizationDelete, isPending: isCheckingDelete } = useMutation(FrontierServiceQueries.checkOrganizationDelete); @@ -187,7 +185,6 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { ); } catch (error) { handleConnectError(error, { - // The check returns this only when the organization is disabled. FailedPrecondition: () => deleteBlockersDialogHandle.openWithPayload({ blockers: [{ type: ORGANIZATION_DISABLED }] From 63d4fe4a46b310a74bf96f6736bf052acc49c6fa Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 3 Nov 2026 20:20:16 +0530 Subject: [PATCH 06/18] feat(sdk): let the host app word the blocked delete dialog FrontierProvider takes a slots prop. Its deleteBlockedDialog slot gets the blockers of a delete and returns a DeleteBlockedDialog with the host app's title, description and one line per resource namespace or blocker type. With no slot, or when the slot returns undefined, the SDK shows its own dialog. DeleteBlocker is the SDK's own shape for a blocker. A delete check and a refused delete both give it, without the server's developer message. The organization delete uses a shared useDeleteFlow hook: check on click, then open the confirmation or the blocked dialog. A refused delete now closes the confirmation and shows the blocked dialog instead of a toast. A failed_precondition with no violations means a disabled organization. Bumps @raystack/proton to 94668c36, the commit the server builds from. --- web/pnpm-lock.yaml | 16 +- .../delete-blocked-dialog.module.css} | 0 .../delete-blocked-dialog.tsx | 158 ++++++++++++++++ .../components/delete-blocked-dialog/index.ts | 9 + web/sdk/client/contexts/FrontierContext.tsx | 11 +- web/sdk/client/hooks/useDeleteFlow.ts | 65 +++++++ web/sdk/client/index.ts | 7 +- web/sdk/client/utils/delete-blockers.test.ts | 159 ++++++++-------- web/sdk/client/utils/delete-blockers.ts | 170 ++++++++---------- .../components/delete-blockers-dialog.tsx | 70 -------- .../components/delete-organization-dialog.tsx | 41 ++--- web/sdk/client/views/general/general-view.tsx | 67 +++---- web/sdk/package.json | 2 +- web/sdk/shared/types.ts | 35 ++++ 14 files changed, 496 insertions(+), 314 deletions(-) rename web/sdk/client/{views/general/components/delete-blockers-dialog.module.css => components/delete-blocked-dialog/delete-blocked-dialog.module.css} (100%) create mode 100644 web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx create mode 100644 web/sdk/client/components/delete-blocked-dialog/index.ts create mode 100644 web/sdk/client/hooks/useDeleteFlow.ts delete mode 100644 web/sdk/client/views/general/components/delete-blockers-dialog.tsx diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4278b5664..2ee7dc093 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-680ef693ec1059e996f6a92e6936a56d1a8ac1cd - version: 0.1.0-680ef693ec1059e996f6a92e6936a56d1a8ac1cd(@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) @@ -1587,16 +1587,16 @@ packages: '@types/react': optional: true - '@raystack/proton@0.1.0-680ef693ec1059e996f6a92e6936a56d1a8ac1cd': - resolution: {integrity: sha512-1+ImQj+EuRWRrYBA8oBafM+eSQv/DSVdJG8uFqLJGxiJjJint0FhVWANPRkXKPz4UpaPEsGEnbxkkFnK4jMXCg==} + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': + resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: '@tanstack/react-query': optional: true - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': - resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} + '@raystack/proton@0.1.0-94668c362cad03165629c14c7b1abf753e885496': + resolution: {integrity: sha512-C1dqPzOnF0Pb1Pyx5kKUPFtclNFravCNVM1kpfvgk2dUgkNlYd7kDCQpma0LOhs/gkddt2usu+U4qRjhykgyQQ==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: @@ -8076,7 +8076,7 @@ snapshots: - '@date-fns/tz' - date-fns - '@raystack/proton@0.1.0-680ef693ec1059e996f6a92e6936a56d1a8ac1cd(@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-859ba765e6cfd44736ddcf42664b742fe7fd916e(@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) @@ -8090,7 +8090,7 @@ snapshots: - react - react-dom - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@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/views/general/components/delete-blockers-dialog.module.css b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css similarity index 100% rename from web/sdk/client/views/general/components/delete-blockers-dialog.module.css rename to web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css 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 000000000..b56938857 --- /dev/null +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -0,0 +1,158 @@ +'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 { DeleteBlockedPayload }; + +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 OpenDeleteBlockedContext = createContext( + null +); + +export interface DeleteBlockedDialogHostProps { + handle: DeleteBlockedDialogHandle; +} + +// The dialog the SDK opens when a delete is blocked. Its body comes from the +// deleteBlockedDialog slot on FrontierProvider, or DeleteBlockedDialog. +export function DeleteBlockedDialogHost({ handle }: DeleteBlockedDialogHostProps) { + return ( + + {({ payload }) => + payload ? ( + + handle.close()} + /> + + ) : null + } + + ); +} + +// Calls the slot inside a component of its own, so the slot can use hooks. +function DeleteBlockedBody({ + 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); + // A slot that renders nothing would leave an empty modal with no Close. + const isEmpty = body == null || typeof body === 'boolean'; + return ( + + {isEmpty ? : body} + + ); +} + +export interface DeleteBlockedDialogProps { + /** Default: the SDK title for the entity. */ + title?: string; + /** Default: the SDK intro for the entity. */ + description?: string; + /** Default: the open dialog's blockers. Pass `[]` for no bullets. */ + blockers?: DeleteBlocker[]; + /** One line per resource namespace or blocker type. Missing keys use the SDK line. */ + messages?: Record; +} + +/** + * The body of the dialog that says why a delete is blocked. Return it from + * the `deleteBlockedDialog` slot on FrontierProvider. It renders only inside + * the dialog the SDK has open. + */ +export function DeleteBlockedDialog({ + title, + description, + blockers, + messages = {} +}: DeleteBlockedDialogProps) { + const context = useContext(OpenDeleteBlockedContext); + 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 000000000..44b765605 --- /dev/null +++ b/web/sdk/client/components/delete-blocked-dialog/index.ts @@ -0,0 +1,9 @@ +export { + DeleteBlockedDialog, + DeleteBlockedDialogHost +} from './delete-blocked-dialog'; +export type { + DeleteBlockedDialogProps, + DeleteBlockedDialogHandle, + DeleteBlockedPayload +} from './delete-blocked-dialog'; diff --git a/web/sdk/client/contexts/FrontierContext.tsx b/web/sdk/client/contexts/FrontierContext.tsx index 6574e71c4..9b9a0293d 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/useDeleteFlow.ts b/web/sdk/client/hooks/useDeleteFlow.ts new file mode 100644 index 000000000..49bfe57d2 --- /dev/null +++ b/web/sdk/client/hooks/useDeleteFlow.ts @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { AlertDialog, toastManager } from '@raystack/apsara'; +import type { DeleteBlocker } from '../../shared/types'; +import type { + DeleteBlockedDialogHandle, + DeleteBlockedPayload +} from '../components/delete-blocked-dialog'; +import { blockersFromError, toDeleteBlockers } from '../utils/delete-blockers'; +import { useTerminology } from './useTerminology'; +import { handleConnectError } from '~/utils/error'; + +export interface UseDeleteFlowOptions { + entity: DeleteBlockedPayload['entity']; + check: ( + payload: TPayload + ) => Promise<{ canDelete: boolean; blockers: DeleteBlocker[] }>; + confirmDialogHandle: ReturnType>; + blockedDialogHandle: DeleteBlockedDialogHandle; +} + +// Runs a delete in two dialogs. startDelete checks first, then opens the +// confirmation, or the blocked dialog when something blocks the delete. +// Pass handleDeleteError the error of the delete itself: a refused delete +// closes the confirmation and opens the blocked dialog. +export function useDeleteFlow({ + entity, + check, + confirmDialogHandle, + blockedDialogHandle +}: UseDeleteFlowOptions) { + 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 => showBlocked(blockersFromError(err)), + 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/index.ts b/web/sdk/client/index.ts index 443fb2b05..778ced371 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 index feddc5a24..471e53310 100644 --- a/web/sdk/client/utils/delete-blockers.test.ts +++ b/web/sdk/client/utils/delete-blockers.test.ts @@ -2,104 +2,93 @@ import { Code, ConnectError } from '@connectrpc/connect'; import { GENERIC_DELETE_BLOCKED_MESSAGE, ORGANIZATION_DISABLED, - blockerItems, - deleteBlockedDescription, - instructionLines + UNKNOWN_BLOCKER, + blockerLines, + blockersFromError } from './delete-blockers'; const wording = { organizationLabel: 'workspace', basePlanTitle: 'Standard Plan' }; -describe('blockerItems', () => { - it('gives a label and one full sentence per kind of blocker', () => { - const items = blockerItems( +const blocker = (type: string, subject = '') => ({ type, subject, count: 0 }); + +describe('blockerLines', () => { + it('writes the SDK line for each known type', () => { + const lines = blockerLines( [ - { type: 'ACTIVE_SUBSCRIPTION' }, - { type: 'UNPAID_INVOICE' }, - { type: 'NEGATIVE_TOKEN_BALANCE' } + blocker('ACTIVE_SUBSCRIPTION'), + blocker('UNPAID_INVOICE'), + blocker('NEGATIVE_TOKEN_BALANCE'), + blocker(ORGANIZATION_DISABLED) ], + {}, wording ); - expect(items).toEqual([ - { - label: 'Active subscription', - instruction: - 'Downgrade your subscription to the Standard Plan before deleting this workspace.' - }, - { - label: 'Outstanding invoice', - instruction: - 'Pay all outstanding invoices before deleting this workspace.' - }, - { - label: 'Negative token balance', - instruction: - 'Purchase enough tokens to clear your outstanding token balance before deleting this workspace.' - } + expect(lines).toEqual([ + 'Active subscription: Downgrade your subscription to the Standard Plan before deleting this workspace.', + 'Outstanding invoice: Pay all outstanding invoices before deleting this workspace.', + 'Negative token balance: Purchase enough tokens to clear your outstanding token balance before deleting this workspace.', + 'Disabled workspace: Contact your administrator to enable this workspace before deleting it.' ]); }); - it('names the host app terminology for a disabled organization', () => { - expect(blockerItems([{ type: ORGANIZATION_DISABLED }], wording)).toEqual([ - { - label: 'Disabled workspace', - instruction: - 'Contact your administrator to enable this workspace before deleting it.' - } + it('falls back to plain words when the host app has no base plan', () => { + expect( + blockerLines([blocker('ACTIVE_SUBSCRIPTION')], {}, { + organizationLabel: 'organization' + }) + ).toEqual([ + 'Active subscription: Downgrade your subscription to the standard plan before deleting this organization.' ]); }); - it('shows blockers of the same kind once', () => { - const items = blockerItems( - [{ type: 'UNPAID_INVOICE' }, { type: 'UNPAID_INVOICE' }], - wording - ); - expect(items).toHaveLength(1); - }); - - it('adds the generic message once, with no label, for unknown kinds', () => { - const items = blockerItems( + it('prefers a message for the subject, then for the type', () => { + const lines = blockerLines( [ - { type: 'UNPAID_INVOICE' }, - { type: 'SOMETHING_NEW' }, - { type: 'SOMETHING_ELSE' } + blocker('RESOURCES_EXIST', 'app/thing'), + blocker('RESOURCES_EXIST', 'app/other'), + blocker('UNPAID_INVOICE', 'invoice-1') ], - wording - ); - expect(items).toEqual([ { - label: 'Outstanding invoice', - instruction: - 'Pay all outstanding invoices before deleting this workspace.' + 'app/thing': 'Things exist.', + RESOURCES_EXIST: 'Resources exist.', + UNPAID_INVOICE: 'Pay up.' }, - { instruction: GENERIC_DELETE_BLOCKED_MESSAGE } - ]); + wording + ); + expect(lines).toEqual(['Things exist.', 'Resources exist.', 'Pay up.']); }); - it('gives the generic message when there are no blockers', () => { - expect(blockerItems([], wording)).toEqual([ - { instruction: GENERIC_DELETE_BLOCKED_MESSAGE } - ]); + it('shows identical lines once', () => { + const lines = blockerLines( + [blocker('UNPAID_INVOICE', 'invoice-1'), blocker('UNPAID_INVOICE', 'invoice-2')], + {}, + wording + ); + expect(lines).toHaveLength(1); }); -}); -describe('instructionLines', () => { - it('falls back to plain words when the host app set no terminology', () => { - const lines = instructionLines([{ type: 'ACTIVE_SUBSCRIPTION' }]); + it('adds the generic line once, last, for blockers with no line', () => { + const lines = blockerLines( + [ + blocker('RESOURCES_EXIST', 'app/thing'), + blocker('UNPAID_INVOICE'), + blocker('SOMETHING_NEW') + ], + {}, + wording + ); expect(lines).toEqual([ - 'Downgrade your subscription to the standard plan before deleting this organization.' + 'Outstanding invoice: Pay all outstanding invoices before deleting this workspace.', + GENERIC_DELETE_BLOCKED_MESSAGE ]); }); - 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]); + it('gives no lines for no blockers', () => { + expect(blockerLines([], {}, wording)).toEqual([]); }); }); -describe('deleteBlockedDescription', () => { +describe('blockersFromError', () => { it('reads the blockers out of the precondition failure detail', () => { const err = new ConnectError('blocked', Code.FailedPrecondition); err.details.push({ @@ -107,21 +96,35 @@ describe('deleteBlockedDescription', () => { value: new Uint8Array(), debug: { violations: [ - { type: 'ACTIVE_SUBSCRIPTION', subject: 'sub-1' }, - { type: 'NEGATIVE_TOKEN_BALANCE', subject: 'acct-1' } + { type: 'ACTIVE_SUBSCRIPTION', subject: 'sub-1', description: 'developer text' }, + { type: 'RESOURCES_EXIST', subject: 'app/thing' }, + { subject: 'no-type' } ] } }); - expect(deleteBlockedDescription(err, wording)).toBe( - 'Downgrade your subscription to the Standard Plan before deleting this workspace. ' + - 'Purchase enough tokens to clear your outstanding token balance before deleting this workspace.' - ); + expect(blockersFromError(err)).toEqual([ + { type: 'ACTIVE_SUBSCRIPTION', subject: 'sub-1', count: 0 }, + { type: 'RESOURCES_EXIST', subject: 'app/thing', count: 0 } + ]); + }); + + it('treats an error with no precondition failure as a disabled organization', () => { + const err = new ConnectError('org is disabled', Code.FailedPrecondition); + expect(blockersFromError(err)).toEqual([ + { type: ORGANIZATION_DISABLED, subject: '', count: 0 } + ]); }); - it('returns the generic message when the error carries no blockers', () => { + it('gives an unknown blocker when the violations cannot be read', () => { const err = new ConnectError('blocked', Code.FailedPrecondition); - expect(deleteBlockedDescription(err, wording)).toBe( + err.details.push({ + type: 'google.rpc.PreconditionFailure', + value: new Uint8Array() + }); + const blockers = blockersFromError(err); + expect(blockers).toEqual([{ type: UNKNOWN_BLOCKER, subject: '', count: 0 }]); + expect(blockerLines(blockers, {}, wording)).toEqual([ GENERIC_DELETE_BLOCKED_MESSAGE - ); + ]); }); }); diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index 0d792fa19..b162662da 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -1,4 +1,18 @@ import { ConnectError } from '@connectrpc/connect'; +import type { DeleteBlocker } from '../../shared/types'; + +// A request on a disabled organization fails with failed_precondition and +// carries no PreconditionFailure. The SDK gives that case its own blocker +// type, so it shows in the same dialog as the other blockers. +export const ORGANIZATION_DISABLED = 'ORGANIZATION_DISABLED'; + +// A PreconditionFailure whose violations cannot be read. It has no line, so +// the dialog shows the generic one. +export const UNKNOWN_BLOCKER = 'UNKNOWN'; + +// Used once for all blockers that have no line. +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,110 +21,80 @@ export interface BlockerWording { basePlanTitle?: string; } -const DEFAULT_WORDING: BlockerWording = { - organizationLabel: 'organization' -}; - -export const ORGANIZATION_DISABLED = 'ORGANIZATION_DISABLED'; - -const BLOCKERS: Record< - string, - { - label: (wording: BlockerWording) => string; - instruction: (wording: BlockerWording) => string; - } -> = { - ORGANIZATION_DISABLED: { - label: ({ organizationLabel }) => `Disabled ${organizationLabel}`, - instruction: ({ organizationLabel }) => - `Contact your administrator to enable this ${organizationLabel} before deleting it.` - }, - ACTIVE_SUBSCRIPTION: { - label: () => 'Active subscription', - instruction: ({ organizationLabel, basePlanTitle }) => - `Downgrade your subscription to the ${ - basePlanTitle || 'standard plan' - } before deleting this ${organizationLabel}.` - }, - UNPAID_INVOICE: { - label: () => 'Outstanding invoice', - instruction: ({ organizationLabel }) => - `Pay all outstanding invoices before deleting this ${organizationLabel}.` - }, - NEGATIVE_TOKEN_BALANCE: { - label: () => 'Negative token balance', - instruction: ({ organizationLabel }) => - `Purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.` - } +const DEFAULT_LINES: Record string> = { + [ORGANIZATION_DISABLED]: ({ organizationLabel }) => + `Disabled ${organizationLabel}: Contact your administrator to enable this ${organizationLabel} before deleting it.`, + ACTIVE_SUBSCRIPTION: ({ organizationLabel, basePlanTitle }) => + `Active subscription: Downgrade your subscription to the ${ + basePlanTitle || 'standard plan' + } 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}.` }; -// 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.'; - -export interface BlockerItem { - label?: string; - instruction: string; -} - -export function blockerItems( - blockers: { type: string }[], - wording: BlockerWording = DEFAULT_WORDING -): BlockerItem[] { - const items: BlockerItem[] = []; +// Returns one line per blocker: messages[subject], else messages[type], +// else the SDK line for the type. Identical lines show once. +export function blockerLines( + blockers: DeleteBlocker[], + messages: Record, + wording: BlockerWording +): string[] { + const lines = new Set(); let hasUnknown = false; - for (const type of new Set(blockers.map(blocker => blocker.type))) { - const blocker = BLOCKERS[type]; - if (blocker) { - items.push({ - label: blocker.label(wording), - instruction: blocker.instruction(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 || items.length === 0) { - items.push({ instruction: GENERIC_DELETE_BLOCKED_MESSAGE }); + if (hasUnknown) { + lines.add(GENERIC_DELETE_BLOCKED_MESSAGE); } - return items; + return [...lines]; } -export function instructionLines( - blockers: { type: string }[], - wording: BlockerWording = DEFAULT_WORDING -): string[] { - return blockerItems(blockers, wording).map(item => item.instruction); +export function toDeleteBlockers(blockers: DeleteBlocker[]): DeleteBlocker[] { + return blockers.map(({ type, subject, count }) => ({ type, subject, count })); } -// 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' - ); - return instructionLines(blockers, wording).join(' '); +// Reads the blockers from a failed_precondition error. A refused delete +// sends them as google.rpc.PreconditionFailure violations, which Connect +// exposes in the detail's debug field. Violations carry no count. +export function blockersFromError(err: ConnectError): DeleteBlocker[] { + const failures = err.details.filter( + detail => + 'type' in detail && detail.type === 'google.rpc.PreconditionFailure' + ); + if (failures.length === 0) { + return [{ type: ORGANIZATION_DISABLED, subject: '', count: 0 }]; } - return GENERIC_DELETE_BLOCKED_MESSAGE; + const blockers = failures.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 ?? '', + count: 0 + })) + : []; + }); + return blockers.length > 0 + ? blockers + : [{ type: UNKNOWN_BLOCKER, subject: '', count: 0 }]; +} + +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-blockers-dialog.tsx b/web/sdk/client/views/general/components/delete-blockers-dialog.tsx deleted file mode 100644 index df7070238..000000000 --- a/web/sdk/client/views/general/components/delete-blockers-dialog.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { AlertDialog, Button, Flex, Text } from '@raystack/apsara'; -import { useFrontier } from '../../../contexts/FrontierContext'; -import { useTerminology } from '../../../hooks/useTerminology'; -import { blockerItems } from '../../../utils/delete-blockers'; -import styles from './delete-blockers-dialog.module.css'; - -export type DeleteBlockersPayload = { blockers: { type: string }[] }; - -export interface DeleteBlockersDialogProps { - handle: ReturnType>; -} - -export function DeleteBlockersDialog({ handle }: DeleteBlockersDialogProps) { - const t = useTerminology(); - const { basePlan } = useFrontier(); - const orgLabelLower = t.organization({ case: 'lower' }); - - return ( - - {({ payload: rawPayload }) => { - const payload = rawPayload as DeleteBlockersPayload | undefined; - const items = blockerItems(payload?.blockers ?? [], { - organizationLabel: orgLabelLower, - basePlanTitle: basePlan?.title - }); - return ( - - - Unable to delete {orgLabelLower} - - - - - Please resolve the following issues before deleting this{' '} - {orgLabelLower}: - - } - > - {items.map(({ label, instruction }) => ( -
  • - {label ? `${label}: ${instruction}` : instruction} -
  • - ))} -
    -
    -
    - - - Close - - } - /> - -
    - ); - }} -
    - ); -} 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 6ca4eb2db..a6e3120ae 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -21,8 +21,6 @@ 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'; const deleteOrgSchema = yup .object({ @@ -31,26 +29,37 @@ const deleteOrgSchema = yup .required(); export interface DeleteOrganizationDialogProps { - handle: ReturnType; + handle: ReturnType>; onDeleteSuccess?: () => void; + onDeleteError: (error: unknown) => void; } export const DeleteOrganizationDialog = ({ handle, - onDeleteSuccess + onDeleteSuccess, + onDeleteError }: DeleteOrganizationDialogProps) => ( - + { + // The handle keeps its open state after this view unmounts, so + // close it before the host app navigates away. + handle.close(); + onDeleteSuccess?.(); + }} + onDeleteError={onDeleteError} + /> ); function DeleteOrganizationForm({ - onDeleteSuccess -}: Pick) { + 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); @@ -92,24 +101,10 @@ function DeleteOrganizationForm({ }); 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 (
    diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 3b011f2e2..fb6c0960f 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -30,17 +30,17 @@ import { import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; -import { ORGANIZATION_DISABLED } from '../../utils/delete-blockers'; +import { useDeleteFlow } from '../../hooks/useDeleteFlow'; 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 { DeleteOrganizationDialog } from './components/delete-organization-dialog'; import { - DeleteBlockersDialog, - type DeleteBlockersPayload -} from './components/delete-blockers-dialog'; + DeleteBlockedDialogHost, + type DeleteBlockedPayload +} from '../../components/delete-blocked-dialog'; +import { DeleteOrganizationDialog } from './components/delete-organization-dialog'; import styles from './general-view.module.css'; import { handleConnectError } from '~/utils/error'; @@ -54,9 +54,9 @@ const generalSchema = yup type FormData = yup.InferType; -const deleteBlockersDialogHandle = - AlertDialog.createHandle(); -const deleteOrganizationDialogHandle = AlertDialog.createHandle(); +const deleteOrganizationDialogHandle = AlertDialog.createHandle(); +const deleteBlockedDialogHandle = + AlertDialog.createHandle(); export interface GeneralViewProps { onDeleteSuccess?: () => void; @@ -108,8 +108,24 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; - const { mutateAsync: checkOrganizationDelete, isPending: isCheckingDelete } = - useMutation(FrontierServiceQueries.checkOrganizationDelete); + const { mutateAsync: checkOrganizationDelete } = useMutation( + FrontierServiceQueries.checkOrganizationDelete + ); + const { + startDelete, + isChecking: isCheckingDelete, + handleDeleteError + } = useDeleteFlow({ + entity: 'organization', + check: () => + checkOrganizationDelete( + create(CheckOrganizationDeleteRequestSchema, { + id: organization?.id ?? '' + }) + ), + confirmDialogHandle: deleteOrganizationDialogHandle, + blockedDialogHandle: deleteBlockedDialogHandle + }); // Update organization form const { mutateAsync: updateOrganization } = useMutation( @@ -176,32 +192,6 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } } - async function onDeleteClick() { - if (!organization?.id) return; - let check; - try { - check = await checkOrganizationDelete( - create(CheckOrganizationDeleteRequestSchema, { id: organization.id }) - ); - } catch (error) { - handleConnectError(error, { - FailedPrecondition: () => - deleteBlockersDialogHandle.openWithPayload({ - blockers: [{ type: ORGANIZATION_DISABLED }] - }), - PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", 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' }), - }); - return; - } - if (!check.canDelete) { - deleteBlockersDialogHandle.openWithPayload({ blockers: check.blockers }); - return; - } - deleteOrganizationDialogHandle.open(null); - } - const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); @@ -324,7 +314,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = {