From c3177d91d2cf7088cb702f035071d1813f5b5313 Mon Sep 17 00:00:00 2001 From: tomolld Date: Sun, 29 Sep 2024 16:43:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=82=AF=E3=82=B9=E3=83=88=E3=82=92number=E3=81=A8pageId?= =?UTF-8?q?=E3=81=A7unique=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-branch-on-issue-assign.yml | 98 ---------- docker-compose.yml | 14 ++ .../translate/functions/mutations.server.ts | 1 - .../$userName+/functions/queries.server.ts | 17 +- web/app/routes/$userName+/index.test.tsx | 32 +++- web/app/routes/$userName+/index.tsx | 2 +- .../page+/$slug+/edit/_edit.test.tsx | 174 ++++++++++++++++++ .../$userName+/page+/$slug+/edit/_edit.tsx | 18 +- .../$slug+/edit/components/EditHeader.tsx | 19 +- .../$slug+/edit/components/editor/Editor.tsx | 5 + .../$slug+/edit/functions/mutations.server.ts | 59 +++--- .../$slug+/edit/functions/queries.server.ts | 10 +- .../$userName+/page+/$slug+/edit/types.ts | 2 +- .../edit/utils/extractTextElementInfo.ts | 8 +- .../edit/utils/getPageSourceLanguage.ts | 7 +- .../page+/$slug+/functions/queries.server.ts | 5 - .../routes/$userName+/page+/$slug+/index.tsx | 37 ++-- web/app/routes/$userName+/types.ts | 10 - web/app/routes/functions/queries.server.ts | 13 +- web/app/routes/home/_home.tsx | 2 +- .../routes/home/functions/queries.server.ts | 9 + .../routes/search/functions/queries.server.ts | 22 ++- web/app/routes/search/route.tsx | 13 +- web/package.json | 64 +++---- .../migrations/20240926014041_/migration.sql | 77 ++++++++ .../migrations/20240926014415_/migration.sql | 21 +++ .../migrations/20240929072341_/migration.sql | 8 + web/prisma/schema.prisma | 44 ++--- web/prisma/seed.ts | 2 - web/vitest.config.ts | 2 + 30 files changed, 512 insertions(+), 283 deletions(-) delete mode 100644 .github/workflows/create-branch-on-issue-assign.yml create mode 100644 web/app/routes/$userName+/page+/$slug+/edit/_edit.test.tsx delete mode 100644 web/app/routes/$userName+/types.ts create mode 100644 web/prisma/migrations/20240926014041_/migration.sql create mode 100644 web/prisma/migrations/20240926014415_/migration.sql create mode 100644 web/prisma/migrations/20240929072341_/migration.sql diff --git a/.github/workflows/create-branch-on-issue-assign.yml b/.github/workflows/create-branch-on-issue-assign.yml deleted file mode 100644 index fcc69687..00000000 --- a/.github/workflows/create-branch-on-issue-assign.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Create Branch on Issue Assign - -on: - issues: - types: [assigned] - workflow_dispatch: - inputs: - issue_number: - description: "Issue number to process" - required: true - type: number - -jobs: - check_labels_and_create_branch: - runs-on: ubuntu-latest - permissions: - issues: write - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check labels and create branch - id: check_and_create - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - ISSUE_NUMBER="${{ github.event.inputs.issue_number }}" - else - ISSUE_NUMBER="${{ github.event.issue.number }}" - fi - - ISSUE_DATA=$(gh issue view $ISSUE_NUMBER --json number,title,assignees,labels) - ASSIGNEE=$(echo "$ISSUE_DATA" | jq -r '.assignees[0].login // empty') - LABELS=$(echo "$ISSUE_DATA" | jq -r '.labels[].name // empty') - - echo "Debug: ISSUE_DATA=$ISSUE_DATA" - echo "Debug: ASSIGNEE=$ASSIGNEE" - echo "Debug: LABELS=$LABELS" - - if [ -z "$ASSIGNEE" ]; then - echo "Error: Issue is not assigned" - exit 1 - fi - - # Define label to branch prefix mapping - declare -A LABEL_PREFIX_MAP - LABEL_PREFIX_MAP=( - ["bug"]="fix" - ["feature"]="feat" - ["enhancement"]="enhance" - ["documentation"]="doc" - ["refactor"]="refactor" - ["chore"]="chore" - ) - - VALID_LABELS=$(echo "${!LABEL_PREFIX_MAP[@]}" | tr ' ' '|') - BRANCH_LABEL=$(echo "$LABELS" | tr ' ' '\n' | grep -m1 -E "$VALID_LABELS") - - if [ -z "$BRANCH_LABEL" ]; then - echo "no_valid_label=true" >> $GITHUB_OUTPUT - echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT - exit 0 - fi - - echo "Debug: Matching label found: $BRANCH_LABEL" - - BRANCH_PREFIX=${LABEL_PREFIX_MAP[$BRANCH_LABEL]} - BRANCH_NAME="${ASSIGNEE,,}/${BRANCH_PREFIX}-issue-${ISSUE_NUMBER}" - - echo "Debug: BRANCH_NAME=$BRANCH_NAME" - - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - git checkout -b "$BRANCH_NAME" - git push origin "$BRANCH_NAME" - - gh issue comment $ISSUE_NUMBER --body "Created branch \`$BRANCH_NAME\`" - - - name: Comment on missing label - if: steps.check_and_create.outputs.no_valid_label == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ steps.check_and_create.outputs.issue_number }} - run: | - COMMENT="Thank you for working on this issue. To help us categorize and prioritize it better, please add one of the following labels: - - - bug: Something isn't working as expected - - feature: New feature implementation - - enhancement: Improvement to existing features - - documentation: Improvements or additions to documentation - - refactor: Code refactoring - - chore: General maintenance tasks - - Adding an appropriate label will help our team quickly understand the nature of the issue and create the correct branch. Once you've added a label, we'll automatically create a branch for this issue. Thank you for your cooperation!" - - gh issue comment $ISSUE_NUMBER --body "$COMMENT" diff --git a/docker-compose.yml b/docker-compose.yml index 499fd102..5e12ea70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,18 @@ services: volumes: - db:/var/lib/postgresql/data + test_db: + image: postgres:16 + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + ports: + - '5433:5432' + volumes: + - test_db:/var/lib/postgresql/data + redis: image: redis:6.2-alpine restart: always @@ -49,6 +61,8 @@ services: volumes: db: driver: local + test_db: + driver: local redis: driver: local minio_data: diff --git a/web/app/features/translate/functions/mutations.server.ts b/web/app/features/translate/functions/mutations.server.ts index 2e74f249..03079b3b 100644 --- a/web/app/features/translate/functions/mutations.server.ts +++ b/web/app/features/translate/functions/mutations.server.ts @@ -41,7 +41,6 @@ export async function getLatestSourceTexts(pageId: number) { orderBy: { createdAt: "desc", }, - distinct: ["number"], select: { id: true, number: true, diff --git a/web/app/routes/$userName+/functions/queries.server.ts b/web/app/routes/$userName+/functions/queries.server.ts index 6e9dfeac..6b7f437e 100644 --- a/web/app/routes/$userName+/functions/queries.server.ts +++ b/web/app/routes/$userName+/functions/queries.server.ts @@ -1,21 +1,20 @@ import { prisma } from "~/utils/prisma"; import { sanitizeUser } from "~/utils/sanitizeUser"; -import type { PageListItem, sanitizedUserWithPages } from "../types"; export async function fetchSanitizedUserWithPages( userName: string, isOwnProfile: boolean, -): Promise { +) { const user = await prisma.user.findUnique({ where: { userName }, include: { pages: { - select: { - id: true, - title: true, - slug: true, - isPublished: true, - createdAt: true, + include: { + sourceTexts: { + where: { + number: 0, + }, + }, }, where: { isArchived: false, @@ -28,7 +27,7 @@ export async function fetchSanitizedUserWithPages( if (!user) return null; - const pages: PageListItem[] = user.pages.map((page) => ({ + const pages = user.pages.map((page) => ({ ...page, })); return { diff --git a/web/app/routes/$userName+/index.test.tsx b/web/app/routes/$userName+/index.test.tsx index 81fa8a23..a34b1719 100644 --- a/web/app/routes/$userName+/index.test.tsx +++ b/web/app/routes/$userName+/index.test.tsx @@ -25,22 +25,37 @@ describe("UserProfile", () => { pages: { create: [ { - title: "Public Page", slug: "public-page", isPublished: true, content: "This is a test content", + sourceTexts: { + create: { + number: 0, + text: "Public Page", + }, + }, }, { - title: "Private Page", slug: "private-page", isPublished: false, content: "This is a test content2", + sourceTexts: { + create: { + number: 0, + text: "Private Page", + }, + }, }, { - title: "Archived Page", slug: "archived-page", isArchived: true, content: "This is a test content3", + sourceTexts: { + create: { + number: 0, + text: "Archived Page", + }, + }, }, ], }, @@ -124,17 +139,16 @@ describe("UserProfile", () => { const menuButtons = await screen.findAllByLabelText("More options"); expect(menuButtons.length).toBeGreaterThan(0); - await userEvent.click(menuButtons[0]); - expect(await screen.findByText("Edit")).toBeInTheDocument(); expect(await screen.findByText("Make Private")).toBeInTheDocument(); await userEvent.click(await screen.findByText("Make Private")); - waitFor(() => { - userEvent.click(menuButtons[0]); - expect(screen.findByText("Make Public")).toBeInTheDocument(); - }); + // const menuButtons2 = await screen.findAllByLabelText("More options"); + // expect(menuButtons2.length).toBeGreaterThan(0); + // await userEvent.click(menuButtons2[0]); + // await screen.debug(); + // expect(await screen.findByText("Make Public")).toBeInTheDocument(); }); test("action handles archive correctly", async () => { diff --git a/web/app/routes/$userName+/index.tsx b/web/app/routes/$userName+/index.tsx index 83fbd291..ec42aafd 100644 --- a/web/app/routes/$userName+/index.tsx +++ b/web/app/routes/$userName+/index.tsx @@ -215,7 +215,7 @@ export default function UserPage() { {page.isPublished ? "" : } - {page.title} + {page.sourceTexts.filter((item) => item.number === 0)[0].text} {pageCreatedAt} diff --git a/web/app/routes/$userName+/page+/$slug+/edit/_edit.test.tsx b/web/app/routes/$userName+/page+/$slug+/edit/_edit.test.tsx new file mode 100644 index 00000000..743870e4 --- /dev/null +++ b/web/app/routes/$userName+/page+/$slug+/edit/_edit.test.tsx @@ -0,0 +1,174 @@ +import { createRemixStub } from "@remix-run/testing"; +import { render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { authenticator } from "~/utils/auth.server"; +import { prisma } from "~/utils/prisma"; +import EditPage, { loader, action } from "./_edit"; +vi.mock("~/utils/auth.server", () => ({ + authenticator: { + isAuthenticated: vi.fn(), + }, +})); +vi.mock("./components/editor/EditorBubbleMenu", () => ({ + EditorBubbleMenu: vi.fn(() => ( +
Mocked EditorBubbleMenu
+ )), +})); +vi.mock("./components/editor/EditorFloatingMenu", () => ({ + EditorFloatingMenu: vi.fn(() => ( +
+ Mocked EditorFloatingMenu +
+ )), +})); +describe("EditPage", () => { + function getBoundingClientRect(): DOMRect { + const rec = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }; + return { ...rec, toJSON: () => rec }; + } + + class FakeDOMRectList extends Array implements DOMRectList { + item(index: number): DOMRect | null { + return this[index]; + } + } + + document.elementFromPoint = (): null => null; + HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; + HTMLElement.prototype.getClientRects = (): DOMRectList => + new FakeDOMRectList(); + Range.prototype.getBoundingClientRect = getBoundingClientRect; + Range.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList(); + let sourceTextIds: number[]; + beforeEach(async () => { + const user = await prisma.user.create({ + data: { + userName: "testuser", + displayName: "Test User", + email: "testuser@example.com", + icon: "https://example.com/icon.jpg", + profile: "This is a test profile", + pages: { + create: [ + { + slug: "test-page", + isPublished: false, + content: + "

hello

world

This is a test content

", + sourceTexts: { + create: [ + { number: 0, text: "Test Title" }, + { number: 1, text: "hello" }, + { number: 2, text: "world" }, + { number: 3, text: "This is a test content" }, + ], + }, + }, + ], + }, + }, + include: { pages: { include: { sourceTexts: true } } }, + }); + + const page = user.pages[0]; + sourceTextIds = page.sourceTexts.map((st) => st.id); + const updatedContent = page.content.replace( + /

/g, + (match, number) => { + const sourceText = page.sourceTexts.find( + (st) => st.number === Number.parseInt(number), + ); + return `

`; + }, + ); + + await prisma.page.update({ + where: { id: page.id }, + data: { content: updatedContent }, + }); + }); + + test("loader returns correct data for authenticated user", async () => { + // @ts-ignore + vi.mocked(authenticator.isAuthenticated).mockResolvedValue({ + id: 1, + userName: "testuser", + }); + + const RemixStub = createRemixStub([ + { + path: "/:userName/page/:slug/edit", + Component: EditPage, + loader, + }, + ]); + + render(); + + expect(await screen.findByText("Test Title")).toBeInTheDocument(); + expect( + await screen.findByText("This is a test content"), + ).toBeInTheDocument(); + }); + + test("action handles form submission correctly", async () => { + // @ts-ignore + vi.mocked(authenticator.isAuthenticated).mockResolvedValue({ + id: 1, + userName: "testuser", + }); + + const RemixStub = createRemixStub([ + { + path: "/:userName/page/:slug/edit", + Component: EditPage, + loader, + action, + }, + ]); + + render(); + const firstParagraph = await screen.findByText("hello"); + await userEvent.type(firstParagraph, "updated "); + await userEvent.keyboard("{enter}"); + + await userEvent.click(await screen.findByTestId("change-publish-button")); + await userEvent.click(await screen.findByTestId("public-button")); + await userEvent.click(await screen.findByTestId("save-button")); + + expect(await screen.findByTestId("save-button-check")).toBeInTheDocument(); + + await waitFor(async () => { + const updatedPage = await prisma.page.findFirst({ + where: { slug: "test-page" }, + include: { sourceTexts: true }, + }); + console.log(updatedPage?.sourceTexts); + expect(updatedPage).not.toBeNull(); + expect(updatedPage?.sourceTexts).toHaveLength(5); + for (const id of sourceTextIds) { + expect(updatedPage?.sourceTexts.some((st) => st.id === id)).toBe(true); + } + + expect(updatedPage?.sourceTexts[0].text).toBe("Test Title"); + expect(updatedPage?.sourceTexts[1].text).toBe("updated"); + expect(updatedPage?.sourceTexts[1].id).toBe(sourceTextIds[1]); + expect(updatedPage?.sourceTexts[2].text).toBe("hello"); + expect(updatedPage?.sourceTexts[3].id).toBe(sourceTextIds[2]); + expect(updatedPage?.sourceTexts[3].text).toBe("world"); + expect(updatedPage?.sourceTexts[4].id).toBe(sourceTextIds[3]); + expect(updatedPage?.sourceTexts[4].text).toBe("This is a test content"); + }); + }); +}); diff --git a/web/app/routes/$userName+/page+/$slug+/edit/_edit.tsx b/web/app/routes/$userName+/page+/$slug+/edit/_edit.tsx index 5c5708b4..02fb3896 100644 --- a/web/app/routes/$userName+/page+/$slug+/edit/_edit.tsx +++ b/web/app/routes/$userName+/page+/$slug+/edit/_edit.tsx @@ -36,7 +36,7 @@ export const meta: MetaFunction = ({ data }) => { if (!data) { return [{ title: "Edit Page" }]; } - return [{ title: `Edit ${data.page?.title}` }]; + return [{ title: `Edit ${data.title}` }]; }; export const editPageSchema = z.object({ @@ -71,9 +71,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } const page = await getPageBySlug(slug); + const title = page?.sourceTexts.find( + (sourceText) => sourceText.number === 0, + )?.text; const allTags = await getAllTags(); - return typedjson({ currentUser, page, allTags }); + return typedjson({ currentUser, page, allTags, title }); } export async function action({ request, params }: ActionFunctionArgs) { @@ -108,7 +111,8 @@ export async function action({ request, params }: ActionFunctionArgs) { ); const sourceLanguage = await getPageSourceLanguage(numberedContent, title); - //createOrUpdateSourceTextsでpageIdを使用するため、ここで一旦pageを作成する + //翻訳との結びつきを維持するため、sourceTextIdを付与したpage.contentを保存し、sourceTextのnumberが変わってもsourceTextIdで紐付けられるようにしている。 + //そのため、sourceTextIdを付与したpage.contentを保存しなければならないが、createOrUpdateSourceTextsでpageIdを使用するため、ここで一旦pageを作成する const page = await createOrUpdatePage( currentUser.id, slug, @@ -140,7 +144,8 @@ export async function action({ request, params }: ActionFunctionArgs) { } export default function EditPage() { - const { currentUser, page, allTags } = useTypedLoaderData(); + const { currentUser, page, allTags, title } = + useTypedLoaderData(); const fetcher = useFetcher(); const [form, fields] = useForm({ onValidate({ formData }) { @@ -151,7 +156,7 @@ export default function EditPage() { constraint: getZodConstraint(editPageSchema), shouldValidate: "onInput", defaultValue: { - title: page?.title, + title: title, pageContent: page?.content, isPublished: page?.isPublished.toString(), tags: page?.tagPages.map((tagPage) => tagPage.tag.name) || [], @@ -185,12 +190,13 @@ export default function EditPage() {

setHasUnsavedChanges(true)} + data-testid="title-input" />

{fields.title.errors?.map((error) => ( diff --git a/web/app/routes/$userName+/page+/$slug+/edit/components/EditHeader.tsx b/web/app/routes/$userName+/page+/$slug+/edit/components/EditHeader.tsx index 32ae6a8d..fe99a54f 100644 --- a/web/app/routes/$userName+/page+/$slug+/edit/components/EditHeader.tsx +++ b/web/app/routes/$userName+/page+/$slug+/edit/components/EditHeader.tsx @@ -75,7 +75,7 @@ export function EditHeader({ return ; } if (!hasUnsavedChanges) { - return ; + return ; } return ( <> @@ -114,6 +114,7 @@ export function EditHeader({ size="sm" className="rounded-full md:absolute md:left-1/2 md:transform md:-translate-x-1/2" disabled={isSubmitting || !hasUnsavedChanges} + data-testid="save-button" > {renderButtonIcon()} @@ -135,7 +136,11 @@ export function EditHeader({
- @@ -157,6 +162,7 @@ export function EditHeader({ ); }} className="bg-transparent text-gray-900 text-sm w-full p-2 focus:outline-none" + data-testid="tags-select" />

max 5 tags

{tagsMeta.allErrors && ( @@ -177,7 +183,12 @@ export function EditHeader({
-