Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ export const AuthLoginHandler = ({ authLoginUsecase }: AuthLoginHandlerProps) =>
Note the effects that fire on the same state end up in the same `useEffect`, instead of split
across a handler and a controller in two files.

## A handler's props are usecases, not repositories (D-order-boundary)

Per `docs/trd-order-handler-usecase-boundary.md`: a handler's props are usecases, plus rarely a
single documented pure display value (e.g. `MenuListHandler`'s SSR-seeded `preparingCount` — a
read-only integer with no repository call or state transition behind it, see that TRD's D6). A
repository never appears in a Handler's prop list. If a handler needs one more side effect or
value than its usecases already expose, that's a usecase missing the responsibility, not a reason
to hand the handler a repository:

- Build the usecase that needs the repository in the composition root (`app/{pos,order}/<X>.tsx`),
the same place every other usecase in the codebase is constructed, and pass the *usecase* down.
- If a usecase must be reconstructed later once a value becomes known (e.g. a payment reference
discovered only after another usecase's fetch resolves), don't rebuild it inline in the handler.
Construct it once in the composition root and re-sync it via a dispatched action instead — see
`usePaymentCancelSyncedTo` (`presentation/handlers/hooks/usePaymentCancelSyncedTo.ts`) and
`PaymentCancelUsecase`'s `SYNC_PARAMS` action for the reference shape.

## Browser-lifecycle guards live in `utils/`, not `handlers/hooks/`

`beforeunload` and Next's `router.events` are browser/Next-only APIs, and `.eslintrc.json` bans
Expand Down
519 changes: 519 additions & 0 deletions docs/trd-order-handler-usecase-boundary.md

Large diffs are not rendered by default.

32 changes: 18 additions & 14 deletions libs/ui/src/app/order/Cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PaymentMethod } from '../../domain/entities/Payment';
import { PublicTable } from '../../domain/entities/PublicTable';
import { CartUsecase } from '../../domain/usecases/cart';
import { CheckoutUsecase } from '../../domain/usecases/checkout';
import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel';
import { TableResolveUsecase } from '../../domain/usecases/tableResolve';
import { CartHandler } from '../../presentation/handlers/order/CartHandler';

Expand All @@ -33,15 +34,12 @@ export function Cart({
const cartQueryRepository = new UrlCartQueryRepository();
const paymentRepository = new ApiPaymentRepository(sessionRepository);

const tableResolveUsecase = new TableResolveUsecase(publicTableRepository, {
code,
table,
});
const tableResolveUsecase = new TableResolveUsecase(
publicTableRepository,
{ code, table },
{ sessionRepository }
);
const cartUsecase = new CartUsecase(cartRepository, cartQueryRepository);
const checkoutUsecase = new CheckoutUsecase(paymentRepository, {
customerName,
customerWhatsappNumber,
});
const enabled = process.env['NEXT_PUBLIC_ORDER_CHECKOUT_ENABLED'] === 'true';
const isCashPaymentEnabled =
process.env['NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED'] === 'true';
Expand All @@ -54,18 +52,24 @@ export function Cart({
];
const cashierLocation =
process.env['NEXT_PUBLIC_ORDER_CASHIER_LOCATION'] || 'Lantai 1';
const checkoutUsecase = new CheckoutUsecase(paymentRepository, {
customerName,
customerWhatsappNumber,
enabled,
enabledMethods,
cashierLocation,
});
const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, {
reference: '',
method: 'qris',
});

return (
<CartHandler
tableResolveUsecase={tableResolveUsecase}
cartUsecase={cartUsecase}
checkoutUsecase={checkoutUsecase}
sessionRepository={sessionRepository}
paymentRepository={paymentRepository}
enabled={enabled}
enabledMethods={enabledMethods}
cashierLocation={cashierLocation}
tableCode={code}
paymentCancelUsecase={paymentCancelUsecase}
preparingCount={preparingCount}
/>
);
Expand Down
19 changes: 11 additions & 8 deletions libs/ui/src/app/order/MenuList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Variant } from '../../domain/entities/Variant';
import { CartUsecase } from '../../domain/usecases/cart';
import { MenuItemDetailUsecase } from '../../domain/usecases/menuItemDetail';
import { MenuListUsecase } from '../../domain/usecases/menuList';
import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel';
import { TableResolveUsecase } from '../../domain/usecases/tableResolve';
import { MenuListHandler } from '../../presentation/handlers/order/MenuListHandler';

Expand Down Expand Up @@ -45,10 +46,11 @@ export function MenuList({
const paymentRepository = new ApiPaymentRepository(sessionRepository);
const menuListQueryRepository = new UrlMenuListQueryRepository();

const tableResolveUsecase = new TableResolveUsecase(publicTableRepository, {
code,
table,
});
const tableResolveUsecase = new TableResolveUsecase(
publicTableRepository,
{ code, table },
{ sessionRepository, cartRepository }
);
const menuListUsecase = new MenuListUsecase(
menuRepository,
menuListQueryRepository,
Expand All @@ -63,17 +65,18 @@ export function MenuList({
product: selectedProduct,
});
const cartUsecase = new CartUsecase(cartRepository, new UrlCartQueryRepository());
const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, {
reference: '',
method: 'qris',
});

return (
<MenuListHandler
tableResolveUsecase={tableResolveUsecase}
menuListUsecase={menuListUsecase}
menuItemDetailUsecase={menuItemDetailUsecase}
cartUsecase={cartUsecase}
cartRepository={cartRepository}
paymentRepository={paymentRepository}
sessionRepository={sessionRepository}
tableCode={code}
paymentCancelUsecase={paymentCancelUsecase}
preparingCount={preparingCount}
/>
);
Expand Down
15 changes: 6 additions & 9 deletions libs/ui/src/app/order/OrderHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ export function OrderHistory({ sessionId, payments }: OrderHistoryProps) {
const sessionRepository = new CookieSessionRepository(sessionId);
const paymentRepository = new ApiPaymentRepository(sessionRepository);

const orderHistoryUsecase = new OrderHistoryUsecase(paymentRepository, {
payments,
});

return (
<OrderHistoryHandler
orderHistoryUsecase={orderHistoryUsecase}
sessionRepository={sessionRepository}
/>
const orderHistoryUsecase = new OrderHistoryUsecase(
paymentRepository,
{ payments },
{ sessionRepository }
);

return <OrderHistoryHandler orderHistoryUsecase={orderHistoryUsecase} />;
}
18 changes: 11 additions & 7 deletions libs/ui/src/app/order/OrderStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApiPaymentRepository } from '../../data/api/payment';
import { CookieSessionRepository } from '../../data/session/CookieSessionRepository';
import { Payment } from '../../domain/entities/Payment';
import { OrderStatusUsecase } from '../../domain/usecases/orderStatus';
import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel';
import { OrderStatusHandler } from '../../presentation/handlers/order/OrderStatusHandler';

export type OrderStatusProps = {
Expand All @@ -23,19 +24,22 @@ export function OrderStatus({
accessKey
);

const orderStatusUsecase = new OrderStatusUsecase(paymentRepository, {
reference,
payment,
});
const cashierLocation =
process.env['NEXT_PUBLIC_ORDER_CASHIER_LOCATION'] || 'Lantai 1';
const orderStatusUsecase = new OrderStatusUsecase(
paymentRepository,
{ reference, payment, cashierLocation },
{ sessionRepository }
);
const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, {
reference: payment?.reference ?? '',
method: payment?.method ?? 'qris',
});

return (
<OrderStatusHandler
orderStatusUsecase={orderStatusUsecase}
paymentRepository={paymentRepository}
sessionRepository={sessionRepository}
cashierLocation={cashierLocation}
paymentCancelUsecase={paymentCancelUsecase}
/>
);
}
22 changes: 22 additions & 0 deletions libs/ui/src/domain/usecases/checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ describe('CheckoutUsecase', () => {
expect(checkout.state.whatsappNumber).toBe('0812345678');
});

it('should default enabled to false, enabledMethods to [qris], and cashierLocation to Lantai 1', () => {
const repository = new MockPaymentRepository();
const checkout = createTester(repository);

expect(checkout.state.enabled).toBe(false);
expect(checkout.state.enabledMethods).toEqual(['qris']);
expect(checkout.state.cashierLocation).toBe('Lantai 1');
});

it('should seed enabled, enabledMethods, and cashierLocation from params', () => {
const repository = new MockPaymentRepository();
const checkout = createTester(repository, {
enabled: true,
enabledMethods: ['qris', 'cash', 'cod'],
cashierLocation: 'Lantai 2',
});

expect(checkout.state.enabled).toBe(true);
expect(checkout.state.enabledMethods).toEqual(['qris', 'cash', 'cod']);
expect(checkout.state.cashierLocation).toBe('Lantai 2');
});

it('should reach creatingPayment keeping the raw number as typed, and submit the normalized number', async () => {
const repository = new MockPaymentRepository();
const checkoutSpy = jest.spyOn(repository, 'checkout');
Expand Down
9 changes: 9 additions & 0 deletions libs/ui/src/domain/usecases/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ type Context = {
nameErrorMessage: string | null;
whatsappNumberErrorMessage: string | null;
errorMessage: string | null;
enabled: boolean;
enabledMethods: PaymentMethod[];
cashierLocation: string;
};

export type CheckoutState = (
Expand Down Expand Up @@ -57,6 +60,9 @@ export type CheckoutAction =
export type CheckoutParams = {
customerName?: string;
customerWhatsappNumber?: string;
enabled?: boolean;
enabledMethods?: PaymentMethod[];
cashierLocation?: string;
};

function validateName(name: string): string | null {
Expand Down Expand Up @@ -126,6 +132,9 @@ export class CheckoutUsecase extends Usecase<
nameErrorMessage: null,
whatsappNumberErrorMessage: null,
errorMessage: null,
enabled: this.params.enabled ?? false,
enabledMethods: this.params.enabledMethods ?? ['qris'],
cashierLocation: this.params.cashierLocation ?? 'Lantai 1',
};
}

Expand Down
40 changes: 39 additions & 1 deletion libs/ui/src/domain/usecases/orderHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
OrderHistoryState,
OrderHistoryParams,
} from './orderHistory';
import { MockPaymentRepository } from '../../data/mock';
import { MockPaymentRepository, MockSessionRepository } from '../../data/mock';
import { UsecaseTester, flushPromises } from '../../utils/usecase';

const createTester = (
Expand All @@ -28,27 +28,31 @@ describe('OrderHistoryUsecase', () => {
type: 'loading',
payments: [],
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'loaded',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});

orderHistory.dispatch({ type: 'FETCH' });
expect(orderHistory.state).toEqual({
type: 'revalidating',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'loaded',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});
});
});
Expand All @@ -63,13 +67,15 @@ describe('OrderHistoryUsecase', () => {
type: 'loading',
payments: [],
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'error',
payments: [],
errorMessage: 'Failed to fetch orders',
tableCode: null,
});

repository.setShouldFailFetchPayments(false);
Expand All @@ -78,13 +84,15 @@ describe('OrderHistoryUsecase', () => {
type: 'loading',
payments: [],
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'loaded',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});
});

Expand All @@ -97,6 +105,7 @@ describe('OrderHistoryUsecase', () => {
type: 'loaded',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});

repository.setShouldFailFetchPayments(true);
Expand All @@ -105,13 +114,15 @@ describe('OrderHistoryUsecase', () => {
type: 'revalidating',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'loaded',
payments: repository.payments,
errorMessage: null,
tableCode: null,
});
});
});
Expand All @@ -126,6 +137,7 @@ describe('OrderHistoryUsecase', () => {
type: 'loaded',
payments,
errorMessage: null,
tableCode: null,
});
});

Expand All @@ -139,13 +151,39 @@ describe('OrderHistoryUsecase', () => {
type: 'loading',
payments: [],
errorMessage: null,
tableCode: null,
});

await flushPromises();
expect(orderHistory.state).toEqual({
type: 'loaded',
payments: [],
errorMessage: null,
tableCode: null,
});
});

describe('tableCode', () => {
it('reads the table code from the injected session repository', () => {
const repository = new MockPaymentRepository();
const sessionRepository = new MockSessionRepository();
sessionRepository.setTableCode('3F7H9K2M5P');

const usecase = new OrderHistoryUsecase(
repository,
{ payments: [] },
{ sessionRepository }
);

expect(usecase.getInitialState().tableCode).toBe('3F7H9K2M5P');
});

it('is null when no session repository dependency is provided', () => {
const repository = new MockPaymentRepository();

const usecase = new OrderHistoryUsecase(repository, { payments: [] });

expect(usecase.getInitialState().tableCode).toBeNull();
});
});
});
Loading
Loading