From 3113a74b8d102ed71e39a34039389c54fa0909b1 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Thu, 26 Oct 2023 15:50:53 -0500 Subject: [PATCH] feat: add GitHub team import to new list page (#2006) --- .../molecules/AuthSection/auth-section.tsx | 5 +- .../github-team-sync-dialog.tsx | 132 ++++++++++++++++++ lib/hooks/fetchGithubOrgTeams.ts | 42 ++++++ lib/hooks/fetchGithubTeamMembers.ts | 43 ++++++ lib/hooks/useSupabaseAuth.ts | 14 +- lib/hooks/useUserOrganizations.ts | 22 +++ next-types.d.ts | 19 +++ pages/hub/lists/new.tsx | 92 +++++++++++- 8 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 components/organisms/GitHubTeamSyncDialog/github-team-sync-dialog.tsx create mode 100644 lib/hooks/fetchGithubOrgTeams.ts create mode 100644 lib/hooks/fetchGithubTeamMembers.ts create mode 100644 lib/hooks/useUserOrganizations.ts diff --git a/components/molecules/AuthSection/auth-section.tsx b/components/molecules/AuthSection/auth-section.tsx index bafed90332..1296e4edae 100644 --- a/components/molecules/AuthSection/auth-section.tsx +++ b/components/molecules/AuthSection/auth-section.tsx @@ -149,7 +149,10 @@ const AuthSection: React.FC = ({}) => { + + + + + + ); +}; + +export default GitHubTeamSyncDialog; diff --git a/lib/hooks/fetchGithubOrgTeams.ts b/lib/hooks/fetchGithubOrgTeams.ts new file mode 100644 index 0000000000..e1cb45baac --- /dev/null +++ b/lib/hooks/fetchGithubOrgTeams.ts @@ -0,0 +1,42 @@ +import { Octokit } from "octokit"; +import { supabase } from "lib/utils/supabase"; + +interface GhOrgTeamResponse { + data: GhOrgTeam[]; + status: number; + headers: {}; +} + +const fetchGithubOrgTeams = async (orgName: string | null) => { + const sessionResponse = await supabase.auth.getSession(); + const sessionToken = sessionResponse?.data.session?.provider_token; + + if (!sessionToken) { + return { + data: [], + isError: true, + }; + } + + try { + const octokit = new Octokit({ auth: sessionToken }); + + const response: GhOrgTeamResponse = await octokit.request("GET /orgs/{orgName}/teams", { + orgName, + }); + + return { + data: response.data ?? [], + isError: null, + }; + } catch (e) { + console.log(e); + + return { + data: [], + isError: true, + }; + } +}; + +export { fetchGithubOrgTeams }; diff --git a/lib/hooks/fetchGithubTeamMembers.ts b/lib/hooks/fetchGithubTeamMembers.ts new file mode 100644 index 0000000000..2b521f9e1f --- /dev/null +++ b/lib/hooks/fetchGithubTeamMembers.ts @@ -0,0 +1,43 @@ +import { Octokit } from "octokit"; +import { supabase } from "lib/utils/supabase"; + +interface GhOrgTeamMembersResponse { + data: GhOrgTeamMember[]; + status: number; + headers: {}; +} + +const fetchGithubOrgTeamMembers = async (orgName: string, teamSlug: string) => { + const sessionResponse = await supabase.auth.getSession(); + const sessionToken = sessionResponse?.data.session?.provider_token; + + if (!sessionToken) { + return { + data: [], + isError: true, + }; + } + + try { + const octokit = new Octokit({ auth: sessionToken }); + + const response: GhOrgTeamMembersResponse = await octokit.request("GET /orgs/{orgName}/teams/{teamSlug}/members", { + orgName, + teamSlug, + }); + + return { + data: response.data ?? [], + isError: null, + }; + } catch (e) { + console.log(e); + + return { + data: [], + isError: true, + }; + } +}; + +export { fetchGithubOrgTeamMembers }; diff --git a/lib/hooks/useSupabaseAuth.ts b/lib/hooks/useSupabaseAuth.ts index 5f678f0477..6e6c4962e4 100644 --- a/lib/hooks/useSupabaseAuth.ts +++ b/lib/hooks/useSupabaseAuth.ts @@ -10,6 +10,7 @@ const useSupabaseAuth = (loadSession = false) => { const sessionToken = useStore((state) => state.sessionToken); const providerToken = useStore((state) => state.providerToken); const userId = useStore((state) => state.userId); + const username: string | null = useStore((state) => state.user?.user_metadata.user_name); useEffect(() => { async function getUserSession() { @@ -42,13 +43,20 @@ const useSupabaseAuth = (loadSession = false) => { signIn: (data: SignInWithOAuthCredentials) => { supabase.auth.signInWithOAuth({ ...data, - options: data.options ?? { - redirectTo: process.env.NEXT_PUBLIC_BASE_URL ?? "/", - }, + options: data.options + ? { + ...data.options, + scopes: "read:org", + } + : { + redirectTo: process.env.NEXT_PUBLIC_BASE_URL ?? "/", + scopes: "read:org", + }, }); }, signOut: () => supabase.auth.signOut(), user, + username, sessionToken, providerToken, userId, diff --git a/lib/hooks/useUserOrganizations.ts b/lib/hooks/useUserOrganizations.ts new file mode 100644 index 0000000000..85dce84ac0 --- /dev/null +++ b/lib/hooks/useUserOrganizations.ts @@ -0,0 +1,22 @@ +import useSWR, { Fetcher } from "swr"; +import publicApiFetcher from "lib/utils/public-api-fetcher"; + +interface UserConnectionResponse { + data?: DbUserOrganization[]; + meta: Meta; +} + +const useUserOrganizations = (username: string | null) => { + const { data, error } = useSWR( + username ? `users/${username}/organizations` : null, + publicApiFetcher as Fetcher + ); + + return { + data: data?.data ?? [], + isLoading: !error && !data, + isError: !!error, + }; +}; + +export { useUserOrganizations }; diff --git a/next-types.d.ts b/next-types.d.ts index 0399ae57dc..067cf26566 100644 --- a/next-types.d.ts +++ b/next-types.d.ts @@ -384,3 +384,22 @@ interface DBProjectContributor { issues_created: number; comments: number; } + +interface DbUserOrganization { + id: number; + user_id: number; + organization_id: number; + user: DbUser; + organization_user: DbUser; +} + +interface GhOrgTeam { + id: number; + name: string; + slug: string; +} + +interface GhOrgTeamMember { + id: number; + login: string; +} diff --git a/pages/hub/lists/new.tsx b/pages/hub/lists/new.tsx index 6012c47a0c..e33320846e 100644 --- a/pages/hub/lists/new.tsx +++ b/pages/hub/lists/new.tsx @@ -11,10 +11,12 @@ import Title from "components/atoms/Typography/title"; import TopNav from "components/organisms/TopNav/top-nav"; import Footer from "components/organisms/Footer/footer"; import InfoCard from "components/molecules/InfoCard/info-card"; -import GitHubImportDialog from "components/organisms/GitHubImportDialog/github-import-dialog"; import useSupabaseAuth from "lib/hooks/useSupabaseAuth"; import { useToast } from "lib/hooks/useToast"; +import GitHubImportDialog from "components/organisms/GitHubImportDialog/github-import-dialog"; +import GitHubTeamSyncDialog from "components/organisms/GitHubTeamSyncDialog/github-team-sync-dialog"; +import { fetchGithubOrgTeamMembers } from "lib/hooks/fetchGithubTeamMembers"; interface CreateListPayload { name: string; @@ -31,12 +33,13 @@ interface GhFollowing { const CreateListPage = () => { const router = useRouter(); const { toast } = useToast(); - const { sessionToken, providerToken, user } = useSupabaseAuth(); + const { sessionToken, providerToken, user, username } = useSupabaseAuth(); const [name, setName] = useState(""); const [isPublic, setIsPublic] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); const [submitted, setSubmitted] = useState(false); const handleOnNameChange = (value: string) => { @@ -143,6 +146,59 @@ const CreateListPage = () => { } }; + const handleGitHubTeamSync = async (props: { follow: boolean; organization: string; teamSlug: string }) => { + if (!user || !providerToken) { + toast({ description: "Unable to connect to GitHub! Try logging out and re-connecting.", variant: "warning" }); + return; + } + const { organization, teamSlug } = props; + + setSubmitted(true); + const req = await fetchGithubOrgTeamMembers(organization, teamSlug); + + if (req.isError) { + toast({ description: "Unable to sync team", variant: "warning" }); + setSubmitted(false); + return; + } + + const teamList: GhOrgTeamMember[] = req.data; + + if (teamList.length === 0) { + toast({ description: "The selected team is empty", variant: "danger" }); + setSubmitted(false); + return; + } + + const response = await createList({ + name, + contributors: teamList.map((user) => ({ id: user.id, login: user.login })), + is_public: isPublic, + }); + + if (response) { + if (props.follow) { + fetch(`${process.env.NEXT_PUBLIC_API_URL}/users/${user?.user_metadata.user_name}/follows`, { + method: "PUT", + headers: { + Authorization: `Bearer ${sessionToken}`, + "Content-type": "application/json", + }, + body: JSON.stringify({ + usernames: teamList.map((member) => member.login), + }), + }).catch(() => {}); + + toast({ description: "List created successfully", variant: "success" }); + } + + router.push(`/lists/${response.id}/overview`); + } else { + toast({ description: "An error occurred!", variant: "danger" }); + setSubmitted(false); + } + }; + return (
@@ -198,7 +254,25 @@ const CreateListPage = () => { /> { + if (!name) { + toast({ + description: "List name is required", + variant: "danger", + }); + + return; + } + + setIsTeamModalOpen(true); + }} + /> + + { @@ -217,6 +291,18 @@ const CreateListPage = () => {
+
+
+
+ + setIsTeamModalOpen(false)} + handleSync={handleGitHubTeamSync} + loading={submitted} + username={username} + /> + setIsModalOpen(false)}