diff --git a/packages/manager/src/constants/API_ENDPOINTS.ts b/packages/manager/src/constants/API_ENDPOINTS.ts index 3fa458d0b3..2ca70eb68e 100644 --- a/packages/manager/src/constants/API_ENDPOINTS.ts +++ b/packages/manager/src/constants/API_ENDPOINTS.ts @@ -79,7 +79,7 @@ export const API_ENDPOINTS: APIEndpoints = (() => { PrismicOembed: "https://oembed.prismic.io", PrismicUnsplash: "https://unsplash.prismic.io", SliceMachineV1: - "https://21vvgrh0s6.execute-api.us-east-1.amazonaws.com/v1/", + "https://rrvzk2wqlh.execute-api.us-east-1.amazonaws.com/v1/", }; } } diff --git a/packages/slice-machine/components/Navigation/index.tsx b/packages/slice-machine/components/Navigation/index.tsx index 01c1a32b4b..9bc327a10f 100644 --- a/packages/slice-machine/components/Navigation/index.tsx +++ b/packages/slice-machine/components/Navigation/index.tsx @@ -141,17 +141,29 @@ const Navigation: FC = () => { )} - } - onClick={() => { - void telemetry.track({ - event: "users-invite-button-clicked", - }); - }} - target="_blank" - /> + + } + active={router.asPath.startsWith("/settings")} + component={Link} + /> + + + + } + onClick={() => { + void telemetry.track({ + event: "users-invite-button-clicked", + }); + }} + target="_blank" + /> + diff --git a/packages/slice-machine/pages/settings.tsx b/packages/slice-machine/pages/settings.tsx new file mode 100644 index 0000000000..0328b190fe --- /dev/null +++ b/packages/slice-machine/pages/settings.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import Head from "next/head"; +import { BaseStyles } from "theme-ui"; +import { + AppLayout, + AppLayoutBreadcrumb, + AppLayoutContent, + AppLayoutHeader, +} from "@components/AppLayout"; +import { ConnectGitRepository } from "@src/features/git/ConnectGitRepository/ConnectGitRepository"; + +const Settings: React.FC = () => { + return ( + <> + + Settings - Slice Machine + + + + + + + + + + + + + ); +}; + +export default Settings; diff --git a/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.module.css b/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.module.css new file mode 100644 index 0000000000..c3ba8bfad8 --- /dev/null +++ b/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.module.css @@ -0,0 +1,15 @@ +.root { + max-width: 600px; +} + +.buttons { + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + gap: 8px; +} + +.owners { + display: grid; + gap: 8px; +} diff --git a/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.tsx b/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.tsx new file mode 100644 index 0000000000..07c3cf956c --- /dev/null +++ b/packages/slice-machine/src/features/git/ConnectGitRepository/ConnectGitRepository.tsx @@ -0,0 +1,388 @@ +import { PropsWithChildren, Suspense, useState } from "react"; +import { + Button, + ButtonGroup, + ErrorBoundary, + Form, + FormInput, + ProgressCircle, + Select, + SelectItem, + Table, + TableBody, + TableCell, + TableRow, + Text, +} from "@prismicio/editor-ui"; + +import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { managerClient } from "@src/managerClient"; +import { useUser } from "@src/hooks/useUser"; + +import { useGitOwners } from "../useGitOwners"; +import { useGitRepos } from "../useGitRepos"; +import { useHasWriteAPIToken } from "../useHasWriteAPIToken"; +import { useLinkedGitRepos } from "../useLinkedGitRepos"; +import { useLinkedGitReposActions } from "../useLinkedGitReposActions"; +import { useWriteAPITokenActions } from "../useWriteAPITokenActions"; + +import * as styles from "./ConnectGitRepository.module.css"; + +const GITHUB_APP_SLUG = "prismic-prod-internal-test"; + +// TODO: Export types from `@slicemachine/manager` +type GitOwner = Awaited< + ReturnType<(typeof managerClient)["git"]["fetchOwners"]> +>[number]; +type GitRepo = Awaited< + ReturnType<(typeof managerClient)["git"]["fetchRepos"]> +>[number]; + +type ConnectButtonBaseProps = PropsWithChildren<{ + provider: "gitHub" | "gitLab" | "bitbucket"; + disabled?: boolean; +}>; + +function ConnectButton(props: ConnectButtonBaseProps) { + const { provider, children, disabled } = props; + + const [isLoading, setIsLoading] = useState(false); + + const connect = async () => { + switch (provider) { + case "gitHub": { + setIsLoading(true); + + const state = await managerClient.git.createGitHubAuthState(); + + const url = new URL( + `https://github.com/apps/${GITHUB_APP_SLUG}/installations/new`, + ); + url.searchParams.set("state", state.key); + + window.open(url, "git-hub-app-installation"); + + return; + } + } + }; + + return ( + + ); +} + +function ConnectButtons() { + return ( +
    +
  • + GitHub +
  • +
  • + + GitLab (soon) + +
  • +
  • + + Bitbucket (soon) + +
  • +
+ ); +} + +type SelectOwnerBaseProps = { + owners: GitOwner[]; + onSelect: (owner: GitOwner) => void; +}; + +function SelectOwnerBase(props: SelectOwnerBaseProps) { + const { owners, onSelect } = props; + + const onValueChange = (value: string) => { + const [provider, id] = value.split("@"); + const owner = owners.find((o) => o.provider === provider && o.id === id); + + if (owner) { + onSelect(owner); + } + }; + + return ( + + ); +} + +function SelectOwner(props: SelectOwnerBaseProps) { + return ( + }> + + + ); +} + +type SelectRepoBaseProps = { + owner?: { + provider: "gitHub"; + name: string; + }; + onSelect: (repo: GitRepo) => void; +}; + +function SelectRepoBase(props: SelectRepoBaseProps) { + const { onSelect } = props; + + const repos = useGitRepos( + props.owner + ? { + provider: props.owner?.provider, + owner: props.owner?.name, + } + : undefined, + ); + + return ( + + + {repos?.map((repo) => { + return ( + + + + {repo.name} + + + + + + + ); + })} + +
+ ); +} + +function SelectRepo(props: SelectRepoBaseProps) { + return ( + }> + + + ); +} + +function RepoSelector() { + const owners = useGitOwners(); + const { linkRepo } = useLinkedGitReposActions(); + + const [selectedOwner, setSelectedOwner] = useState(); + + if (owners.length < 1) { + return ; + } + + return ( +
+ + setSelectedOwner(owner)} + /> + + {selectedOwner ? ( + void linkRepo(repo)} + /> + ) : ( +
Select a user/owner first
+ )} +
+ ); +} + +type UpdateOrDeleteWriteAPIFormProps = { + repo: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +function UpdateOrDeleteWriteAPIForm(props: UpdateOrDeleteWriteAPIFormProps) { + const { repo } = props; + + const { deleteToken } = useWriteAPITokenActions({ git: repo }); + const [isUpdating, setIsUpdating] = useState(false); + + return isUpdating ? ( + setIsUpdating(false)} + onSuccess={() => setIsUpdating(false)} + /> + ) : ( +
+ You have a Write API token saved. + + + + +
+ ); +} + +type UpdateWriteAPIFormProps = { + repo: { + provider: "gitHub"; + owner: string; + name: string; + }; + withCancel?: boolean; + onCancel?: () => void | Promise; + onSuccess?: () => void | Promise; +}; + +function UpdateWriteAPIForm(props: UpdateWriteAPIFormProps) { + const { repo, withCancel, onCancel, onSuccess } = props; + + const { updateToken } = useWriteAPITokenActions({ git: repo }); + + const [token, setToken] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async () => { + if (token) { + setIsSubmitting(true); + await updateToken(token); + await onSuccess?.(); + } + }; + + return ( +
+
void onSubmit()}> + + + {withCancel === true ? ( + + ) : null} + +
+ ); +} + +type LinkedRepositoryProps = { + linkedRepo: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +function LinkedRepository(props: LinkedRepositoryProps) { + const { linkedRepo } = props; + + const { unlinkRepo } = useLinkedGitReposActions(); + const hasWriteAPIToken = useHasWriteAPIToken({ git: linkedRepo }); + + return ( +
+
+ Linked: [{linkedRepo.provider}] {linkedRepo.owner}/{linkedRepo.name} + +
+ {hasWriteAPIToken ? ( + + ) : ( + + )} +
+ ); +} + +function LoggedInContents() { + const linkedGitRepos = useLinkedGitRepos(); + + if ("error" in linkedGitRepos) { + return
TODO: Handle error
; + } + + if (linkedGitRepos.repos.length === 0) { + return ; + } + + const linkedRepo = linkedGitRepos.repos[0]; + + return ; +} + +function LoggedOutContents() { + const { openLoginModal } = useSliceMachineActions(); + + return ( +
+ You must be logged in to connect a Git repository. + +
+ ); +} + +function Contents() { + const { isLoggedIn } = useUser(); + + if (!isLoggedIn) { + return ; + } + + return ; +} + +export function ConnectGitRepository() { + return ( +
+ Connected Git Repository + { + return
Error
; + }} + > + }> + + +
+
+ ); +} diff --git a/packages/slice-machine/src/features/git/types.ts b/packages/slice-machine/src/features/git/types.ts new file mode 100644 index 0000000000..a3395aee9d --- /dev/null +++ b/packages/slice-machine/src/features/git/types.ts @@ -0,0 +1,9 @@ +export type PrismicRepoSpecifier = { + domain: string; +}; + +export type GitRepoSpecifier = { + provider: "gitHub"; + owner: string; + name: string; +}; diff --git a/packages/slice-machine/src/features/git/useGitOwners.ts b/packages/slice-machine/src/features/git/useGitOwners.ts new file mode 100644 index 0000000000..4ab5e7da9f --- /dev/null +++ b/packages/slice-machine/src/features/git/useGitOwners.ts @@ -0,0 +1,10 @@ +import { useRequest } from "@prismicio/editor-support/Suspense"; +import { managerClient } from "@src/managerClient"; + +const getGitOwners = async () => { + return await managerClient.git.fetchOwners(); +}; + +export const useGitOwners = () => { + return useRequest(getGitOwners, []); +}; diff --git a/packages/slice-machine/src/features/git/useGitRepos.ts b/packages/slice-machine/src/features/git/useGitRepos.ts new file mode 100644 index 0000000000..22ede88d29 --- /dev/null +++ b/packages/slice-machine/src/features/git/useGitRepos.ts @@ -0,0 +1,26 @@ +import { useRequest } from "@prismicio/editor-support/Suspense"; +import { managerClient } from "@src/managerClient"; + +async function getGitRepos( + provider?: "gitHub", + owner?: string, + query?: string, + page?: number, +) { + if (provider === undefined || owner === undefined) { + return; + } + + return await managerClient.git.fetchRepos({ provider, owner, query, page }); +} + +export const useGitRepos = ( + args?: Parameters[0], +) => { + return useRequest(getGitRepos, [ + args?.provider, + args?.owner, + args?.query, + args?.page, + ]); +}; diff --git a/packages/slice-machine/src/features/git/useHasWriteAPIToken.ts b/packages/slice-machine/src/features/git/useHasWriteAPIToken.ts new file mode 100644 index 0000000000..0a82dfe173 --- /dev/null +++ b/packages/slice-machine/src/features/git/useHasWriteAPIToken.ts @@ -0,0 +1,34 @@ +import { useRequest } from "@prismicio/editor-support/Suspense"; +import { useSliceMachineConfig } from "@src/hooks/useSliceMachineConfig"; +import { managerClient } from "@src/managerClient"; + +import { GitRepoSpecifier } from "./types"; + +export const getHasWriteAPIToken = async ( + prismicDomain: string, + gitProvider: "gitHub", + gitOwner: string, + gitName: string, +): Promise => { + return await managerClient.git.checkHasWriteAPIToken({ + prismic: { + domain: prismicDomain, + }, + git: { + provider: gitProvider, + owner: gitOwner, + name: gitName, + }, + }); +}; + +export const useHasWriteAPIToken = (args: { git: GitRepoSpecifier }) => { + const [config] = useSliceMachineConfig(); + + return useRequest(getHasWriteAPIToken, [ + config.repositoryName, + args.git.provider, + args.git.owner, + args.git.name, + ]); +}; diff --git a/packages/slice-machine/src/features/git/useLinkedGitRepos.ts b/packages/slice-machine/src/features/git/useLinkedGitRepos.ts new file mode 100644 index 0000000000..d715718a1a --- /dev/null +++ b/packages/slice-machine/src/features/git/useLinkedGitRepos.ts @@ -0,0 +1,31 @@ +import { useRequest } from "@prismicio/editor-support/Suspense"; +import { useSliceMachineConfig } from "@src/hooks/useSliceMachineConfig"; +import { managerClient } from "@src/managerClient"; + +type UseLinkedGitReposReturnType = + | { + repos: Awaited< + ReturnType<(typeof managerClient)["git"]["fetchLinkedRepos"]> + >; + } + | { error: unknown }; + +export const getLinkedGitRepos = async ( + prismicDomain: string, +): Promise => { + try { + const repos = await managerClient.git.fetchLinkedRepos({ + prismic: { domain: prismicDomain }, + }); + + return { repos }; + } catch (error) { + return { error }; + } +}; + +export const useLinkedGitRepos = () => { + const [config] = useSliceMachineConfig(); + + return useRequest(getLinkedGitRepos, [config.repositoryName]); +}; diff --git a/packages/slice-machine/src/features/git/useLinkedGitReposActions.ts b/packages/slice-machine/src/features/git/useLinkedGitReposActions.ts new file mode 100644 index 0000000000..7b86ab9bc0 --- /dev/null +++ b/packages/slice-machine/src/features/git/useLinkedGitReposActions.ts @@ -0,0 +1,36 @@ +import { revalidateData } from "@prismicio/editor-support/Suspense"; +import { useSliceMachineConfig } from "@src/hooks/useSliceMachineConfig"; +import { managerClient } from "@src/managerClient"; + +import { GitRepoSpecifier } from "./types"; +import { getHasWriteAPIToken } from "./useHasWriteAPIToken"; +import { getLinkedGitRepos } from "./useLinkedGitRepos"; + +export const useLinkedGitReposActions = () => { + const [config] = useSliceMachineConfig(); + + return { + linkRepo: async (git: GitRepoSpecifier) => { + await managerClient.git.linkRepo({ + prismic: { domain: config.repositoryName }, + git, + }); + + revalidateData(getLinkedGitRepos, [config.repositoryName]); + }, + unlinkRepo: async (git: GitRepoSpecifier) => { + await managerClient.git.unlinkRepo({ + prismic: { domain: config.repositoryName }, + git, + }); + + revalidateData(getLinkedGitRepos, [config.repositoryName]); + revalidateData(getHasWriteAPIToken, [ + config.repositoryName, + git.provider, + git.owner, + git.name, + ]); + }, + }; +}; diff --git a/packages/slice-machine/src/features/git/useWriteAPITokenActions.ts b/packages/slice-machine/src/features/git/useWriteAPITokenActions.ts new file mode 100644 index 0000000000..956dffb2a3 --- /dev/null +++ b/packages/slice-machine/src/features/git/useWriteAPITokenActions.ts @@ -0,0 +1,40 @@ +import { revalidateData } from "@prismicio/editor-support/Suspense"; +import { useSliceMachineConfig } from "@src/hooks/useSliceMachineConfig"; +import { managerClient } from "@src/managerClient"; + +import { GitRepoSpecifier } from "./types"; +import { getHasWriteAPIToken } from "./useHasWriteAPIToken"; + +export const useWriteAPITokenActions = (args: { git: GitRepoSpecifier }) => { + const [config] = useSliceMachineConfig(); + + return { + updateToken: async (token: string) => { + await managerClient.git.updateWriteAPIToken({ + prismic: { domain: config.repositoryName }, + git: args.git, + token, + }); + + revalidateData(getHasWriteAPIToken, [ + config.repositoryName, + args.git.provider, + args.git.owner, + args.git.name, + ]); + }, + deleteToken: async () => { + await managerClient.git.deleteWriteAPIToken({ + prismic: { domain: config.repositoryName }, + git: args.git, + }); + + revalidateData(getHasWriteAPIToken, [ + config.repositoryName, + args.git.provider, + args.git.owner, + args.git.name, + ]); + }, + }; +}; diff --git a/packages/slice-machine/src/hooks/useUser.ts b/packages/slice-machine/src/hooks/useUser.ts new file mode 100644 index 0000000000..280b1f1af0 --- /dev/null +++ b/packages/slice-machine/src/hooks/useUser.ts @@ -0,0 +1,14 @@ +import { useRequest } from "@prismicio/editor-support/Suspense"; +import { managerClient } from "@src/managerClient"; + +const getUser = async () => { + const isLoggedIn = await managerClient.user.checkIsLoggedIn(); + + return { + isLoggedIn, + }; +}; + +export const useUser = () => { + return useRequest(getUser, []); +};