Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
574ad14
feat(sdk): check delete blockers on click and show them in a dialog
rohanchkrabrty Oct 22, 2026
2ad36cc
chore(sdk): update proton to 680ef693
rohanchkrabrty Oct 22, 2026
dbbfb79
fix(sdk): open the delete dialog only when the delete check passes
rohanchkrabrty Oct 26, 2026
3c792c6
Merge remote-tracking branch 'origin/main' into feat/workspace-delete…
rohanchkrabrty Oct 26, 2026
4770603
refactor(sdk): open the delete organization dialog with a handle
rohanchkrabrty Oct 26, 2026
488d0ca
chore(sdk): drop comments from the delete flow changes
rohanchkrabrty Oct 26, 2026
072fe99
Merge remote-tracking branch 'origin/main' into feat/workspace-delete…
rohanchkrabrty Nov 3, 2026
63d4fe4
feat(sdk): let the host app word the blocked delete dialog
rohanchkrabrty Nov 3, 2026
56698ff
chore(sdk): drop comments from the delete blocked dialog changes
rohanchkrabrty Nov 3, 2026
2c01e92
fix(sdk): address review of the delete blocked dialog
rohanchkrabrty Nov 3, 2026
5cacc30
chore(sdk): remove the delete blockers tests
rohanchkrabrty Nov 3, 2026
5797739
refactor(sdk): rename useDeleteFlow to useDeleteCheck
rohanchkrabrty Nov 3, 2026
a9080d3
fix(sdk): create the delete dialog handles per mount
rohanchkrabrty Nov 3, 2026
6277d9b
chore(sdk): drop the useTokens enabled option and format the delete f…
rohanchkrabrty Nov 4, 2026
b923bac
fix(sdk): show a toast when a refused delete has no readable blockers
rohanchkrabrty Nov 4, 2026
7a874bf
refactor(sdk): render the whole blocked dialog from DeleteBlockedDialog
rohanchkrabrty Nov 4, 2026
6b001b4
fix(sdk): show a toast when a delete is refused for a disabled workspace
rohanchkrabrty Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.content {
width: 480px !important;
}

.list {
margin: 0;
padding-left: var(--rs-space-5);
Comment thread
Shreyag02 marked this conversation as resolved.
Resolved
list-style-type: disc;
}
Original file line number Diff line number Diff line change
@@ -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<DeleteBlockedPayload>
>;

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<DeleteBlockedContext | null>(
null
);

export interface DeleteBlockedDialogRootProps {
handle: DeleteBlockedDialogHandle;
}

export function DeleteBlockedDialogRoot({
handle
}: DeleteBlockedDialogRootProps) {
return (
<AlertDialog handle={handle}>
{({ payload }) =>
payload ? (
<DeleteBlockedContent
payload={payload}
close={() => handle.close()}
/>
) : null
}
</AlertDialog>
);
}

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const isEmpty = body == null || typeof body === 'boolean';
return (
<DeleteBlockedDialogContext.Provider value={context}>
{isEmpty ? <DeleteBlockedDialog /> : body}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</DeleteBlockedDialogContext.Provider>
);
}

export interface DeleteBlockedDialogProps {
title?: string;
description?: string;
blockers?: DeleteBlocker[];
messages?: Record<string, string>;
}

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 (
<AlertDialog.Content className={styles.content}>
<AlertDialog.Header>
<AlertDialog.Title>{title ?? copy.title}</AlertDialog.Title>
</AlertDialog.Header>
<AlertDialog.Body>
<Flex direction="column" gap={5}>
<Text size="small" variant="secondary">
{description ?? copy.description}
</Text>
{lines.length > 0 && (
<Text
size="small"
variant="secondary"
render={<ul className={styles.list} />}
>
{lines.map(line => (
<li key={line}>{line}</li>
))}
</Text>
)}
</Flex>
</AlertDialog.Body>
<AlertDialog.Footer>
<AlertDialog.Close
render={
<Button
variant="outline"
color="neutral"
data-test-id="frontier-sdk-delete-blocked-close-btn"
>
Close
</Button>
}
/>
</AlertDialog.Footer>
</AlertDialog.Content>
);
}
8 changes: 8 additions & 0 deletions web/sdk/client/components/delete-blocked-dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
DeleteBlockedDialog,
DeleteBlockedDialogRoot
} from './delete-blocked-dialog';
export type {
DeleteBlockedDialogProps,
DeleteBlockedDialogHandle
} from './delete-blocked-dialog';
11 changes: 9 additions & 2 deletions web/sdk/client/contexts/FrontierContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { FrontierServiceQueries } from '~hooks';

import {
FrontierClientOptions,
FrontierProviderProps
FrontierProviderProps,
FrontierProviderSlots
} from '../../shared/types';

import {
Expand Down Expand Up @@ -47,6 +48,8 @@ import { useLastActiveTracker } from '../hooks/useLastActiveTracker';
interface FrontierContextProviderProps {
config: FrontierClientOptions;

slots: FrontierProviderSlots;

organizations: Organization[];

groups: Group[];
Expand Down Expand Up @@ -123,6 +126,8 @@ const defaultConfig: FrontierClientOptions = {
const initialValues: FrontierContextProviderProps = {
config: defaultConfig,

slots: {},

organizations: [],

groups: [],
Expand Down Expand Up @@ -175,7 +180,8 @@ FrontierContext.displayName = 'FrontierContext ';

export const FrontierContextProvider = ({
children,
config
config,
slots = {}
}: FrontierProviderProps) => {
const [activeOrganization, setActiveOrganization] = useState<Organization>();
const [isActiveOrganizationLoading, setIsActiveOrganizationLoading] =
Expand Down Expand Up @@ -322,6 +328,7 @@ export const FrontierContextProvider = ({
...config,
billing: { ...defaultConfig.billing, ...config.billing }
},
slots,
organizations,
groups,
user,
Expand Down
98 changes: 98 additions & 0 deletions web/sdk/client/hooks/useDeleteCheck.ts
Original file line number Diff line number Diff line change
@@ -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<TPayload> {
entity: DeleteBlockedPayload['entity'];
check: (
payload: TPayload
) => Promise<{ canDelete: boolean; blockers: Blocker[] }>;
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
confirmDialogHandle: ReturnType<typeof AlertDialog.createHandle<TPayload>>;
blockedDialogHandle: DeleteBlockedDialogHandle;
}

export function useDeleteCheck<TPayload>({
entity,
check,
confirmDialogHandle,
blockedDialogHandle
}: UseDeleteCheckOptions<TPayload>) {
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 };
}
10 changes: 2 additions & 8 deletions web/sdk/client/hooks/useTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,7 @@ interface UseTokensReturn {
fetchTokenBalance: () => Promise<any>;
}

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 {
Expand All @@ -35,7 +29,7 @@ export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => {
id: billingAccount?.id ?? ''
}),
{
enabled: !!billingAccount?.id && (options.enabled ?? true),
enabled: !!billingAccount?.id,
retry: false
}
);
Expand Down
7 changes: 6 additions & 1 deletion web/sdk/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading