Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions apps/web/src/app/admin/api/organizations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,36 @@ export function useAdminOrganizationKiloPassSummary(organizationId: string) {
);
}

export function useAdminOrganizationServiceFeeExemption(organizationId: string) {
const trpc = useTRPC();
return useQuery(
trpc.organizations.admin.getServiceFeeExemption.queryOptions({
organizationId,
})
);
}

export function useSetOrganizationServiceFeeExemption() {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.organizations.admin.setServiceFeeExemption.mutationOptions({
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({
queryKey: trpc.organizations.admin.getServiceFeeExemption.queryKey({
organizationId: variables.organizationId,
}),
});
void queryClient.invalidateQueries({
queryKey: trpc.organizations.admin.getDetails.queryKey({
organizationId: variables.organizationId,
}),
});
},
})
);
}

export function useAdminOrganizationHierarchy(organizationId: string, enabled: boolean) {
const trpc = useTRPC();
return useQuery(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { OrganizationAdminCreditTransactions } from './OrganizationAdminCreditTr
import { OrganizationAdminDelete } from './OrganizationAdminDelete';
import { OrganizationAdminCreditGrant } from './OrganizationAdminCreditGrant';
import { OrganizationAdminCreditNullify } from './OrganizationAdminCreditNullify';
import { OrganizationAdminServiceFeeExemption } from './OrganizationAdminServiceFeeExemption';
import { OrganizationAdminCreatedBy } from './OrganizationAdminCreatedBy';
import { OrganizationAdminHierarchyManagement } from './OrganizationAdminHierarchyManagement';
import { OrganizationAdminKiloPass } from './OrganizationAdminKiloPass';
Expand Down Expand Up @@ -66,6 +67,7 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId:
<OrganizationAdminCreatedBy organizationId={organizationId} />
<OrganizationAdminCreditGrant organizationId={organizationId} />
<OrganizationAdminCreditNullify organizationId={organizationId} />
<OrganizationAdminServiceFeeExemption organizationId={organizationId} />
<OrganizationWorkOSCard organizationId={organizationId} />
</div>
<div className="space-y-8 lg:col-span-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from '@jest/globals';
import {
canSubmitServiceFeeExemption,
resolveServiceFeeExemptionDialogOpenChange,
SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
shouldBlockServiceFeeExemptionDialogDismiss,
} from './OrganizationAdminServiceFeeExemption.dialog-state';

describe('resolveServiceFeeExemptionDialogOpenChange', () => {
it('ignores close requests while the mutation is pending', () => {
expect(
resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: false, isMutationPending: true })
).toBeNull();
});

it('ignores reopen requests while the mutation is pending so state is never reset mid-flight', () => {
expect(
resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: true })
).toBeNull();
});

it('resets the mutation only when the dialog opens while idle', () => {
expect(
resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: false })
).toEqual({ open: true, resetMutation: true });
});

it('closes without resetting the mutation while idle', () => {
expect(
resolveServiceFeeExemptionDialogOpenChange({
requestedOpen: false,
isMutationPending: false,
})
).toEqual({ open: false, resetMutation: false });
});
});

describe('shouldBlockServiceFeeExemptionDialogDismiss', () => {
it('blocks Escape, overlay pointer-down, and outside interaction only while pending', () => {
expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: true })).toBe(true);
expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: false })).toBe(false);
});
});

describe('canSubmitServiceFeeExemption', () => {
it('rejects a pending mutation even with a valid reason to prevent duplicates', () => {
expect(
canSubmitServiceFeeExemption({
trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
isMutationPending: true,
})
).toBe(false);
});

it('enforces the trimmed reason length bounds while idle', () => {
const idle = { isMutationPending: false };
expect(
canSubmitServiceFeeExemption({
trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH - 1,
...idle,
})
).toBe(false);
expect(
canSubmitServiceFeeExemption({
trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
...idle,
})
).toBe(true);
expect(
canSubmitServiceFeeExemption({
trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
...idle,
})
).toBe(true);
expect(
canSubmitServiceFeeExemption({
trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH + 1,
...idle,
})
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Pure dialog-state rules for OrganizationAdminServiceFeeExemption, extracted
* so the pending-mutation dismiss guards are testable without a DOM (the repo
* has no component-test runner).
*/

// Mirrors ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_* in
// @/lib/service-fees/organization-exemptions, which is server-only and cannot
// be imported from a client component. The router remains the enforcement
// boundary; these only drive client-side enablement and hints.
export const SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH = 3;
export const SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH = 500;

export type ServiceFeeExemptionDialogOpenChange = {
open: boolean;
resetMutation: boolean;
};

/**
* Radix fires onOpenChange for the trigger, Cancel, the close button, Escape,
* and overlay pointer-down. While the set-exemption mutation is in flight,
* every open/close request must be ignored: closing would discard the pending
* UI, and a close-then-reopen would reset the mutation state, clear the
* isPending guard, and allow a duplicate mutation.
*
* Returns null when the request must be ignored, otherwise the next dialog
* state. The mutation is reset only on a fresh open so a previous error does
* not leak into the next attempt.
*/
export function resolveServiceFeeExemptionDialogOpenChange(input: {
requestedOpen: boolean;
isMutationPending: boolean;
}): ServiceFeeExemptionDialogOpenChange | null {
if (input.isMutationPending) return null;
return { open: input.requestedOpen, resetMutation: input.requestedOpen };
}

/**
* Guarding onOpenChange alone is not enough for a controlled dialog: Radix
* processes Escape and overlay pointer-down in its own handlers before asking
* React, so DialogContent must also preventDefault those events while the
* mutation is pending. This predicate drives all three content-level guards
* (onEscapeKeyDown, onPointerDownOutside, onInteractOutside).
*/
export function shouldBlockServiceFeeExemptionDialogDismiss(input: {
isMutationPending: boolean;
}): boolean {
return input.isMutationPending;
}

/**
* Confirm stays inert until the trimmed reason is within the allowed length
* and no mutation is in flight, so double-clicks or repeated Enter presses
* cannot fire a duplicate mutation.
*/
export function canSubmitServiceFeeExemption(input: {
trimmedReasonLength: number;
isMutationPending: boolean;
}): boolean {
return (
!input.isMutationPending &&
input.trimmedReasonLength >= SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH &&
input.trimmedReasonLength <= SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH
);
}
Loading