From 2c4ec7dc86cbec59560678656718c02cf5a64c29 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Fri, 20 Oct 2023 12:13:42 -0700 Subject: [PATCH] web,graphql: Add releases pages --- packages/graphql-server/schema.gql | 2 + .../graphql-server/src/tags/tag.queries.ts | 12 ++ .../graphql-server/src/tags/tag.resolver.ts | 67 ++++++- .../CustomCheckbox/index.module.css | 74 ++++++++ .../web/components/CustomCheckbox/index.tsx | 42 +++++ .../MobileHeaderSelector.tsx | 2 +- packages/web/components/DatabaseNav/index.tsx | 2 +- packages/web/components/DatabaseNav/utils.ts | 5 +- .../DatabasePage/ForDocs/DocsPage/index.tsx | 25 +-- .../DatabasePage/ForDocs/NewDocPage/index.tsx | 5 +- .../NewReleaseForm/index.module.css | 24 +++ .../NewReleaseForm/index.test.tsx | 127 +++++++++++++ .../NewReleasePage/NewReleaseForm/index.tsx | 93 ++++++++++ .../NewReleasePage/NewReleaseForm/mocks.ts | 46 +++++ .../NewReleasePage/NewReleaseForm/queries.ts | 19 ++ .../NewReleaseForm/useCreateTag.ts | 75 ++++++++ .../NewReleasePage/index.module.css | 3 + .../ForReleases/NewReleasePage/index.tsx | 16 ++ .../ForReleases/ReleaseList/List.tsx | 56 ++++++ .../ForReleases/ReleaseList/ReleaseHeader.tsx | 32 ++++ .../ReleaseList/ReleaseListItem.tsx | 73 ++++++++ .../ForReleases/ReleaseList/index.module.css | 114 ++++++++++++ .../ForReleases/ReleaseList/index.test.tsx | 170 ++++++++++++++++++ .../ForReleases/ReleaseList/index.tsx | 97 ++++++++++ .../ForReleases/ReleaseList/mocks.ts | 93 ++++++++++ .../ForReleases/ReleaseList/queries.ts | 7 + .../ForReleases/ReleaseList/useTagList.ts | 46 +++++ .../DatabasePage/ForReleases/index.tsx | 36 ++++ .../pageComponents/DatabasePage/index.tsx | 2 + packages/web/gen/graphql-types.tsx | 107 +++++++++++ packages/web/lib/mobileUtils.ts | 8 +- packages/web/lib/refetchQueries.ts | 10 ++ packages/web/lib/urls.ts | 6 + .../[databaseName]/releases/index.tsx | 35 ++++ .../database/[databaseName]/releases/new.tsx | 33 ++++ 35 files changed, 1542 insertions(+), 22 deletions(-) create mode 100644 packages/web/components/CustomCheckbox/index.module.css create mode 100644 packages/web/components/CustomCheckbox/index.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.module.css create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.test.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/mocks.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/queries.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/useCreateTag.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/index.module.css create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/index.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/List.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/ReleaseHeader.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/ReleaseListItem.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/index.module.css create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/index.test.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/index.tsx create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/mocks.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/queries.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/ReleaseList/useTagList.ts create mode 100644 packages/web/components/pageComponents/DatabasePage/ForReleases/index.tsx create mode 100644 packages/web/pages/database/[databaseName]/releases/index.tsx create mode 100644 packages/web/pages/database/[databaseName]/releases/new.tsx diff --git a/packages/graphql-server/schema.gql b/packages/graphql-server/schema.gql index 584b6408..a9a63cc3 100644 --- a/packages/graphql-server/schema.gql +++ b/packages/graphql-server/schema.gql @@ -201,4 +201,6 @@ type Mutation { deleteBranch(databaseName: String!, branchName: String!): Boolean! addDatabaseConnection(url: String, useEnv: Boolean): String! createDatabase(databaseName: String!): Boolean! + createTag(tagName: String!, databaseName: String!, message: String, fromRefName: String!): Tag! + deleteTag(databaseName: String!, tagName: String!): Boolean! } \ No newline at end of file diff --git a/packages/graphql-server/src/tags/tag.queries.ts b/packages/graphql-server/src/tags/tag.queries.ts index cca59993..80104ee3 100644 --- a/packages/graphql-server/src/tags/tag.queries.ts +++ b/packages/graphql-server/src/tags/tag.queries.ts @@ -1,3 +1,15 @@ export const tagsQuery = `SELECT * FROM dolt_tags ORDER BY date DESC`; export const tagQuery = `SELECT * FROM dolt_tags WHERE tag_name=?`; + +export const callDeleteTag = `CALL DOLT_TAG("-d", ?)`; + +export const getCallNewTag = (hasMessage = false, hasAuthor = false) => + `CALL DOLT_TAG(?, ?${hasMessage ? `, "-m", ?` : ""}${getAuthorNameString( + hasAuthor, + )})`; + +export function getAuthorNameString(hasAuthor: boolean): string { + if (!hasAuthor) return ""; + return `, "--author", ?`; +} diff --git a/packages/graphql-server/src/tags/tag.resolver.ts b/packages/graphql-server/src/tags/tag.resolver.ts index 9878b76c..2fe41560 100644 --- a/packages/graphql-server/src/tags/tag.resolver.ts +++ b/packages/graphql-server/src/tags/tag.resolver.ts @@ -1,8 +1,41 @@ -import { Args, Query, Resolver } from "@nestjs/graphql"; +import { + Args, + ArgsType, + Field, + Mutation, + Query, + Resolver, +} from "@nestjs/graphql"; import { DataSourceService } from "../dataSources/dataSource.service"; import { DBArgs, TagArgs } from "../utils/commonTypes"; import { Tag, TagList, fromDoltRowRes } from "./tag.model"; -import { tagQuery, tagsQuery } from "./tag.queries"; +import { + callDeleteTag, + getCallNewTag, + tagQuery, + tagsQuery, +} from "./tag.queries"; + +// @InputType() +// class AuthorInfo { +// @Field() +// name: string; + +// @Field() +// email: string; +// } + +@ArgsType() +class CreateTagArgs extends TagArgs { + @Field({ nullable: true }) + message?: string; + + @Field() + fromRefName: string; + + // @Field({ nullable: true }) + // author?: AuthorInfo; +} @Resolver(_of => Tag) export class TagResolver { @@ -26,4 +59,34 @@ export class TagResolver { return fromDoltRowRes(args.databaseName, res[0]); }, args.databaseName); } + + @Mutation(_returns => Tag) + async createTag(@Args() args: CreateTagArgs): Promise { + return this.dss.query(async query => { + await query(getCallNewTag(!!args.message), [ + args.tagName, + args.fromRefName, + args.message, + // getAuthorString(args.author), + ]); + const res = await query(tagQuery, [args.tagName]); + if (!res.length) throw new Error("Error creating tag"); + return fromDoltRowRes(args.databaseName, res[0]); + }, args.databaseName); + } + + @Mutation(_returns => Boolean) + async deleteTag(@Args() args: TagArgs): Promise { + return this.dss.query(async query => { + await query(callDeleteTag, [args.tagName]); + return true; + }, args.databaseName); + } } + +// export type CommitAuthor = { name: string; email: string }; + +// function getAuthorString(commitAuthor?: CommitAuthor): string { +// if (!commitAuthor) return ""; +// return `${commitAuthor.name} <${commitAuthor.email}>`; +// } diff --git a/packages/web/components/CustomCheckbox/index.module.css b/packages/web/components/CustomCheckbox/index.module.css new file mode 100644 index 00000000..306a3798 --- /dev/null +++ b/packages/web/components/CustomCheckbox/index.module.css @@ -0,0 +1,74 @@ +.container { + @apply block relative pl-10 mb-2 text-primary font-semibold cursor-pointer select-none; + + input { + @apply absolute left-0 opacity-0; + } +} + +.disabledContainer { + @apply text-ld-darkgrey; + + &:hover { + @apply cursor-default; + } +} + +.blueContainer { + input { + @apply text-ld-mediumblue; + } +} + +.checkmark { + @apply absolute -top-0.5 left-0 h-5 w-5 bg-white rounded-sm mt-1 border border-gray-300; +} + +.disabledContainer .checkmark { + @apply bg-gray-100 border-gray-300; +} + +.blueContainer .checkmark { + @apply border-acc-hoverlinkblue; +} + +.container:hover input ~ .checkmark { + @apply border-acc-lightgrey; +} + +.blueContainer:hover input ~ .checkmark { + @apply border-acc-hoverlinkblue; +} + +.checkmark > svg { + @apply absolute hidden; + content: ""; +} + +.container .checkmark > svg { + @apply text-acc-linkblue font-thin -top-[6px] -left-[1px] text-[28px]; +} + +.blueContainer .checkmark > svg { + @apply text-ld-mediumblue; +} + +.container input:checked ~ .checkmark { + @apply bg-white; +} + +.container input:checked ~ .checkmark > svg { + @apply block; +} + +.container input:focus ~ .checkmark { + @apply widget-shadow-lightblue; +} + +.container:hover input:disabled ~ .checkmark { + @apply border-gray-300; +} + +.description { + @apply text-acc-darkgrey ml-10 mb-6; +} diff --git a/packages/web/components/CustomCheckbox/index.tsx b/packages/web/components/CustomCheckbox/index.tsx new file mode 100644 index 00000000..8585100e --- /dev/null +++ b/packages/web/components/CustomCheckbox/index.tsx @@ -0,0 +1,42 @@ +import { FiCheck } from "@react-icons/all-files/fi/FiCheck"; +import cx from "classnames"; +import { ChangeEvent } from "react"; +import css from "./index.module.css"; + +type Props = { + name: string; + onChange: (e: ChangeEvent) => void; + checked: boolean; + label?: string; + description?: string; + className?: string; + disabled?: boolean; + blue?: boolean; +}; + +export default function CustomCheckbox({ + className, + blue = false, + label, + description, + ...props +}: Props) { + return ( +
+ + {description &&

{description}

} +
+ ); +} diff --git a/packages/web/components/DatabaseHeaderAndNav/MobileHeaderSelector.tsx b/packages/web/components/DatabaseHeaderAndNav/MobileHeaderSelector.tsx index e04a1687..8d070dc0 100644 --- a/packages/web/components/DatabaseHeaderAndNav/MobileHeaderSelector.tsx +++ b/packages/web/components/DatabaseHeaderAndNav/MobileHeaderSelector.tsx @@ -22,7 +22,7 @@ const getTabOptions = (isDolt: boolean, hideDoltFeature: boolean): Option[] => { { value: "ref", label: "Database" }, { value: "about", label: "About", isDisabled: !isDolt }, { value: "commitLog", label: "Commit Log", isDisabled: !isDolt }, - // { value: "releases", label: "Releases", isDisabled: !isDolt }, + { value: "releases", label: "Releases", isDisabled: !isDolt }, // { value: "pulls", label: "Pull Requests", isDisabled: !isDolt }, ]; }; diff --git a/packages/web/components/DatabaseNav/index.tsx b/packages/web/components/DatabaseNav/index.tsx index 753767d8..7e2c991f 100644 --- a/packages/web/components/DatabaseNav/index.tsx +++ b/packages/web/components/DatabaseNav/index.tsx @@ -22,7 +22,7 @@ type QueryProps = Props & { }; }; -const tabs = ["Database", "About", "Commit Log"]; +const tabs = ["Database", "About", "Commit Log", "Releases"]; export default function DatabaseNav(props: Props) { const { isDolt } = useIsDolt(); diff --git a/packages/web/components/DatabaseNav/utils.ts b/packages/web/components/DatabaseNav/utils.ts index 66fc5af9..7dc75af6 100644 --- a/packages/web/components/DatabaseNav/utils.ts +++ b/packages/web/components/DatabaseNav/utils.ts @@ -6,6 +6,7 @@ import { defaultDoc, ref, RefUrl, + releases, } from "@lib/urls"; import { Route } from "@lib/urlUtils"; @@ -17,8 +18,8 @@ function getUrlFromName(name: string): [DatabaseUrl, RefUrl?] { return [database, defaultDoc]; case "Commit Log": return [database, commitLog]; - // case "Releases": - // return [releases]; + case "Releases": + return [releases]; // case "Pull Requests": // return [pulls]; default: diff --git a/packages/web/components/pageComponents/DatabasePage/ForDocs/DocsPage/index.tsx b/packages/web/components/pageComponents/DatabasePage/ForDocs/DocsPage/index.tsx index d255d04d..f6dcde71 100644 --- a/packages/web/components/pageComponents/DatabasePage/ForDocs/DocsPage/index.tsx +++ b/packages/web/components/pageComponents/DatabasePage/ForDocs/DocsPage/index.tsx @@ -1,5 +1,6 @@ import DocMarkdown from "@components/DocMarkdown"; import Page404 from "@components/Page404"; +import NotDoltWrapper from "@components/util/NotDoltWrapper"; import QueryHandler from "@components/util/QueryHandler"; import { DocForDocPageFragment, @@ -57,17 +58,19 @@ export default function DocsPage({ params, title }: Props) { leftNavInitiallyOpen title={title} > - } - render={data => - data.docOrDefaultDoc ? ( - - ) : ( - - ) - } - /> + + } + render={data => + data.docOrDefaultDoc ? ( + + ) : ( + + ) + } + /> + ); } diff --git a/packages/web/components/pageComponents/DatabasePage/ForDocs/NewDocPage/index.tsx b/packages/web/components/pageComponents/DatabasePage/ForDocs/NewDocPage/index.tsx index fc82a483..2c942efd 100644 --- a/packages/web/components/pageComponents/DatabasePage/ForDocs/NewDocPage/index.tsx +++ b/packages/web/components/pageComponents/DatabasePage/ForDocs/NewDocPage/index.tsx @@ -1,3 +1,4 @@ +import NotDoltWrapper from "@components/util/NotDoltWrapper"; import { RefParams } from "@lib/params"; import { newDoc } from "@lib/urls"; import DatabasePage from "../../component"; @@ -15,7 +16,9 @@ export default function NewDocPage({ params }: Props) { initialTabIndex={1} // smallHeaderBreadcrumbs={} > - + + + ); } diff --git a/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.module.css b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.module.css new file mode 100644 index 00000000..07a2e6c1 --- /dev/null +++ b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.module.css @@ -0,0 +1,24 @@ +.container { + @apply max-w-2xl mx-auto; +} + +.label { + @apply font-semibold text-primary mt-4 mb-2; +} + +.input { + @apply mt-4; + @apply max-w-md; +} + +.error { + @apply text-center max-w-xs; +} + +.textarea { + @apply max-w-none; +} + +.textarea > textarea { + @apply bg-white; +} diff --git a/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.test.tsx b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.test.tsx new file mode 100644 index 00000000..9a010afd --- /dev/null +++ b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.test.tsx @@ -0,0 +1,127 @@ +import { MockedProvider } from "@apollo/client/testing"; +import useMockRouter, { actions } from "@hooks/useMockRouter"; +import { setupAndWait } from "@lib/testUtils.test"; +import { releases } from "@lib/urls"; +import { screen, waitFor } from "@testing-library/react"; +import selectEvent from "react-select-event"; +import NewTagForm from "."; +import * as mocks from "./mocks"; + +const jestRouter = jest.spyOn(require("next/router"), "useRouter"); + +jest.mock("next/router", () => { + return { + useRouter: () => { + return { route: "", pathname: "", query: "", asPath: "" }; + }, + }; +}); + +describe("tests NewTagForm", () => { + beforeEach(() => { + mocks.createNewTagData.mockClear(); + }); + + const fillTagForm = async () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + useMockRouter(jestRouter, {}); + const { user } = await setupAndWait( + + + , + ); + + await user.click(await screen.findByRole("combobox")); + await selectEvent.select( + screen.getByRole("combobox"), + mocks.fromBranch.branchName, + ); + + const [nameInput, messageInput] = screen.getAllByRole("textbox"); + + await user.type(nameInput, mocks.tagName); + await user.type(messageInput, mocks.message); + + return user; + }; + + it("renders correctly", async () => { + await fillTagForm(); + + expect( + screen.getByText("Pick a branch or recent commit"), + ).toBeInTheDocument(); + expect(screen.getByText(mocks.fromBranch.branchName)).toBeInTheDocument(); + + const [nameInput, messageInput] = screen.getAllByRole("textbox"); + + expect(screen.getByText(/tag name/i)).toBeInTheDocument(); + expect(nameInput).toHaveDisplayValue(mocks.tagName); + + expect(screen.getByText(/description/i)).toBeInTheDocument(); + expect(messageInput).toHaveDisplayValue(mocks.message); + + expect( + screen.getByRole("button", { + name: /create release/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /cancel/i, + }), + ).toBeInTheDocument(); + }); + + it("creates a new tag", async () => { + const user = await fillTagForm(); + + await user.click( + screen.getByRole("button", { + name: /create release/i, + }), + ); + + await waitFor(() => + expect(mocks.createNewTagData.mock.calls).toHaveLength(1), + ); + + // Navigating to releases page + const { href, as } = releases(mocks.dbParams); + await waitFor(() => { + expect(actions.push).toHaveBeenCalledWith(href, as); + }); + }); + + it.skip("disables button when not filled out", async () => { + const { user } = await setupAndWait( + + + , + ); + + const btn = screen.getByRole("button", { + name: /create release/i, + }); + expect(btn).toBeDisabled(); + + await user.click(await screen.findByRole("combobox")); + await user.click(screen.getAllByText("main")[1]); + + expect(btn).toBeDisabled(); + + await user.type(screen.getAllByRole("textbox")[0], mocks.tagName); + expect(btn).toBeEnabled(); + + await user.type(screen.getAllByRole("textbox")[1], mocks.message); + expect(btn).toBeEnabled(); + }); +}); diff --git a/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.tsx b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.tsx new file mode 100644 index 00000000..86bcb970 --- /dev/null +++ b/packages/web/components/pageComponents/DatabasePage/ForReleases/NewReleasePage/NewReleaseForm/index.tsx @@ -0,0 +1,93 @@ +import Button from "@components/Button"; +import ButtonsWithError from "@components/ButtonsWithError"; +import CustomFormSelect from "@components/CustomFormSelect"; +import ErrorMsg from "@components/ErrorMsg"; +import FormInput from "@components/FormInput"; +import Loader from "@components/Loader"; +import Textarea from "@components/Textarea"; +import { OptionalRefParams } from "@lib/params"; +import { releases } from "@lib/urls"; +import { useRouter } from "next/router"; +import { SyntheticEvent } from "react"; +import css from "./index.module.css"; +import useCreateTag from "./useCreateTag"; + +type Props = { + params: OptionalRefParams; +}; + +export default function NewTagForm(props: Props): JSX.Element { + const router = useRouter(); + + const createTagRes = useCreateTag(props.params); + + const goToReleases = () => { + const { href, as } = releases(props.params); + router.push(href, as).catch(console.error); + }; + + const onSubmit = async (e: SyntheticEvent) => { + e.preventDefault(); + + const data = await createTagRes.createTag(); + if (!data) return; + + goToReleases(); + }; + + return ( +
+
+
+ createTagRes.setFormData({ fromRefName: s })} + /> + createTagRes.setFormData({ tagName: s })} + label="Tag name" + placeholder="i.e. v1" + className={css.input} + data-cy="new-tag-name-input" + /> +