Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8,698 changes: 4,029 additions & 4,669 deletions packages/lit-components/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/lit-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"type-check": "tsc --noEmit",
"lint": "eslint src --ext .ts",
"format": "prettier --write src",
"test": "vitest run --project unit",
"test:watch": "vitest --project unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
Expand Down Expand Up @@ -67,6 +69,7 @@
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"vite": "^5.4.0",
"msw": "^2.4.9",
"vitest": "^4.0.14"
},
"repository": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/web-components-vite';
import '../../index';
import { BankingInfoFormView } from './BankingInfoFormView';

const meta: Meta = {
title: 'Components/BankingInfoForm',
component: 'sql-banking-info-form',
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj;

const baseProps = {
headerText: 'Banking Information',
submitLabel: 'Save',
accountNameLabel: 'Account Holder Name',
bankNameLabel: 'Bank Name',
accountNumberLabel: 'Account Number',
routingNumberLabel: 'Routing Number',
programId: 'demo-program',
accountName: 'Jane Doe',
setAccountName: () => undefined,
bankName: 'Mint Bank',
setBankName: () => undefined,
accountNumber: '****1234',
setAccountNumber: () => undefined,
routingNumber: '110000000',
setRoutingNumber: () => undefined,
error: '',
loading: false,
success: false,
onSubmit: (event: Event) => event.preventDefault(),
};

export const Default: Story = {
render: () => BankingInfoFormView(baseProps),
};

export const WithError: Story = {
render: () =>
BankingInfoFormView({
...baseProps,
error: 'All fields are required',
}),
};

export const Success: Story = {
render: () =>
BankingInfoFormView({
...baseProps,
success: true,
}),
};

export const Loading: Story = {
render: () =>
BankingInfoFormView({
...baseProps,
loading: true,
}),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getProps } from '../../helpers';
import { useComponent } from '../../hooks/useComponent';
import { BankingInfoFormView } from './BankingInfoFormView';
import { useBankingInfoForm } from './useBankingInfoForm';

export interface BankingInfoFormProps {
headerText: string;
submitLabel: string;
accountNameLabel: string;
bankNameLabel: string;
accountNumberLabel: string;
routingNumberLabel: string;
programId?: string;
}

declare global {
interface HTMLElementTagNameMap {
'sql-banking-info-form': HTMLElement;
}
}

export const BankingInfoForm = useComponent<BankingInfoFormProps>(
(host) => {
const rawProps = getProps(host) as Partial<Record<keyof BankingInfoFormProps, unknown>>;
const props: BankingInfoFormProps = {
headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Banking Information',
submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Save',
accountNameLabel:
typeof rawProps.accountNameLabel === 'string' ? rawProps.accountNameLabel : 'Account Holder Name',
bankNameLabel: typeof rawProps.bankNameLabel === 'string' ? rawProps.bankNameLabel : 'Bank Name',
accountNumberLabel:
typeof rawProps.accountNumberLabel === 'string' ? rawProps.accountNumberLabel : 'Account Number',
routingNumberLabel:
typeof rawProps.routingNumberLabel === 'string' ? rawProps.routingNumberLabel : 'Routing Number',
programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined,
};

const hookProps = useBankingInfoForm(props);

return BankingInfoFormView({ ...props, ...hookProps });
},
'sql-banking-info-form',
[
'header-text',
'submit-label',
'account-name-label',
'bank-name-label',
'account-number-label',
'routing-number-label',
'program-id',
] as const
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { html } from 'lit';
import { BankingInfoFormProps } from './BankingInfoForm';
import { useBankingInfoForm } from './useBankingInfoForm';

const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? '');

export function BankingInfoFormView(props: BankingInfoFormProps & ReturnType<typeof useBankingInfoForm>) {
return html`
<style>
:host {
display: block;
}

.banking-form {
display: flex;
flex-direction: column;
gap: var(--sl-spacing-medium);
max-width: 480px;
}

.banking-header {
margin: 0;
font-size: var(--sl-font-size-large);
font-weight: var(--sl-font-weight-semibold);
}

form {
display: flex;
flex-direction: column;
gap: var(--sl-spacing-medium);
}
</style>
<div class="banking-form" part="sqm-base">
<h3 class="banking-header">${props.headerText}</h3>
${props.error ? html`<sl-alert variant="danger" open>${props.error}</sl-alert>` : ''}
${props.success ? html`<sl-alert variant="success" open>Banking information saved.</sl-alert>` : ''}
<form @submit="${props.onSubmit}">
<sl-input
label="${props.accountNameLabel}"
value="${props.accountName}"
@sl-input="${(event: Event) => props.setAccountName(getInputValue(event))}"
?disabled="${props.loading}"
></sl-input>
<sl-input
label="${props.bankNameLabel}"
value="${props.bankName}"
@sl-input="${(event: Event) => props.setBankName(getInputValue(event))}"
?disabled="${props.loading}"
></sl-input>
<sl-input
label="${props.accountNumberLabel}"
value="${props.accountNumber}"
@sl-input="${(event: Event) => props.setAccountNumber(getInputValue(event))}"
?disabled="${props.loading}"
></sl-input>
<sl-input
label="${props.routingNumberLabel}"
value="${props.routingNumber}"
@sl-input="${(event: Event) => props.setRoutingNumber(getInputValue(event))}"
?disabled="${props.loading}"
></sl-input>
<sl-button type="submit" variant="primary" ?loading="${props.loading}">${props.submitLabel}</sl-button>
</form>
</div>
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BankingInfoForm';
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const componentBoilerplateMock = vi.hoisted(() => ({
useProgramId: vi.fn(() => 'test-program'),
useMutation: vi.fn(() => [vi.fn().mockResolvedValue({ success: true })]),
}));

const universalHooksMock = vi.hoisted(() => {
const setters: Array<ReturnType<typeof vi.fn>> = [];
const values: unknown[] = [];

return {
setters,
values,
useState: vi.fn((initial: unknown) => {
const index = setters.length;
const setter = vi.fn();
setters.push(setter);
return [index < values.length ? values[index] : initial, setter] as const;
}),
};
});

vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock);
vi.mock('@saasquatch/universal-hooks', () => ({
useState: universalHooksMock.useState,
}));

import { useBankingInfoForm } from './useBankingInfoForm';

describe('useBankingInfoForm', () => {
const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event;

beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
universalHooksMock.setters.length = 0;
universalHooksMock.values.length = 0;
componentBoilerplateMock.useProgramId.mockReturnValue('test-program');
componentBoilerplateMock.useMutation.mockReturnValue([vi.fn().mockResolvedValue({ success: true })]);
});

it('should return initial state', () => {
const result = useBankingInfoForm({ programId: '' } as any);

expect(result.accountName).toBe('');
expect(result.bankName).toBe('');
expect(result.accountNumber).toBe('');
expect(result.routingNumber).toBe('');
expect(result.error).toBe('');
expect(result.loading).toBe(false);
expect(result.success).toBe(false);
});

it('should validate empty fields', async () => {
const submitBanking = vi.fn();
componentBoilerplateMock.useMutation.mockReturnValue([submitBanking]);
const result = useBankingInfoForm({ programId: '' } as any);
const event = createEvent();

await result.onSubmit(event);

expect(event.preventDefault).toHaveBeenCalled();
expect(universalHooksMock.setters[4]).toHaveBeenCalledWith('All fields are required');
expect(submitBanking).not.toHaveBeenCalled();
});

it('should submit banking info with the resolved program id', async () => {
universalHooksMock.values.push('Ada Lovelace', 'Bank of Tests', '123456789', '987654321');
const submitBanking = vi.fn().mockResolvedValue({ success: true });
componentBoilerplateMock.useMutation.mockReturnValue([submitBanking]);
const result = useBankingInfoForm({ programId: 'fallback-program' } as any);

await result.onSubmit(createEvent());

expect(universalHooksMock.setters[5]).toHaveBeenNthCalledWith(1, true);
expect(universalHooksMock.setters[4]).toHaveBeenCalledWith('');
expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(1, false);
expect(submitBanking).toHaveBeenCalledWith({
bankingInfo: {
accountName: 'Ada Lovelace',
bankName: 'Bank of Tests',
accountNumber: '123456789',
routingNumber: '987654321',
},
programId: 'test-program',
});
expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(2, true);
expect(universalHooksMock.setters[5]).toHaveBeenNthCalledWith(2, false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useMutation, useProgramId } from '@saasquatch/component-boilerplate';
import { useState } from '@saasquatch/universal-hooks';
import { gql } from 'graphql-request';
import { BankingInfoFormProps } from './BankingInfoForm';

const SUBMIT_BANKING = gql`
mutation submitBankingInfo($bankingInfo: BankingInfoInput!, $programId: ID) {
submitBankingInfo(bankingInfo: $bankingInfo, programId: $programId) {
success
}
}
`;

export function useBankingInfoForm(props: BankingInfoFormProps) {
const programId = useProgramId() || props.programId;
const [submitBanking] = useMutation(SUBMIT_BANKING);
const [accountName, setAccountName] = useState('');
const [bankName, setBankName] = useState('');
const [accountNumber, setAccountNumber] = useState('');
const [routingNumber, setRoutingNumber] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);

async function onSubmit(e: Event) {
e.preventDefault();
if (!accountName || !bankName || !accountNumber || !routingNumber) {
setError('All fields are required');
return;
}
setLoading(true);
setError('');
setSuccess(false);
try {
await submitBanking({
bankingInfo: { accountName, bankName, accountNumber, routingNumber },
programId,
});
setSuccess(true);
} catch {
setError('Failed to save banking information. Please try again.');
}
setLoading(false);
}

return {
accountName,
setAccountName,
bankName,
setBankName,
accountNumber,
setAccountNumber,
routingNumber,
setRoutingNumber,
error,
loading,
success,
onSubmit,
};
}
Loading