feat: add removable reference feature vertical slice

This commit is contained in:
donghyeon-ka
2026-07-26 14:56:34 +09:00
parent 980981bc86
commit c11be43f20
87 changed files with 1881 additions and 1114 deletions
@@ -0,0 +1,14 @@
import { lazy } from "react";
import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
} as const;
@@ -0,0 +1,60 @@
import { useState, type FormEvent } from "react";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { PageHeader } from "../../../presentation/components/page-header.jsx";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const { filters, query, mutation } = useReferenceFeature();
const [name, setName] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = await mutation.submit({ name });
if (result.ok) setName("");
}
return (
<section className="ui-page">
<PageHeader
eyebrow="제거 가능한 수직 슬라이스"
title="Reference feature"
description="URL codec, application input, HTTP/schema/mapper와 query/mutation 상태를 한 경로로 검증합니다."
/>
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li
key={resource.resourceId}
data-optimistic={resource.optimistic || undefined}
>
{resource.title}
</li>
))}
</ul>
</AsyncSurface>
<AsyncSurface
state={mutation.state}
onResolveConflict={mutation.resolveConflict}
>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="reference-resource-name"> </label>
<input
id="reference-resource-name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<Button type="submit" disabled={mutation.state.overlay.mutationPending}>
</Button>
</form>
</AsyncSurface>
</section>
);
}
@@ -0,0 +1,62 @@
import { useApplication } from "../../../presentation/providers/application-provider.js";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import type { ReferenceResourceView } from "../contracts/reference-mapper.js";
import {
REFERENCE_FEATURE_ID,
referenceQueryKeys,
} from "../contracts/reference-feature-contract.js";
import type {
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
typeof (candidate as ReferenceFeatureInput).createResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const routeInput = useRouteInput();
const filters = routeInput.search as ReferenceListFilters;
const queryKey = referenceQueryKeys.list(filters);
const query = useApplicationQuery({
queryKey,
execute: ({ signal }) => input.listResources(filters, { signal }),
});
const mutation = useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
optimistic: {
queryKey,
update(previous, command: Readonly<{ name: string }>) {
const current = Array.isArray(previous)
? (previous as readonly ReferenceResourceView[])
: [];
return [
...current,
{
resourceId: `optimistic:${command.name}`,
title: command.name,
createdAtLabel: null,
optimistic: true,
},
];
},
},
});
return Object.freeze({ filters, query, mutation });
}