Skip to content
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
128 changes: 128 additions & 0 deletions apps/web/src/pages/VenueFormPage.hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";

/**
* 会場フォームのフックが早期 return より前で揃っていること (#372)。
*
* `/venues/:id/edit` を開いた直後は会場の取得が終わっていないので
* `existing.data` は undefined で、権限なしの早期 return を通らない。
* 応答が届いて「編集権限なし」と分かった瞬間に早期 return を通るようになる。
* このとき早期 return より後ろにフックがあると、2回目の描画で呼ぶフックが
* 減るため React が "Rendered fewer hooks than expected" で落ちる。
* 読み込み中→権限なしの差し替えを実際に起こして、落ちないことを確かめる。
*/

type VenueQuery = { data: unknown; isLoading: boolean };

let venueQuery: VenueQuery;

const idleMutation = () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
});

vi.mock("../api/venueHooks.js", () => ({
useVenue: () => venueQuery,
useCreateVenue: () => idleMutation(),
useUpdateVenue: () => idleMutation(),
useDeleteVenue: () => idleMutation(),
}));
// 会場本体の編集と関係ない部品(自前で API を呼ぶ)は外す
vi.mock("../components/VenueAdminsCard.js", () => ({
VenueAdminsCard: () => null,
}));

const { VenueFormPage } = await import("./VenueFormPage.js");

/** 取得が終わっていない状態(クエリ実行中) */
const loading: VenueQuery = { data: undefined, isLoading: true };

/** 権限のない利用者に返る応答 */
function withoutPermission(): VenueQuery {
return {
isLoading: false,
data: {
venue: {
id: "v-1",
name: "テスト会場",
description: "",
area: "東京",
address: "",
addressPublic: false,
capacity: null,
equipment: "",
terms: "",
contact: "",
status: "open",
imageUpdatedAt: null,
},
owner: null,
isOwner: false,
isManager: false,
},
};
}

/** 管理できる利用者に返る応答 */
function withPermission(): VenueQuery {
const granted = withoutPermission();
return {
...granted,
data: { ...(granted.data as object), isOwner: true, isManager: true },
};
}

function draw() {
return render(
<MemoryRouter initialEntries={["/venues/v-1/edit"]}>
<Routes>
<Route path="/venues/:id/edit" element={<VenueFormPage />} />
</Routes>
</MemoryRouter>,
);
}

beforeEach(() => {
venueQuery = loading;
});

describe("会場フォームのフックの順序 (#372)", () => {
it("読み込み中から権限なしに変わってもフックの数が変わらない", () => {
const { rerender } = draw();
// 取得前はフォームが出ている(早期 return を通っていない)
expect(screen.getByLabelText(/会場名/)).toBeInTheDocument();

// 応答が届いて「権限なし」と分かる。ここで早期 return を通るようになる
venueQuery = withoutPermission();
expect(() =>
rerender(
<MemoryRouter initialEntries={["/venues/v-1/edit"]}>
<Routes>
<Route path="/venues/:id/edit" element={<VenueFormPage />} />
</Routes>
</MemoryRouter>,
),
).not.toThrow();

expect(
screen.getByText("この会場の編集権限がありません。"),
).toBeInTheDocument();
expect(screen.queryByLabelText(/会場名/)).not.toBeInTheDocument();
});

it("読み込み中から権限ありに変わると既存値が入ったフォームが出る", () => {
const { rerender } = draw();
venueQuery = withPermission();
rerender(
<MemoryRouter initialEntries={["/venues/v-1/edit"]}>
<Routes>
<Route path="/venues/:id/edit" element={<VenueFormPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByLabelText(/会場名/)).toHaveValue("テスト会場");
});
});
11 changes: 6 additions & 5 deletions apps/web/src/pages/VenueFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export function VenueFormPage() {
const [open, setOpen] = useState(true);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// 訳した文言ではなく真偽値で持つ(言語を切り替えたときに前の言語が残らないように)
const [imageFailed, setImageFailed] = useState(false);
// 作成後に写真だけ失敗したとき、再submitで会場を二重作成しないための保存済みID
const [savedId, setSavedId] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);

// 編集時は既存値をロード(1回だけ)
Expand All @@ -67,6 +71,8 @@ export function VenueFormPage() {
setOpen(v.status === "open");
}, [existing.data, isEdit]);

// フックはすべてこの上で呼ぶ。ここから下は早期 return があるので、
// 下でフックを足すと読み込み中と権限判明後で呼ぶ数が変わってしまう (#372)
if (
isEdit &&
existing.data &&
Expand Down Expand Up @@ -109,11 +115,6 @@ export function VenueFormPage() {
}
};

// 訳した文言ではなく真偽値で持つ(言語を切り替えたときに前の言語が残らないように)
const [imageFailed, setImageFailed] = useState(false);
// 作成後に写真だけ失敗したとき、再submitで会場を二重作成しないための保存済みID
const [savedId, setSavedId] = useState<string | null>(null);

const afterSave = async (venueId: string) => {
setSavedId(venueId);
if (await uploadImage(venueId)) {
Expand Down
Loading