566 lines
14 KiB
Markdown
566 lines
14 KiB
Markdown
|
|
# Example Usage of New Type Patterns
|
||
|
|
|
||
|
|
This document shows practical examples of how to use the new unified type patterns from `@customer-portal/domain`.
|
||
|
|
|
||
|
|
## 1. Basic Async State Usage
|
||
|
|
|
||
|
|
### Custom Hook with New Async State
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useUser.ts
|
||
|
|
import { useState, useEffect } from 'react';
|
||
|
|
import {
|
||
|
|
AsyncState,
|
||
|
|
createIdleState,
|
||
|
|
createLoadingState,
|
||
|
|
createSuccessState,
|
||
|
|
createErrorState,
|
||
|
|
type User
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
|
||
|
|
export function useUser(userId: string) {
|
||
|
|
const [state, setState] = useState<AsyncState<User>>(createIdleState());
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!userId) return;
|
||
|
|
|
||
|
|
setState(createLoadingState());
|
||
|
|
|
||
|
|
fetchUser(userId)
|
||
|
|
.then(user => setState(createSuccessState(user)))
|
||
|
|
.catch(error => setState(createErrorState(error.message)));
|
||
|
|
}, [userId]);
|
||
|
|
|
||
|
|
return state;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Component Using Async State
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/UserProfile.tsx
|
||
|
|
import React from 'react';
|
||
|
|
import { isLoading, isError, isSuccess, isIdle } from '@customer-portal/domain';
|
||
|
|
import { useUser } from '../hooks/useUser';
|
||
|
|
|
||
|
|
interface UserProfileProps {
|
||
|
|
userId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function UserProfile({ userId }: UserProfileProps) {
|
||
|
|
const userState = useUser(userId);
|
||
|
|
|
||
|
|
if (isIdle(userState)) {
|
||
|
|
return <div>Ready to load user...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading(userState)) {
|
||
|
|
return <div className="spinner">Loading user...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isError(userState)) {
|
||
|
|
return (
|
||
|
|
<div className="error">
|
||
|
|
<h3>Error loading user</h3>
|
||
|
|
<p>{userState.error}</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isSuccess(userState)) {
|
||
|
|
return (
|
||
|
|
<div className="user-profile">
|
||
|
|
<h2>{userState.data.name}</h2>
|
||
|
|
<p>Email: {userState.data.email}</p>
|
||
|
|
<p>Phone: {userState.data.phone}</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// This should never happen with discriminated unions
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 2. Form State Usage
|
||
|
|
|
||
|
|
### Custom Form Hook
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useLoginForm.ts
|
||
|
|
import { useState } from 'react';
|
||
|
|
import {
|
||
|
|
FormState,
|
||
|
|
createFormState,
|
||
|
|
updateFormField,
|
||
|
|
getFormValues,
|
||
|
|
hasFormErrors,
|
||
|
|
isFormDirty
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
|
||
|
|
interface LoginFormData {
|
||
|
|
email: string;
|
||
|
|
password: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useLoginForm() {
|
||
|
|
const [formState, setFormState] = useState<FormState<LoginFormData>>(
|
||
|
|
createFormState({
|
||
|
|
email: '',
|
||
|
|
password: '',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
const updateField = (field: keyof LoginFormData, value: string, error?: string) => {
|
||
|
|
setFormState(prev => ({
|
||
|
|
...prev,
|
||
|
|
[field]: updateFormField(prev[field], value, error),
|
||
|
|
isValid: !error && !hasFormErrors(prev),
|
||
|
|
}));
|
||
|
|
};
|
||
|
|
|
||
|
|
const validateEmail = (email: string): string | undefined => {
|
||
|
|
if (!email) return 'Email is required';
|
||
|
|
if (!/\S+@\S+\.\S+/.test(email)) return 'Email is invalid';
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const validatePassword = (password: string): string | undefined => {
|
||
|
|
if (!password) return 'Password is required';
|
||
|
|
if (password.length < 8) return 'Password must be at least 8 characters';
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEmailChange = (email: string) => {
|
||
|
|
const error = validateEmail(email);
|
||
|
|
updateField('email', email, error);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePasswordChange = (password: string) => {
|
||
|
|
const error = validatePassword(password);
|
||
|
|
updateField('password', password, error);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSubmit = async () => {
|
||
|
|
const formData = getFormValues(formState);
|
||
|
|
|
||
|
|
// Validate all fields
|
||
|
|
const emailError = validateEmail(formData.email);
|
||
|
|
const passwordError = validatePassword(formData.password);
|
||
|
|
|
||
|
|
if (emailError || passwordError) {
|
||
|
|
setFormState(prev => ({
|
||
|
|
...prev,
|
||
|
|
email: { ...prev.email, error: emailError },
|
||
|
|
password: { ...prev.password, error: passwordError },
|
||
|
|
isValid: false,
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setFormState(prev => ({ ...prev, isSubmitting: true }));
|
||
|
|
|
||
|
|
try {
|
||
|
|
await login(formData);
|
||
|
|
// Handle success
|
||
|
|
} catch (error) {
|
||
|
|
// Handle error
|
||
|
|
setFormState(prev => ({
|
||
|
|
...prev,
|
||
|
|
isSubmitting: false,
|
||
|
|
errors: { email: 'Login failed. Please check your credentials.' }
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
formState,
|
||
|
|
handleEmailChange,
|
||
|
|
handlePasswordChange,
|
||
|
|
handleSubmit,
|
||
|
|
isValid: formState.isValid && !hasFormErrors(formState),
|
||
|
|
isDirty: isFormDirty(formState),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Login Form Component
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/LoginForm.tsx
|
||
|
|
import React from 'react';
|
||
|
|
import { useLoginForm } from '../hooks/useLoginForm';
|
||
|
|
|
||
|
|
export function LoginForm() {
|
||
|
|
const {
|
||
|
|
formState,
|
||
|
|
handleEmailChange,
|
||
|
|
handlePasswordChange,
|
||
|
|
handleSubmit,
|
||
|
|
isValid,
|
||
|
|
isDirty,
|
||
|
|
} = useLoginForm();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||
|
|
<div className="form-field">
|
||
|
|
<label htmlFor="email">Email</label>
|
||
|
|
<input
|
||
|
|
id="email"
|
||
|
|
type="email"
|
||
|
|
value={formState.email.value}
|
||
|
|
onChange={(e) => handleEmailChange(e.target.value)}
|
||
|
|
className={formState.email.error ? 'error' : ''}
|
||
|
|
/>
|
||
|
|
{formState.email.error && (
|
||
|
|
<span className="error-message">{formState.email.error}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-field">
|
||
|
|
<label htmlFor="password">Password</label>
|
||
|
|
<input
|
||
|
|
id="password"
|
||
|
|
type="password"
|
||
|
|
value={formState.password.value}
|
||
|
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||
|
|
className={formState.password.error ? 'error' : ''}
|
||
|
|
/>
|
||
|
|
{formState.password.error && (
|
||
|
|
<span className="error-message">{formState.password.error}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={!isValid || formState.isSubmitting}
|
||
|
|
className="submit-button"
|
||
|
|
>
|
||
|
|
{formState.isSubmitting ? 'Logging in...' : 'Login'}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{isDirty && (
|
||
|
|
<p className="form-hint">You have unsaved changes</p>
|
||
|
|
)}
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 3. API Integration with Adapters
|
||
|
|
|
||
|
|
### Service Layer with Adapters
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// services/userService.ts
|
||
|
|
import {
|
||
|
|
adaptWhmcsResponse,
|
||
|
|
adaptSalesforceResponse,
|
||
|
|
unwrapResponse,
|
||
|
|
isSuccessResponse,
|
||
|
|
type ApiResponse,
|
||
|
|
type User,
|
||
|
|
type WhmcsClientId,
|
||
|
|
createWhmcsClientId
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
|
||
|
|
class UserService {
|
||
|
|
async getUser(userId: string): Promise<User> {
|
||
|
|
// Get user from WHMCS
|
||
|
|
const whmcsResponse = await this.whmcsApi.getClient(
|
||
|
|
createWhmcsClientId(parseInt(userId))
|
||
|
|
);
|
||
|
|
|
||
|
|
const unifiedResponse = adaptWhmcsResponse(whmcsResponse);
|
||
|
|
return unwrapResponse(unifiedResponse);
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateUser(userId: string, userData: Partial<User>): Promise<User> {
|
||
|
|
// Update in both WHMCS and Salesforce
|
||
|
|
const [whmcsResponse, sfResponse] = await Promise.all([
|
||
|
|
this.whmcsApi.updateClient(createWhmcsClientId(parseInt(userId)), userData),
|
||
|
|
this.salesforceApi.updateContact(userId, userData),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const whmcsResult = adaptWhmcsResponse(whmcsResponse);
|
||
|
|
const sfResult = adaptSalesforceResponse(sfResponse);
|
||
|
|
|
||
|
|
// Check both responses
|
||
|
|
if (!isSuccessResponse(whmcsResult)) {
|
||
|
|
throw new Error(`WHMCS update failed: ${whmcsResult.error.message}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isSuccessResponse(sfResult)) {
|
||
|
|
throw new Error(`Salesforce update failed: ${sfResult.error.message}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return whmcsResult.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
async searchUsers(query: string): Promise<User[]> {
|
||
|
|
try {
|
||
|
|
const response = await this.api.searchUsers(query);
|
||
|
|
const adaptedResponse = adaptWhmcsResponse(response);
|
||
|
|
return unwrapResponse(adaptedResponse);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('User search failed:', error);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const userService = new UserService();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Hook with API Integration
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useUserSearch.ts
|
||
|
|
import { useState, useCallback } from 'react';
|
||
|
|
import {
|
||
|
|
AsyncState,
|
||
|
|
createIdleState,
|
||
|
|
createLoadingState,
|
||
|
|
createSuccessState,
|
||
|
|
createErrorState,
|
||
|
|
type User
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
import { userService } from '../services/userService';
|
||
|
|
|
||
|
|
export function useUserSearch() {
|
||
|
|
const [searchState, setSearchState] = useState<AsyncState<User[]>>(createIdleState());
|
||
|
|
const [query, setQuery] = useState('');
|
||
|
|
|
||
|
|
const searchUsers = useCallback(async (searchQuery: string) => {
|
||
|
|
if (!searchQuery.trim()) {
|
||
|
|
setSearchState(createIdleState());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setSearchState(createLoadingState());
|
||
|
|
|
||
|
|
try {
|
||
|
|
const users = await userService.searchUsers(searchQuery);
|
||
|
|
setSearchState(createSuccessState(users));
|
||
|
|
} catch (error) {
|
||
|
|
setSearchState(createErrorState(error instanceof Error ? error.message : 'Search failed'));
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return {
|
||
|
|
searchState,
|
||
|
|
query,
|
||
|
|
setQuery,
|
||
|
|
searchUsers,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 4. Paginated Data Usage
|
||
|
|
|
||
|
|
### Paginated List Hook
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useUserList.ts
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import {
|
||
|
|
PaginatedAsyncState,
|
||
|
|
PaginationParams,
|
||
|
|
createIdleState,
|
||
|
|
createLoadingState,
|
||
|
|
createSuccessState,
|
||
|
|
createErrorState,
|
||
|
|
validatePaginationParams,
|
||
|
|
type User
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
|
||
|
|
export function useUserList(initialParams: PaginationParams = {}) {
|
||
|
|
const [state, setState] = useState<PaginatedAsyncState<User>>(createIdleState());
|
||
|
|
const [params, setParams] = useState(() => validatePaginationParams(initialParams));
|
||
|
|
|
||
|
|
const loadUsers = useCallback(async (paginationParams: PaginationParams) => {
|
||
|
|
setState(createLoadingState());
|
||
|
|
|
||
|
|
try {
|
||
|
|
const validatedParams = validatePaginationParams(paginationParams);
|
||
|
|
const response = await userService.getUsers(validatedParams);
|
||
|
|
|
||
|
|
setState({
|
||
|
|
status: 'success',
|
||
|
|
data: response.data,
|
||
|
|
pagination: response.pagination,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
setState(createErrorState(error instanceof Error ? error.message : 'Failed to load users'));
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const goToPage = useCallback((page: number) => {
|
||
|
|
const newParams = { ...params, page };
|
||
|
|
setParams(newParams);
|
||
|
|
loadUsers(newParams);
|
||
|
|
}, [params, loadUsers]);
|
||
|
|
|
||
|
|
const changePageSize = useCallback((limit: number) => {
|
||
|
|
const newParams = { ...params, limit, page: 1 };
|
||
|
|
setParams(newParams);
|
||
|
|
loadUsers(newParams);
|
||
|
|
}, [params, loadUsers]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadUsers(params);
|
||
|
|
}, [loadUsers, params]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
state,
|
||
|
|
params,
|
||
|
|
goToPage,
|
||
|
|
changePageSize,
|
||
|
|
reload: () => loadUsers(params),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Paginated List Component
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// components/UserList.tsx
|
||
|
|
import React from 'react';
|
||
|
|
import { isLoading, isError, isSuccess } from '@customer-portal/domain';
|
||
|
|
import { useUserList } from '../hooks/useUserList';
|
||
|
|
|
||
|
|
export function UserList() {
|
||
|
|
const { state, params, goToPage, changePageSize, reload } = useUserList({
|
||
|
|
page: 1,
|
||
|
|
limit: 10,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (isLoading(state)) {
|
||
|
|
return <div className="loading">Loading users...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isError(state)) {
|
||
|
|
return (
|
||
|
|
<div className="error">
|
||
|
|
<p>Error: {state.error}</p>
|
||
|
|
<button onClick={reload}>Retry</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isSuccess(state)) {
|
||
|
|
return (
|
||
|
|
<div className="user-list">
|
||
|
|
<div className="user-list-header">
|
||
|
|
<h2>Users ({state.pagination.total})</h2>
|
||
|
|
<select
|
||
|
|
value={params.limit}
|
||
|
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||
|
|
>
|
||
|
|
<option value={10}>10 per page</option>
|
||
|
|
<option value={25}>25 per page</option>
|
||
|
|
<option value={50}>50 per page</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="users">
|
||
|
|
{state.data.map(user => (
|
||
|
|
<div key={user.id} className="user-card">
|
||
|
|
<h3>{user.name}</h3>
|
||
|
|
<p>{user.email}</p>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="pagination">
|
||
|
|
<button
|
||
|
|
disabled={!state.pagination.hasPrev}
|
||
|
|
onClick={() => goToPage(params.page - 1)}
|
||
|
|
>
|
||
|
|
Previous
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<span>
|
||
|
|
Page {state.pagination.page} of {state.pagination.totalPages}
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<button
|
||
|
|
disabled={!state.pagination.hasNext}
|
||
|
|
onClick={() => goToPage(params.page + 1)}
|
||
|
|
>
|
||
|
|
Next
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 5. Selection State Usage
|
||
|
|
|
||
|
|
### Selection Hook
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// hooks/useSelection.ts
|
||
|
|
import { useState, useCallback } from 'react';
|
||
|
|
import {
|
||
|
|
SelectionState,
|
||
|
|
createSelectionState,
|
||
|
|
updateSelectionState
|
||
|
|
} from '@customer-portal/domain';
|
||
|
|
|
||
|
|
export function useSelection<T>() {
|
||
|
|
const [selectionState, setSelectionState] = useState<SelectionState<T>>(
|
||
|
|
createSelectionState()
|
||
|
|
);
|
||
|
|
|
||
|
|
const toggleItem = useCallback((item: T) => {
|
||
|
|
setSelectionState(prev => {
|
||
|
|
const isSelected = prev.selected.includes(item);
|
||
|
|
return updateSelectionState(prev, item, !isSelected);
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const selectAll = useCallback((items: T[]) => {
|
||
|
|
setSelectionState({
|
||
|
|
selected: [...items],
|
||
|
|
selectAll: true,
|
||
|
|
indeterminate: false,
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const deselectAll = useCallback(() => {
|
||
|
|
setSelectionState(createSelectionState());
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return {
|
||
|
|
selectionState,
|
||
|
|
toggleItem,
|
||
|
|
selectAll,
|
||
|
|
deselectAll,
|
||
|
|
hasSelection: selectionState.selected.length > 0,
|
||
|
|
selectedCount: selectionState.selected.length,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Key Benefits Demonstrated
|
||
|
|
|
||
|
|
1. **Type Safety**: Impossible states are eliminated
|
||
|
|
2. **Consistency**: Same patterns across all components
|
||
|
|
3. **Reusability**: Hooks and utilities can be shared
|
||
|
|
4. **Maintainability**: Clear separation of concerns
|
||
|
|
5. **Developer Experience**: Better IntelliSense and debugging
|
||
|
|
|
||
|
|
## Next Steps
|
||
|
|
|
||
|
|
1. Use these patterns in your new components
|
||
|
|
2. Gradually migrate existing components
|
||
|
|
3. Add tests for your hooks and components
|
||
|
|
4. Customize the patterns for your specific needs
|
||
|
|
|
||
|
|
These examples show how the new type patterns lead to more robust, maintainable, and type-safe code while providing an excellent developer experience.
|