Skip to content

Commit

Permalink
Add custom autocomplete component with jira features
Browse files Browse the repository at this point in the history
  • Loading branch information
kurukimi committed Jun 17, 2024
1 parent 00fc71f commit b393b04
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 66 deletions.
63 changes: 15 additions & 48 deletions web/src/components/entry-dialog/DimensionComboBox.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useQuery } from "@apollo/client";
import { Autocomplete, FormControl, Grid, ListItem, TextField } from "@mui/material";
import { Autocomplete, FormControl, Grid, TextField } from "@mui/material";
import { Control, Controller, ControllerProps, FieldValues, UseFormReturn } from "react-hook-form";
import { FindDimensionOptionsDocument } from "../../graphql/generated/graphql";
import { useGetIssues } from "../../jira/jiraApi";
import { issueKeyToSummary } from "../../jira/jiraUtils";
import { jiraQueryMaxResults } from "../../jira/jiraConfig";
import useInfiniteScroll from "react-infinite-scroll-hook";
import JiraIssueAutoComplete from "../../jira/components/JiraIssueAutoComplete";
import { useIsJiraAuthenticated } from "../../jira/jiraApi";

type DimensionComboBoxProps<T extends FieldValues> = {
form: UseFormReturn<T>;
Expand All @@ -20,31 +18,10 @@ const DimensionComboBox = <T extends FieldValues>({
title,
rules,
}: DimensionComboBoxProps<T>) => {
const { data, loading } = useQuery(FindDimensionOptionsDocument);
const { data } = useQuery(FindDimensionOptionsDocument);
const options = data?.findDimensionOptions[name] || [];

const issuesEnabled = name === "issue" && !loading;
const {
data: dataPages,
error,
fetchNextPage,
hasNextPage,
isLoading,
} = useGetIssues(options, issuesEnabled);

const [sentryRef, { rootRef }] = useInfiniteScroll({
loading: isLoading,
hasNextPage,
disabled: !!error,
onLoadMore: fetchNextPage,
delayInMs: 0,
});

const issueData = dataPages?.pages;
const keyToSummary = issueData ? issueKeyToSummary(issueData) : {};
const getOptionText = (option: string) =>
keyToSummary[option] ? `${option}: ${keyToSummary[option]}` : option;

const { isJiraAuth } = useIsJiraAuthenticated();
return (
<Grid item xs={12} md={6}>
<FormControl fullWidth>
Expand All @@ -53,31 +30,21 @@ const DimensionComboBox = <T extends FieldValues>({
name={name}
rules={rules}
render={({ field: { value, onChange } }) => {
if (name === "issue" && isJiraAuth)
return (
<JiraIssueAutoComplete
form={form}
value={value}
onChange={onChange}
title={title}
options={options}
/>
);
return (
<Autocomplete
value={value}
onChange={(_, value) => onChange(value)}
options={options}
renderOption={(props, option) => {
const optionInd = options.indexOf(option);
const shouldLoadMore = optionInd % jiraQueryMaxResults === 0 && optionInd > 0;
return (
<ListItem {...props} ref={shouldLoadMore ? sentryRef : undefined}>
{getOptionText(option)}
</ListItem>
);
}}
filterOptions={(options, state) => {
return options.filter((option) =>
getOptionText(option)
.toLowerCase()
.trim()
.includes(state.inputValue.toLowerCase().trim()),
);
}}
ListboxProps={{
ref: rootRef,
}}
autoHighlight
renderInput={(params) => (
<TextField
Expand Down
6 changes: 3 additions & 3 deletions web/src/jira/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import axios from "axios";
import { useNotificationState } from "../components/global-notification/useNotification";
import { jiraApiUrl, keijoJiraApiUrl } from "./jiraConfig";
import { queryClient } from "./queryClient";
import { JiraAuthLink } from "./JiraAuthLink";
import { JiraAuthLink } from "./components/JiraAuthLink";

export const axiosJira = axios.create({
const axiosJira = axios.create({
baseURL: jiraApiUrl,
});

Expand Down Expand Up @@ -42,4 +42,4 @@ axiosKeijo.interceptors.response.use(
},
);

export { axiosKeijo };
export { axiosKeijo, axiosJira };
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from "@mui/material";
import { keijoJiraApiUrl } from "./jiraConfig";
import { keijoJiraApiUrl } from "../jiraConfig";

export const JiraAuthLink = <Link href={keijoJiraApiUrl}>Authorize Keijo to use Jira</Link>;
108 changes: 108 additions & 0 deletions web/src/jira/components/JiraIssueAutoComplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Autocomplete, ListItem, TextField } from "@mui/material";
import { FieldValues, UseFormReturn } from "react-hook-form";
import { FindDimensionOptionsDocument } from "../../graphql/generated/graphql";
import { useGetIssues, useSearchIssues } from "../../jira/jiraApi";
import { issueKeyToSummary } from "../../jira/jiraUtils";
import { jiraQueryMaxResults } from "../../jira/jiraConfig";
import useInfiniteScroll from "react-infinite-scroll-hook";
import { useDebounceValue } from "usehooks-ts";
import { useQuery } from "@apollo/client";

type JiraIssueFieldProps<T extends FieldValues> = {
form: UseFormReturn<T>;
title: string;
options: Array<string>;
value: unknown;
onChange: (val: unknown) => void;
};

const JiraIssueAutoComplete = <T extends FieldValues>({
form,
title,
options,
value,
onChange,
}: JiraIssueFieldProps<T>) => {
const { loading } = useQuery(FindDimensionOptionsDocument);

const [issueFilter, setIssueFilter] = useDebounceValue("", 300);
const {
data: dataPages,
error,
fetchNextPage,
hasNextPage,
isLoading: pagesLoading,
} = useGetIssues({ issueKeys: options, enabled: !loading });

const { data: searchedIssueData, isLoading: searchLoading } = useSearchIssues({
issueKeys: options,
searchFilter: issueFilter,
});

const [sentryRef, { rootRef }] = useInfiniteScroll({
loading: pagesLoading,
hasNextPage,
disabled: !!error,
onLoadMore: fetchNextPage,
delayInMs: 0,
});

const pagedIssueData = dataPages?.pages;
const queriedKeys = [
...(pagedIssueData || []),
...(searchedIssueData ? [searchedIssueData] : []),
];
const keyToSummary = { ...issueKeyToSummary(queriedKeys) };
const queriedOptions = [
...new Set([
...options.slice(0, (dataPages?.pages.length || 1) * jiraQueryMaxResults),
...Object.keys(keyToSummary),
]),
];
const getOptionText = (option: string) =>
keyToSummary[option] ? `${option}: ${keyToSummary[option]}` : option;

return (
<Autocomplete
value={value}
loading={pagesLoading || searchLoading}
onChange={(_, value) => onChange(value)}
options={queriedOptions}
renderOption={(props, option, state) => {
const shouldLoadMore = (state.index + 1) % jiraQueryMaxResults === 0 && state.index > 0;
return (
<ListItem {...props} ref={shouldLoadMore ? sentryRef : undefined}>
{getOptionText(option as string)}
</ListItem>
);
}}
filterOptions={(options, state) => {
if ((state.inputValue && state.inputValue !== issueFilter) || searchLoading) return [];
return options.filter((option) =>
getOptionText(option as string)
.toLowerCase()
.trim()
.includes(state.inputValue.toLowerCase().trim()),
);
}}
ListboxProps={{
ref: rootRef,
}}
onInputChange={(_, value, reason) => {
if (reason === "input") setIssueFilter(value);
}}
autoHighlight
renderInput={(params) => (
<TextField
{...params}
label={title}
onChange={onChange}
error={!!form.formState.errors["issue"]}
helperText={form.formState.errors["issue"]?.message as string}
/>
)}
/>
);
};

export default JiraIssueAutoComplete;
76 changes: 62 additions & 14 deletions web/src/jira/jiraApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,30 @@ export type JiraIssueResult = {
};
export type JiraIssueResults = Array<JiraIssueResult>;

type UseGetIssuesProps = {
issueKeys: Array<string>;
enabled?: boolean;
};

type UseSearchIssuesProps = {
issueKeys: Array<string>;
searchFilter: string;
};

const getAccessToken = async () => {
return (await axiosKeijo.get("/access-token")).data;
};

const useGetAccessToken = (): UseQueryResult<{ access_token: string }> => {
return useQuery({
queryKey: ["accessToken"],
queryFn: async () => await getAccessToken(),
retry: 1,
queryFn: getAccessToken,
retry: false,
});
};

const getIssues = async (
issueKeys: Array<string>,
jql: string,
accessToken: string,
startAt: number = 0,
numOfIssues: number = jiraQueryMaxResults,
Expand All @@ -38,33 +48,71 @@ const getIssues = async (
maxResults: numOfIssues,
startAt: startAt,
validateQuery: "warn",
jql: `key in (${issueKeys.map((key) => `'${key}'`).join(", ")})`,
jql: jql,
},
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
).data;
};

export const useGetIssues = (
issueKeys: Array<string>,
enabled: boolean = true,
): UseInfiniteQueryResult<{ pages: JiraIssueResults; pageParams: Array<number> }> => {
export const useGetIssues = ({
issueKeys,
enabled,
}: UseGetIssuesProps): UseInfiniteQueryResult<{
pages: JiraIssueResults;
pageParams: Array<number>;
}> => {
const { data: tokenData } = useGetAccessToken();

return useInfiniteQuery({
queryKey: ["issues"],
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
queryFn: async ({ pageParam }) =>
await getIssues(issueKeys, tokenData?.access_token || "", pageParam),
queryFn: async ({ pageParam }) => {
return await getIssues(
`key in (${issueKeys
.slice(pageParam, pageParam + jiraQueryMaxResults)
.map((key) => `'${key}'`)
.join(", ")})`,
tokenData?.access_token || "",
0,
);
},
initialPageParam: 0,
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
const allResultsFetched = lastPage && lastPage.total === lastPage.issues.length;
getNextPageParam: (_lastPage, _allPages, lastPageParam) => {
const nextPageStartIndex = lastPageParam + jiraQueryMaxResults;
if (allResultsFetched || nextPageStartIndex >= issueKeys.length - 1) return;
if (nextPageStartIndex >= issueKeys.length - 1) return;
return nextPageStartIndex;
},
enabled: enabled && !!tokenData?.access_token,
});
};

export const useSearchIssues = ({
issueKeys,
searchFilter,
}: UseSearchIssuesProps): UseQueryResult<JiraIssueResult> => {
const { data: tokenData } = useGetAccessToken();

const filteredKeys = issueKeys.filter((option) =>
option.toLowerCase().trim().includes(searchFilter.toLowerCase().trim()),
);
return useQuery({
queryKey: ["issueSearch", searchFilter],
queryFn: async () => {
return await getIssues(
`${filteredKeys.length ? `key in (${filteredKeys.map((key) => `'${key}'`).join(", ")}) OR ` : ""}key in (${issueKeys
.map((key) => `'${key}'`)
.join(", ")}) ${searchFilter ? `AND summary ~ '${searchFilter.trim()}*'` : ""}`,
tokenData?.access_token || "",
0,
);
},
enabled: !!searchFilter,
});
};

export const useIsJiraAuthenticated = () => {
const { data, error, isLoading } = useGetAccessToken();
return { isJiraAuth: !isLoading && data && !error, data, error };
};

0 comments on commit b393b04

Please sign in to comment.