Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

フロントエンドのデータ取得の流れを整備 #476

Merged
merged 6 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,25 @@ jobs:

- name: Lint
run: pnpm run --recursive --parallel --aggregate-output lint
test:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自分の中ではフロントエンドのビルドもCIで確認するようなイメージがあるんだけど、それはなくても大丈夫?

name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: frontend/package.json
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install

- name: Test
run: pnpm run --recursive --parallel --aggregate-output test
10 changes: 8 additions & 2 deletions frontend/packages/contestant/.storybook/tanstack-decorator.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react";
import {
Link,
RouterProvider,
createMemoryHistory,
createRootRoute,
Expand All @@ -11,10 +12,16 @@ import {
/* eslint-disable react-refresh/only-export-components */

const StoryContext = createContext(undefined);
const storyPath = "/__story__";

function NotFound() {
const state = useRouterState();
return <div>Simulated route not found for path: {state.location.href}</div>;
return (
<div>
<p>Simulated route not found for path: {state.location.href}</p>
<Link to={storyPath}>Back to story</Link>
</div>
);
}

function RoutedStory() {
Expand All @@ -25,7 +32,6 @@ function RoutedStory() {
return <Story />;
}

const storyPath = "/__story__";
const rootRoute = createRootRoute({
notFoundComponent: NotFound,
});
Expand Down
165 changes: 165 additions & 0 deletions frontend/packages/contestant/app/__test__/msw/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
type RequestHandlerOptions,
type ResponseResolver,
RequestHandler,
} from "msw";
import {
type UniversalHandler,
universalServerRequestFromFetch,
universalServerResponseToFetch,
} from "@connectrpc/connect/protocol";
import {
type ConnectRouter,
type MethodImpl,
ServiceImpl,
createConnectRouter,
} from "@connectrpc/connect";
import type { DescMethod, DescService } from "@bufbuild/protobuf";

export type ConnectHandlerInfo = {
header: string;
kind: "service" | "rpc";
name: string;
};

type ConnectRequestParsedResult = {
handler: UniversalHandler | undefined;
};

export type ConnectResolverExtras = {
handler: UniversalHandler;
};

export type ConnectResponseResolver = ResponseResolver<ConnectResolverExtras>;

export type ConnectHandlerOptions = RequestHandlerOptions;
const defaultConnectResolver: ConnectResponseResolver = async ({
request,
handler,
}) => {
const ureq = universalServerRequestFromFetch(request.clone(), {});
const ures = await handler(ureq);
return universalServerResponseToFetch(ures);
};

export class ConnectHandler extends RequestHandler<
ConnectHandlerInfo,
ConnectRequestParsedResult,
ConnectResolverExtras
> {
#router: ConnectRouter;
constructor(
info: Omit<ConnectHandlerInfo, "header">,
routes: (router: ConnectRouter) => void,
options?: ConnectHandlerOptions,
) {
super({
info: {
...info,
header: `${info.kind} ${info.name}`,
},
resolver: defaultConnectResolver,
options,
});

const router = createConnectRouter();
routes(router);
this.#router = router;
}

#handler(req: Request): UniversalHandler | undefined {
const url = new URL(req.url);
const handler = this.#router.handlers.find(
(h) => h.requestPath == url.pathname,
);
if (handler == null) {
return;
}
if (!handler.allowedMethods.includes(req.method)) {
return;
}
return handler;
}

parse({ request }: { request: Request }) {
return Promise.resolve({
handler: this.#handler(request),
});
}

predicate({
parsedResult: { handler },
}: {
request: Request;
parsedResult: ConnectRequestParsedResult;
}) {
if (handler == null) {
return false;
}
return true;
}

extendResolverArgs({
parsedResult: { handler },
}: {
request: Request;
parsedResult: ConnectRequestParsedResult;
}) {
return {
handler: handler!,
};
}

log({
request,
response,
parsedResult: { handler },
}: {
request: Request;
response: Response;
parsedResult: ConnectRequestParsedResult;
}): void {
if (handler == null) {
throw new Error("handler is null");
}
const method = handler.method;

console.groupCollapsed(
`${method.methodKind} ${method.parent.typeName}/${method.name} (${response.status} ${response.statusText})`,
);
console.log("Request:", {
url: new URL(request.url),
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
});
console.log("Response:", {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
});
console.groupEnd();
}
}

export const connect = {
rpc: <M extends DescMethod>(
method: M,
impl: MethodImpl<M>,
options?: ConnectHandlerOptions,
) =>
new ConnectHandler(
{ kind: method.kind, name: `${method.parent.typeName}/${method.name}` },
(router) => router.rpc(method, impl),
options,
),
service: <S extends DescService>(
service: S,
impl: ServiceImpl<S>,
options?: ConnectHandlerOptions,
) =>
new ConnectHandler(
{ kind: "service", name: service.typeName },
(router) => router.service(service, impl),
options,
),
};
Comment on lines +144 to +165
Copy link
Collaborator Author

@tosuke tosuke Sep 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[zatsu] なぜかなくて人々が困っている様子が見える(connectrpc/connect-es#825) のでライブラリとして外に切り出してもいいかもね

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自分のリポジトリで公開して、それを利用する形にしてしまって良いのでは
ICTSCとしては全然OKですよ

14 changes: 14 additions & 0 deletions frontend/packages/contestant/app/__test__/msw/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { afterAll, afterEach, beforeAll } from "vitest";
import type { RequestHandler, SharedOptions } from "msw";
import { setupServer } from "msw/node";

export function setupMSW(
handlers: readonly RequestHandler[] = [],
options: Partial<SharedOptions> = { onUnhandledRequest: "error" },
) {
const server = setupServer(...handlers);
beforeAll(() => server.listen(options));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
return server;
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,52 @@
import type { ReactNode } from "react";
import { use, type ReactNode } from "react";
import { clsx } from "clsx";
import {
Menu,
MenuItems,
MenuItem,
Button,
type ButtonProps,
MenuButton,
} from "@headlessui/react";
import {
MaterialSymbol,
type MaterialSymbolType,
} from "@app/components/material-symbol";
import { type User } from "@app/features/account";

export function AccountMenu({
children,
static: staticOpen,
user: userPromise,
}: {
readonly children?: ReactNode;
readonly static?: boolean;
readonly user: Promise<User | undefined>;
}) {
const user = use(userPromise);
return user != null && <AccountMenuView name={user.name} />;
}

export function AccountMenuView({ name }: { readonly name: string }) {
return (
<Menu>
{children}
<MenuButton
title="アカウントメニュー"
className="flex size-[50px] items-center justify-center rounded-full transition data-[hover]:bg-surface-0/50"
>
<MaterialSymbol
icon="person"
fill
size={40}
className="text-surface-0"
/>
</MenuButton>

<MenuItems
static={staticOpen}
anchor={{ to: "bottom", gap: 15 }}
transition
className={clsx(
"flex w-[200px] flex-col gap-[5px] rounded-[12px] bg-surface-0 py-[15px] drop-shadow",
"transition duration-200 ease-out data-[closed]:opacity-0",
)}
>
<span className="mx-[15px] text-14 text-text">ictsc</span>
<span className="mx-[15px] text-14 text-text">{name}</span>
<MenuItem>
<AccountMenuButton icon="settings">アカウント設定</AccountMenuButton>
</MenuItem>
Expand Down
38 changes: 18 additions & 20 deletions frontend/packages/contestant/app/components/app-shell/header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { type ReactNode } from "react";
import { MenuButton } from "@headlessui/react";
import { Suspense, type ReactNode } from "react";
import { Link } from "@tanstack/react-router";
import { MaterialSymbol } from "@app/components/material-symbol";
import { Logo } from "@app/components/logo";
import { AccountMenu } from "./account-menu";
import { User } from "@app/features/account";

export function Header({ user }: { readonly user: Promise<User | undefined> }) {
return (
<HeaderView
accountMenu={
<Suspense>
<AccountMenu user={user} />
</Suspense>
}
/>
);
}

export type HeaderViewProps = {
readonly contestState?: ReactNode;
readonly accountMenu?: ReactNode;
};

export function HeaderView({ contestState }: HeaderViewProps) {
export function HeaderView({ contestState, accountMenu }: HeaderViewProps) {
return (
<div className="flex size-full items-center border-b-[3px] border-primary bg-surface-0">
<div className="flex-none">
Expand All @@ -18,23 +30,9 @@ export function HeaderView({ contestState }: HeaderViewProps) {
</Link>
</div>
<div className="ml-auto flex h-full items-center">
{contestState != null && (
<div className="mr-[30px]">{contestState}</div>
)}
<div className="mr-[30px]">{contestState}</div>
<div className="flex h-full w-[140px] items-center justify-end bg-primary pt-[3px] [clip-path:polygon(40%_0,100%_0,100%_100%,0_100%)]">
<AccountMenu>
<MenuButton
title="アカウントメニュー"
className="mr-[30px] flex size-[50px] items-center justify-center rounded-full transition data-[hover]:bg-surface-0/50"
>
<MaterialSymbol
icon="person"
fill
size={40}
className="text-surface-0"
/>
</MenuButton>
</AccountMenu>
<div className="mr-[30px]">{accountMenu}</div>
</div>
</div>
</div>
Expand Down
13 changes: 9 additions & 4 deletions frontend/packages/contestant/app/components/app-shell/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { type ReactNode, useReducer } from "react";
import { Layout } from "./layout";
import { HeaderView } from "./header";
import { Header } from "./header";
import { NavbarView } from "./navbar";
import { type User } from "@app/features/account";

// 仮置きのAPIなしで動くやつ
export function AppShell({ children }: { readonly children: ReactNode }) {
type AppShellProps = {
readonly children: ReactNode;
readonly me: Promise<User | undefined>;
};

export function AppShell({ children, me }: AppShellProps) {
const [collapsed, toggle] = useReducer((o) => !o, false);
return (
<Layout
header={<HeaderView />}
header={<Header user={me} />}
navbar={<NavbarView collapsed={collapsed} onOpenToggleClick={toggle} />}
navbarCollapsed={collapsed}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Layout } from "./layout";
import { HeaderView } from "./header";
import { NavbarView } from "./navbar";
import { ContestStateView } from "./contest-state";
import { AccountMenuView } from "./account-menu";

function AppShell() {
const [collapsed, toggle] = useReducer((o) => !o, false);
Expand All @@ -14,6 +15,7 @@ function AppShell() {
contestState={
<ContestStateView state="before" restDurationSeconds={73850} />
}
accountMenu={<AccountMenuView name="Alice" />}
/>
}
navbar={<NavbarView collapsed={collapsed} onOpenToggleClick={toggle} />}
Expand Down
Loading