Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul caching / Add React-Query #35

Merged
merged 4 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:@tanstack/eslint-plugin-query/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@
},
"dependencies": {
"@gitkraken/provider-apis": "0.19.1",
"@tanstack/query-async-storage-persister": "5.32.0",
"@tanstack/react-query": "5.32.0",
"@tanstack/react-query-persist-client": "5.32.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"webextension-polyfill": "0.10.0"
},
"devDependencies": {
"@playwright/test": "1.35.1",
"@tanstack/eslint-plugin-query": "5.28.11",
"@types/chrome": "0.0.246",
"@types/react-dom": "18.2.23",
"@types/webextension-polyfill": "0.10.1",
Expand Down
46 changes: 18 additions & 28 deletions src/gkApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies, storage } from 'webextension-polyfill';
import { checkOrigins } from './permissions-helper';
import { DefaultCacheTimeMinutes, sessionCachedFetch, updateExtensionIcon } from './shared';
import { updateExtensionIcon } from './shared';
import type { Provider, ProviderConnection, ProviderToken, PullRequestDraftCounts, User } from './types';

declare const MODE: 'production' | 'development' | 'none';
Expand All @@ -14,7 +14,7 @@ const onLoggedOut = () => {
void storage.session.clear();
};

const getAccessToken = async () => {
export const getAccessToken = async () => {
// Check if the user has granted permission to GitKraken.dev
if (!(await checkOrigins(['gitkraken.dev']))) {
// If not, just assume we're logged out
Expand Down Expand Up @@ -48,26 +48,18 @@ export const fetchUser = async () => {
return null;
}

// Since the user object is unlikely to change, we can cache it for much longer than other data
const user = await sessionCachedFetch('user', 60 * 12 /* 12 hours */, async () => {
const res = await fetch(`${gkApiUrl}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
return null;
}

return res.json() as Promise<User>;
const res = await fetch(`${gkApiUrl}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!user) {
if (!res.ok) {
onLoggedOut();
return null;
}

const user = (await res.json()) as User;
void updateExtensionIcon(true);
return user;
};
Expand Down Expand Up @@ -105,20 +97,18 @@ export const fetchProviderConnections = async () => {
return null;
}

return sessionCachedFetch('providerConnections', DefaultCacheTimeMinutes, async () => {
const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
return null;
}
if (!res.ok) {
return null;
}

const payload = await res.json();
return payload.data as ProviderConnection[];
});
const payload = await res.json();
return payload.data as ProviderConnection[];
};

export const refreshProviderToken = async (provider: Provider) => {
Expand Down
167 changes: 59 additions & 108 deletions src/popup/components/FocusView.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import type { PullRequestBucket } from '@gitkraken/provider-apis';
import { GitProviderUtils } from '@gitkraken/provider-apis';
import React, { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useEffect, useMemo, useState } from 'react';
import { storage } from 'webextension-polyfill';
import { openGitKrakenDeepLink } from '../../deepLink';
import { fetchDraftCounts, fetchProviderConnections } from '../../gkApi';
import { fetchFocusViewData, ProviderMeta } from '../../providers';
import { DefaultCacheTimeMinutes, GKDotDevUrl, sessionCachedFetch } from '../../shared';
import { ProviderMeta } from '../../providers';
import { GKDotDevUrl } from '../../shared';
import type {
FocusViewData,
FocusViewSupportedProvider,
GitPullRequestWithUniqueID,
PullRequestBucketWithUniqueIDs,
} from '../../types';
import { useFocusViewConnectedProviders, useFocusViewDataQuery, usePullRequestDraftCountsQuery } from '../hooks';
import { ConnectAProvider } from './ConnectAProvider';

type PullRequestRowProps = {
userId: string;
pullRequest: GitPullRequestWithUniqueID;
provider: FocusViewSupportedProvider;
draftCount?: number;
};

const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRowProps) => {
const PullRequestRow = ({ userId, pullRequest, provider, draftCount = 0 }: PullRequestRowProps) => {
const queryClient = useQueryClient();

return (
<>
<div className="pull-request">
Expand All @@ -33,9 +35,8 @@ const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRo
target="_blank"
onClick={() => {
// Since there is a decent chance that the PR will be acted upon after the user clicks on it,
// invalidate the cache so that the PR shows up in the appropriate bucket (or not at all) the
// next time the popup is opened.
void storage.session.remove('focusViewData');
// mark the focus view data as stale so that it will be refetched when the user returns.
void queryClient.invalidateQueries({ queryKey: [userId, 'focusViewData', provider] });
}}
title={`View pull request on ${ProviderMeta[provider].name}`}
>
Expand Down Expand Up @@ -72,12 +73,13 @@ const PullRequestRow = ({ pullRequest, provider, draftCount = 0 }: PullRequestRo
};

type BucketProps = {
userId: string;
bucket: PullRequestBucketWithUniqueIDs;
provider: FocusViewSupportedProvider;
prDraftCountsByEntityID: Record<string, { count: number } | undefined>;
prDraftCountsByEntityID?: Record<string, { count: number } | undefined>;
};

const Bucket = ({ bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
const Bucket = ({ userId, bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
return (
<div className="pull-request-bucket">
<div className="pull-request-bucket-header text-sm text-secondary bold">
Expand All @@ -87,118 +89,67 @@ const Bucket = ({ bucket, provider, prDraftCountsByEntityID }: BucketProps) => {
{bucket.pullRequests.map(pullRequest => (
<PullRequestRow
key={pullRequest.id}
userId={userId}
pullRequest={pullRequest}
provider={provider}
draftCount={prDraftCountsByEntityID[pullRequest.uniqueId]?.count}
draftCount={prDraftCountsByEntityID?.[pullRequest.uniqueId]?.count}
/>
))}
</div>
);
};

export const FocusView = () => {
const [connectedProviders, setConnectedProviders] = useState<FocusViewSupportedProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState<FocusViewSupportedProvider>();
const [prDraftCountsByEntityID, setPRDraftCountsByEntityID] = useState<
Record<string, { count: number } | undefined>
>({});
const [pullRequestBuckets, setPullRequestBuckets] = useState<PullRequestBucket[]>();
const [isFirstLoad, setIsFirstLoad] = useState(true);
const [isLoadingPullRequests, setIsLoadingPullRequests] = useState(true);
export const FocusView = ({ userId }: { userId: string }) => {
const [selectedProvider, setSelectedProvider] = useState<FocusViewSupportedProvider | null | undefined>();
const [filterString, setFilterString] = useState('');

useEffect(() => {
const loadData = async () => {
const [providerConnections, { focusViewSelectedProvider: savedSelectedProvider }] = await Promise.all([
fetchProviderConnections(),
storage.local.get('focusViewSelectedProvider'),
]);
const connectedProviders = useFocusViewConnectedProviders(userId);
const focusViewDataQuery = useFocusViewDataQuery(userId, selectedProvider);
const prDraftCountsQuery = usePullRequestDraftCountsQuery(
userId,
selectedProvider,
focusViewDataQuery.data?.pullRequests,
);

const supportedProviders = (providerConnections || [])
.filter(
connection =>
(connection.provider === 'github' ||
connection.provider === 'gitlab' ||
connection.provider === 'bitbucket' ||
connection.provider === 'azure') &&
!connection.domain,
)
.map(connection => connection.provider as FocusViewSupportedProvider);
// This effect sets which provider is selected after the provider connections are loaded/changed
useEffect(() => {
const selectInitialProvider = async () => {
if (!connectedProviders) {
return;
}

setConnectedProviders(supportedProviders);
if (connectedProviders && connectedProviders.length > 0) {
const { focusViewSelectedProvider } = await storage.local.get('focusViewSelectedProvider');

if (supportedProviders && supportedProviders.length > 0) {
const providerToSelect =
savedSelectedProvider && supportedProviders.includes(savedSelectedProvider)
? (savedSelectedProvider as FocusViewSupportedProvider)
: supportedProviders[0];
focusViewSelectedProvider && connectedProviders.includes(focusViewSelectedProvider)
? (focusViewSelectedProvider as FocusViewSupportedProvider)
: connectedProviders[0];

setSelectedProvider(providerToSelect);
void storage.local.set({ focusViewSelectedProvider: providerToSelect });
} else {
setIsLoadingPullRequests(false);
setIsFirstLoad(false);
// Clear the cache so that if the user connects a provider, we'll fetch it the next
// time the popup is opened.
void storage.session.remove('providerConnections');
setSelectedProvider(null);
void storage.local.remove('focusViewSelectedProvider');
}
};

void loadData();
}, []);

useEffect(() => {
const loadData = async () => {
if (!selectedProvider) {
return;
}

setIsLoadingPullRequests(true);
void selectInitialProvider();
}, [connectedProviders]);

let focusViewData: FocusViewData | null = null;
try {
focusViewData = await sessionCachedFetch('focusViewData', DefaultCacheTimeMinutes, () =>
fetchFocusViewData(selectedProvider),
);
} catch (e) {
// If there was an error, fall through to the next if block to at least end the loading state.
}

if (!focusViewData) {
setPullRequestBuckets([]);
setIsLoadingPullRequests(false);
setIsFirstLoad(false);
return;
}

const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(
focusViewData.pullRequests,
focusViewData.providerUser,
);
const buckets = Object.values(bucketsMap)
.filter(bucket => bucket.pullRequests.length)
.sort((a, b) => a.priority - b.priority);
const pullRequestBuckets = useMemo(() => {
if (!focusViewDataQuery.data) {
return null;
}

setPullRequestBuckets(buckets);
setIsLoadingPullRequests(false);
setIsFirstLoad(false);

if (selectedProvider === 'github' && focusViewData.pullRequests.length) {
const draftCounts = await sessionCachedFetch('focusViewDraftCounts', DefaultCacheTimeMinutes, () => {
if (!focusViewData) {
return null;
}
const prUniqueIds = focusViewData.pullRequests.map(pr => pr.uniqueId);
return fetchDraftCounts(prUniqueIds);
});
if (draftCounts) {
setPRDraftCountsByEntityID(draftCounts.counts);
}
}
};

void loadData();
}, [selectedProvider]);
const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(
focusViewDataQuery.data.pullRequests,
focusViewDataQuery.data.providerUser,
);
return Object.values(bucketsMap)
.filter(bucket => bucket.pullRequests.length)
.sort((a, b) => a.priority - b.priority);
}, [focusViewDataQuery.data]);

const lowercaseFilterString = filterString.toLowerCase().trim();
const filteredBuckets = lowercaseFilterString
Expand All @@ -213,13 +164,12 @@ export const FocusView = () => {
: pullRequestBuckets;

const onProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
void storage.session.remove(['focusViewData', 'focusViewDraftCounts']);
void storage.local.set({ focusViewSelectedProvider: e.target.value });
setSelectedProvider(e.target.value as FocusViewSupportedProvider);
setFilterString('');
};

if (isFirstLoad) {
if (selectedProvider === undefined) {
return (
<div className="focus-view text-center">
<i className="fa-regular fa-spinner-third fa-spin" />
Expand All @@ -237,7 +187,7 @@ export const FocusView = () => {
<div className="focus-view-text-filter">
<i className="fa-regular fa-search icon text-xl" />
<input
disabled={isLoadingPullRequests}
disabled={focusViewDataQuery.isLoading}
onChange={e => setFilterString(e.target.value)}
placeholder="Search for pull requests"
value={filterString}
Expand All @@ -247,14 +197,14 @@ export const FocusView = () => {
)}
</div>
)}
{selectedProvider && connectedProviders.length > 1 && (
{selectedProvider && connectedProviders && connectedProviders.length > 1 && (
<div className="provider-select text-secondary">
PRs: <img src={ProviderMeta[selectedProvider].iconSrc} height={14} />
<select
className="text-secondary"
value={selectedProvider}
onChange={onProviderChange}
disabled={isLoadingPullRequests}
disabled={focusViewDataQuery.isLoading}
>
{connectedProviders.map(provider => (
<option key={provider} value={provider}>
Expand All @@ -264,7 +214,7 @@ export const FocusView = () => {
</select>
</div>
)}
{isLoadingPullRequests ? (
{focusViewDataQuery.isLoading ? (
<div className="text-center">
<i className="fa-regular fa-spinner-third fa-spin" />
</div>
Expand All @@ -273,9 +223,10 @@ export const FocusView = () => {
{filteredBuckets?.map(bucket => (
<Bucket
key={bucket.id}
userId={userId}
bucket={bucket as PullRequestBucketWithUniqueIDs}
provider={selectedProvider}
prDraftCountsByEntityID={prDraftCountsByEntityID}
prDraftCountsByEntityID={prDraftCountsQuery.data}
/>
))}
</div>
Expand Down
Loading
Loading