diff --git a/apps/app/components/integration/guide.tsx b/apps/app/components/integration/guide.tsx
index ce33f16d2..ad9ffe559 100644
--- a/apps/app/components/integration/guide.tsx
+++ b/apps/app/components/integration/guide.tsx
@@ -52,7 +52,7 @@ const IntegrationGuide = () => {
handleClose={() => setDeleteImportModal(false)}
data={importToDelete}
/>
-
+
{!provider && (
<>
diff --git a/apps/app/components/integration/jira/confirm-import.tsx b/apps/app/components/integration/jira/confirm-import.tsx
new file mode 100644
index 000000000..79c56bf4b
--- /dev/null
+++ b/apps/app/components/integration/jira/confirm-import.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+
+// react hook form
+import { useFormContext } from "react-hook-form";
+
+// types
+import { IJiraImporterForm } from "types";
+
+export const JiraConfirmImport: React.FC = () => {
+ const { watch } = useFormContext
();
+
+ return (
+
+
+
+
Confirm
+
+
+
+
+
+
{watch("data.total_issues")}
+
Issues
+
+
+
{watch("data.total_states")}
+
States
+
+
+
{watch("data.total_modules")}
+
Modules
+
+
+
{watch("data.total_labels")}
+
Labels
+
+
+
+ {watch("data.users").filter((user) => user.import).length}
+
+
User
+
+
+
+
+ );
+};
diff --git a/apps/app/components/integration/jira/give-details.tsx b/apps/app/components/integration/jira/give-details.tsx
new file mode 100644
index 000000000..fb4032b68
--- /dev/null
+++ b/apps/app/components/integration/jira/give-details.tsx
@@ -0,0 +1,178 @@
+import React from "react";
+
+// next
+import Link from "next/link";
+
+// react hook form
+import { useFormContext, Controller } from "react-hook-form";
+
+// icons
+import { PlusIcon } from "@heroicons/react/20/solid";
+
+// hooks
+import useProjects from "hooks/use-projects";
+
+// components
+import { Input, CustomSelect } from "components/ui";
+
+import { IJiraImporterForm } from "types";
+
+export const JiraGetImportDetail: React.FC = () => {
+ const {
+ register,
+ control,
+ formState: { errors },
+ } = useFormContext();
+
+ const { projects } = useProjects();
+
+ return (
+
+
+
+
Jira Personal Access Token
+
+ Get to know your access token by navigating to{" "}
+
+
+ Atlassian Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
Jira Project Key
+
If XXX-123 is your issue, then enter XXX
+
+
+
+
+
+
+
+
+
Jira Email Address
+
+ Enter the Gmail account that you use in Jira account
+
+
+
+
+
+
+
+
+
+
Jira Installation or Cloud Host Name
+
Enter your companies cloud host name
+
+
+
+
+
+
+
+
+
Import to project
+
Select which project you want to import to.
+
+
+
(
+
+ {value && value !== ""
+ ? projects.find((p) => p.id === value)?.name
+ : "Select Project"}
+
+ }
+ >
+ {projects.length > 0 ? (
+ projects.map((project) => (
+
+ {project.name}
+
+ ))
+ ) : (
+
+
You don{"'"}t have any project. Please create a project first.
+
+ )}
+
+
+
+
+ )}
+ />
+
+
+
+ );
+};
diff --git a/apps/app/components/integration/jira/import-users.tsx b/apps/app/components/integration/jira/import-users.tsx
new file mode 100644
index 000000000..fa48bc772
--- /dev/null
+++ b/apps/app/components/integration/jira/import-users.tsx
@@ -0,0 +1,145 @@
+import { FC } from "react";
+
+// next
+import { useRouter } from "next/router";
+
+// react-hook-form
+import { useFormContext, useFieldArray, Controller } from "react-hook-form";
+
+// hooks
+import useWorkspaceMembers from "hooks/use-workspace-members";
+
+// components
+import { ToggleSwitch, Input, CustomSelect, CustomSearchSelect, Avatar } from "components/ui";
+
+import { IJiraImporterForm } from "types";
+
+export const JiraImportUsers: FC = () => {
+ const {
+ control,
+ watch,
+ register,
+ formState: { errors },
+ } = useFormContext();
+
+ const { fields } = useFieldArray({
+ control,
+ name: "data.users",
+ });
+
+ const router = useRouter();
+ const { workspaceSlug } = router.query;
+
+ const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString());
+
+ const options =
+ members?.map((member) => ({
+ value: member.member.email,
+ query:
+ (member.member.first_name && member.member.first_name !== ""
+ ? member.member.first_name
+ : member.member.email) +
+ " " +
+ member.member.last_name ?? "",
+ content: (
+
+
+ {member.member.first_name && member.member.first_name !== ""
+ ? member.member.first_name + " (" + member.member.email + ")"
+ : member.member.email}
+
+ ),
+ })) ?? [];
+
+ return (
+
+
+
+
Users
+
Update, invite or choose not to invite assignee
+
+
+ (
+
+ )}
+ />
+
+
+
+ {watch("data.invite_users") && (
+
+
+
+
+ {fields.map((user, index) => (
+
+
+
+ (
+
+ {Boolean(value) ? value : ("Ignore" as any)}
+
+ }
+ >
+ Invite by email
+ Map to existing
+ Do not import
+
+ )}
+ />
+
+
+ {watch(`data.users.${index}.import`) === "invite" && (
+
+ )}
+ {watch(`data.users.${index}.import`) === "map" && (
+ (
+
+ )}
+ />
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/apps/app/components/integration/jira/index.ts b/apps/app/components/integration/jira/index.ts
index 1efe34c51..321e4f313 100644
--- a/apps/app/components/integration/jira/index.ts
+++ b/apps/app/components/integration/jira/index.ts
@@ -1 +1,39 @@
export * from "./root";
+export * from "./give-details";
+export * from "./jira-project-detail";
+export * from "./import-users";
+export * from "./confirm-import";
+
+import { IJiraImporterForm } from "types";
+
+export type TJiraIntegrationSteps =
+ | "import-configure"
+ | "display-import-data"
+ | "select-import-data"
+ | "import-users"
+ | "import-confirmation";
+
+export interface IJiraIntegrationData {
+ state: TJiraIntegrationSteps;
+}
+
+export const jiraFormDefaultValues: IJiraImporterForm = {
+ metadata: {
+ cloud_hostname: "",
+ api_token: "",
+ project_key: "",
+ email: "",
+ },
+ config: {
+ epics_to_modules: false,
+ },
+ data: {
+ users: [],
+ invite_users: true,
+ total_issues: 0,
+ total_labels: 0,
+ total_modules: 0,
+ total_states: 0,
+ },
+ project_id: "",
+};
diff --git a/apps/app/components/integration/jira/jira-project-detail.tsx b/apps/app/components/integration/jira/jira-project-detail.tsx
new file mode 100644
index 000000000..48220a8c1
--- /dev/null
+++ b/apps/app/components/integration/jira/jira-project-detail.tsx
@@ -0,0 +1,168 @@
+import React, { useEffect } from "react";
+
+// next
+import { useRouter } from "next/router";
+
+// swr
+import useSWR from "swr";
+
+// react hook form
+import { useFormContext, Controller } from "react-hook-form";
+
+// services
+import jiraImporterService from "services/integration/jira.service";
+
+// fetch keys
+import { JIRA_IMPORTER_DETAIL } from "constants/fetch-keys";
+
+import { IJiraImporterForm, IJiraMetadata } from "types";
+
+// components
+import { Spinner, ToggleSwitch } from "components/ui";
+
+import type { IJiraIntegrationData, TJiraIntegrationSteps } from "./";
+
+type Props = {
+ setCurrentStep: React.Dispatch>;
+ setDisableTopBarAfter: React.Dispatch>;
+};
+
+export const JiraProjectDetail: React.FC = (props) => {
+ const { setCurrentStep, setDisableTopBarAfter } = props;
+
+ const {
+ watch,
+ setValue,
+ control,
+ formState: { errors },
+ } = useFormContext();
+
+ const router = useRouter();
+ const { workspaceSlug } = router.query;
+
+ const params: IJiraMetadata = {
+ api_token: watch("metadata.api_token"),
+ project_key: watch("metadata.project_key"),
+ email: watch("metadata.email"),
+ cloud_hostname: watch("metadata.cloud_hostname"),
+ };
+
+ const { data: projectInfo, error } = useSWR(
+ workspaceSlug &&
+ !errors.metadata?.api_token &&
+ !errors.metadata?.project_key &&
+ !errors.metadata?.email &&
+ !errors.metadata?.cloud_hostname
+ ? JIRA_IMPORTER_DETAIL(workspaceSlug.toString(), params)
+ : null,
+ workspaceSlug &&
+ !errors.metadata?.api_token &&
+ !errors.metadata?.project_key &&
+ !errors.metadata?.email &&
+ !errors.metadata?.cloud_hostname
+ ? () => jiraImporterService.getJiraProjectInfo(workspaceSlug.toString(), params)
+ : null
+ );
+
+ useEffect(() => {
+ if (!projectInfo) return;
+
+ setValue("data.total_issues", projectInfo.issues);
+ setValue("data.total_labels", projectInfo.labels);
+ setValue(
+ "data.users",
+ projectInfo.users?.map((user) => ({
+ email: user.emailAddress,
+ import: false,
+ username: user.displayName,
+ }))
+ );
+ setValue("data.total_states", projectInfo.states);
+ setValue("data.total_modules", projectInfo.modules);
+ }, [projectInfo, setValue]);
+
+ useEffect(() => {
+ if (error) setDisableTopBarAfter("display-import-data");
+ else setDisableTopBarAfter(null);
+ }, [error, setDisableTopBarAfter]);
+
+ useEffect(() => {
+ if (!projectInfo && !error) setDisableTopBarAfter("display-import-data");
+ else if (!error) setDisableTopBarAfter(null);
+ }, [projectInfo, error, setDisableTopBarAfter]);
+
+ if (!projectInfo && !error) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Something went wrong. Please{" "}
+ {" "}
+ and check your Jira project details.
+
+
+ );
+ }
+
+ return (
+
+
+
+
Import Data
+
Import Completed. We have found:
+
+
+
+
{projectInfo?.issues}
+
Issues
+
+
+
{projectInfo?.states}
+
States
+
+
+
{projectInfo?.modules}
+
Modules
+
+
+
{projectInfo?.labels}
+
Labels
+
+
+
{projectInfo?.users?.length}
+
Users
+
+
+
+
+
+
+
Import Epics
+
Import epics as modules
+
+
+ (
+
+ )}
+ />
+
+
+
+ );
+};
diff --git a/apps/app/components/integration/jira/root.tsx b/apps/app/components/integration/jira/root.tsx
index 15a89c3e4..b9bd732c0 100644
--- a/apps/app/components/integration/jira/root.tsx
+++ b/apps/app/components/integration/jira/root.tsx
@@ -1 +1,224 @@
-export const JiraImporterRoot = () => <>>;
+import React, { useState } from "react";
+
+// next
+import Link from "next/link";
+import Image from "next/image";
+import { useRouter } from "next/router";
+
+// swr
+import { mutate } from "swr";
+
+// react hook form
+import { FormProvider, useForm } from "react-hook-form";
+
+// icons
+import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline";
+import { CogIcon, CloudUploadIcon, UsersIcon, CheckIcon } from "components/icons";
+
+// services
+import jiraImporterService from "services/integration/jira.service";
+
+// fetch keys
+import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys";
+
+// components
+import { PrimaryButton, SecondaryButton } from "components/ui";
+import {
+ JiraGetImportDetail,
+ JiraProjectDetail,
+ JiraImportUsers,
+ JiraConfirmImport,
+ jiraFormDefaultValues,
+ TJiraIntegrationSteps,
+ IJiraIntegrationData,
+} from "./";
+
+import JiraLogo from "public/services/jira.png";
+
+import { IJiraImporterForm } from "types";
+
+const integrationWorkflowData: Array<{
+ title: string;
+ key: TJiraIntegrationSteps;
+ icon: React.FC>;
+}> = [
+ {
+ title: "Configure",
+ key: "import-configure",
+ icon: CogIcon,
+ },
+ {
+ title: "Import Data",
+ key: "display-import-data",
+ icon: ListBulletIcon,
+ },
+ {
+ title: "Users",
+ key: "import-users",
+ icon: UsersIcon,
+ },
+ {
+ title: "Confirm",
+ key: "import-confirmation",
+ icon: CheckIcon,
+ },
+];
+
+export const JiraImporterRoot = () => {
+ const [currentStep, setCurrentStep] = useState({
+ state: "import-configure",
+ });
+ const [disableTopBarAfter, setDisableTopBarAfter] = useState(null);
+
+ const router = useRouter();
+ const { workspaceSlug } = router.query;
+
+ const methods = useForm({
+ defaultValues: jiraFormDefaultValues,
+ mode: "all",
+ reValidateMode: "onChange",
+ });
+
+ const isValid = methods.formState.isValid;
+
+ const onSubmit = async (data: IJiraImporterForm) => {
+ if (!workspaceSlug) return;
+
+ await jiraImporterService
+ .createJiraImporter(workspaceSlug.toString(), data)
+ .then(() => {
+ mutate(IMPORTER_SERVICES_LIST(workspaceSlug.toString()));
+ router.push(`/${workspaceSlug}/settings/import-export`);
+ })
+ .catch((err) => {
+ console.log(err);
+ });
+ };
+
+ const activeIntegrationState = () => {
+ const currentElementIndex = integrationWorkflowData.findIndex(
+ (i) => i?.key === currentStep?.state
+ );
+
+ return currentElementIndex;
+ };
+
+ return (
+
+
+
+
+
Cancel import & go back
+
+
+
+
+
+
+
+
+
+ {integrationWorkflowData.map((integration, index) => (
+
+
+ {index < integrationWorkflowData.length - 1 && (
+
+ {" "}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/app/components/ui/custom-search-select.tsx b/apps/app/components/ui/custom-search-select.tsx
index c8cea10be..fd7188db6 100644
--- a/apps/app/components/ui/custom-search-select.tsx
+++ b/apps/app/components/ui/custom-search-select.tsx
@@ -18,6 +18,7 @@ type CustomSearchSelectProps = {
noChevron?: boolean;
customButton?: JSX.Element;
optionsClassName?: string;
+ input?: boolean;
disabled?: boolean;
selfPositioned?: boolean;
multiple?: boolean;
@@ -34,6 +35,7 @@ export const CustomSearchSelect = ({
noChevron = false,
customButton,
optionsClassName = "",
+ input = false,
disabled = false,
selfPositioned = false,
multiple = false,
@@ -68,7 +70,9 @@ export const CustomSearchSelect = ({
void;
+ label?: string;
+ disabled?: boolean;
+ className?: string;
+};
+
+export const ToggleSwitch: React.FC = (props) => {
+ const { value, onChange, label, disabled, className } = props;
+
+ return (
+
+ {label}
+
+
+ );
+};
diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts
index aff1d99f4..7090d1f18 100644
--- a/apps/app/constants/fetch-keys.ts
+++ b/apps/app/constants/fetch-keys.ts
@@ -1,3 +1,5 @@
+import { IJiraMetadata } from "types";
+
const paramsToKey = (params: any) => {
const { state, priority, assignees, created_by, labels } = params;
@@ -122,6 +124,12 @@ export const APP_INTEGRATIONS = "APP_INTEGRATIONS";
export const WORKSPACE_INTEGRATIONS = (workspaceSlug: string) =>
`WORKSPACE_INTEGRATIONS_${workspaceSlug.toUpperCase()}`;
+export const JIRA_IMPORTER_DETAIL = (workspaceSlug: string, params: IJiraMetadata) => {
+ const { api_token, cloud_hostname, email, project_key } = params;
+
+ return `JIRA_IMPORTER_DETAIL_${workspaceSlug.toUpperCase()}_${api_token}_${cloud_hostname}_${email}_${project_key}`;
+};
+
//import-export
export const IMPORTER_SERVICES_LIST = (workspaceSlug: string) =>
`IMPORTER_SERVICES_LIST_${workspaceSlug.toUpperCase()}`;
@@ -153,6 +161,7 @@ export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${page
// estimates
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
-export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
+export const ESTIMATE_DETAILS = (estimateId: string) =>
+ `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
export const ESTIMATE_POINTS_LIST = (estimateId: string) =>
`ESTIMATES_POINTS_LIST_${estimateId.toUpperCase()}`;
diff --git a/apps/app/constants/workspace.ts b/apps/app/constants/workspace.ts
index 2864855d3..24f886af9 100644
--- a/apps/app/constants/workspace.ts
+++ b/apps/app/constants/workspace.ts
@@ -75,11 +75,11 @@ export const IMPORTERS_EXPORTERS_LIST = [
description: "Import issues from GitHub repositories and sync them.",
logo: GithubLogo,
},
- // {
- // provider: "jira",
- // type: "import",
- // title: "Jira",
- // description: "Import issues and epics from Jira projects and epics.",
- // logo: JiraLogo,
- // },
+ {
+ provider: "jira",
+ type: "import",
+ title: "Jira",
+ description: "Import issues and epics from Jira projects and epics.",
+ logo: JiraLogo,
+ },
];
diff --git a/apps/app/hooks/use-workspace-members.tsx b/apps/app/hooks/use-workspace-members.tsx
new file mode 100644
index 000000000..b41f4535d
--- /dev/null
+++ b/apps/app/hooks/use-workspace-members.tsx
@@ -0,0 +1,43 @@
+import useSWR from "swr";
+// services
+import workspaceService from "services/workspace.service";
+// fetch-keys
+import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
+// hooks
+import useUser from "./use-user";
+
+const useWorkspaceMembers = (workspaceSlug?: string) => {
+ const { user } = useUser();
+
+ const { data: workspaceMembers, error: workspaceMemberErrors } = useSWR(
+ workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug) : null,
+ workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug) : null
+ );
+
+ const hasJoined = workspaceMembers?.some((item: any) => item.member.id === (user as any)?.id);
+
+ const isOwner = workspaceMembers?.some(
+ (item) => item.member.id === (user as any)?.id && item.role === 20
+ );
+ const isMember = workspaceMembers?.some(
+ (item) => item.member.id === (user as any)?.id && item.role === 15
+ );
+ const isViewer = workspaceMembers?.some(
+ (item) => item.member.id === (user as any)?.id && item.role === 10
+ );
+ const isGuest = workspaceMembers?.some(
+ (item) => item.member.id === (user as any)?.id && item.role === 5
+ );
+
+ return {
+ workspaceMembers,
+ workspaceMemberErrors,
+ hasJoined,
+ isOwner,
+ isMember,
+ isViewer,
+ isGuest,
+ };
+};
+
+export default useWorkspaceMembers;
diff --git a/apps/app/package.json b/apps/app/package.json
index 8279ecb2d..5374c6363 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -56,6 +56,8 @@
"eslint-config-custom": "*",
"eslint-config-next": "12.2.2",
"postcss": "^8.4.14",
+ "prettier": "^2.8.7",
+ "prettier-plugin-tailwindcss": "^0.2.7",
"tailwindcss": "^3.1.6",
"tsconfig": "*",
"typescript": "4.7.4"
diff --git a/apps/app/services/integration/jira.service.ts b/apps/app/services/integration/jira.service.ts
new file mode 100644
index 000000000..20ad8166a
--- /dev/null
+++ b/apps/app/services/integration/jira.service.ts
@@ -0,0 +1,34 @@
+import APIService from "services/api.service";
+
+// types
+import { IJiraMetadata, IJiraResponse, IJiraImporterForm } from "types";
+
+const { NEXT_PUBLIC_API_BASE_URL } = process.env;
+
+class JiraImportedService extends APIService {
+ constructor() {
+ super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
+ }
+
+ async getJiraProjectInfo(workspaceSlug: string, params: IJiraMetadata): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/importers/jira`, {
+ params,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async createJiraImporter(workspaceSlug: string, data: IJiraImporterForm): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/projects/importers/jira/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
+
+const jiraImporterService = new JiraImportedService();
+
+export default jiraImporterService;
diff --git a/apps/app/types/importer/index.ts b/apps/app/types/importer/index.ts
index 1e3f8c2ad..134248a71 100644
--- a/apps/app/types/importer/index.ts
+++ b/apps/app/types/importer/index.ts
@@ -1,4 +1,5 @@
export * from "./github-importer";
+export * from "./jira-importer";
import { IProjectLite } from "types/projects";
// types
diff --git a/apps/app/types/importer/jira-importer.d.ts b/apps/app/types/importer/jira-importer.d.ts
new file mode 100644
index 000000000..cd9e31ad2
--- /dev/null
+++ b/apps/app/types/importer/jira-importer.d.ts
@@ -0,0 +1,58 @@
+export interface IJiraImporterForm {
+ metadata: IJiraMetadata;
+ config: IJiraConfig;
+ data: IJiraData;
+ project_id: string;
+}
+
+export interface IJiraConfig {
+ epics_to_modules: boolean;
+}
+
+export interface IJiraData {
+ users: User[];
+ invite_users: boolean;
+ total_issues: number;
+ total_labels: number;
+ total_states: number;
+ total_modules: number;
+}
+
+export interface User {
+ username: string;
+ import: "invite" | "map" | false;
+ email: string;
+}
+
+export interface IJiraMetadata {
+ cloud_hostname: string;
+ api_token: string;
+ project_key: string;
+ email: string;
+}
+
+export interface IJiraResponse {
+ issues: number;
+ modules: number;
+ labels: number;
+ states: number;
+ users: IJiraResponseUser[];
+}
+
+export interface IJiraResponseUser {
+ self: string;
+ accountId: string;
+ accountType: string;
+ emailAddress: string;
+ avatarUrls: AvatarUrls;
+ displayName: string;
+ active: boolean;
+ locale: string;
+}
+
+export interface IJiraResponseAvatarUrls {
+ "48x48": string;
+ "24x24": string;
+ "16x16": string;
+ "32x32": string;
+}
diff --git a/yarn.lock b/yarn.lock
index ae33bba9a..99ac4cf56 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6485,6 +6485,16 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
+prettier-plugin-tailwindcss@^0.2.7:
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.7.tgz#2314d728cce9c9699ced41a01253eb48b4218da5"
+ integrity sha512-jQopIOgjLpX+y8HeD56XZw7onupRTC0cw7eKKUimI7vhjkPF5/1ltW5LyqaPtSyc8HvEpvNZsvvsGFa2qpa59w==
+
+prettier@^2.8.7:
+ version "2.8.7"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450"
+ integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==
+
prettier@latest:
version "2.8.4"
resolved "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz"