feat: implement update label functionality with associated stores and API integration
All checks were successful
Frontend CI / build (push) Successful in 1m39s

This commit is contained in:
SquidSpirit 2025-10-15 22:01:25 +08:00
parent 300ae2dc05
commit add274be1b
10 changed files with 180 additions and 9 deletions

View File

@ -7,11 +7,13 @@ import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService'; import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import { LabelRepositoryImpl } from '$lib/label/adapter/gateway/labelRepositoryImpl'; import { LabelRepositoryImpl } from '$lib/label/adapter/gateway/labelRepositoryImpl';
import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore'; import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore';
import { LabelUpdatedStore } from '$lib/label/adapter/presenter/labelUpdatedStore';
import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel'; import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore'; import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore';
import type { LabelRepository } from '$lib/label/application/gateway/labelRepository'; import type { LabelRepository } from '$lib/label/application/gateway/labelRepository';
import { CreateLabelUseCase } from '$lib/label/application/useCase/createLabelUseCase'; import { CreateLabelUseCase } from '$lib/label/application/useCase/createLabelUseCase';
import { GetAllLabelsUseCase } from '$lib/label/application/useCase/getAllLabelsUseCase'; import { GetAllLabelsUseCase } from '$lib/label/application/useCase/getAllLabelsUseCase';
import { UpdateLabelUseCase } from '$lib/label/application/useCase/updateLabelUseCase';
import { LabelApiServiceImpl } from '$lib/label/framework/api/labelApiServiceImpl'; import { LabelApiServiceImpl } from '$lib/label/framework/api/labelApiServiceImpl';
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService'; import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl'; import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
@ -74,6 +76,10 @@ export class Container {
createLabelCreatedStore(): LabelCreatedStore { createLabelCreatedStore(): LabelCreatedStore {
return new LabelCreatedStore(this.useCases.createLabelUseCase); return new LabelCreatedStore(this.useCases.createLabelUseCase);
} }
createLabelUpdatedStore(): LabelUpdatedStore {
return new LabelUpdatedStore(this.useCases.updateLabelUseCase);
}
} }
class ApiServices { class ApiServices {
@ -153,6 +159,7 @@ class UseCases {
private _getAllLabelsUseCase?: GetAllLabelsUseCase; private _getAllLabelsUseCase?: GetAllLabelsUseCase;
private _getLabelUseCase?: GetLabelUseCase; private _getLabelUseCase?: GetLabelUseCase;
private _createLabelUseCase?: CreateLabelUseCase; private _createLabelUseCase?: CreateLabelUseCase;
private _updateLabelUseCase?: UpdateLabelUseCase;
constructor(repositories: Repositories) { constructor(repositories: Repositories) {
this.repositories = repositories; this.repositories = repositories;
@ -197,4 +204,9 @@ class UseCases {
this._createLabelUseCase ??= new CreateLabelUseCase(this.repositories.labelRepository); this._createLabelUseCase ??= new CreateLabelUseCase(this.repositories.labelRepository);
return this._createLabelUseCase; return this._createLabelUseCase;
} }
get updateLabelUseCase(): UpdateLabelUseCase {
this._updateLabelUseCase ??= new UpdateLabelUseCase(this.repositories.labelRepository);
return this._updateLabelUseCase;
}
} }

View File

@ -1,8 +1,10 @@
import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto'; import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto'; import type { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto';
import type { UpdateLabelRequestDto } from '$lib/label/adapter/gateway/updateLabelRequestDto';
export interface LabelApiService { export interface LabelApiService {
getAllLabels(): Promise<LabelResponseDto[]>; getAllLabels(): Promise<LabelResponseDto[]>;
getLabelById(id: number): Promise<LabelResponseDto | null>; getLabelById(id: number): Promise<LabelResponseDto | null>;
createLabel(payload: CreateLabelRequestDto): Promise<LabelResponseDto>; createLabel(payload: CreateLabelRequestDto): Promise<LabelResponseDto>;
updateLabel(id: number, payload: UpdateLabelRequestDto): Promise<LabelResponseDto>;
} }

View File

@ -1,8 +1,10 @@
import { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto'; import { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService'; import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import { UpdateLabelRequestDto } from '$lib/label/adapter/gateway/updateLabelRequestDto';
import type { import type {
CreateLabelParams, CreateLabelParams,
LabelRepository, LabelRepository,
UpdateLabelParams,
} from '$lib/label/application/gateway/labelRepository'; } from '$lib/label/application/gateway/labelRepository';
import type { Label } from '$lib/label/domain/entity/label'; import type { Label } from '$lib/label/domain/entity/label';
@ -24,4 +26,10 @@ export class LabelRepositoryImpl implements LabelRepository {
const responseDto = await this.labelApiService.createLabel(requestDto); const responseDto = await this.labelApiService.createLabel(requestDto);
return responseDto.toEntity(); return responseDto.toEntity();
} }
async updateLabel(id: number, params: UpdateLabelParams): Promise<Label> {
const requestDto = UpdateLabelRequestDto.fromParams(params);
const responseDto = await this.labelApiService.updateLabel(id, requestDto);
return responseDto.toEntity();
}
} }

View File

@ -0,0 +1,26 @@
import { ColorDto } from '$lib/label/adapter/gateway/colorDto';
import type { UpdateLabelParams } from '$lib/label/application/gateway/labelRepository';
export class UpdateLabelRequestDto {
readonly name: string;
readonly color: ColorDto;
private constructor(props: { name: string; color: ColorDto }) {
this.name = props.name;
this.color = props.color;
}
static fromParams(params: UpdateLabelParams): UpdateLabelRequestDto {
return new UpdateLabelRequestDto({
name: params.name,
color: ColorDto.fromEntity(params.color),
});
}
toJson() {
return {
name: this.name,
color: this.color.toJson(),
};
}
}

View File

@ -0,0 +1,47 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import type { UpdateLabelParams } from '$lib/label/application/gateway/labelRepository';
import type { UpdateLabelUseCase } from '$lib/label/application/useCase/updateLabelUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type LabelState = AsyncState<LabelViewModel>;
interface UpdateLabelTriggerParams {
id: number;
params: UpdateLabelParams;
}
export class LabelUpdatedStore implements BaseStore<LabelState, UpdateLabelTriggerParams> {
static readonly name = 'LabelUpdatedStore';
private readonly state = writable<LabelState>(AsyncState.idle<LabelViewModel>(null));
constructor(private readonly updateLabelUseCase: UpdateLabelUseCase) {}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (params: UpdateLabelTriggerParams) => this.updateLabel(params);
}
private async updateLabel(params: UpdateLabelTriggerParams): Promise<LabelState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: LabelState;
try {
const label = await this.updateLabelUseCase.execute(params.id, params.params);
const labelViewModel = LabelViewModel.fromEntity(label);
result = AsyncState.success(labelViewModel);
} catch (error) {
captureException(error);
result = AsyncState.error(error, get(this.state).data);
}
this.state.set(result);
return result;
}
}

View File

@ -5,9 +5,15 @@ export interface LabelRepository {
getAllLabels(): Promise<Label[]>; getAllLabels(): Promise<Label[]>;
getLabelById(id: number): Promise<Label | null>; getLabelById(id: number): Promise<Label | null>;
createLabel(params: CreateLabelParams): Promise<Label>; createLabel(params: CreateLabelParams): Promise<Label>;
updateLabel(id: number, params: UpdateLabelParams): Promise<Label>;
} }
export interface CreateLabelParams { export interface CreateLabelParams {
name: string; name: string;
color: Color; color: Color;
} }
export interface UpdateLabelParams {
name: string;
color: Color;
}

View File

@ -0,0 +1,13 @@
import type { Label } from '$lib/label/domain/entity/label';
import type {
LabelRepository,
UpdateLabelParams,
} from '$lib/label/application/gateway/labelRepository';
export class UpdateLabelUseCase {
constructor(private readonly labelRepository: LabelRepository) {}
async execute(id: number, params: UpdateLabelParams): Promise<Label> {
return this.labelRepository.updateLabel(id, params);
}
}

View File

@ -3,6 +3,7 @@ import { Environment } from '$lib/environment';
import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto'; import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService'; import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto'; import { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto';
import type { UpdateLabelRequestDto } from '$lib/label/adapter/gateway/updateLabelRequestDto';
export class LabelApiServiceImpl implements LabelApiService { export class LabelApiServiceImpl implements LabelApiService {
constructor(private readonly fetchFn: typeof fetch) {} constructor(private readonly fetchFn: typeof fetch) {}
@ -52,4 +53,21 @@ export class LabelApiServiceImpl implements LabelApiService {
const data = await response.json(); const data = await response.json();
return LabelResponseDto.fromJson(data); return LabelResponseDto.fromJson(data);
} }
async updateLabel(id: number, payload: UpdateLabelRequestDto): Promise<LabelResponseDto> {
const url = new URL(`label/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload.toJson()),
});
if (!response.ok) {
throw new HttpError(response.status, url);
}
const data = await response.json();
return LabelResponseDto.fromJson(data);
}
} }

View File

@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore'; import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore';
import { LabelUpdatedStore } from '$lib/label/adapter/presenter/labelUpdatedStore';
import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
import ColorCode from '$lib/label/framework/ui/ColorCode.svelte'; import ColorCode from '$lib/label/framework/ui/ColorCode.svelte';
import EditLabelDialog, { import EditLabelDialog, {
type EditLabelDialogFormParams, type EditLabelDialogFormParams,
} from '$lib/label/framework/ui/EditLabelDialog.svelte'; } from '$lib/label/framework/ui/EditLabelDialog.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte'; import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const { id }: { id: number } = $props(); const { id }: { id: number } = $props();
@ -14,6 +17,10 @@
const { trigger: loadLabel } = labelLoadedStore; const { trigger: loadLabel } = labelLoadedStore;
const label = $derived(labelLoadedState.data); const label = $derived(labelLoadedState.data);
const labelUpdatedStore = getContext<LabelUpdatedStore>(LabelUpdatedStore.name);
const labelUpdatedState = $derived($labelUpdatedStore);
const { trigger: updateLabel } = labelUpdatedStore;
const formDefaultValues: EditLabelDialogFormParams | null = $derived.by(() => { const formDefaultValues: EditLabelDialogFormParams | null = $derived.by(() => {
if (labelLoadedState.data === null) { if (labelLoadedState.data === null) {
return null; return null;
@ -25,6 +32,32 @@
}); });
async function onSubmit(params: EditLabelDialogFormParams): Promise<boolean> { async function onSubmit(params: EditLabelDialogFormParams): Promise<boolean> {
if (!label) {
toast.error('Label not found');
return false;
}
const colorViewModel = ColorViewModel.fromHex(params.color);
const color = colorViewModel.toEntity();
const state = await updateLabel({
id: label.id,
params: {
name: params.name,
color: color,
},
});
if (state.isSuccess()) {
loadLabel(label.id);
toast.success('Label updated successfully');
} else if (state.isError()) {
toast.error('Failed to update label', {
description: state.error.message,
});
return false;
}
return true; return true;
} }
@ -34,13 +67,15 @@
<div class="dashboard-container mb-10"> <div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Label Details</h1> <h1 class="py-16 text-5xl font-bold text-gray-800">Label Details</h1>
<EditLabelDialog {#key formDefaultValues}
title="Update Label" <EditLabelDialog
triggerButtonText="Edit" title="Update Label"
disabled={!labelLoadedState.isSuccess()} triggerButtonText="Edit"
defaultValues={formDefaultValues ?? undefined} disabled={!labelLoadedState.isSuccess() || labelUpdatedState.isLoading()}
{onSubmit} defaultValues={formDefaultValues ?? undefined}
/> {onSubmit}
/>
{/key}
</div> </div>
<div class="grid grid-cols-[auto_1fr] gap-x-8 gap-y-3"> <div class="grid grid-cols-[auto_1fr] gap-x-8 gap-y-3">
<span class="font-medium whitespace-nowrap">ID</span> <span class="font-medium whitespace-nowrap">ID</span>

View File

@ -4,6 +4,7 @@
import { Container } from '$lib/container'; import { Container } from '$lib/container';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel'; import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore'; import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore';
import { LabelUpdatedStore } from '$lib/label/adapter/presenter/labelUpdatedStore';
import LabelContentDashboardPage from '$lib/label/framework/ui/LabelContentDashboardPage.svelte'; import LabelContentDashboardPage from '$lib/label/framework/ui/LabelContentDashboardPage.svelte';
const { data, params }: PageProps = $props(); const { data, params }: PageProps = $props();
@ -13,8 +14,11 @@
const container = getContext<Container>(Container.name); const container = getContext<Container>(Container.name);
const initialData = LabelViewModel.rehydrate(data.dehydratedData); const initialData = LabelViewModel.rehydrate(data.dehydratedData);
const store = container.createLabelLoadedStore(initialData); const labelLoadedStore = container.createLabelLoadedStore(initialData);
setContext(LabelLoadedStore.name, store); setContext(LabelLoadedStore.name, labelLoadedStore);
const labelUpdatedStore = container.createLabelUpdatedStore();
setContext(LabelUpdatedStore.name, labelUpdatedStore);
</script> </script>
<LabelContentDashboardPage id={numericId} /> <LabelContentDashboardPage id={numericId} />