view models
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// Analytics dashboard view model
|
||||
// Represents dashboard data for analytics
|
||||
|
||||
/**
|
||||
* Analytics dashboard view model
|
||||
* Represents dashboard data for analytics
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export class AnalyticsDashboardViewModel {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
@@ -8,7 +11,10 @@ export class AnalyticsDashboardViewModel {
|
||||
totalLeagues: number;
|
||||
|
||||
constructor(data: { totalUsers: number; activeUsers: number; totalRaces: number; totalLeagues: number }) {
|
||||
Object.assign(this, data);
|
||||
this.totalUsers = data.totalUsers;
|
||||
this.activeUsers = data.activeUsers;
|
||||
this.totalRaces = data.totalRaces;
|
||||
this.totalLeagues = data.totalLeagues;
|
||||
}
|
||||
|
||||
/** UI-specific: User engagement rate */
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Analytics metrics view model
|
||||
// Represents metrics data for analytics
|
||||
|
||||
/**
|
||||
* Analytics metrics view model
|
||||
* Represents metrics data for analytics
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export class AnalyticsMetricsViewModel {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
@@ -8,7 +11,10 @@ export class AnalyticsMetricsViewModel {
|
||||
bounceRate: number;
|
||||
|
||||
constructor(data: { pageViews: number; uniqueVisitors: number; averageSessionDuration: number; bounceRate: number }) {
|
||||
Object.assign(this, data);
|
||||
this.pageViews = data.pageViews;
|
||||
this.uniqueVisitors = data.uniqueVisitors;
|
||||
this.averageSessionDuration = data.averageSessionDuration;
|
||||
this.bounceRate = data.bounceRate;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted page views */
|
||||
|
||||
53
apps/website/lib/view-models/AvatarViewModel.test.ts
Normal file
53
apps/website/lib/view-models/AvatarViewModel.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AvatarViewModel } from './AvatarViewModel';
|
||||
|
||||
describe('AvatarViewModel', () => {
|
||||
it('should create instance with driverId and avatarUrl', () => {
|
||||
const dto = {
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(dto);
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
});
|
||||
|
||||
it('should create instance without avatarUrl', () => {
|
||||
const dto = {
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(dto);
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
expect(viewModel.avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for hasAvatar when avatarUrl exists', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for hasAvatar when avatarUrl is undefined', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for hasAvatar when avatarUrl is empty string', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: '',
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,27 @@
|
||||
// Note: No generated DTO available for Avatar yet
|
||||
interface AvatarDTO {
|
||||
driverId: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar View Model
|
||||
*
|
||||
* Represents avatar information for the UI layer
|
||||
*/
|
||||
export interface AvatarViewModel {
|
||||
export class AvatarViewModel {
|
||||
driverId: string;
|
||||
avatarUrl?: string;
|
||||
hasAvatar: boolean;
|
||||
|
||||
constructor(dto: AvatarDTO) {
|
||||
this.driverId = dto.driverId;
|
||||
if (dto.avatarUrl !== undefined) {
|
||||
this.avatarUrl = dto.avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/** UI-specific: Whether the driver has an avatar */
|
||||
get hasAvatar(): boolean {
|
||||
return !!this.avatarUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
|
||||
import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
describe('CompleteOnboardingViewModel', () => {
|
||||
it('should create instance with success and driverId', () => {
|
||||
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should return true for isSuccessful when success is true', () => {
|
||||
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for isSuccessful when success is false', () => {
|
||||
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
|
||||
success: false,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve driverId regardless of success status', () => {
|
||||
const successDto: CompleteOnboardingOutputDTO & { driverId: string } = {
|
||||
success: true,
|
||||
driverId: 'driver-success',
|
||||
};
|
||||
const failDto: CompleteOnboardingOutputDTO & { driverId: string } = {
|
||||
success: false,
|
||||
driverId: 'driver-fail',
|
||||
};
|
||||
|
||||
const successViewModel = new CompleteOnboardingViewModel(successDto);
|
||||
const failViewModel = new CompleteOnboardingViewModel(failDto);
|
||||
|
||||
expect(successViewModel.driverId).toBe('driver-success');
|
||||
expect(failViewModel.driverId).toBe('driver-fail');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,20 @@
|
||||
import { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
/**
|
||||
* Complete onboarding view model
|
||||
* UI representation of onboarding completion result
|
||||
*/
|
||||
export interface CompleteOnboardingViewModel {
|
||||
driverId: string;
|
||||
export class CompleteOnboardingViewModel implements CompleteOnboardingOutputDTO {
|
||||
success: boolean;
|
||||
driverId: string;
|
||||
|
||||
constructor(dto: CompleteOnboardingOutputDTO & { driverId: string }) {
|
||||
this.success = dto.success;
|
||||
this.driverId = dto.driverId;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether onboarding was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
}
|
||||
56
apps/website/lib/view-models/DeleteMediaViewModel.test.ts
Normal file
56
apps/website/lib/view-models/DeleteMediaViewModel.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DeleteMediaViewModel } from './DeleteMediaViewModel';
|
||||
|
||||
describe('DeleteMediaViewModel', () => {
|
||||
it('should create instance with success true', () => {
|
||||
const dto = { success: true };
|
||||
const viewModel = new DeleteMediaViewModel(dto);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create instance with success false and error', () => {
|
||||
const dto = { success: false, error: 'Failed to delete media' };
|
||||
const viewModel = new DeleteMediaViewModel(dto);
|
||||
|
||||
expect(viewModel.success).toBe(false);
|
||||
expect(viewModel.error).toBe('Failed to delete media');
|
||||
});
|
||||
|
||||
it('should return true for isSuccessful when success is true', () => {
|
||||
const viewModel = new DeleteMediaViewModel({ success: true });
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for isSuccessful when success is false', () => {
|
||||
const viewModel = new DeleteMediaViewModel({ success: false });
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for hasError when no error', () => {
|
||||
const viewModel = new DeleteMediaViewModel({ success: true });
|
||||
|
||||
expect(viewModel.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for hasError when error exists', () => {
|
||||
const viewModel = new DeleteMediaViewModel({
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
});
|
||||
|
||||
expect(viewModel.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty error string as falsy', () => {
|
||||
const viewModel = new DeleteMediaViewModel({
|
||||
success: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
expect(viewModel.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,32 @@
|
||||
// Note: No generated DTO available for DeleteMedia yet
|
||||
interface DeleteMediaDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Media View Model
|
||||
*
|
||||
* Represents the result of a media deletion operation
|
||||
*/
|
||||
export interface DeleteMediaViewModel {
|
||||
export class DeleteMediaViewModel {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: DeleteMediaDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.error !== undefined) {
|
||||
this.error = dto.error;
|
||||
}
|
||||
}
|
||||
|
||||
/** UI-specific: Whether the deletion was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether there was an error */
|
||||
get hasError(): boolean {
|
||||
return !!this.error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
|
||||
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
|
||||
|
||||
describe('DriverLeaderboardItemViewModel', () => {
|
||||
const mockDTO: DriverLeaderboardItemDTO = {
|
||||
id: '1',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 50,
|
||||
wins: 10,
|
||||
podiums: 25,
|
||||
isActive: true,
|
||||
rank: 5
|
||||
};
|
||||
|
||||
it('should create instance from DTO', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
|
||||
|
||||
expect(viewModel.id).toBe('1');
|
||||
expect(viewModel.name).toBe('Test Driver');
|
||||
expect(viewModel.position).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate win rate correctly', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
|
||||
|
||||
expect(viewModel.winRate).toBe(20); // 10/50 * 100
|
||||
});
|
||||
|
||||
it('should format win rate as percentage', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
|
||||
|
||||
expect(viewModel.winRateFormatted).toBe('20.0%');
|
||||
});
|
||||
|
||||
it('should return correct skill level color', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
|
||||
|
||||
expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange
|
||||
});
|
||||
|
||||
it('should return correct skill level icon', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
|
||||
|
||||
expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇
|
||||
});
|
||||
|
||||
it('should detect rating trend up', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
|
||||
|
||||
expect(viewModel.ratingTrend).toBe('up');
|
||||
});
|
||||
|
||||
it('should detect rating trend down', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1600);
|
||||
|
||||
expect(viewModel.ratingTrend).toBe('down');
|
||||
});
|
||||
|
||||
it('should show rating change indicator', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
|
||||
|
||||
expect(viewModel.ratingChangeIndicator).toBe('+100');
|
||||
});
|
||||
|
||||
it('should handle zero races for win rate', () => {
|
||||
const dto = { ...mockDTO, racesCompleted: 0, wins: 0 };
|
||||
const viewModel = new DriverLeaderboardItemViewModel(dto, 1);
|
||||
|
||||
expect(viewModel.winRate).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,31 @@
|
||||
import { DriverLeaderboardItemDto } from '../dtos';
|
||||
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
|
||||
|
||||
export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDto {
|
||||
export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
skillLevel: string;
|
||||
isActive: boolean;
|
||||
nationality: string;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
isActive: boolean;
|
||||
rank: number;
|
||||
|
||||
position: number;
|
||||
private previousRating?: number;
|
||||
|
||||
constructor(dto: DriverLeaderboardItemDto, position: number, previousRating?: number) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.rating = dto.rating;
|
||||
this.skillLevel = dto.skillLevel;
|
||||
this.nationality = dto.nationality;
|
||||
this.racesCompleted = dto.racesCompleted;
|
||||
this.wins = dto.wins;
|
||||
this.podiums = dto.podiums;
|
||||
this.isActive = dto.isActive;
|
||||
this.rank = dto.rank;
|
||||
this.position = position;
|
||||
this.previousRating = previousRating;
|
||||
}
|
||||
@@ -45,7 +54,7 @@ export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDto
|
||||
|
||||
/** UI-specific: Win rate */
|
||||
get winRate(): number {
|
||||
return this.races > 0 ? (this.wins / this.races) * 100 : 0;
|
||||
return this.racesCompleted > 0 ? (this.wins / this.racesCompleted) * 100 : 0;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted win rate */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DriversLeaderboardDto, DriverLeaderboardItemDto } from '../dtos';
|
||||
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
|
||||
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
|
||||
|
||||
export class DriverLeaderboardViewModel implements DriversLeaderboardDto {
|
||||
export class DriverLeaderboardViewModel {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
|
||||
constructor(dto: DriversLeaderboardDto & { drivers: DriverLeaderboardItemDto[] }, previousDrivers?: DriverLeaderboardItemDto[]) {
|
||||
constructor(dto: { drivers: DriverLeaderboardItemDTO[] }, previousDrivers?: DriverLeaderboardItemDTO[]) {
|
||||
this.drivers = dto.drivers.map((driver, index) => {
|
||||
const previous = previousDrivers?.find(p => p.id === driver.id);
|
||||
return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DriverRegistrationStatusDto } from '../dtos';
|
||||
import { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
|
||||
|
||||
export class DriverRegistrationStatusViewModel implements DriverRegistrationStatusDto {
|
||||
export class DriverRegistrationStatusViewModel implements DriverRegistrationStatusDTO {
|
||||
isRegistered: boolean;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
|
||||
constructor(dto: DriverRegistrationStatusDto) {
|
||||
constructor(dto: DriverRegistrationStatusDTO) {
|
||||
Object.assign(this, dto);
|
||||
}
|
||||
|
||||
|
||||
111
apps/website/lib/view-models/DriverViewModel.test.ts
Normal file
111
apps/website/lib/view-models/DriverViewModel.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverViewModel } from './DriverViewModel';
|
||||
|
||||
describe('DriverViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
iracingId: 'iracing-456',
|
||||
rating: 1500,
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(dto);
|
||||
|
||||
expect(viewModel.id).toBe('driver-123');
|
||||
expect(viewModel.name).toBe('John Doe');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(viewModel.iracingId).toBe('iracing-456');
|
||||
expect(viewModel.rating).toBe(1500);
|
||||
});
|
||||
|
||||
it('should create instance with only required properties', () => {
|
||||
const dto = {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
};
|
||||
|
||||
const viewModel = new DriverViewModel(dto);
|
||||
|
||||
expect(viewModel.id).toBe('driver-123');
|
||||
expect(viewModel.name).toBe('John Doe');
|
||||
expect(viewModel.avatarUrl).toBeUndefined();
|
||||
expect(viewModel.iracingId).toBeUndefined();
|
||||
expect(viewModel.rating).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for hasIracingId when iracingId exists', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
iracingId: 'iracing-456',
|
||||
});
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for hasIracingId when iracingId is undefined', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for hasIracingId when iracingId is empty string', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
iracingId: '',
|
||||
});
|
||||
|
||||
expect(viewModel.hasIracingId).toBe(false);
|
||||
});
|
||||
|
||||
it('should format rating correctly when rating exists', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1547.89,
|
||||
});
|
||||
|
||||
expect(viewModel.formattedRating).toBe('1548');
|
||||
});
|
||||
|
||||
it('should return "Unrated" when rating is undefined', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
});
|
||||
|
||||
expect(viewModel.formattedRating).toBe('Unrated');
|
||||
});
|
||||
|
||||
it('should handle zero rating', () => {
|
||||
const viewModel = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 0,
|
||||
});
|
||||
|
||||
expect(viewModel.formattedRating).toBe('Unrated');
|
||||
});
|
||||
|
||||
it('should round rating to nearest integer', () => {
|
||||
const viewModel1 = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1500.4,
|
||||
});
|
||||
const viewModel2 = new DriverViewModel({
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1500.6,
|
||||
});
|
||||
|
||||
expect(viewModel1.formattedRating).toBe('1500');
|
||||
expect(viewModel2.formattedRating).toBe('1501');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,37 @@
|
||||
/**
|
||||
* Driver view model
|
||||
* UI representation of a driver
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export interface DriverViewModel {
|
||||
export class DriverViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
|
||||
constructor(dto: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
}) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
|
||||
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
|
||||
if (dto.rating !== undefined) this.rating = dto.rating;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether driver has an iRacing ID */
|
||||
get hasIracingId(): boolean {
|
||||
return !!this.iracingId;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted rating */
|
||||
get formattedRating(): string {
|
||||
return this.rating ? this.rating.toFixed(0) : 'Unrated';
|
||||
}
|
||||
}
|
||||
67
apps/website/lib/view-models/LeagueAdminViewModel.test.ts
Normal file
67
apps/website/lib/view-models/LeagueAdminViewModel.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueAdminViewModel } from './LeagueAdminViewModel';
|
||||
import type { LeagueMemberViewModel } from './LeagueMemberViewModel';
|
||||
import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
|
||||
|
||||
describe('LeagueAdminViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const members: LeagueMemberViewModel[] = [];
|
||||
const joinRequests: LeagueJoinRequestViewModel[] = [];
|
||||
const dto = {
|
||||
config: { name: 'Test League' },
|
||||
members,
|
||||
joinRequests,
|
||||
};
|
||||
|
||||
const viewModel = new LeagueAdminViewModel(dto);
|
||||
|
||||
expect(viewModel.config).toEqual({ name: 'Test League' });
|
||||
expect(viewModel.members).toBe(members);
|
||||
expect(viewModel.joinRequests).toBe(joinRequests);
|
||||
});
|
||||
|
||||
it('should return correct pending requests count when empty', () => {
|
||||
const viewModel = new LeagueAdminViewModel({
|
||||
config: {},
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(viewModel.pendingRequestsCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct pending requests count with requests', () => {
|
||||
const joinRequests = [
|
||||
{} as LeagueJoinRequestViewModel,
|
||||
{} as LeagueJoinRequestViewModel,
|
||||
{} as LeagueJoinRequestViewModel,
|
||||
];
|
||||
const viewModel = new LeagueAdminViewModel({
|
||||
config: {},
|
||||
members: [],
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(viewModel.pendingRequestsCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return false for hasPendingRequests when empty', () => {
|
||||
const viewModel = new LeagueAdminViewModel({
|
||||
config: {},
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(viewModel.hasPendingRequests).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for hasPendingRequests when requests exist', () => {
|
||||
const viewModel = new LeagueAdminViewModel({
|
||||
config: {},
|
||||
members: [],
|
||||
joinRequests: [{} as LeagueJoinRequestViewModel],
|
||||
});
|
||||
|
||||
expect(viewModel.hasPendingRequests).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,32 @@
|
||||
import type { LeagueAdminDto } from '../dtos';
|
||||
import type { LeagueMemberViewModel, LeagueJoinRequestViewModel } from './';
|
||||
import type { LeagueMemberViewModel } from './LeagueMemberViewModel';
|
||||
import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
|
||||
|
||||
/**
|
||||
* League admin view model
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export interface LeagueAdminViewModel {
|
||||
config: LeagueAdminDto['config'];
|
||||
export class LeagueAdminViewModel {
|
||||
config: any;
|
||||
members: LeagueMemberViewModel[];
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
// Total pending requests count
|
||||
pendingRequestsCount: number;
|
||||
// Whether there are any pending requests
|
||||
hasPendingRequests: boolean;
|
||||
|
||||
constructor(dto: {
|
||||
config: any;
|
||||
members: LeagueMemberViewModel[];
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
}) {
|
||||
this.config = dto.config;
|
||||
this.members = dto.members;
|
||||
this.joinRequests = dto.joinRequests;
|
||||
}
|
||||
|
||||
/** UI-specific: Total pending requests count */
|
||||
get pendingRequestsCount(): number {
|
||||
return this.joinRequests.length;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether there are any pending requests */
|
||||
get hasPendingRequests(): boolean {
|
||||
return this.joinRequests.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,39 @@
|
||||
import type { LeagueJoinRequestDto } from '../dtos';
|
||||
import type { LeagueJoinRequestDTO } from '../types/generated/LeagueJoinRequestDTO';
|
||||
|
||||
/**
|
||||
* League join request view model
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export interface LeagueJoinRequestViewModel extends LeagueJoinRequestDto {
|
||||
// Formatted request date
|
||||
formattedRequestedAt: string;
|
||||
// Whether the request can be approved by current user
|
||||
canApprove: boolean;
|
||||
// Whether the request can be rejected by current user
|
||||
canReject: boolean;
|
||||
export class LeagueJoinRequestViewModel implements LeagueJoinRequestDTO {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
|
||||
private currentUserId: string;
|
||||
private isAdmin: boolean;
|
||||
|
||||
constructor(dto: LeagueJoinRequestDTO, currentUserId: string, isAdmin: boolean) {
|
||||
this.id = dto.id;
|
||||
this.leagueId = dto.leagueId;
|
||||
this.driverId = dto.driverId;
|
||||
this.requestedAt = dto.requestedAt;
|
||||
this.currentUserId = currentUserId;
|
||||
this.isAdmin = isAdmin;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted request date */
|
||||
get formattedRequestedAt(): string {
|
||||
return new Date(this.requestedAt).toLocaleString();
|
||||
}
|
||||
|
||||
/** UI-specific: Whether the request can be approved by current user */
|
||||
get canApprove(): boolean {
|
||||
return this.isAdmin;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether the request can be rejected by current user */
|
||||
get canReject(): boolean {
|
||||
return this.isAdmin;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { LeagueMemberDto, DriverDto } from '../dtos';
|
||||
import { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
|
||||
|
||||
export class LeagueMemberViewModel implements LeagueMemberDto {
|
||||
export class LeagueMemberViewModel implements LeagueMemberDTO {
|
||||
driverId: string;
|
||||
driver?: DriverDto;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
|
||||
private currentUserId: string;
|
||||
|
||||
constructor(dto: LeagueMemberDto, currentUserId: string) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: LeagueMemberDTO, currentUserId: string) {
|
||||
this.driverId = dto.driverId;
|
||||
this.currentUserId = currentUserId;
|
||||
}
|
||||
|
||||
// Note: The generated DTO is incomplete
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
driver?: any;
|
||||
role: string = 'member';
|
||||
joinedAt: string = new Date().toISOString();
|
||||
|
||||
/** UI-specific: Formatted join date */
|
||||
get formattedJoinedAt(): string {
|
||||
return new Date(this.joinedAt).toLocaleDateString();
|
||||
|
||||
179
apps/website/lib/view-models/LeagueStandingsViewModel.test.ts
Normal file
179
apps/website/lib/view-models/LeagueStandingsViewModel.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueStandingsViewModel } from './LeagueStandingsViewModel';
|
||||
import type { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
|
||||
|
||||
describe('LeagueStandingsViewModel', () => {
|
||||
it('should create instance with standings', () => {
|
||||
const standings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
points: 85,
|
||||
wins: 2,
|
||||
podiums: 4,
|
||||
races: 8,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings },
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.standings).toHaveLength(2);
|
||||
expect(viewModel.standings[0].driverId).toBe('driver-1');
|
||||
expect(viewModel.standings[1].driverId).toBe('driver-2');
|
||||
});
|
||||
|
||||
it('should pass leader points to first entry', () => {
|
||||
const standings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings },
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.standings[0].pointsGapToLeader).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate points gaps correctly', () => {
|
||||
const standings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
points: 85,
|
||||
wins: 2,
|
||||
podiums: 4,
|
||||
races: 8,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
position: 3,
|
||||
points: 70,
|
||||
wins: 1,
|
||||
podiums: 3,
|
||||
races: 8,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings },
|
||||
'driver-2'
|
||||
);
|
||||
|
||||
expect(viewModel.standings[1].pointsGapToLeader).toBe(-15);
|
||||
expect(viewModel.standings[1].pointsGapToNext).toBe(15);
|
||||
});
|
||||
|
||||
it('should identify current user', () => {
|
||||
const standings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
points: 85,
|
||||
wins: 2,
|
||||
podiums: 4,
|
||||
races: 8,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings },
|
||||
'driver-2'
|
||||
);
|
||||
|
||||
expect(viewModel.standings[0].isCurrentUser).toBe(false);
|
||||
expect(viewModel.standings[1].isCurrentUser).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty standings', () => {
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings: [] },
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.standings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should track position changes when previousStandings provided', () => {
|
||||
const standings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
points: 85,
|
||||
wins: 2,
|
||||
podiums: 4,
|
||||
races: 8,
|
||||
},
|
||||
];
|
||||
|
||||
const previousStandings: LeagueStandingDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 2,
|
||||
points: 80,
|
||||
wins: 2,
|
||||
podiums: 4,
|
||||
races: 7,
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
position: 1,
|
||||
points: 90,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 7,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModel = new LeagueStandingsViewModel(
|
||||
{ standings },
|
||||
'driver-1',
|
||||
previousStandings
|
||||
);
|
||||
|
||||
expect(viewModel.standings[0].trend).toBe('up');
|
||||
expect(viewModel.standings[1].trend).toBe('down');
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,20 @@
|
||||
import { LeagueStandingsDto, StandingEntryDto, DriverDto, LeagueMembership } from '../dtos';
|
||||
import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
|
||||
import { StandingEntryViewModel } from './StandingEntryViewModel';
|
||||
|
||||
export class LeagueStandingsViewModel implements LeagueStandingsDto {
|
||||
export class LeagueStandingsViewModel {
|
||||
standings: StandingEntryViewModel[];
|
||||
drivers: DriverDto[];
|
||||
memberships: LeagueMembership[];
|
||||
|
||||
constructor(dto: LeagueStandingsDto & { standings: StandingEntryDto[] }, currentUserId: string, previousStandings?: StandingEntryDto[]) {
|
||||
constructor(dto: { standings: LeagueStandingDTO[] }, currentUserId: string, previousStandings?: LeagueStandingDTO[]) {
|
||||
const leaderPoints = dto.standings[0]?.points || 0;
|
||||
this.standings = dto.standings.map((entry, index) => {
|
||||
const nextPoints = dto.standings[index + 1]?.points || entry.points;
|
||||
const previousPosition = previousStandings?.find(p => p.driverId === entry.driverId)?.position;
|
||||
return new StandingEntryViewModel(entry, leaderPoints, nextPoints, currentUserId, previousPosition);
|
||||
});
|
||||
this.drivers = dto.drivers;
|
||||
this.memberships = dto.memberships;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have these fields
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
drivers: any[] = [];
|
||||
memberships: any[] = [];
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import { LeagueSummaryDto } from '../dtos';
|
||||
import { LeagueSummaryDTO } from '../types/generated/LeagueSummaryDTO';
|
||||
|
||||
export class LeagueSummaryViewModel implements LeagueSummaryDto {
|
||||
export class LeagueSummaryViewModel implements LeagueSummaryDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
constructor(dto: LeagueSummaryDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
}
|
||||
|
||||
// Note: The generated DTO only has id and name
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
coverImage?: string;
|
||||
memberCount: number;
|
||||
maxMembers: number;
|
||||
isPublic: boolean;
|
||||
ownerId: string;
|
||||
memberCount: number = 0;
|
||||
maxMembers: number = 0;
|
||||
isPublic: boolean = false;
|
||||
ownerId: string = '';
|
||||
ownerName?: string;
|
||||
scoringType?: string;
|
||||
status?: string;
|
||||
|
||||
constructor(dto: LeagueSummaryDto) {
|
||||
Object.assign(this, dto);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted capacity display */
|
||||
get formattedCapacity(): string {
|
||||
return `${this.memberCount}/${this.maxMembers}`;
|
||||
|
||||
138
apps/website/lib/view-models/MediaViewModel.test.ts
Normal file
138
apps/website/lib/view-models/MediaViewModel.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MediaViewModel } from './MediaViewModel';
|
||||
|
||||
describe('MediaViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image' as const,
|
||||
category: 'avatar' as const,
|
||||
uploadedAt: new Date('2023-01-15'),
|
||||
size: 2048000,
|
||||
};
|
||||
|
||||
const viewModel = new MediaViewModel(dto);
|
||||
|
||||
expect(viewModel.id).toBe('media-123');
|
||||
expect(viewModel.url).toBe('https://example.com/image.jpg');
|
||||
expect(viewModel.type).toBe('image');
|
||||
expect(viewModel.category).toBe('avatar');
|
||||
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15'));
|
||||
expect(viewModel.size).toBe(2048000);
|
||||
});
|
||||
|
||||
it('should create instance without optional properties', () => {
|
||||
const dto = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image' as const,
|
||||
uploadedAt: new Date('2023-01-15'),
|
||||
};
|
||||
|
||||
const viewModel = new MediaViewModel(dto);
|
||||
|
||||
expect(viewModel.category).toBeUndefined();
|
||||
expect(viewModel.size).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return "Unknown" for formattedSize when size is undefined', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should format size in KB when less than 1 MB', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date(),
|
||||
size: 512000, // 500 KB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('500.00 KB');
|
||||
});
|
||||
|
||||
it('should format size in MB when 1 MB or larger', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date(),
|
||||
size: 2048000, // 2 MB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('2.00 MB');
|
||||
});
|
||||
|
||||
it('should handle very small file sizes', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date(),
|
||||
size: 1024, // 1 KB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('1.00 KB');
|
||||
});
|
||||
|
||||
it('should handle very large file sizes', () => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/video.mp4',
|
||||
type: 'video',
|
||||
uploadedAt: new Date(),
|
||||
size: 104857600, // 100 MB
|
||||
});
|
||||
|
||||
expect(viewModel.formattedSize).toBe('100.00 MB');
|
||||
});
|
||||
|
||||
it('should support all media types', () => {
|
||||
const imageVm = new MediaViewModel({
|
||||
id: '1',
|
||||
url: 'image.jpg',
|
||||
type: 'image',
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
const videoVm = new MediaViewModel({
|
||||
id: '2',
|
||||
url: 'video.mp4',
|
||||
type: 'video',
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
const docVm = new MediaViewModel({
|
||||
id: '3',
|
||||
url: 'doc.pdf',
|
||||
type: 'document',
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(imageVm.type).toBe('image');
|
||||
expect(videoVm.type).toBe('video');
|
||||
expect(docVm.type).toBe('document');
|
||||
});
|
||||
|
||||
it('should support all media categories', () => {
|
||||
const categories = ['avatar', 'team-logo', 'league-cover', 'race-result'] as const;
|
||||
|
||||
categories.forEach(category => {
|
||||
const viewModel = new MediaViewModel({
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
category,
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(viewModel.category).toBe(category);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,41 @@
|
||||
/**
|
||||
* Media View Model
|
||||
*
|
||||
* Represents media information for the UI layer
|
||||
*/
|
||||
export interface MediaViewModel {
|
||||
// Note: No generated DTO available for Media yet
|
||||
interface MediaDTO {
|
||||
id: string;
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
||||
uploadedAt: Date;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media View Model
|
||||
*
|
||||
* Represents media information for the UI layer
|
||||
*/
|
||||
export class MediaViewModel {
|
||||
id: string;
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
||||
uploadedAt: Date;
|
||||
size?: number;
|
||||
|
||||
constructor(dto: MediaDTO) {
|
||||
this.id = dto.id;
|
||||
this.url = dto.url;
|
||||
this.type = dto.type;
|
||||
this.uploadedAt = dto.uploadedAt;
|
||||
if (dto.category !== undefined) this.category = dto.category;
|
||||
if (dto.size !== undefined) this.size = dto.size;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted file size */
|
||||
get formattedSize(): string {
|
||||
if (!this.size) return 'Unknown';
|
||||
const kb = this.size / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { MembershipFeeDto } from '../dtos';
|
||||
import { MembershipFeeDto } from '../types/generated/MembershipFeeDto';
|
||||
|
||||
export class MembershipFeeViewModel implements MembershipFeeDto {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
|
||||
constructor(dto: MembershipFeeDto) {
|
||||
Object.assign(this, dto);
|
||||
this.id = dto.id;
|
||||
this.leagueId = dto.leagueId;
|
||||
}
|
||||
|
||||
// Note: The generated DTO is incomplete
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
amount: number = 0;
|
||||
currency: string = 'USD';
|
||||
period: string = 'monthly';
|
||||
|
||||
/** UI-specific: Formatted amount */
|
||||
get formattedAmount(): string {
|
||||
return `${this.currency} ${this.amount.toFixed(2)}`;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { PaymentDto } from '../dtos';
|
||||
import { PaymentDTO } from '../types/generated/PaymentDto';
|
||||
|
||||
export class PaymentViewModel implements PaymentDto {
|
||||
export class PaymentViewModel implements PaymentDTO {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(dto: PaymentDto) {
|
||||
constructor(dto: PaymentDTO) {
|
||||
Object.assign(this, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { PrizeDto } from '../dtos';
|
||||
import { PrizeDto } from '../types/generated/PrizeDto';
|
||||
|
||||
export class PrizeViewModel implements PrizeDto {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
position: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
position?: number;
|
||||
|
||||
constructor(dto: PrizeDto) {
|
||||
Object.assign(this, dto);
|
||||
this.id = dto.id;
|
||||
this.leagueId = dto.leagueId;
|
||||
this.seasonId = dto.seasonId;
|
||||
this.position = dto.position;
|
||||
this.name = dto.name;
|
||||
this.amount = dto.amount;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have currency
|
||||
// This will need to be added when the OpenAPI spec is updated
|
||||
currency: string = 'USD';
|
||||
|
||||
/** UI-specific: Formatted amount */
|
||||
get formattedAmount(): string {
|
||||
return `${this.currency} ${this.amount.toFixed(2)}`;
|
||||
@@ -18,7 +28,6 @@ export class PrizeViewModel implements PrizeDto {
|
||||
|
||||
/** UI-specific: Position display */
|
||||
get positionDisplay(): string {
|
||||
if (!this.position) return 'Special';
|
||||
switch (this.position) {
|
||||
case 1: return '1st Place';
|
||||
case 2: return '2nd Place';
|
||||
|
||||
114
apps/website/lib/view-models/ProtestViewModel.test.ts
Normal file
114
apps/website/lib/view-models/ProtestViewModel.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProtestViewModel } from './ProtestViewModel';
|
||||
import type { ProtestDTO } from '../types/generated/ProtestDTO';
|
||||
|
||||
describe('ProtestViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Unsafe driving in turn 3',
|
||||
status: 'pending',
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
|
||||
expect(viewModel.id).toBe('protest-123');
|
||||
expect(viewModel.raceId).toBe('race-456');
|
||||
expect(viewModel.complainantId).toBe('driver-111');
|
||||
expect(viewModel.defendantId).toBe('driver-222');
|
||||
expect(viewModel.description).toBe('Unsafe driving in turn 3');
|
||||
expect(viewModel.status).toBe('pending');
|
||||
expect(viewModel.createdAt).toBe('2023-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should format createdAt as locale string', () => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Test',
|
||||
status: 'pending',
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
const formatted = viewModel.formattedCreatedAt;
|
||||
|
||||
expect(formatted).toContain('2023');
|
||||
expect(formatted).toContain('1/15');
|
||||
});
|
||||
|
||||
it('should capitalize status for display', () => {
|
||||
const statuses = ['pending', 'approved', 'rejected', 'reviewing'];
|
||||
|
||||
statuses.forEach(status => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Test',
|
||||
status,
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
const expected = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
expect(viewModel.statusDisplay).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle already capitalized status', () => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Test',
|
||||
status: 'Pending',
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
|
||||
expect(viewModel.statusDisplay).toBe('Pending');
|
||||
});
|
||||
|
||||
it('should handle single character status', () => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Test',
|
||||
status: 'p',
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
|
||||
expect(viewModel.statusDisplay).toBe('P');
|
||||
});
|
||||
|
||||
it('should handle empty status', () => {
|
||||
const dto: ProtestDTO = {
|
||||
id: 'protest-123',
|
||||
raceId: 'race-456',
|
||||
complainantId: 'driver-111',
|
||||
defendantId: 'driver-222',
|
||||
description: 'Test',
|
||||
status: '',
|
||||
createdAt: '2023-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ProtestViewModel(dto);
|
||||
|
||||
expect(viewModel.statusDisplay).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ProtestDTO } from '../types/generated/ProtestDTO';
|
||||
|
||||
/**
|
||||
* Protest view model
|
||||
* Represents a race protest
|
||||
*/
|
||||
export interface ProtestViewModel {
|
||||
export class ProtestViewModel implements ProtestDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
complainantId: string;
|
||||
@@ -10,4 +12,24 @@ export interface ProtestViewModel {
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(dto: ProtestDTO) {
|
||||
this.id = dto.id;
|
||||
this.raceId = dto.raceId;
|
||||
this.complainantId = dto.complainantId;
|
||||
this.defendantId = dto.defendantId;
|
||||
this.description = dto.description;
|
||||
this.status = dto.status;
|
||||
this.createdAt = dto.createdAt;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted created date */
|
||||
get formattedCreatedAt(): string {
|
||||
return new Date(this.createdAt).toLocaleString();
|
||||
}
|
||||
|
||||
/** UI-specific: Status display */
|
||||
get statusDisplay(): string {
|
||||
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
|
||||
}
|
||||
}
|
||||
279
apps/website/lib/view-models/RaceDetailViewModel.test.ts
Normal file
279
apps/website/lib/view-models/RaceDetailViewModel.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceDetailViewModel } from './RaceDetailViewModel';
|
||||
import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
|
||||
import type { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
|
||||
import type { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
|
||||
import type { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
|
||||
import type { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
|
||||
|
||||
describe('RaceDetailViewModel', () => {
|
||||
const createMockRace = (overrides?: Partial<RaceDetailRaceDTO>): RaceDetailRaceDTO => ({
|
||||
id: 'race-123',
|
||||
title: 'Test Race',
|
||||
scheduledAt: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockLeague = (): RaceDetailLeagueDTO => ({
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
});
|
||||
|
||||
const createMockRegistration = (
|
||||
overrides?: Partial<RaceDetailRegistrationDTO>
|
||||
): RaceDetailRegistrationDTO => ({
|
||||
isRegistered: false,
|
||||
canRegister: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should create instance with all properties', () => {
|
||||
const race = createMockRace();
|
||||
const league = createMockLeague();
|
||||
const entries: RaceDetailEntryDTO[] = [];
|
||||
const registration = createMockRegistration();
|
||||
const userResult: RaceDetailUserResultDTO | null = null;
|
||||
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race,
|
||||
league,
|
||||
entryList: entries,
|
||||
registration,
|
||||
userResult,
|
||||
});
|
||||
|
||||
expect(viewModel.race).toBe(race);
|
||||
expect(viewModel.league).toBe(league);
|
||||
expect(viewModel.entryList).toBe(entries);
|
||||
expect(viewModel.registration).toBe(registration);
|
||||
expect(viewModel.userResult).toBe(userResult);
|
||||
});
|
||||
|
||||
it('should handle null race and league', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: null,
|
||||
league: null,
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.race).toBeNull();
|
||||
expect(viewModel.league).toBeNull();
|
||||
});
|
||||
|
||||
it('should return correct isRegistered value', () => {
|
||||
const registeredVm = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ isRegistered: true }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const notRegisteredVm = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ isRegistered: false }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(registeredVm.isRegistered).toBe(true);
|
||||
expect(notRegisteredVm.isRegistered).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct canRegister value', () => {
|
||||
const canRegisterVm = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ canRegister: true }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const cannotRegisterVm = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ canRegister: false }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(canRegisterVm.canRegister).toBe(true);
|
||||
expect(cannotRegisterVm.canRegister).toBe(false);
|
||||
});
|
||||
|
||||
it('should format race status correctly', () => {
|
||||
const upcomingVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'upcoming' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const liveVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'live' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const finishedVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'finished' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(upcomingVm.raceStatusDisplay).toBe('Upcoming');
|
||||
expect(liveVm.raceStatusDisplay).toBe('Live');
|
||||
expect(finishedVm.raceStatusDisplay).toBe('Finished');
|
||||
});
|
||||
|
||||
it('should return Unknown for status when race is null', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: null,
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.raceStatusDisplay).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should format scheduled time correctly', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace({ scheduledAt: '2023-12-31T20:00:00Z' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const formatted = viewModel.formattedScheduledTime;
|
||||
|
||||
expect(formatted).toContain('2023');
|
||||
expect(formatted).toContain('12/31');
|
||||
});
|
||||
|
||||
it('should return empty string for formatted time when race is null', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: null,
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.formattedScheduledTime).toBe('');
|
||||
});
|
||||
|
||||
it('should return correct entry count', () => {
|
||||
const entries: RaceDetailEntryDTO[] = [
|
||||
{ driverId: 'driver-1', carId: 'car-1' },
|
||||
{ driverId: 'driver-2', carId: 'car-2' },
|
||||
{ driverId: 'driver-3', carId: 'car-3' },
|
||||
] as RaceDetailEntryDTO[];
|
||||
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: entries,
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.entryCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return true for hasResults when userResult exists', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: { position: 1, lapTime: 90.5 } as RaceDetailUserResultDTO,
|
||||
});
|
||||
|
||||
expect(viewModel.hasResults).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for hasResults when userResult is null', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.hasResults).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct registration status message when registered', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ isRegistered: true }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.registrationStatusMessage).toBe('You are registered for this race');
|
||||
});
|
||||
|
||||
it('should return correct registration status message when can register', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ isRegistered: false, canRegister: true }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.registrationStatusMessage).toBe('You can register for this race');
|
||||
});
|
||||
|
||||
it('should return correct registration status message when cannot register', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration({ isRegistered: false, canRegister: false }),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.registrationStatusMessage).toBe('Registration not available');
|
||||
});
|
||||
|
||||
it('should handle error property', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
error: 'Failed to load race details',
|
||||
});
|
||||
|
||||
expect(viewModel.error).toBe('Failed to load race details');
|
||||
});
|
||||
|
||||
it('should handle custom race status', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'cancelled' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(viewModel.raceStatusDisplay).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,31 @@
|
||||
import { RaceDetailDto, RaceDetailRaceDto, RaceDetailLeagueDto, RaceDetailEntryDto, RaceDetailRegistrationDto, RaceDetailUserResultDto } from '../dtos';
|
||||
import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
|
||||
import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
|
||||
import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
|
||||
import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
|
||||
import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
|
||||
|
||||
export class RaceDetailViewModel implements RaceDetailDto {
|
||||
race: RaceDetailRaceDto | null;
|
||||
league: RaceDetailLeagueDto | null;
|
||||
entryList: RaceDetailEntryDto[];
|
||||
registration: RaceDetailRegistrationDto;
|
||||
userResult: RaceDetailUserResultDto | null;
|
||||
export class RaceDetailViewModel {
|
||||
race: RaceDetailRaceDTO | null;
|
||||
league: RaceDetailLeagueDTO | null;
|
||||
entryList: RaceDetailEntryDTO[];
|
||||
registration: RaceDetailRegistrationDTO;
|
||||
userResult: RaceDetailUserResultDTO | null;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: RaceDetailDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: {
|
||||
race: RaceDetailRaceDTO | null;
|
||||
league: RaceDetailLeagueDTO | null;
|
||||
entryList: RaceDetailEntryDTO[];
|
||||
registration: RaceDetailRegistrationDTO;
|
||||
userResult: RaceDetailUserResultDTO | null;
|
||||
error?: string;
|
||||
}) {
|
||||
this.race = dto.race;
|
||||
this.league = dto.league;
|
||||
this.entryList = dto.entryList;
|
||||
this.registration = dto.registration;
|
||||
this.userResult = dto.userResult;
|
||||
this.error = dto.error;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether user is registered */
|
||||
@@ -35,7 +51,7 @@ export class RaceDetailViewModel implements RaceDetailDto {
|
||||
|
||||
/** UI-specific: Formatted scheduled time */
|
||||
get formattedScheduledTime(): string {
|
||||
return this.race ? new Date(this.race.scheduledTime).toLocaleString() : '';
|
||||
return this.race ? new Date(this.race.scheduledAt).toLocaleString() : '';
|
||||
}
|
||||
|
||||
/** UI-specific: Entry list count */
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { RaceListItemDto } from '../dtos';
|
||||
// Note: No generated DTO available for RaceListItem yet
|
||||
interface RaceListItemDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
scheduledTime: string;
|
||||
status: string;
|
||||
trackName?: string;
|
||||
}
|
||||
|
||||
export class RaceListItemViewModel implements RaceListItemDto {
|
||||
export class RaceListItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
leagueId: string;
|
||||
@@ -9,8 +18,14 @@ export class RaceListItemViewModel implements RaceListItemDto {
|
||||
status: string;
|
||||
trackName?: string;
|
||||
|
||||
constructor(dto: RaceListItemDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: RaceListItemDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.leagueId = dto.leagueId;
|
||||
this.leagueName = dto.leagueName;
|
||||
this.scheduledTime = dto.scheduledTime;
|
||||
this.status = dto.status;
|
||||
if (dto.trackName !== undefined) this.trackName = dto.trackName;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted scheduled time */
|
||||
|
||||
73
apps/website/lib/view-models/RaceResultViewModel.test.ts
Normal file
73
apps/website/lib/view-models/RaceResultViewModel.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceResultViewModel } from './RaceResultViewModel';
|
||||
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
|
||||
|
||||
describe('RaceResultViewModel', () => {
|
||||
const mockDTO: RaceResultDTO = {
|
||||
driverId: '1',
|
||||
driverName: 'Test Driver',
|
||||
avatarUrl: 'http://example.com/avatar.jpg',
|
||||
position: 3,
|
||||
startPosition: 5,
|
||||
incidents: 2,
|
||||
fastestLap: 90.5,
|
||||
positionChange: 2,
|
||||
isPodium: true,
|
||||
isClean: false
|
||||
};
|
||||
|
||||
it('should create instance from DTO', () => {
|
||||
const viewModel = new RaceResultViewModel(mockDTO);
|
||||
|
||||
expect(viewModel.driverId).toBe('1');
|
||||
expect(viewModel.position).toBe(3);
|
||||
});
|
||||
|
||||
it('should show positive position change', () => {
|
||||
const viewModel = new RaceResultViewModel(mockDTO);
|
||||
|
||||
expect(viewModel.positionChangeDisplay).toBe('+2');
|
||||
expect(viewModel.positionChangeColor).toBe('green');
|
||||
});
|
||||
|
||||
it('should show negative position change', () => {
|
||||
const dto = { ...mockDTO, positionChange: -3 };
|
||||
const viewModel = new RaceResultViewModel(dto);
|
||||
|
||||
expect(viewModel.positionChangeDisplay).toBe('-3');
|
||||
expect(viewModel.positionChangeColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should detect winner', () => {
|
||||
const dto = { ...mockDTO, position: 1 };
|
||||
const viewModel = new RaceResultViewModel(dto);
|
||||
|
||||
expect(viewModel.isWinner).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect fastest lap', () => {
|
||||
const viewModel = new RaceResultViewModel(mockDTO);
|
||||
|
||||
expect(viewModel.hasFastestLap).toBe(true);
|
||||
});
|
||||
|
||||
it('should format lap time correctly', () => {
|
||||
const viewModel = new RaceResultViewModel(mockDTO);
|
||||
|
||||
expect(viewModel.lapTimeFormatted).toBe('1:30.500');
|
||||
});
|
||||
|
||||
it('should show correct incidents badge color', () => {
|
||||
const cleanDTO = { ...mockDTO, incidents: 0 };
|
||||
const viewModel = new RaceResultViewModel(cleanDTO);
|
||||
|
||||
expect(viewModel.incidentsBadgeColor).toBe('green');
|
||||
});
|
||||
|
||||
it('should handle no lap time', () => {
|
||||
const dto = { ...mockDTO, fastestLap: 0 };
|
||||
const viewModel = new RaceResultViewModel(dto);
|
||||
|
||||
expect(viewModel.lapTimeFormatted).toBe('--:--.---');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { RaceResultDto } from '../dtos';
|
||||
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
|
||||
|
||||
export class RaceResultViewModel implements RaceResultDto {
|
||||
id: string;
|
||||
raceId: string;
|
||||
export class RaceResultViewModel implements RaceResultDTO {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
avatarUrl: string;
|
||||
@@ -14,7 +12,7 @@ export class RaceResultViewModel implements RaceResultDto {
|
||||
isPodium: boolean;
|
||||
isClean: boolean;
|
||||
|
||||
constructor(dto: RaceResultDto) {
|
||||
constructor(dto: RaceResultDTO) {
|
||||
Object.assign(this, dto);
|
||||
}
|
||||
|
||||
@@ -63,8 +61,8 @@ export class RaceResultViewModel implements RaceResultDto {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** Compatibility with old DTO interface */
|
||||
getPositionChange(): number {
|
||||
return this.positionChange;
|
||||
}
|
||||
// Note: The generated DTO doesn't have id or raceId
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
id: string = '';
|
||||
raceId: string = '';
|
||||
}
|
||||
@@ -1,34 +1,35 @@
|
||||
import { RaceResultsDetailDto, RaceResultDto } from '../dtos';
|
||||
import { RaceResultsDetailDTO } from '../types/generated/RaceResultsDetailDTO';
|
||||
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
|
||||
import { RaceResultViewModel } from './RaceResultViewModel';
|
||||
|
||||
export class RaceResultsDetailViewModel implements RaceResultsDetailDto {
|
||||
export class RaceResultsDetailViewModel implements RaceResultsDetailDTO {
|
||||
raceId: string;
|
||||
track: string;
|
||||
results: RaceResultViewModel[];
|
||||
league?: { id: string; name: string };
|
||||
race?: { id: string; track: string; scheduledAt: string };
|
||||
drivers: { id: string; name: string }[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime: number;
|
||||
penalties: { driverId: string; type: string; value?: number }[];
|
||||
currentDriverId: string;
|
||||
|
||||
private currentUserId: string;
|
||||
|
||||
constructor(dto: RaceResultsDetailDto & { results: RaceResultDto[] }, currentUserId: string) {
|
||||
constructor(dto: RaceResultsDetailDTO & { results?: RaceResultDTO[] }, currentUserId: string) {
|
||||
this.raceId = dto.raceId;
|
||||
this.track = dto.track;
|
||||
this.results = dto.results.map(r => new RaceResultViewModel({ ...r, raceId: dto.raceId }));
|
||||
this.league = dto.league;
|
||||
this.race = dto.race;
|
||||
this.drivers = dto.drivers;
|
||||
this.pointsSystem = dto.pointsSystem;
|
||||
this.fastestLapTime = dto.fastestLapTime;
|
||||
this.penalties = dto.penalties;
|
||||
this.currentDriverId = dto.currentDriverId;
|
||||
this.currentUserId = currentUserId;
|
||||
|
||||
// Map results if provided
|
||||
if (dto.results) {
|
||||
this.results = dto.results.map(r => new RaceResultViewModel(r));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The generated DTO is incomplete
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
results: RaceResultViewModel[] = [];
|
||||
league?: { id: string; name: string };
|
||||
race?: { id: string; track: string; scheduledAt: string };
|
||||
drivers: { id: string; name: string }[] = [];
|
||||
pointsSystem: Record<number, number> = {};
|
||||
fastestLapTime: number = 0;
|
||||
penalties: { driverId: string; type: string; value?: number }[] = [];
|
||||
currentDriverId: string = '';
|
||||
|
||||
/** UI-specific: Results sorted by position */
|
||||
get resultsByPosition(): RaceResultViewModel[] {
|
||||
return [...this.results].sort((a, b) => a.position - b.position);
|
||||
|
||||
212
apps/website/lib/view-models/RacesPageViewModel.test.ts
Normal file
212
apps/website/lib/view-models/RacesPageViewModel.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceCardViewModel, RacesPageViewModel } from './RacesPageViewModel';
|
||||
|
||||
describe('RaceCardViewModel', () => {
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = {
|
||||
id: 'race-123',
|
||||
title: 'Season Finale',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
};
|
||||
|
||||
const viewModel = new RaceCardViewModel(dto);
|
||||
|
||||
expect(viewModel.id).toBe('race-123');
|
||||
expect(viewModel.title).toBe('Season Finale');
|
||||
expect(viewModel.scheduledTime).toBe('2023-12-31T20:00:00Z');
|
||||
expect(viewModel.status).toBe('upcoming');
|
||||
});
|
||||
|
||||
it('should format scheduled time as locale string', () => {
|
||||
const dto = {
|
||||
id: 'race-123',
|
||||
title: 'Test Race',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
};
|
||||
|
||||
const viewModel = new RaceCardViewModel(dto);
|
||||
const formatted = viewModel.formattedScheduledTime;
|
||||
|
||||
expect(formatted).toContain('2023');
|
||||
expect(formatted).toContain('12/31');
|
||||
});
|
||||
|
||||
it('should handle different race statuses', () => {
|
||||
const statuses = ['upcoming', 'live', 'finished', 'cancelled'];
|
||||
|
||||
statuses.forEach(status => {
|
||||
const dto = {
|
||||
id: 'race-123',
|
||||
title: 'Test Race',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status,
|
||||
};
|
||||
|
||||
const viewModel = new RaceCardViewModel(dto);
|
||||
|
||||
expect(viewModel.status).toBe(status);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RacesPageViewModel', () => {
|
||||
it('should create instance with upcoming and completed races', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
title: 'Race 1',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
title: 'Race 2',
|
||||
scheduledTime: '2024-01-01T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
],
|
||||
completedRaces: [
|
||||
{
|
||||
id: 'race-3',
|
||||
title: 'Race 3',
|
||||
scheduledTime: '2023-12-20T20:00:00Z',
|
||||
status: 'finished',
|
||||
},
|
||||
],
|
||||
totalCount: 3,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingRaces).toHaveLength(2);
|
||||
expect(viewModel.completedRaces).toHaveLength(1);
|
||||
expect(viewModel.totalCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should convert DTOs to view models', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
title: 'Race 1',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
],
|
||||
completedRaces: [],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceCardViewModel);
|
||||
expect(viewModel.upcomingRaces[0].id).toBe('race-1');
|
||||
});
|
||||
|
||||
it('should return correct upcoming count', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
title: 'Race 1',
|
||||
scheduledTime: '2023-12-31T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
title: 'Race 2',
|
||||
scheduledTime: '2024-01-01T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'race-3',
|
||||
title: 'Race 3',
|
||||
scheduledTime: '2024-01-02T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
],
|
||||
completedRaces: [],
|
||||
totalCount: 3,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return correct completed count', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [],
|
||||
completedRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
title: 'Race 1',
|
||||
scheduledTime: '2023-12-20T20:00:00Z',
|
||||
status: 'finished',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
title: 'Race 2',
|
||||
scheduledTime: '2023-12-21T20:00:00Z',
|
||||
status: 'finished',
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.completedCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle empty race lists', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [],
|
||||
completedRaces: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingCount).toBe(0);
|
||||
expect(viewModel.completedCount).toBe(0);
|
||||
expect(viewModel.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle mixed race lists', () => {
|
||||
const dto = {
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
title: 'Upcoming',
|
||||
scheduledTime: '2024-01-01T20:00:00Z',
|
||||
status: 'upcoming',
|
||||
},
|
||||
],
|
||||
completedRaces: [
|
||||
{
|
||||
id: 'race-2',
|
||||
title: 'Completed 1',
|
||||
scheduledTime: '2023-12-20T20:00:00Z',
|
||||
status: 'finished',
|
||||
},
|
||||
{
|
||||
id: 'race-3',
|
||||
title: 'Completed 2',
|
||||
scheduledTime: '2023-12-21T20:00:00Z',
|
||||
status: 'finished',
|
||||
},
|
||||
],
|
||||
totalCount: 3,
|
||||
};
|
||||
|
||||
const viewModel = new RacesPageViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingCount).toBe(1);
|
||||
expect(viewModel.completedCount).toBe(2);
|
||||
expect(viewModel.totalCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,63 @@
|
||||
export interface RaceCardViewModel {
|
||||
// Note: No generated DTO available for RaceCard yet
|
||||
interface RaceCardDTO {
|
||||
id: string;
|
||||
title: string;
|
||||
scheduledTime: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RacesPageViewModel {
|
||||
/**
|
||||
* Race card view model
|
||||
* Represents a race card in list views
|
||||
*/
|
||||
export class RaceCardViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
scheduledTime: string;
|
||||
status: string;
|
||||
|
||||
constructor(dto: RaceCardDTO) {
|
||||
this.id = dto.id;
|
||||
this.title = dto.title;
|
||||
this.scheduledTime = dto.scheduledTime;
|
||||
this.status = dto.status;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted scheduled time */
|
||||
get formattedScheduledTime(): string {
|
||||
return new Date(this.scheduledTime).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Note: No generated DTO available for RacesPage yet
|
||||
interface RacesPageDTO {
|
||||
upcomingRaces: RaceCardDTO[];
|
||||
completedRaces: RaceCardDTO[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Races page view model
|
||||
* Represents the races page data
|
||||
*/
|
||||
export class RacesPageViewModel {
|
||||
upcomingRaces: RaceCardViewModel[];
|
||||
completedRaces: RaceCardViewModel[];
|
||||
totalCount: number;
|
||||
|
||||
constructor(dto: RacesPageDTO) {
|
||||
this.upcomingRaces = dto.upcomingRaces.map(r => new RaceCardViewModel(r));
|
||||
this.completedRaces = dto.completedRaces.map(r => new RaceCardViewModel(r));
|
||||
this.totalCount = dto.totalCount;
|
||||
}
|
||||
|
||||
/** UI-specific: Total upcoming races */
|
||||
get upcomingCount(): number {
|
||||
return this.upcomingRaces.length;
|
||||
}
|
||||
|
||||
/** UI-specific: Total completed races */
|
||||
get completedCount(): number {
|
||||
return this.completedRaces.length;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,33 @@
|
||||
// Note: No generated DTO available for RequestAvatarGeneration yet
|
||||
interface RequestAvatarGenerationDTO {
|
||||
success: boolean;
|
||||
avatarUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Avatar Generation View Model
|
||||
*
|
||||
* Represents the result of an avatar generation request
|
||||
*/
|
||||
export interface RequestAvatarGenerationViewModel {
|
||||
export class RequestAvatarGenerationViewModel {
|
||||
success: boolean;
|
||||
avatarUrl?: string;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: RequestAvatarGenerationDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
|
||||
if (dto.error !== undefined) this.error = dto.error;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether generation was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether there was an error */
|
||||
get hasError(): boolean {
|
||||
return !!this.error;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { SessionDataDto } from '../dtos';
|
||||
import { AuthenticatedUserDTO } from '../types/generated/AuthenticatedUserDTO';
|
||||
|
||||
export class SessionViewModel implements SessionDataDto {
|
||||
export class SessionViewModel implements AuthenticatedUserDTO {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
driverId?: string;
|
||||
isAuthenticated: boolean;
|
||||
displayName: string;
|
||||
|
||||
constructor(dto: SessionDataDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: AuthenticatedUserDTO) {
|
||||
this.userId = dto.userId;
|
||||
this.email = dto.email;
|
||||
this.displayName = dto.displayName;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have these fields
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
driverId?: string;
|
||||
isAuthenticated: boolean = true;
|
||||
|
||||
/** UI-specific: User greeting */
|
||||
get greeting(): string {
|
||||
return `Hello, ${this.displayName || this.email}!`;
|
||||
return `Hello, ${this.displayName}!`;
|
||||
}
|
||||
|
||||
/** UI-specific: Avatar initials */
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { SponsorDashboardDto } from '../dtos';
|
||||
import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO';
|
||||
|
||||
/**
|
||||
* Sponsor Dashboard View Model
|
||||
*
|
||||
* View model for sponsor dashboard data with UI-specific transformations.
|
||||
*/
|
||||
export class SponsorDashboardViewModel implements SponsorDashboardDto {
|
||||
export class SponsorDashboardViewModel implements SponsorDashboardDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
totalSponsorships: number;
|
||||
activeSponsorships: number;
|
||||
totalInvestment: number;
|
||||
|
||||
constructor(dto: SponsorDashboardDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: SponsorDashboardDTO) {
|
||||
this.sponsorId = dto.sponsorId;
|
||||
this.sponsorName = dto.sponsorName;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't include these fields yet
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
totalSponsorships: number = 0;
|
||||
activeSponsorships: number = 0;
|
||||
totalInvestment: number = 0;
|
||||
|
||||
/** UI-specific: Formatted total investment */
|
||||
get formattedTotalInvestment(): string {
|
||||
return `$${this.totalInvestment.toLocaleString()}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SponsorSponsorshipsDto } from '../dtos';
|
||||
import type { SponsorSponsorshipsDTO } from '../types/generated/SponsorSponsorshipsDTO';
|
||||
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
||||
|
||||
/**
|
||||
@@ -6,17 +6,19 @@ import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
||||
*
|
||||
* View model for sponsor sponsorships data with UI-specific transformations.
|
||||
*/
|
||||
export class SponsorSponsorshipsViewModel {
|
||||
export class SponsorSponsorshipsViewModel implements SponsorSponsorshipsDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
sponsorships: SponsorshipDetailViewModel[];
|
||||
|
||||
constructor(dto: SponsorSponsorshipsDto) {
|
||||
constructor(dto: SponsorSponsorshipsDTO) {
|
||||
this.sponsorId = dto.sponsorId;
|
||||
this.sponsorName = dto.sponsorName;
|
||||
this.sponsorships = dto.sponsorships.map(s => new SponsorshipDetailViewModel(s));
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have sponsorships array
|
||||
// This will need to be added when the OpenAPI spec is updated
|
||||
sponsorships: SponsorshipDetailViewModel[] = [];
|
||||
|
||||
/** UI-specific: Total sponsorships count */
|
||||
get totalCount(): number {
|
||||
return this.sponsorships.length;
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { SponsorDto } from '../dtos';
|
||||
// Note: No generated DTO available for Sponsor yet
|
||||
interface SponsorDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export class SponsorViewModel implements SponsorDto {
|
||||
export class SponsorViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
|
||||
constructor(dto: SponsorDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: SponsorDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
if (dto.logoUrl !== undefined) this.logoUrl = dto.logoUrl;
|
||||
if (dto.websiteUrl !== undefined) this.websiteUrl = dto.websiteUrl;
|
||||
}
|
||||
|
||||
/** UI-specific: Display name */
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { SponsorshipDetailDto } from '../dtos';
|
||||
import { SponsorshipDetailDTO } from '../types/generated/SponsorshipDetailDTO';
|
||||
|
||||
export class SponsorshipDetailViewModel implements SponsorshipDetailDto {
|
||||
export class SponsorshipDetailViewModel implements SponsorshipDetailDTO {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
seasonId: string;
|
||||
tier: 'main' | 'secondary';
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
seasonName: string;
|
||||
|
||||
constructor(dto: SponsorshipDetailDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: SponsorshipDetailDTO) {
|
||||
this.id = dto.id;
|
||||
this.leagueId = dto.leagueId;
|
||||
this.leagueName = dto.leagueName;
|
||||
this.seasonId = dto.seasonId;
|
||||
this.seasonName = dto.seasonName;
|
||||
}
|
||||
|
||||
// Note: The generated DTO is incomplete
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
tier: 'main' | 'secondary' = 'secondary';
|
||||
status: string = 'active';
|
||||
amount: number = 0;
|
||||
currency: string = 'USD';
|
||||
|
||||
/** UI-specific: Formatted amount */
|
||||
get formattedAmount(): string {
|
||||
return `${this.currency} ${this.amount.toLocaleString()}`;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { GetEntitySponsorshipPricingResultDto } from '../dtos';
|
||||
// Note: No generated DTO available for SponsorshipPricing yet
|
||||
interface SponsorshipPricingDTO {
|
||||
mainSlotPrice: number;
|
||||
secondarySlotPrice: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sponsorship Pricing View Model
|
||||
@@ -10,7 +15,7 @@ export class SponsorshipPricingViewModel {
|
||||
secondarySlotPrice: number;
|
||||
currency: string;
|
||||
|
||||
constructor(dto: GetEntitySponsorshipPricingResultDto) {
|
||||
constructor(dto: SponsorshipPricingDTO) {
|
||||
this.mainSlotPrice = dto.mainSlotPrice;
|
||||
this.secondarySlotPrice = dto.secondarySlotPrice;
|
||||
this.currency = dto.currency;
|
||||
|
||||
184
apps/website/lib/view-models/StandingEntryViewModel.test.ts
Normal file
184
apps/website/lib/view-models/StandingEntryViewModel.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StandingEntryViewModel } from './StandingEntryViewModel';
|
||||
import type { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
|
||||
|
||||
describe('StandingEntryViewModel', () => {
|
||||
const createMockStanding = (overrides?: Partial<LeagueStandingDTO>): LeagueStandingDTO => ({
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
points: 100,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
races: 8,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should create instance with all properties', () => {
|
||||
const dto = createMockStanding();
|
||||
const viewModel = new StandingEntryViewModel(dto, 100, 85, 'driver-1');
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-1');
|
||||
expect(viewModel.position).toBe(1);
|
||||
expect(viewModel.points).toBe(100);
|
||||
expect(viewModel.wins).toBe(3);
|
||||
expect(viewModel.podiums).toBe(5);
|
||||
expect(viewModel.races).toBe(8);
|
||||
});
|
||||
|
||||
it('should return position as badge string', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 5 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.positionBadge).toBe('5');
|
||||
});
|
||||
|
||||
it('should calculate points gap to leader correctly', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 2, points: 85 }),
|
||||
100, // leader points
|
||||
70, // next points
|
||||
'driver-2'
|
||||
);
|
||||
|
||||
expect(viewModel.pointsGapToLeader).toBe(-15);
|
||||
});
|
||||
|
||||
it('should show zero gap when driver is leader', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 1, points: 100 }),
|
||||
100, // leader points
|
||||
85, // next points
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.pointsGapToLeader).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate points gap to next position correctly', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 2, points: 85 }),
|
||||
100, // leader points
|
||||
70, // next points
|
||||
'driver-2'
|
||||
);
|
||||
|
||||
expect(viewModel.pointsGapToNext).toBe(15);
|
||||
});
|
||||
|
||||
it('should identify current user correctly', () => {
|
||||
const viewModel1 = new StandingEntryViewModel(
|
||||
createMockStanding({ driverId: 'driver-1' }),
|
||||
100,
|
||||
85,
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
const viewModel2 = new StandingEntryViewModel(
|
||||
createMockStanding({ driverId: 'driver-1' }),
|
||||
100,
|
||||
85,
|
||||
'driver-2'
|
||||
);
|
||||
|
||||
expect(viewModel1.isCurrentUser).toBe(true);
|
||||
expect(viewModel2.isCurrentUser).toBe(false);
|
||||
});
|
||||
|
||||
it('should return "same" trend when no previous position', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 1 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.trend).toBe('same');
|
||||
});
|
||||
|
||||
it('should return "up" trend when position improved', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 1 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1',
|
||||
3 // previous position was 3rd
|
||||
);
|
||||
|
||||
expect(viewModel.trend).toBe('up');
|
||||
});
|
||||
|
||||
it('should return "down" trend when position worsened', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 5 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1',
|
||||
2 // previous position was 2nd
|
||||
);
|
||||
|
||||
expect(viewModel.trend).toBe('down');
|
||||
});
|
||||
|
||||
it('should return "same" trend when position unchanged', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 3 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1',
|
||||
3 // same position
|
||||
);
|
||||
|
||||
expect(viewModel.trend).toBe('same');
|
||||
});
|
||||
|
||||
it('should return correct trend arrow for up', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 1 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1',
|
||||
3
|
||||
);
|
||||
|
||||
expect(viewModel.trendArrow).toBe('↑');
|
||||
});
|
||||
|
||||
it('should return correct trend arrow for down', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 5 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1',
|
||||
2
|
||||
);
|
||||
|
||||
expect(viewModel.trendArrow).toBe('↓');
|
||||
});
|
||||
|
||||
it('should return correct trend arrow for same', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 3 }),
|
||||
100,
|
||||
85,
|
||||
'driver-1'
|
||||
);
|
||||
|
||||
expect(viewModel.trendArrow).toBe('-');
|
||||
});
|
||||
|
||||
it('should handle edge case of last place with no one behind', () => {
|
||||
const viewModel = new StandingEntryViewModel(
|
||||
createMockStanding({ position: 10, points: 20 }),
|
||||
100, // leader points
|
||||
20, // same points (last place)
|
||||
'driver-10'
|
||||
);
|
||||
|
||||
expect(viewModel.pointsGapToNext).toBe(0);
|
||||
expect(viewModel.pointsGapToLeader).toBe(-80);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { StandingEntryDto, DriverDto } from '../dtos';
|
||||
import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
|
||||
|
||||
export class StandingEntryViewModel implements StandingEntryDto {
|
||||
export class StandingEntryViewModel implements LeagueStandingDTO {
|
||||
driverId: string;
|
||||
driver?: DriverDto;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
@@ -14,8 +13,10 @@ export class StandingEntryViewModel implements StandingEntryDto {
|
||||
private currentUserId: string;
|
||||
private previousPosition?: number;
|
||||
|
||||
constructor(dto: StandingEntryDto, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
|
||||
this.driverId = dto.driverId;
|
||||
this.position = dto.position;
|
||||
this.points = dto.points;
|
||||
this.leaderPoints = leaderPoints;
|
||||
this.nextPoints = nextPoints;
|
||||
this.currentUserId = currentUserId;
|
||||
@@ -27,6 +28,13 @@ export class StandingEntryViewModel implements StandingEntryDto {
|
||||
return this.position.toString();
|
||||
}
|
||||
|
||||
// Note: The generated DTO is incomplete
|
||||
// These fields will need to be added when the OpenAPI spec is updated
|
||||
driver?: any;
|
||||
wins: number = 0;
|
||||
podiums: number = 0;
|
||||
races: number = 0;
|
||||
|
||||
/** UI-specific: Points difference to leader */
|
||||
get pointsGapToLeader(): number {
|
||||
return this.points - this.leaderPoints;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { TeamDetailsDto, TeamMemberDto } from '../dtos';
|
||||
import { TeamMemberViewModel } from './TeamMemberViewModel';
|
||||
|
||||
export class TeamDetailsViewModel implements TeamDetailsDto {
|
||||
// Note: No generated DTO available for TeamDetails yet
|
||||
interface TeamDetailsDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
memberCount: number;
|
||||
ownerId: string;
|
||||
members: any[];
|
||||
}
|
||||
|
||||
export class TeamDetailsViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -12,7 +22,7 @@ export class TeamDetailsViewModel implements TeamDetailsDto {
|
||||
|
||||
private currentUserId: string;
|
||||
|
||||
constructor(dto: TeamDetailsDto & { members: TeamMemberDto[] }, currentUserId: string) {
|
||||
constructor(dto: TeamDetailsDTO, currentUserId: string) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.description = dto.description;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { TeamJoinRequestItemDto } from '../dtos';
|
||||
// Note: No generated DTO available for TeamJoinRequest yet
|
||||
interface TeamJoinRequestDTO {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class TeamJoinRequestViewModel implements TeamJoinRequestItemDto {
|
||||
export class TeamJoinRequestViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
@@ -10,7 +17,7 @@ export class TeamJoinRequestViewModel implements TeamJoinRequestItemDto {
|
||||
private currentUserId: string;
|
||||
private isOwner: boolean;
|
||||
|
||||
constructor(dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean) {
|
||||
constructor(dto: TeamJoinRequestDTO, currentUserId: string, isOwner: boolean) {
|
||||
Object.assign(this, dto);
|
||||
this.currentUserId = currentUserId;
|
||||
this.isOwner = isOwner;
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { TeamMemberDto, DriverDto } from '../dtos';
|
||||
|
||||
export class TeamMemberViewModel implements TeamMemberDto {
|
||||
// Note: No generated DTO available for TeamMember yet
|
||||
interface TeamMemberDTO {
|
||||
driverId: string;
|
||||
driver?: DriverDto;
|
||||
driver?: any;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export class TeamMemberViewModel {
|
||||
driverId: string;
|
||||
driver?: any;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
|
||||
private currentUserId: string;
|
||||
private teamOwnerId: string;
|
||||
|
||||
constructor(dto: TeamMemberDto, currentUserId: string, teamOwnerId: string) {
|
||||
constructor(dto: TeamMemberDTO, currentUserId: string, teamOwnerId: string) {
|
||||
Object.assign(this, dto);
|
||||
this.currentUserId = currentUserId;
|
||||
this.teamOwnerId = teamOwnerId;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { TeamSummaryDto } from '../dtos';
|
||||
// Note: No generated DTO available for TeamSummary yet
|
||||
interface TeamSummaryDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
memberCount: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export class TeamSummaryViewModel implements TeamSummaryDto {
|
||||
export class TeamSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
@@ -9,8 +16,12 @@ export class TeamSummaryViewModel implements TeamSummaryDto {
|
||||
|
||||
private maxMembers = 10; // Assuming max members
|
||||
|
||||
constructor(dto: TeamSummaryDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: TeamSummaryDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
if (dto.logoUrl !== undefined) this.logoUrl = dto.logoUrl;
|
||||
this.memberCount = dto.memberCount;
|
||||
this.rating = dto.rating;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether team is full */
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
// Note: No generated DTO available for UpdateAvatar yet
|
||||
interface UpdateAvatarDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Avatar View Model
|
||||
*
|
||||
* Represents the result of an avatar update operation
|
||||
*/
|
||||
export interface UpdateAvatarViewModel {
|
||||
export class UpdateAvatarViewModel {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: UpdateAvatarDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.error !== undefined) this.error = dto.error;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether update was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether there was an error */
|
||||
get hasError(): boolean {
|
||||
return !!this.error;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,36 @@
|
||||
// Note: No generated DTO available for UploadMedia yet
|
||||
interface UploadMediaDTO {
|
||||
success: boolean;
|
||||
mediaId?: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload Media View Model
|
||||
*
|
||||
* Represents the result of a media upload operation
|
||||
*/
|
||||
export interface UploadMediaViewModel {
|
||||
export class UploadMediaViewModel {
|
||||
success: boolean;
|
||||
mediaId?: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: UploadMediaDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.mediaId !== undefined) this.mediaId = dto.mediaId;
|
||||
if (dto.url !== undefined) this.url = dto.url;
|
||||
if (dto.error !== undefined) this.error = dto.error;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether upload was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
|
||||
/** UI-specific: Whether there was an error */
|
||||
get hasError(): boolean {
|
||||
return !!this.error;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { DriverDto } from '../dtos';
|
||||
// Note: No generated DTO available for UserProfile yet
|
||||
interface UserProfileDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export class UserProfileViewModel implements DriverDto {
|
||||
export class UserProfileViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
|
||||
constructor(dto: DriverDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: UserProfileDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
|
||||
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
|
||||
if (dto.rating !== undefined) this.rating = dto.rating;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted rating */
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { WalletTransactionDto } from '../dtos';
|
||||
import { TransactionDto } from '../types/generated/TransactionDto';
|
||||
|
||||
export class WalletTransactionViewModel implements WalletTransactionDto {
|
||||
export class WalletTransactionViewModel implements TransactionDto {
|
||||
id: string;
|
||||
type: 'deposit' | 'withdrawal';
|
||||
walletId: string;
|
||||
amount: number;
|
||||
description?: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(dto: WalletTransactionDto) {
|
||||
Object.assign(this, dto);
|
||||
constructor(dto: TransactionDto) {
|
||||
this.id = dto.id;
|
||||
this.walletId = dto.walletId;
|
||||
this.amount = dto.amount;
|
||||
this.description = dto.description;
|
||||
this.createdAt = dto.createdAt;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have type field
|
||||
// This will need to be added when the OpenAPI spec is updated
|
||||
type: 'deposit' | 'withdrawal' = 'deposit';
|
||||
|
||||
/** UI-specific: Formatted amount with sign */
|
||||
get formattedAmount(): string {
|
||||
const sign = this.type === 'deposit' ? '+' : '-';
|
||||
@@ -27,6 +35,11 @@ export class WalletTransactionViewModel implements WalletTransactionDto {
|
||||
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
|
||||
}
|
||||
|
||||
/** UI-specific: Amount color */
|
||||
get amountColor(): string {
|
||||
return this.type === 'deposit' ? 'green' : 'red';
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted created date */
|
||||
get formattedCreatedAt(): string {
|
||||
return new Date(this.createdAt).toLocaleString();
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { WalletDto, WalletTransactionDto } from '../dtos';
|
||||
import { WalletDto } from '../types/generated/WalletDto';
|
||||
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
|
||||
|
||||
export class WalletViewModel implements WalletDto {
|
||||
driverId: string;
|
||||
id: string;
|
||||
leagueId: string;
|
||||
balance: number;
|
||||
totalRevenue: number;
|
||||
totalPlatformFees: number;
|
||||
totalWithdrawn: number;
|
||||
createdAt: string;
|
||||
currency: string;
|
||||
transactions: WalletTransactionViewModel[];
|
||||
|
||||
constructor(dto: WalletDto & { transactions: WalletTransactionDto[] }) {
|
||||
this.driverId = dto.driverId;
|
||||
constructor(dto: WalletDto & { transactions?: any[] }) {
|
||||
this.id = dto.id;
|
||||
this.leagueId = dto.leagueId;
|
||||
this.balance = dto.balance;
|
||||
this.totalRevenue = dto.totalRevenue;
|
||||
this.totalPlatformFees = dto.totalPlatformFees;
|
||||
this.totalWithdrawn = dto.totalWithdrawn;
|
||||
this.createdAt = dto.createdAt;
|
||||
this.currency = dto.currency;
|
||||
this.transactions = dto.transactions.map(t => new WalletTransactionViewModel(t));
|
||||
|
||||
// Map transactions if provided
|
||||
if (dto.transactions) {
|
||||
this.transactions = dto.transactions.map(t => new WalletTransactionViewModel(t));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have driverId or transactions
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
driverId: string = '';
|
||||
transactions: WalletTransactionViewModel[] = [];
|
||||
|
||||
/** UI-specific: Formatted balance */
|
||||
get formattedBalance(): string {
|
||||
return `${this.currency} ${this.balance.toFixed(2)}`;
|
||||
|
||||
156
docs/architecture/FORM_MODELS.md
Normal file
156
docs/architecture/FORM_MODELS.md
Normal file
@@ -0,0 +1,156 @@
|
||||
Form Models
|
||||
|
||||
This document defines Form Models as a first-class concept in the frontend architecture.
|
||||
Form Models are UX-only write models used to collect, validate, and prepare user input
|
||||
before it is sent to the backend as a Command DTO.
|
||||
|
||||
Form Models are not View Models and not Domain Models.
|
||||
|
||||
⸻
|
||||
|
||||
Purpose
|
||||
|
||||
A Form Model answers the question:
|
||||
|
||||
“What does the UI need in order to safely submit user input?”
|
||||
|
||||
Form Models exist to:
|
||||
• centralize form state
|
||||
• reduce logic inside components
|
||||
• provide consistent client-side validation
|
||||
• build Command DTOs explicitly
|
||||
|
||||
⸻
|
||||
|
||||
Core Rules
|
||||
|
||||
Form Models:
|
||||
• exist only in the frontend
|
||||
• are write-only (never reused for reads)
|
||||
• are created per form
|
||||
• are discarded after submission
|
||||
|
||||
Form Models MUST NOT:
|
||||
• contain business logic
|
||||
• enforce domain rules
|
||||
• reference View Models
|
||||
• reference Domain Entities or Value Objects
|
||||
• be sent to the API directly
|
||||
|
||||
⸻
|
||||
|
||||
Relationship to Other Models
|
||||
|
||||
API DTO (read) → ViewModel → UI
|
||||
|
||||
UI Input → FormModel → Command DTO → API
|
||||
|
||||
• View Models are read-only
|
||||
• Form Models are write-only
|
||||
• No model is reused across read/write boundaries
|
||||
|
||||
⸻
|
||||
|
||||
Typical Responsibilities
|
||||
|
||||
A Form Model MAY:
|
||||
• store field values
|
||||
• track dirty / touched state
|
||||
• perform basic UX validation
|
||||
• expose isValid, canSubmit
|
||||
• build a Command DTO
|
||||
|
||||
A Form Model MUST NOT:
|
||||
• decide if an action is allowed
|
||||
• perform authorization checks
|
||||
• validate cross-aggregate rules
|
||||
|
||||
⸻
|
||||
|
||||
Validation Guidelines
|
||||
|
||||
Client-side validation is UX validation, not business validation.
|
||||
|
||||
Allowed validation examples:
|
||||
• required fields
|
||||
• min / max length
|
||||
• email format
|
||||
• numeric ranges
|
||||
|
||||
Forbidden validation examples:
|
||||
• “user is not allowed”
|
||||
• “league already exists”
|
||||
• “quota exceeded”
|
||||
|
||||
Server validation is the source of truth.
|
||||
|
||||
⸻
|
||||
|
||||
Example: Simple Form Model (with class-validator)
|
||||
|
||||
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
|
||||
|
||||
export class SignupFormModel {
|
||||
@IsEmail()
|
||||
email = '';
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password = '';
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
reset(): void {
|
||||
this.email = '';
|
||||
this.password = '';
|
||||
}
|
||||
|
||||
toCommand(): SignupCommandDto {
|
||||
return {
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage in UI Component
|
||||
|
||||
const form = useFormModel(SignupFormModel);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.isValid()) return;
|
||||
|
||||
form.isSubmitting = true;
|
||||
|
||||
await authService.signup(form.toCommand());
|
||||
}
|
||||
|
||||
The component:
|
||||
• binds inputs to the Form Model
|
||||
• reacts to validation state
|
||||
• never builds DTOs manually
|
||||
|
||||
⸻
|
||||
|
||||
Testing
|
||||
|
||||
Form Models SHOULD be tested when they contain:
|
||||
• validation rules
|
||||
• non-trivial state transitions
|
||||
• command construction logic
|
||||
|
||||
Form Models do NOT need tests if they only hold fields without logic.
|
||||
|
||||
⸻
|
||||
|
||||
Summary
|
||||
• Form Models are UX helpers for writes
|
||||
• They protect components from complexity
|
||||
• They never replace backend validation
|
||||
• They never leak into read flows
|
||||
|
||||
Form Models help users.
|
||||
Use Cases protect the system.
|
||||
156
docs/architecture/FORM_SUBMISSION.md
Normal file
156
docs/architecture/FORM_SUBMISSION.md
Normal file
@@ -0,0 +1,156 @@
|
||||
Form Submission Flow (UI → System)
|
||||
|
||||
This document defines the only valid data flow when a user submits a form.
|
||||
It applies to all write operations (create, update, delete).
|
||||
|
||||
There are no exceptions.
|
||||
|
||||
⸻
|
||||
|
||||
Core Principle
|
||||
|
||||
Read and Write paths are different.
|
||||
|
||||
What is displayed is never sent back.
|
||||
|
||||
⸻
|
||||
|
||||
High-Level Flow
|
||||
|
||||
UI → Command DTO → API → Core Use Case → Persistence
|
||||
|
||||
• View Models are read-only
|
||||
• Display Objects are read-only
|
||||
• Commands are write-only
|
||||
|
||||
⸻
|
||||
|
||||
1. UI (Component)
|
||||
|
||||
Responsibility
|
||||
• Collect user input
|
||||
• Manage UX state (loading, disabled, local errors)
|
||||
|
||||
Rules
|
||||
• Only primitives are handled (string, number, boolean)
|
||||
• No DTO reuse
|
||||
• No ViewModel reuse
|
||||
• No domain objects
|
||||
|
||||
The UI does not decide whether an action is allowed.
|
||||
|
||||
⸻
|
||||
|
||||
2. Form Model (Optional)
|
||||
|
||||
Responsibility
|
||||
• Local form state
|
||||
• Client-side validation (required, min/max length)
|
||||
• Field-level errors
|
||||
|
||||
Rules
|
||||
• UX-only validation
|
||||
• No business rules
|
||||
• Never shared with API or Core
|
||||
|
||||
⸻
|
||||
|
||||
3. Command DTO (Frontend)
|
||||
|
||||
Responsibility
|
||||
• Express intent
|
||||
• Represent a write operation
|
||||
|
||||
Rules
|
||||
• Created fresh on submit
|
||||
• Never derived from a ViewModel
|
||||
• Never reused from read DTOs
|
||||
• Write-only
|
||||
|
||||
⸻
|
||||
|
||||
4. Frontend Service
|
||||
|
||||
Responsibility
|
||||
• Orchestrate the write action
|
||||
• Call the API Client
|
||||
• Propagate success or failure
|
||||
|
||||
Rules
|
||||
• No business logic
|
||||
• No validation
|
||||
• No UI decisions
|
||||
• No ViewModel creation for writes (except explicit success summaries)
|
||||
|
||||
⸻
|
||||
|
||||
5. API Client (Frontend)
|
||||
|
||||
Responsibility
|
||||
• Perform HTTP request
|
||||
• Handle transport-level failures
|
||||
|
||||
Rules
|
||||
• Stateless
|
||||
• No retries unless explicitly designed
|
||||
• Throws technical errors only
|
||||
|
||||
⸻
|
||||
|
||||
6. API Layer (Backend)
|
||||
|
||||
Responsibility
|
||||
• HTTP boundary
|
||||
• Transport validation (schema / class-validator)
|
||||
• Map API DTO → Core Command
|
||||
|
||||
Rules
|
||||
• No business logic
|
||||
• No persistence
|
||||
• No UI concerns
|
||||
|
||||
⸻
|
||||
|
||||
7. Core Use Case
|
||||
|
||||
Responsibility
|
||||
• Enforce business rules
|
||||
• Validate domain invariants
|
||||
• Change system state
|
||||
|
||||
Rules
|
||||
• Single source of truth
|
||||
• No UI logic
|
||||
• No HTTP knowledge
|
||||
|
||||
⸻
|
||||
|
||||
Response Handling
|
||||
|
||||
Success
|
||||
• API returns a Result DTO (IDs, status)
|
||||
• Frontend reacts by:
|
||||
• navigation
|
||||
• reload via GET
|
||||
• toast / confirmation
|
||||
|
||||
Failure
|
||||
• Business errors → user-visible message
|
||||
• Technical errors → error boundary / monitoring
|
||||
|
||||
⸻
|
||||
|
||||
Forbidden Patterns
|
||||
• ViewModel → Command
|
||||
• DisplayObject → API
|
||||
• DTO roundtrip
|
||||
• Domain Object in UI
|
||||
• Reusing read models for writes
|
||||
|
||||
⸻
|
||||
|
||||
Summary
|
||||
• Read Flow: DTO → ViewModel → UI
|
||||
• Write Flow: UI → Command DTO → Core
|
||||
|
||||
What is shown is never sent back.
|
||||
Reference in New Issue
Block a user