diff --git a/public/loading.webp b/public/loading.webp new file mode 100644 index 0000000..ac95e35 Binary files /dev/null and b/public/loading.webp differ diff --git a/src/App.tsx b/src/App.tsx index ba65e6e..c222108 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,10 @@ import { SettingsDrawer } from "./SettingsDrawer"; import { Dashboard } from "./components/Dashboard"; import { AuthHeader } from "./components/AuthHeader"; import { UnAuthHeader } from "./components/UnAuthHeader"; -import LandingPage from "./components/LandingPage"; +import LandingPage from "./pages/LandingPage"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); export const ConfigContext = React.createContext<{ octokit: GitService | null; @@ -27,8 +30,7 @@ function App() { const onLogin = React.useCallback(() => { if (token) { const octoKit = new GitService( - process.env.REACT_APP_GITHUB_API_URL || - "https://api.github.com/", + process.env.REACT_APP_GITHUB_API_URL || "https://api.github.com/", token ); octoKit.testAuthentication().then((user) => { @@ -91,45 +93,51 @@ function App() { return ( <> - - - - - {!user?.login ? ( - - ) : ( - - )} - - - + + - {octokit && } - {!octokit && } - - {octokit && ( - setOpenSettings(false)} - /> - )} - - + + + {!user?.login ? ( + + ) : ( + + )} + + + + {octokit && } + {!octokit && } + + {octokit && ( + setOpenSettings(false)} + /> + )} + + + ); } diff --git a/src/SettingsDrawer.tsx b/src/SettingsDrawer.tsx index 5d53f4d..549d598 100644 --- a/src/SettingsDrawer.tsx +++ b/src/SettingsDrawer.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Organization } from "./models/Organization"; import { ConfigContext } from "./App"; import { RepoSettingAccordion } from "./components/RepoSettingAccordion"; +import { useQuery } from "@tanstack/react-query"; export type SettingsDrawerProps = { opened: boolean; @@ -13,12 +14,16 @@ export const SettingsDrawer: React.FC = ({ opened, onClose, }) => { - const [orgs, setOrgs] = React.useState([]); const { octokit } = React.useContext(ConfigContext); - - React.useEffect(() => { - if(octokit) octokit?.getOrganizations().then((orgs) => setOrgs(orgs.data)); - }, [octokit]); + const { data: orgs = [] } = useQuery({ + queryKey: ["orgs"], + queryFn: async () => { + if (!octokit) return; + const orgs = await octokit.getOrganizations(); + return orgs.data as Organization[]; + }, + enabled: !!octokit, + }); const orgList = React.useMemo(() => orgs.map((org) => ( diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index efe6b09..7764848 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -4,40 +4,38 @@ import { PullRequest } from "../models/PullRequest"; import PullRequestCard from "./PullRequestCard"; import { Box } from "@mui/material"; import Grid2 from "@mui/material/Unstable_Grid2/Grid2"; -import LandingPage from "./LandingPage"; +import LandingPage from "../pages/LandingPage"; import { MultiselectFilter } from "./MultiselectFilter"; import { InputFilter } from "./InputFilter"; +import { useQuery } from "@tanstack/react-query"; +import { PRLoadingPage } from "../pages/PRLoadingPage"; export type DashboardProps = {}; export const Dashboard: React.FC = () => { const { octokit, repositorySettings } = React.useContext(ConfigContext); - const [pulls, setPulls] = React.useState([]); const activeRepositories = React.useMemo( () => Object.keys(repositorySettings).filter((key) => repositorySettings[key]), [repositorySettings] ); - React.useEffect(() => { - if (octokit && activeRepositories.length) { - const getPulls = async () => { + const { data = [], isLoading } = useQuery({ + queryKey: ["pulls"], + queryFn: async () => { + if (octokit && activeRepositories.length) { const pulls = await Promise.all( activeRepositories.flatMap((repo) => octokit.getPullRequests(repo)) ); - setPulls( - pulls - .flat() - .sort((a, b) => - a.base.repo.full_name.localeCompare(b.base.repo.full_name) - ) as any[] - ); - }; - - getPulls(); - } - }, [octokit, activeRepositories]); + return pulls + .flat() + .sort((a, b) => + a.base.repo.full_name.localeCompare(b.base.repo.full_name) + ); + } + }, + }); const [filter, setFilter] = React.useState(""); const [includeLabels, setIncludeLabels] = React.useState([]); @@ -45,13 +43,13 @@ export const Dashboard: React.FC = () => { const labels: string[] = React.useMemo( () => Array.from( - new Set(pulls.map((pull) => pull.labels.map(({ name }) => name)).flat()) + new Set(data.map((pull) => pull.labels.map(({ name }) => name)).flat()) ), - [pulls] + [data] ); const filteredPulls = React.useMemo(() => { - return pulls.filter((pull) => { + return data.filter((pull) => { if ( includeLabels.length > 0 && !pull.labels.some(({ name }) => includeLabels.includes(name)) @@ -64,7 +62,7 @@ export const Dashboard: React.FC = () => { return false; if ( filter.length > 0 && - !pull.user.login + !pull.user?.login .toLocaleLowerCase() .includes(filter.toLocaleLowerCase()) && !pull.head.repo.full_name @@ -75,34 +73,36 @@ export const Dashboard: React.FC = () => { return true; }); - }, [pulls, filter, includeLabels, excludeLabels]); + }, [data, filter, includeLabels, excludeLabels]); return ( - {pulls.length === 0 ? ( - - ) : ( - - - !excludeLabels.includes(label))} - name="Include labels" - onChange={setIncludeLabels} - /> - !includeLabels.includes(label))} - name="Exclude labels" - onChange={setExcludeLabels} - /> - - )} - - {filteredPulls.map((pull) => ( - - + {isLoading && } + {!isLoading && data.length === 0 && } + {data.length > 0 && ( + <> + + + !excludeLabels.includes(label))} + name="Include labels" + onChange={setIncludeLabels} + /> + !includeLabels.includes(label))} + name="Exclude labels" + onChange={setExcludeLabels} + /> + + + {filteredPulls.map((pull) => ( + + + + ))} - ))} - + + )} ); }; diff --git a/src/components/PullRequestChecks.tsx b/src/components/PullRequestChecks.tsx index f0fd529..7de8fc9 100644 --- a/src/components/PullRequestChecks.tsx +++ b/src/components/PullRequestChecks.tsx @@ -1,9 +1,17 @@ import React from "react"; import { ConfigContext } from "../App"; import { CheckRun } from "../models/CheckRun"; -import { Box, Dialog, Link, Tooltip, Typography } from "@mui/material"; +import { + Box, + CircularProgress, + Dialog, + Link, + Tooltip, + Typography, +} from "@mui/material"; import { CheckCircle, Error, ErrorOutline } from "@mui/icons-material"; import { useOnScreen } from "../hooks/useOnScreen"; +import { useQuery } from "@tanstack/react-query"; export type PullRequestChecksProps = { owner: string; @@ -17,24 +25,22 @@ export const PullRequestChecks: React.FC = ({ prNumber, }) => { const { octokit } = React.useContext(ConfigContext); - const [checks, setChecks] = React.useState(); const [open, setOpen] = React.useState(false); const elementRef = React.useRef(null); const isIntersecting = useOnScreen(elementRef, "100px", true); - React.useEffect(() => { - if (!octokit || !isIntersecting) return; - octokit - .getPRChecksStatus(owner, repo, prNumber) - .then((response) => setChecks(response.data.check_runs)); - - return () => { - setChecks(undefined); - }; - }, [octokit, owner, repo, prNumber, isIntersecting]); + const { isLoading, data: checks = [] } = useQuery({ + queryKey: ["checks", owner, repo, prNumber], + queryFn: async () => { + if (!octokit || !isIntersecting) return; + const response = await octokit.getPRChecksStatus(owner, repo, prNumber); + return response.data.check_runs as CheckRun[]; + }, + enabled: !!octokit && isIntersecting, + }); const allChecksPassed = React.useMemo( - () => checks?.every((check) => check.conclusion === "success"), + () => checks.every((check) => check.conclusion === "success"), [checks] ); @@ -44,41 +50,59 @@ export const PullRequestChecks: React.FC = ({ ref={elementRef} color="text.secondary" onClick={() => !allChecksPassed && setOpen(true)} - sx={{ display: "flex", gap: 1, alignItems: "center", cursor: allChecksPassed ? "default" : "pointer" }} + sx={{ + display: "flex", + gap: 1, + alignItems: "center", + cursor: allChecksPassed ? "default" : "pointer", + }} > Checks:{" "} - {allChecksPassed ? ( - + {isLoading ? ( + + ) : allChecksPassed ? ( + + + ) : ( - + + + )} - setOpen(false)} sx={{ padding: "2em" }}> + setOpen(false)} + sx={{ padding: "2em" }} + > - Checks -
    - {checks?.map((check) => ( -
  • - - - {check.name} - {" "} - {check.conclusion === "success" ? ( - - ) : ( - - )} - { - check.details_url && ( - + Checks +
      + {checks?.map((check) => ( +
    • + + {check.name}{" "} + {check.conclusion === "success" ? ( + + ) : ( + + )} + {check.details_url && ( + Details - ) - } - -
    • - ))} -
    + )} +
    +
  • + ))} +
diff --git a/src/components/PullRequestMergeCheck.tsx b/src/components/PullRequestMergeCheck.tsx index debb151..1ed9315 100644 --- a/src/components/PullRequestMergeCheck.tsx +++ b/src/components/PullRequestMergeCheck.tsx @@ -1,8 +1,9 @@ import React from "react"; import { useOnScreen } from "../hooks/useOnScreen"; import { ConfigContext } from "../App"; -import { Tooltip, Typography } from "@mui/material"; +import { CircularProgress, Tooltip, Typography } from "@mui/material"; import { Block, CallMerge } from "@mui/icons-material"; +import { useQuery } from "@tanstack/react-query"; export type PullRequestMergeCheckProps = { owner: string; @@ -17,22 +18,21 @@ export const PullRequestMergeCheck: React.FC = ({ }) => { const elementRef = React.useRef(null); const isIntersecting = useOnScreen(elementRef, "100px", true); - const [canBeMerged, setCanBeMerged] = React.useState<{ - mergeable: boolean; - mergeableState: string; - }>({ mergeable: false, mergeableState: "" }); const { octokit } = React.useContext(ConfigContext); - React.useEffect(() => { - if (octokit && isIntersecting) { - octokit?.hasMergeConflict(owner, repo, prNumber).then((response) => { - setCanBeMerged({ - mergeable: response.mergeable ?? false, - mergeableState: response.mergeable_state, - }); - }); - } - }, [isIntersecting, octokit, owner, repo, prNumber]); + const { + isLoading, + data: canBeMerged = { mergeable: false, mergeableState: "" }, + } = useQuery({ + queryKey: ["hasMergeConflict", owner, repo, prNumber], + queryFn: async () => { + if (!octokit || !isIntersecting) return; + const pr = await octokit.hasMergeConflict(owner, repo, prNumber); + + return { mergeable: pr.mergeable, mergeableState: pr.mergeable_state }; + }, + enabled: !!octokit && isIntersecting, + }); return ( = ({ sx={{ display: "flex", gap: 1, alignItems: "center" }} > Mergeable: - + {isLoading ? ( + + ) : ( + {canBeMerged.mergeableState === "" ? ( <> ) : canBeMerged.mergeable && @@ -56,7 +59,8 @@ export const PullRequestMergeCheck: React.FC = ({ ) : ( )} - + + )} ); }; diff --git a/src/components/PullRequestsApprovals.tsx b/src/components/PullRequestsApprovals.tsx index 5a2f686..7be8588 100644 --- a/src/components/PullRequestsApprovals.tsx +++ b/src/components/PullRequestsApprovals.tsx @@ -1,8 +1,9 @@ import React from "react"; import { ConfigContext } from "../App"; import { Approvals } from "../models/Approvals"; -import { Avatar, Badge, Box, Tooltip } from '@mui/material'; +import { Avatar, Badge, Box, CircularProgress, Tooltip } from '@mui/material'; import { useOnScreen } from "../hooks/useOnScreen"; +import { useQuery } from "@tanstack/react-query"; export type PullRequestsApprovalsProps = { owner: string; @@ -16,20 +17,18 @@ export const PullRequestsApprovals: React.FC = ({ prNumber, }) => { const { octokit } = React.useContext(ConfigContext); - const [ approvals, setApprovals] = React.useState([]); const elementRef = React.useRef(null); const isIntersecting = useOnScreen(elementRef, "100px", true); - React.useEffect(() => { - if (!octokit || !isIntersecting) return; - octokit - .getPRApprovals(owner, repo, prNumber) - .then((response) => setApprovals(response as Approvals[])); - - return () => { - setApprovals([]); - }; - }, [octokit, owner, repo, prNumber, isIntersecting]); + const {isLoading, data: approvals = []} = useQuery({ + queryKey: ["approvals", owner, repo, prNumber], + queryFn: async () => { + if (!octokit || !isIntersecting) return; + const response = await octokit.getPRApprovals(owner, repo, prNumber); + return response as Approvals[]; + }, + enabled: !!octokit && isIntersecting, + }); const getBadgeProps = (state: string): {badgeContent: string, color: "success" | "error" | "warning" | "info"} => { switch (state) { @@ -57,7 +56,12 @@ export const PullRequestsApprovals: React.FC = ({ return <> - Approvals: {approvals.length ? approvalAvatars : "No reviews"} + Approvals: + { + isLoading ? + : + {approvals.length ? approvalAvatars : "No reviews"} + } ; } \ No newline at end of file diff --git a/src/components/RepoSettingAccordion.tsx b/src/components/RepoSettingAccordion.tsx index 8bc9d8d..c7c411c 100644 --- a/src/components/RepoSettingAccordion.tsx +++ b/src/components/RepoSettingAccordion.tsx @@ -10,6 +10,7 @@ import { Input, List, ListItem, + Typography, } from "@mui/material"; import { ExpandMore } from "@mui/icons-material"; import { Repository } from "../models/Repository"; @@ -17,28 +18,30 @@ import { RepositorySelector } from "./RepositorySelector"; import { ConfigContext } from "../App"; import { OrgTitle } from "./OrgAccordionTitle"; import { UserTitle } from "./UserAccordionTitle"; -import { StarredTitle } from './StarredAccordingTitle'; +import { StarredTitle } from "./StarredAccordingTitle"; +import { useQuery } from "@tanstack/react-query"; export type RepoSettingAccordionProps = { org?: Organization; type: "user" | "org" | "starred"; }; -export const RepoSettingAccordion: React.FC = ({org, type}) => { - const { octokit, handleRepositorySelect, repositorySettings } = React.useContext(ConfigContext); - const [isLoading, setIsLoading] = React.useState(false); - const [repos, setRepos] = React.useState([]); +export const RepoSettingAccordion: React.FC = ({ + org, + type, +}) => { + const { octokit, handleRepositorySelect, repositorySettings } = + React.useContext(ConfigContext); const [selectedRepos, setSelectedRepos] = React.useState([]); - React.useEffect(() => { - if (!octokit) return; - setIsLoading(true); - - const fetchRepos = async () => { + const { isLoading, data: repos = [] } = useQuery({ + queryKey: ["repos", org?.login, type], + queryFn: async () => { + if (!octokit) return; let fetchedRepos: Repository[] = []; switch (type) { case "org": - fetchedRepos = (org && await octokit.getRepos(org.login)) ?? []; + fetchedRepos = (org && (await octokit.getRepos(org.login))) ?? []; break; case "user": fetchedRepos = await octokit.getUserRepos(); @@ -47,28 +50,26 @@ export const RepoSettingAccordion: React.FC = ({org, fetchedRepos = await octokit.getStaredRepos(); break; } - setRepos(fetchedRepos); setSelectedRepos(fetchedRepos); - setIsLoading(false); - } - - fetchRepos(); - }, [octokit, org, type]); + return fetchedRepos; + }, + enabled: !!octokit, + }); const repoList = useMemo( () => selectedRepos - .sort((a, b) => { - const repoAHasSettingsTrue = repositorySettings[a.full_name] === true ? 0 : 1; - const repoBHasSettingsTrue = repositorySettings[b.full_name] === true ? 0 : 1; - if (repoAHasSettingsTrue !== repoBHasSettingsTrue) { - return repoAHasSettingsTrue - repoBHasSettingsTrue; - } - return a.full_name.localeCompare(b.full_name); - }) - .map((repo) => ( - - )), + .sort((a, b) => { + const repoAHasSettingsTrue = + repositorySettings[a.full_name] === true ? 0 : 1; + const repoBHasSettingsTrue = + repositorySettings[b.full_name] === true ? 0 : 1; + if (repoAHasSettingsTrue !== repoBHasSettingsTrue) { + return repoAHasSettingsTrue - repoBHasSettingsTrue; + } + return a.full_name.localeCompare(b.full_name); + }) + .map((repo) => ), [selectedRepos, repositorySettings] ); @@ -101,13 +102,10 @@ export const RepoSettingAccordion: React.FC = ({org, } }, [org, type]); - return ( - }> - {title} - + }>{title} = ({org, onFilterChange(e as React.ChangeEvent) } /> - + - {isLoading &&
Loading...
} - {!isLoading && {repoList}} + {isLoading && Loading...} + {!isLoading && repos.length === 0 && No Repositories} + {!isLoading && repos.length > 0 && {repoList}}
diff --git a/src/components/UserAccordionTitle.tsx b/src/components/UserAccordionTitle.tsx index 71dab47..64fa45c 100644 --- a/src/components/UserAccordionTitle.tsx +++ b/src/components/UserAccordionTitle.tsx @@ -2,16 +2,21 @@ import React from "react"; import { User } from "../models/User"; import { ConfigContext } from "../App"; import { Box, Avatar } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; export const UserTitle: React.FC = () => { - const [user, setUser] = React.useState(); const { octokit } = React.useContext(ConfigContext); - - React.useEffect(() => { - if (!octokit) return; - octokit.testAuthentication().then((response) => setUser(response.data)); - }, [octokit]); + + const { data: user } = useQuery({ + queryKey: ["user"], + queryFn: async () => { + if (!octokit) return; + const response = await octokit.testAuthentication(); + return response.data as User; + }, + enabled: !!octokit, + }); return user ? ( diff --git a/src/components/LandingPage.tsx b/src/pages/LandingPage.tsx similarity index 91% rename from src/components/LandingPage.tsx rename to src/pages/LandingPage.tsx index 168788a..d93299a 100644 --- a/src/components/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -8,8 +8,8 @@ import { Tabs, Typography, } from "@mui/material"; -import PATSetupGuide from "./PATSetupGuide"; -import RepositorySetupGuide from "./RepositorySetupGuide"; +import PATSetupGuide from "../components/PATSetupGuide"; +import RepositorySetupGuide from "../components/RepositorySetupGuide"; function TabPanel(props: { children?: React.ReactNode; @@ -51,7 +51,7 @@ export default function LandingPage({ auth = false }) { }; return ( - + { + return ( + + + + Loading your PRs... + + + Hang tight! We're fetching your pull requests faster than you can say "Merge Conflict"! + + + ); +} \ No newline at end of file diff --git a/src/service/gitService.ts b/src/service/gitService.ts index f6d01dd..4940fc8 100644 --- a/src/service/gitService.ts +++ b/src/service/gitService.ts @@ -67,9 +67,7 @@ export class GitService { } async hasMergeConflict(owner: string, repo: string, prNumber: number) { - const mergeConflicts = await this.octokit.pulls.get({owner, repo, pull_number:prNumber}); - console.log(`Merge conflicts: ${mergeConflicts.data.mergeable} - ${mergeConflicts.data.mergeable_state}`); - + const mergeConflicts = await this.octokit.pulls.get({owner, repo, pull_number:prNumber}); return mergeConflicts.data }