BLOG-126 Post management (list and create) #139
@ -17,6 +17,7 @@ import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoView
|
||||
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
|
||||
import type { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
|
||||
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
|
||||
import { CreatePostUseCase } from '$lib/post/application/useCase/createPostUseCase';
|
||||
import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
|
||||
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
|
||||
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
|
||||
@ -43,7 +44,7 @@ export class Container {
|
||||
}
|
||||
|
||||
createPostBloc(initialData?: PostViewModel): PostBloc {
|
||||
return new PostBloc(this.useCases.getPostUseCase, initialData);
|
||||
return new PostBloc(this.useCases.getPostUseCase, this.useCases.createPostUseCase, initialData);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +109,7 @@ class UseCases {
|
||||
private _uploadImageUseCase?: UploadImageUseCase;
|
||||
private _getAllPostsUseCase?: GetAllPostsUseCase;
|
||||
private _getPostUseCase?: GetPostUseCase;
|
||||
private _createPostUseCase?: CreatePostUseCase;
|
||||
|
||||
constructor(repositories: Repositories) {
|
||||
this.repositories = repositories;
|
||||
@ -127,8 +129,14 @@ class UseCases {
|
||||
this._getAllPostsUseCase ??= new GetAllPostsUseCase(this.repositories.postRepository);
|
||||
return this._getAllPostsUseCase;
|
||||
}
|
||||
|
||||
get getPostUseCase(): GetPostUseCase {
|
||||
this._getPostUseCase ??= new GetPostUseCase(this.repositories.postRepository);
|
||||
return this._getPostUseCase;
|
||||
}
|
||||
|
||||
get createPostUseCase(): CreatePostUseCase {
|
||||
this._createPostUseCase ??= new CreatePostUseCase(this.repositories.postRepository);
|
||||
return this._createPostUseCase;
|
||||
}
|
||||
}
|
||||
|
@ -40,13 +40,9 @@
|
||||
files = undefined;
|
||||
fileInputErrorMessage = null;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={(val) => (open = val)}>
|
||||
<Dialog bind:open>
|
||||
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
@ -76,7 +72,7 @@
|
||||
</form>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button variant="outline" onclick={close} {disabled}>Cancel</Button>
|
||||
<Button variant="outline" onclick={() => (open = false)} {disabled}>Cancel</Button>
|
||||
<Button type="submit" form="upload-image-form" {disabled}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
52
frontend/src/lib/post/adapter/gateway/creatPostRequestDto.ts
Normal file
52
frontend/src/lib/post/adapter/gateway/creatPostRequestDto.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { CreatePostParams } from '$lib/post/application/gateway/postRepository';
|
||||
|
||||
export class CreatePostRequestDto {
|
||||
readonly semanticId: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly content: string;
|
||||
readonly labelIds: number[];
|
||||
readonly previewImageUrl: URL;
|
||||
readonly publishedTime: Date | null;
|
||||
|
||||
private constructor(props: {
|
||||
semanticId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
labelIds: number[];
|
||||
previewImageUrl: URL;
|
||||
publishedTime: Date | null;
|
||||
}) {
|
||||
this.semanticId = props.semanticId;
|
||||
this.title = props.title;
|
||||
this.description = props.description;
|
||||
this.content = props.content;
|
||||
this.labelIds = props.labelIds;
|
||||
this.previewImageUrl = props.previewImageUrl;
|
||||
this.publishedTime = props.publishedTime;
|
||||
}
|
||||
|
||||
static fromParams(params: CreatePostParams): CreatePostRequestDto {
|
||||
return new CreatePostRequestDto({
|
||||
...params,
|
||||
description: '',
|
||||
content: '',
|
||||
labelIds: [],
|
||||
previewImageUrl: new URL('https://example.com'),
|
||||
publishedTime: null,
|
||||
});
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
semantic_id: this.semanticId,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
content: this.content,
|
||||
label_ids: this.labelIds,
|
||||
preview_image_url: this.previewImageUrl,
|
||||
published_time: this.publishedTime,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
|
||||
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||
import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
|
||||
|
||||
export interface PostApiService {
|
||||
getAllPosts(): Promise<PostInfoResponseDto[]>;
|
||||
getPost(id: string): Promise<PostResponseDto | null>;
|
||||
createPost(payload: CreatePostRequestDto): Promise<PostResponseDto>;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
|
||||
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
|
||||
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
|
||||
import type { CreatePostParams, PostRepository } from '$lib/post/application/gateway/postRepository';
|
||||
import type { Post } from '$lib/post/domain/entity/post';
|
||||
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
|
||||
@ -15,4 +16,10 @@ export class PostRepositoryImpl implements PostRepository {
|
||||
const dto = await this.postApiService.getPost(id);
|
||||
return dto?.toEntity() ?? null;
|
||||
}
|
||||
|
||||
async createPost(params: CreatePostParams): Promise<Post> {
|
||||
const requestDto = CreatePostRequestDto.fromParams(params);
|
||||
const responseDto = await this.postApiService.createPost(requestDto);
|
||||
return responseDto.toEntity();
|
||||
}
|
||||
}
|
||||
|
@ -4,12 +4,14 @@ import {
|
||||
type AsyncState,
|
||||
} from '$lib/common/adapter/presenter/asyncState';
|
||||
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
|
||||
import type { CreatePostParams } from '$lib/post/application/gateway/postRepository';
|
||||
import type { CreatePostUseCase } from '$lib/post/application/useCase/createPostUseCase';
|
||||
import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
|
||||
import { captureException } from '@sentry/sveltekit';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export type PostState = AsyncState<PostViewModel>;
|
||||
export type PostEvent = PostLoadedEvent;
|
||||
export type PostEvent = PostLoadedEvent | PostCreatedEvent;
|
||||
|
||||
export class PostBloc {
|
||||
private readonly state = writable<PostState>({
|
||||
@ -18,6 +20,7 @@ export class PostBloc {
|
||||
|
||||
constructor(
|
||||
private readonly getPostUseCase: GetPostUseCase,
|
||||
private readonly createPostUseCase: CreatePostUseCase,
|
||||
initialData?: PostViewModel
|
||||
) {
|
||||
this.state.set({
|
||||
@ -34,6 +37,9 @@ export class PostBloc {
|
||||
switch (event.event) {
|
||||
case PostEventType.PostLoadedEvent:
|
||||
return this.loadPost(event.id);
|
||||
case PostEventType.PostCreatedEvent:
|
||||
const { semanticId, title } = event;
|
||||
return this.createPost({ semanticId, title });
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,13 +64,37 @@ export class PostBloc {
|
||||
this.state.set(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createPost(params: CreatePostParams): Promise<PostState> {
|
||||
this.state.set(StateFactory.loading(get(this.state).data));
|
||||
|
||||
let result: PostState;
|
||||
try {
|
||||
const post = await this.createPostUseCase.execute(params);
|
||||
const postViewModel = PostViewModel.fromEntity(post);
|
||||
result = StateFactory.success(postViewModel);
|
||||
} catch (e) {
|
||||
result = StateFactory.error(e);
|
||||
captureException(e);
|
||||
}
|
||||
|
||||
this.state.set(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export enum PostEventType {
|
||||
PostLoadedEvent,
|
||||
PostCreatedEvent,
|
||||
}
|
||||
|
||||
interface PostLoadedEvent {
|
||||
event: PostEventType.PostLoadedEvent;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PostCreatedEvent {
|
||||
event: PostEventType.PostCreatedEvent;
|
||||
semanticId: string;
|
||||
title: string;
|
||||
}
|
||||
|
@ -4,4 +4,10 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
export interface PostRepository {
|
||||
getAllPosts(): Promise<PostInfo[]>;
|
||||
getPost(id: string): Promise<Post | null>;
|
||||
createPost(params: CreatePostParams): Promise<Post>;
|
||||
}
|
||||
|
||||
export interface CreatePostParams {
|
||||
semanticId: string;
|
||||
title: string;
|
||||
}
|
||||
|
@ -0,0 +1,13 @@
|
||||
import type {
|
||||
CreatePostParams,
|
||||
PostRepository,
|
||||
} from '$lib/post/application/gateway/postRepository';
|
||||
import type { Post } from '$lib/post/domain/entity/post';
|
||||
|
||||
export class CreatePostUseCase {
|
||||
constructor(private readonly postRepository: PostRepository) {}
|
||||
|
||||
async execute(params: CreatePostParams): Promise<Post> {
|
||||
return this.postRepository.createPost(params);
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { HttpError } from '$lib/common/framework/web/httpError';
|
||||
import { HttpStatusCode } from '$lib/common/framework/web/httpStatusCode';
|
||||
import { Environment } from '$lib/environment';
|
||||
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
|
||||
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
|
||||
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
|
||||
@ -17,8 +18,8 @@ export class PostApiServiceImpl implements PostApiService {
|
||||
throw new HttpError(response.status, url);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.map(PostInfoResponseDto.fromJson);
|
||||
const data = await response.json();
|
||||
return data.map(PostInfoResponseDto.fromJson);
|
||||
}
|
||||
|
||||
async getPost(id: string): Promise<PostResponseDto | null> {
|
||||
@ -34,7 +35,24 @@ export class PostApiServiceImpl implements PostApiService {
|
||||
throw new HttpError(response.status, url);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return PostResponseDto.fromJson(json);
|
||||
const data = await response.json();
|
||||
return PostResponseDto.fromJson(data);
|
||||
}
|
||||
|
||||
async createPost(payload: CreatePostRequestDto): Promise<PostResponseDto> {
|
||||
const url = new URL('post', Environment.API_BASE_URL);
|
||||
|
||||
const response = await this.fetchFn(url, {
|
||||
method: 'POST',
|
||||
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 PostResponseDto.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
107
frontend/src/lib/post/framework/ui/CreatePostDialog.svelte
Normal file
107
frontend/src/lib/post/framework/ui/CreatePostDialog.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<script module lang="ts">
|
||||
import z from 'zod';
|
||||
|
||||
const formSchema = z.object({
|
||||
semanticId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/\D/)
|
||||
.regex(/^[a-zA-Z0-9_\-]+$/),
|
||||
title: z.string().trim().nonempty().max(100),
|
||||
});
|
||||
|
||||
type FormParams = z.infer<typeof formSchema>;
|
||||
export type CreatePostDialogFormParams = FormParams;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/common/framework/components/ui/button';
|
||||
import { Dialog } from '$lib/common/framework/components/ui/dialog';
|
||||
import DialogContent from '$lib/common/framework/components/ui/dialog/dialog-content.svelte';
|
||||
import DialogFooter from '$lib/common/framework/components/ui/dialog/dialog-footer.svelte';
|
||||
import DialogHeader from '$lib/common/framework/components/ui/dialog/dialog-header.svelte';
|
||||
import DialogTitle from '$lib/common/framework/components/ui/dialog/dialog-title.svelte';
|
||||
import DialogTrigger from '$lib/common/framework/components/ui/dialog/dialog-trigger.svelte';
|
||||
import Input from '$lib/common/framework/components/ui/input/input.svelte';
|
||||
import Label from '$lib/common/framework/components/ui/label/label.svelte';
|
||||
|
||||
const {
|
||||
disabled,
|
||||
onSubmit: createPost,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onSubmit: (params: FormParams) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
let formData = $state<FormParams>({ semanticId: '', title: '' });
|
||||
let formErrors = $state<Partial<Record<keyof FormParams, string>>>({});
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
formErrors = {};
|
||||
|
||||
const parseResult = formSchema.safeParse(formData);
|
||||
if (parseResult.error) {
|
||||
const errorResult = z.treeifyError(parseResult.error).properties;
|
||||
if (errorResult?.semanticId?.errors) {
|
||||
formErrors.semanticId = errorResult.semanticId.errors[0];
|
||||
}
|
||||
if (errorResult?.title?.errors) {
|
||||
formErrors.title = errorResult.title.errors[0];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await createPost(formData);
|
||||
formData = { semanticId: '', title: '' };
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Create</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeydown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader class="mb-4">
|
||||
<DialogTitle>Create Post</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="create-post-form" onsubmit={onSubmit} class="space-y-3">
|
||||
<div>
|
||||
<Label for="semantic-id-input" class="pb-2">Semantic ID</Label>
|
||||
<Input
|
||||
id="semantic-id-input"
|
||||
type="text"
|
||||
aria-invalid={formErrors.semanticId !== undefined}
|
||||
bind:value={formData.semanticId}
|
||||
/>
|
||||
{#if formErrors.semanticId}
|
||||
<p class="text-sm text-red-500">{formErrors.semanticId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="title-input" class="pb-2">Title</Label>
|
||||
<Input
|
||||
id="title-input"
|
||||
type="text"
|
||||
aria-invalid={formErrors.title !== undefined}
|
||||
bind:value={formData.title}
|
||||
/>
|
||||
{#if formErrors.title}
|
||||
<p class="text-sm text-red-500">{formErrors.title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button variant="outline" onclick={() => (open = false)} {disabled}>Cancel</Button>
|
||||
<Button type="submit" form="create-post-form" {disabled}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
35
frontend/src/lib/post/framework/ui/PostManagementPage.svelte
Normal file
35
frontend/src/lib/post/framework/ui/PostManagementPage.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
|
||||
import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
|
||||
import CreatePostDialog, {
|
||||
type CreatePostDialogFormParams,
|
||||
} from '$lib/post/framework/ui/CreatePostDialog.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const postBloc = getContext<PostBloc>(PostBloc.name);
|
||||
const state = $derived($postBloc);
|
||||
|
||||
const isLoading = $derived(state.status === StatusType.Loading);
|
||||
|
||||
async function onCreatePostDialogSubmit(params: CreatePostDialogFormParams) {
|
||||
const state = await postBloc.dispatch({ event: PostEventType.PostCreatedEvent, ...params });
|
||||
|
||||
if (state.status === StatusType.Success) {
|
||||
toast.success(`Post created successfully with ID: ${state.data.id}`);
|
||||
} else if (state.status === StatusType.Error) {
|
||||
toast.error('Failed to create post', {
|
||||
description: state.error?.message ?? 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard-container mb-10">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="py-16 text-5xl font-bold text-gray-800">Post</h1>
|
||||
<CreatePostDialog disabled={isLoading} onSubmit={onCreatePostDialogSubmit} />
|
||||
</div>
|
||||
<p></p>
|
||||
</div>
|
@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
|
||||
</script>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import HomePage from '$lib/home/framework/ui/HomePage.svelte';
|
||||
</script>
|
||||
|
||||
|
@ -1,18 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
|
||||
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
|
||||
import { Container } from '$lib/container';
|
||||
import { ImageBloc } from '$lib/image/adapter/presenter/imageBloc';
|
||||
import type { ImageRepository } from '$lib/image/application/gateway/imageRepository';
|
||||
import { UploadImageUseCase } from '$lib/image/application/useCase/uploadImageUseCase';
|
||||
import { ImageApiServiceImpl } from '$lib/image/framework/api/imageApiServiceImpl';
|
||||
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
|
||||
import { setContext } from 'svelte';
|
||||
|
||||
const imageApiService: ImageApiService = new ImageApiServiceImpl(fetch);
|
||||
const imageRepository: ImageRepository = new ImageRepositoryImpl(imageApiService);
|
||||
const uploadImageUseCase = new UploadImageUseCase(imageRepository);
|
||||
const imageBloc = new ImageBloc(uploadImageUseCase);
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
const container = getContext<Container>(Container.name);
|
||||
const imageBloc = container.createImageBloc();
|
||||
setContext(ImageBloc.name, imageBloc);
|
||||
</script>
|
||||
|
||||
|
@ -1 +1,12 @@
|
||||
<div>Post</div>
|
||||
<script lang="ts">
|
||||
import { Container } from '$lib/container';
|
||||
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
|
||||
import PostManagementPage from '$lib/post/framework/ui/PostManagementPage.svelte';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
const container = getContext<Container>(Container.name);
|
||||
const postBloc = container.createPostBloc();
|
||||
setContext(PostBloc.name, postBloc);
|
||||
</script>
|
||||
|
||||
<PostManagementPage />
|
||||
|
Loading…
x
Reference in New Issue
Block a user