diff --git a/apps/app/components/command-palette/command-k.tsx b/apps/app/components/command-palette/command-k.tsx index 6f3a0e5ef..a1525a348 100644 --- a/apps/app/components/command-palette/command-k.tsx +++ b/apps/app/components/command-palette/command-k.tsx @@ -739,12 +739,21 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal redirect(`/${workspaceSlug}/settings/import-export`)} + onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none" >
- Import/Export + Import +
+
+ redirect(`/${workspaceSlug}/settings/exports`)} + className="focus:outline-none" + > +
+ + Export
diff --git a/apps/app/components/exporter/export-modal.tsx b/apps/app/components/exporter/export-modal.tsx new file mode 100644 index 000000000..df4729265 --- /dev/null +++ b/apps/app/components/exporter/export-modal.tsx @@ -0,0 +1,185 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import CSVIntegrationService from "services/integration/csv.services"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { SecondaryButton, PrimaryButton, CustomSearchSelect } from "components/ui"; +// types +import { ICurrentUserResponse, IImporterService } from "types"; +// fetch-keys +import useProjects from "hooks/use-projects"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IImporterService | null; + user: ICurrentUserResponse | undefined; + provider: string | string[]; + mutateServices: () => void; +}; + +export const Exporter: React.FC = ({ + isOpen, + handleClose, + user, + provider, + mutateServices, +}) => { + const [exportLoading, setExportLoading] = useState(false); + const router = useRouter(); + const { workspaceSlug } = router.query; + const { projects } = useProjects(); + const { setToastAlert } = useToast(); + + const options = projects?.map((project) => ({ + value: project.id, + query: project.name + project.identifier, + content: ( +
+ {project.identifier} + {project.name} +
+ ), + })); + + const [value, setValue] = React.useState([]); + const [multiple, setMultiple] = React.useState(false); + const onChange = (val: any) => { + setValue(val); + }; + const ExportCSVToMail = async () => { + setExportLoading(true); + if (workspaceSlug && user && typeof provider === "string") { + const payload = { + provider: provider, + project: value, + multiple: multiple, + }; + await CSVIntegrationService.exportCSVService(workspaceSlug as string, payload, user) + .then(() => { + mutateServices(); + router.push(`/${workspaceSlug}/settings/exports`); + setExportLoading(false); + setToastAlert({ + type: "success", + title: "Export Successful", + message: `You will be able to download the exported ${ + provider === "csv" + ? "CSV" + : provider === "xlsx" + ? "Excel" + : provider === "json" + ? "JSON" + : "" + } from the previous export.`, + }); + }) + .catch(() => { + setExportLoading(false); + setToastAlert({ + type: "error", + title: "Error!", + message: "Export was unsuccessful. Please try again.", + }); + }); + } + }; + + return ( + + + +
+ + +
+
+ + +
+
+ +

+ Export to{" "} + {provider === "csv" + ? "CSV" + : provider === "xlsx" + ? "Excel" + : provider === "json" + ? "JSON" + : ""} +

+
+
+
+ onChange(val)} + options={options} + input={true} + label={ + value && value.length > 0 + ? projects && + projects + .filter((p) => value.includes(p.id)) + .map((p) => p.identifier) + .join(", ") + : "All projects" + } + optionsClassName="min-w-full" + multiple + /> +
+
setMultiple(!multiple)} + className="flex items-center gap-2 max-w-min cursor-pointer" + > + setMultiple(!multiple)} + /> +
+ Export the data into separate files +
+
+
+ Cancel + + {exportLoading ? "Exporting..." : "Export"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/exporter/guide.tsx b/apps/app/components/exporter/guide.tsx new file mode 100644 index 000000000..82a4fd453 --- /dev/null +++ b/apps/app/components/exporter/guide.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; + +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// hooks +import useUserAuth from "hooks/use-user-auth"; +// services +import IntegrationService from "services/integration"; +// components +import { Exporter, SingleExport } from "components/exporter"; +// ui +import { Icon, Loader, PrimaryButton } from "components/ui"; +// icons +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +// fetch-keys +import { EXPORT_SERVICES_LIST } from "constants/fetch-keys"; +// constants +import { EXPORTERS_LIST } from "constants/workspace"; + +const IntegrationGuide = () => { + const [refreshing, setRefreshing] = useState(false); + const per_page = 10; + const [cursor, setCursor] = useState(`10:0:0`); + + const router = useRouter(); + const { workspaceSlug, provider } = router.query; + + const { user } = useUserAuth(); + + const { data: exporterServices } = useSWR( + workspaceSlug && cursor + ? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`) + : null, + workspaceSlug && cursor + ? () => IntegrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page) + : null + ); + + const handleCsvClose = () => { + router.replace(`/plane/settings/exports`); + }; + + return ( + <> +
+ <> +
+ {EXPORTERS_LIST.map((service) => ( +
+
+
+ {`${service.title} +
+
+

{service.title}

+

{service.description}

+
+ +
+
+ ))} +
+
+

+
+
Previous Exports
+ +
+
+ + +
+

+ {exporterServices && exporterServices?.results ? ( + exporterServices?.results?.length > 0 ? ( +
+
+ {exporterServices?.results.map((service) => ( + + ))} +
+
+ ) : ( +

No previous export available.

+ ) + ) : ( + + + + + + + )} +
+ + {provider && ( + handleCsvClose()} + data={null} + user={user} + provider={provider} + mutateServices={() => + mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)) + } + /> + )} +
+ + ); +}; + +export default IntegrationGuide; diff --git a/apps/app/components/exporter/index.tsx b/apps/app/components/exporter/index.tsx new file mode 100644 index 000000000..ff15c1192 --- /dev/null +++ b/apps/app/components/exporter/index.tsx @@ -0,0 +1,4 @@ +//layout +export * from "./single-export"; +// csv +export * from "./export-modal"; diff --git a/apps/app/components/exporter/single-export.tsx b/apps/app/components/exporter/single-export.tsx new file mode 100644 index 000000000..34eb1510a --- /dev/null +++ b/apps/app/components/exporter/single-export.tsx @@ -0,0 +1,81 @@ +import React from "react"; +// next imports +import Link from "next/link"; +// ui +import { PrimaryButton } from "components/ui"; // icons +// helpers +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +// types +import { IExportData } from "types"; + +type Props = { + service: IExportData; + refreshing: boolean; +}; + +export const SingleExport: React.FC = ({ service, refreshing }) => { + const provider = service.provider; + const [isLoading, setIsLoading] = React.useState(false); + + const checkExpiry = (inputDateString: string) => { + const currentDate = new Date(); + const expiryDate = new Date(inputDateString); + expiryDate.setDate(expiryDate.getDate() + 7); + return expiryDate > currentDate; + }; + + return ( +
+
+

+ + Export to{" "} + + {provider === "csv" + ? "CSV" + : provider === "xlsx" + ? "Excel" + : provider === "json" + ? "JSON" + : ""} + {" "} + + + {refreshing ? "Refreshing..." : service.status} + +

+
+ {renderShortDateWithYearFormat(service.created_at)}| + Exported by {service?.initiated_by_detail?.display_name} +
+
+ {checkExpiry(service.created_at) ? ( + <> + {service.status == "completed" && ( +
+ + + {isLoading ? "Downloading..." : "Download"} + + +
+ )} + + ) : ( +
Expired
+ )} +
+ ); +}; diff --git a/apps/app/components/integration/github/root.tsx b/apps/app/components/integration/github/root.tsx index fea02bb86..2e5d9e0c4 100644 --- a/apps/app/components/integration/github/root.tsx +++ b/apps/app/components/integration/github/root.tsx @@ -163,7 +163,7 @@ export const GithubImporterRoot: React.FC = ({ user }) => { await GithubIntegrationService.createGithubServiceImport(workspaceSlug as string, payload, user) .then(() => { - router.push(`/${workspaceSlug}/settings/import-export`); + router.push(`/${workspaceSlug}/settings/imports`); mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)); }) .catch(() => @@ -178,7 +178,7 @@ export const GithubImporterRoot: React.FC = ({ user }) => { return (
- +
Cancel import & go back
diff --git a/apps/app/components/integration/guide.tsx b/apps/app/components/integration/guide.tsx index 8c8d05671..0b06e997e 100644 --- a/apps/app/components/integration/guide.tsx +++ b/apps/app/components/integration/guide.tsx @@ -58,7 +58,7 @@ const IntegrationGuide = () => { user={user} />
- {!provider && ( + {(!provider || provider === "csv") && ( <>
@@ -100,7 +100,7 @@ const IntegrationGuide = () => {
diff --git a/apps/app/components/integration/jira/root.tsx b/apps/app/components/integration/jira/root.tsx index b9d08550c..b5c086431 100644 --- a/apps/app/components/integration/jira/root.tsx +++ b/apps/app/components/integration/jira/root.tsx @@ -92,7 +92,7 @@ export const JiraImporterRoot: React.FC = ({ user }) => { .createJiraImporter(workspaceSlug.toString(), data, user) .then(() => { mutate(IMPORTER_SERVICES_LIST(workspaceSlug.toString())); - router.push(`/${workspaceSlug}/settings/import-export`); + router.push(`/${workspaceSlug}/settings/imports`); }) .catch((err) => { console.log(err); @@ -109,7 +109,7 @@ export const JiraImporterRoot: React.FC = ({ user }) => { return (
- +
diff --git a/apps/app/components/ui/integration-and-import-export-banner.tsx b/apps/app/components/ui/integration-and-import-export-banner.tsx index fd24acaab..9173a630e 100644 --- a/apps/app/components/ui/integration-and-import-export-banner.tsx +++ b/apps/app/components/ui/integration-and-import-export-banner.tsx @@ -2,18 +2,17 @@ import { ExclamationIcon } from "components/icons"; type Props = { bannerName: string; + description?: string; }; -export const IntegrationAndImportExportBanner: React.FC = ({ bannerName }) => ( +export const IntegrationAndImportExportBanner: React.FC = ({ bannerName, description }) => (

{bannerName}

-
- -

- Integrations and importers are only available on the cloud version. We plan to open-source - our SDKs in the near future so that the community can request or contribute integrations as - needed. -

-
+ {description && ( +
+ +

{description}

+
+ )}
); diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 295d55f77..97e8dfc90 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -237,6 +237,10 @@ export const JIRA_IMPORTER_DETAIL = (workspaceSlug: string, params: IJiraMetadat export const IMPORTER_SERVICES_LIST = (workspaceSlug: string) => `IMPORTER_SERVICES_LIST_${workspaceSlug.toUpperCase()}`; +//export +export const EXPORT_SERVICES_LIST = (workspaceSlug: string, cursor: string, per_page: string) => + `EXPORTER_SERVICES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`; + // github-importer export const GITHUB_REPOSITORY_INFO = (workspaceSlug: string, repoName: string) => `GITHUB_REPO_INFO_${workspaceSlug.toString().toUpperCase()}_${repoName.toUpperCase()}`; diff --git a/apps/app/constants/workspace.ts b/apps/app/constants/workspace.ts index 76b099ec5..eeb5e2730 100644 --- a/apps/app/constants/workspace.ts +++ b/apps/app/constants/workspace.ts @@ -1,6 +1,9 @@ // services images import GithubLogo from "public/services/github.png"; import JiraLogo from "public/services/jira.png"; +import CSVLogo from "public/services/csv.png"; +import ExcelLogo from "public/services/excel.png"; +import JSONLogo from "public/services/json.png"; export const ROLE = { 5: "Guest", @@ -40,3 +43,27 @@ export const IMPORTERS_EXPORTERS_LIST = [ logo: JiraLogo, }, ]; + +export const EXPORTERS_LIST = [ + { + provider: "csv", + type: "export", + title: "CSV", + description: "Export issues to a CSV file.", + logo: CSVLogo, + }, + { + provider: "xlsx", + type: "export", + title: "Excel", + description: "Export issues to a Excel file.", + logo: ExcelLogo, + }, + { + provider: "json", + type: "export", + title: "JSON", + description: "Export issues to a JSON file.", + logo: JSONLogo, + }, +]; diff --git a/apps/app/layouts/settings-navbar.tsx b/apps/app/layouts/settings-navbar.tsx index d6962940d..462a4b6a0 100644 --- a/apps/app/layouts/settings-navbar.tsx +++ b/apps/app/layouts/settings-navbar.tsx @@ -30,8 +30,12 @@ const SettingsNavbar: React.FC = ({ profilePage = false }) => { href: `/${workspaceSlug}/settings/integrations`, }, { - label: "Import/Export", - href: `/${workspaceSlug}/settings/import-export`, + label: "Imports", + href: `/${workspaceSlug}/settings/imports`, + }, + { + label: "Exports", + href: `/${workspaceSlug}/settings/exports`, }, ]; @@ -103,7 +107,7 @@ const SettingsNavbar: React.FC = ({ profilePage = false }) => {
{ link={`/${workspaceSlug}`} linkTruncate /> - + } >
- - + +
); diff --git a/apps/app/pages/[workspaceSlug]/settings/imports.tsx b/apps/app/pages/[workspaceSlug]/settings/imports.tsx new file mode 100644 index 000000000..a0a46f0bc --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/settings/imports.tsx @@ -0,0 +1,58 @@ +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import workspaceService from "services/workspace.service"; +// layouts +import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +import { SettingsHeader } from "components/workspace"; +// components +import IntegrationGuide from "components/integration/guide"; +import { IntegrationAndImportExportBanner } from "components/ui"; +// ui +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// types +import type { NextPage } from "next"; +// fetch-keys +import { WORKSPACE_DETAILS } from "constants/fetch-keys"; +// helper +import { truncateText } from "helpers/string.helper"; + +const ImportExport: NextPage = () => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { data: activeWorkspace } = useSWR( + workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, + () => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null) + ); + + return ( + + + + + } + > +
+ + + +
+
+ ); +}; + +export default ImportExport; diff --git a/apps/app/public/services/csv.png b/apps/app/public/services/csv.png new file mode 100644 index 000000000..3c35eb9f7 Binary files /dev/null and b/apps/app/public/services/csv.png differ diff --git a/apps/app/public/services/excel.png b/apps/app/public/services/excel.png new file mode 100644 index 000000000..d271880f6 Binary files /dev/null and b/apps/app/public/services/excel.png differ diff --git a/apps/app/public/services/json.png b/apps/app/public/services/json.png new file mode 100644 index 000000000..a5d0dfd22 Binary files /dev/null and b/apps/app/public/services/json.png differ diff --git a/apps/app/services/integration/csv.services.ts b/apps/app/services/integration/csv.services.ts new file mode 100644 index 000000000..f19cc4a74 --- /dev/null +++ b/apps/app/services/integration/csv.services.ts @@ -0,0 +1,42 @@ +import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + +import { ICurrentUserResponse } from "types"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + +class CSVIntegrationService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async exportCSVService( + workspaceSlug: string, + data: { + provider: string; + project: string[]; + }, + user: ICurrentUserResponse + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/export-issues/`, data) + .then((response) => { + if (trackEvent) + trackEventServices.trackExporterEvent( + { + workspaceSlug, + }, + "CSV_EXPORTER_CREATE", + user + ); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +export default new CSVIntegrationService(); diff --git a/apps/app/services/integration/index.ts b/apps/app/services/integration/index.ts index 9cc146866..2b32a5bd0 100644 --- a/apps/app/services/integration/index.ts +++ b/apps/app/services/integration/index.ts @@ -7,6 +7,7 @@ import { ICurrentUserResponse, IImporterService, IWorkspaceIntegration, + IExportServiceResponse, } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -52,6 +53,22 @@ class IntegrationService extends APIService { throw error?.response?.data; }); } + async getExportsServicesList( + workspaceSlug: string, + cursor: string, + per_page: number + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/export-issues`, { + params: { + per_page, + cursor, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } async deleteImporterService( workspaceSlug: string, diff --git a/apps/app/services/track-event.service.ts b/apps/app/services/track-event.service.ts index 3da8b8436..a87a0ff07 100644 --- a/apps/app/services/track-event.service.ts +++ b/apps/app/services/track-event.service.ts @@ -98,6 +98,8 @@ type ImporterEventType = | "JIRA_IMPORTER_CREATE" | "JIRA_IMPORTER_DELETE"; +type ExporterEventType = "CSV_EXPORTER_CREATE"; + type AnalyticsEventType = | "WORKSPACE_SCOPE_AND_DEMAND_ANALYTICS" | "WORKSPACE_CUSTOM_ANALYTICS" @@ -776,6 +778,27 @@ class TrackEventServices extends APIService { }); } + // track exporter function\ + async trackExporterEvent( + data: any, + eventName: ExporterEventType, + user: ICurrentUserResponse | undefined + ): Promise { + const payload = { ...data }; + + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName, + extra: { + ...payload, + }, + user: user, + }, + }); + } + // TODO: add types to the data async trackInboxEvent( data: any, diff --git a/apps/app/types/importer/index.ts b/apps/app/types/importer/index.ts index 134248a71..81e1bb22f 100644 --- a/apps/app/types/importer/index.ts +++ b/apps/app/types/importer/index.ts @@ -32,3 +32,27 @@ export interface IImporterService { token: string; workspace: string; } + +export interface IExportData { + id: string; + created_at: string; + updated_at: string; + project: string[]; + provider: string; + status: string; + url: string; + token: string; + created_by: string; + updated_by: string; + initiated_by_detail: IUserLite; +} +export interface IExportServiceResponse { + count: number; + extra_stats: null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + results: IExportData[]; + total_pages: number; +} diff --git a/apps/app/types/index.d.ts b/apps/app/types/index.d.ts index dbd314826..8c9592917 100644 --- a/apps/app/types/index.d.ts +++ b/apps/app/types/index.d.ts @@ -19,6 +19,7 @@ export * from "./notifications"; export * from "./waitlist"; export * from "./reaction"; + export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object ? ObjectType[Key] extends { pop: any; push: any }