fix: workspace settings internal screens

This commit is contained in:
gakshita
2025-04-29 18:48:28 +05:30
parent b6b7baa9b8
commit 427d4888c4
6 changed files with 439 additions and 201 deletions
@@ -1354,6 +1354,8 @@
"exporting": "Exporting",
"previous_exports": "Previous exports",
"export_separate_files": "Export the data into separate files",
"exporting_projects": "Exporting project",
"format": "Format",
"modal": {
"title": "Export to",
"toasts": {
@@ -7,10 +7,10 @@ import { usePathname } from "next/navigation";
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
// hooks
import { NotAuthorizedView } from "@/components/auth-screens";
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store";
// local components
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
import { WorkspaceSettingsSidebar } from "./sidebar";
export interface IWorkspaceSettingLayout {
@@ -42,7 +42,7 @@ const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) =>
return (
<>
<MobileWorkspaceSettingsTabs />
<CommandPalette />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" />
+112
View File
@@ -0,0 +1,112 @@
import { Download } from "lucide-react";
import { IExportData } from "@plane/types";
import { getDate, getFileURL, renderFormattedDate } from "@plane/utils";
type RowData = IExportData;
const checkExpiry = (inputDateString: string) => {
const currentDate = new Date();
const expiryDate = getDate(inputDateString);
if (!expiryDate) return false;
expiryDate.setDate(expiryDate.getDate() + 7);
return expiryDate > currentDate;
};
export const useExportColumns = () => {
const columns = [
{
key: "Exported By",
content: "Exported By",
tdRender: (rowData: RowData) => {
const { avatar_url, display_name, email } = rowData.initiated_by_detail;
return (
<div className="flex items-center gap-x-2">
<div>
{avatar_url && avatar_url.trim() !== "" ? (
<span className="relative flex h-4 w-4 items-center justify-center rounded-full capitalize text-white">
<img
src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
) : (
<span className="relative flex h-4 w-4 items-center justify-center rounded-full bg-gray-700 capitalize text-white text-xs">
{(email ?? display_name ?? "?")[0]}
</span>
)}
</div>
<div>{display_name}</div>
</div>
);
},
},
{
key: "Exported On",
content: "Exported On",
tdRender: (rowData: RowData) => <span>{renderFormattedDate(rowData.created_at)}</span>,
},
{
key: "Exported projects",
content: "Exported projects",
tdRender: (rowData: RowData) => <div className="text-sm">{rowData.project.length} project(s)</div>,
},
{
key: "Format",
content: "Format",
tdRender: (rowData: RowData) => (
<span className="text-sm">
{rowData.provider === "csv"
? "CSV"
: rowData.provider === "xlsx"
? "Excel"
: rowData.provider === "json"
? "JSON"
: ""}
</span>
),
},
{
key: "Status",
content: "Status",
tdRender: (rowData: RowData) => (
<span
className={`rounded text-xs px-2 py-1 capitalize ${
rowData.status === "completed"
? "bg-green-500/20 text-green-500"
: rowData.status === "processing"
? "bg-yellow-500/20 text-yellow-500"
: rowData.status === "failed"
? "bg-red-500/20 text-red-500"
: rowData.status === "expired"
? "bg-orange-500/20 text-orange-500"
: "bg-gray-500/20 text-gray-500"
}`}
>
{rowData.status}
</span>
),
},
{
key: "Download",
content: "Download",
tdRender: (rowData: RowData) =>
checkExpiry(rowData.created_at) ? (
<>
{rowData.status == "completed" ? (
<a target="_blank" href={rowData?.url} rel="noopener noreferrer">
<button className="w-full flex items-center gap-1 text-custom-primary-100 font-medium">
<Download className="h-4 w-4" />
<div>Download</div>
</button>
</a>
) : (
"-"
)}
</>
) : (
<div className="text-xs text-red-500">Expired</div>
),
},
];
return columns;
};
@@ -0,0 +1,172 @@
import { useState } from "react";
import { intersection } from "lodash";
import { Controller, useForm } from "react-hook-form";
import { EUserPermissions, EUserPermissionsLevel, EXPORTERS_LIST } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { useProject, useUser, useUserPermissions } from "@/hooks/store";
import { ProjectExportService } from "@/services/project/project-export.service";
type Props = {
workspaceSlug: string;
provider: string | null;
mutateServices: () => void;
};
type FormData = {
provider: (typeof EXPORTERS_LIST)[0];
project: string[];
multiple: boolean;
};
const projectExportService = new ProjectExportService();
export const ExportForm = (props: Props) => {
// props
const { workspaceSlug, mutateServices } = props;
// states
const [exportLoading, setExportLoading] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
const { data: user, canPerformAnyCreateAction, projectsWithCreatePermissions } = useUser();
const { workspaceProjectIds, getProjectById } = useProject();
const { t } = useTranslation();
// form
const { handleSubmit, control } = useForm<FormData>({
defaultValues: {
provider: EXPORTERS_LIST[0],
project: [],
multiple: false,
},
});
// derived values
const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0;
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const wsProjectIdsWithCreatePermisisons = projectsWithCreatePermissions
? intersection(workspaceProjectIds, Object.keys(projectsWithCreatePermissions))
: [];
const options = wsProjectIdsWithCreatePermisisons?.map((projectId) => {
const projectDetails = getProjectById(projectId);
return {
value: projectDetails?.id,
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
content: (
<div className="flex items-center gap-2">
<span className="text-[0.65rem] text-custom-text-200 flex-shrink-0">{projectDetails?.identifier}</span>
<span className="truncate">{projectDetails?.name}</span>
</div>
),
};
});
// handlers
const ExportCSVToMail = async (formData: FormData) => {
console.log(formData);
setExportLoading(true);
if (workspaceSlug && user) {
const payload = {
provider: formData.provider.provider,
project: formData.project,
multiple: formData.project.length > 1,
};
await projectExportService
.csvExport(workspaceSlug as string, payload)
.then(() => {
mutateServices();
setExportLoading(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_settings.settings.exports.modal.toasts.success.title"),
message: t("workspace_settings.settings.exports.modal.toasts.success.message", {
entity:
formData.provider.provider === "csv"
? "CSV"
: formData.provider.provider === "xlsx"
? "Excel"
: formData.provider.provider === "json"
? "JSON"
: "",
}),
});
})
.catch(() => {
setExportLoading(false);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("workspace_settings.settings.exports.modal.toasts.error.message"),
});
});
}
};
return (
<form onSubmit={handleSubmit(ExportCSVToMail)} className="flex flex-col gap-4 mt-4">
<div className="flex gap-4">
{/* Project Selector */}
<div className="w-1/2">
<div className="text-sm font-medium text-custom-text-200 mb-2">
{t("workspace_settings.settings.exports.exporting_projects")}
</div>
<Controller
control={control}
name="project"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
input
label={
value && value.length > 0
? value
.map((projectId) => {
const projectDetails = getProjectById(projectId);
return projectDetails?.identifier;
})
.join(", ")
: "All projects"
}
optionsClassName="max-w-48 sm:max-w-[532px]"
placement="bottom-end"
multiple
/>
)}
/>
</div>
{/* Format Selector */}
<div className="w-1/2">
<div className="text-sm font-medium text-custom-text-200 mb-2">
{t("workspace_settings.settings.exports.format")}
</div>
<Controller
control={control}
name="provider"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={t(value.i18n_title)}
optionsClassName="max-w-48 sm:max-w-[532px]"
placement="bottom-end"
buttonClassName="py-2 text-sm"
>
{EXPORTERS_LIST.map((service) => (
<CustomSelect.Option key={service.provider} className="flex items-center gap-2" value={service}>
<span className="truncate">{t(service.i18n_title)}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between ">
<Button variant="primary" type="submit" loading={exportLoading}>
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
</Button>
</div>
</form>
);
};
+16 -199
View File
@@ -1,221 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import useSWR, { mutate } from "swr";
// icons
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
// plane imports
import { EXPORTERS_LIST, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// components
import { DetailedEmptyState } from "@/components/empty-state";
import { Exporter, SingleExport } from "@/components/exporter";
import { ImportExportSettingsLoader } from "@/components/ui";
// constants
import { mutate } from "swr";
import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
// hooks
import { useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// services images
import CSVLogo from "@/public/services/csv.svg";
import ExcelLogo from "@/public/services/excel.svg";
import JSONLogo from "@/public/services/json.svg";
// services
import { IntegrationService } from "@/services/integrations";
const integrationService = new IntegrationService();
const getExporterLogo = (provider: string) => {
switch (provider) {
case "csv":
return CSVLogo;
case "excel":
return ExcelLogo;
case "xlsx":
return ExcelLogo;
case "json":
return JSONLogo;
default:
return "";
}
};
import { ExportForm } from "./export-form";
import { PrevExports } from "./prev-exports";
const IntegrationGuide = observer(() => {
// states
const [refreshing, setRefreshing] = useState(false);
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
// plane hooks
const { t } = useTranslation();
// store hooks
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { allowPermissions } = useUserPermissions();
const { workspaceProjectIds } = useProject();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" });
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 handleRefresh = () => {
setRefreshing(true);
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
};
const handleCsvClose = () => {
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
};
const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0;
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
useEffect(() => {
const interval = setInterval(() => {
if (exporterServices?.results?.some((service) => service.status === "processing")) {
handleRefresh();
} else {
clearInterval(interval);
}
}, 3000);
return () => clearInterval(interval);
}, [exporterServices]);
// state
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
return (
<>
<div className="h-full w-full">
<>
<div>
{EXPORTERS_LIST.map((service) => (
<div
key={service.provider}
className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 py-6"
>
<div className="flex w-full items-start justify-between gap-4">
<div className="item-center flex gap-2.5">
<div className="relative h-10 w-10 flex-shrink-0">
<Image
src={getExporterLogo(service?.provider)}
layout="fill"
objectFit="cover"
alt={`${t(service.i18n_title)} Logo`}
/>
</div>
<div>
<h3 className="flex items-center gap-4 text-sm font-medium">{t(service.i18n_title)}</h3>
<p className="text-sm tracking-tight text-custom-text-200">{t(service.i18n_description)}</p>
</div>
</div>
<div className="flex-shrink-0">
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
<span>
<Button
variant="primary"
className="capitalize"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
>
{t(service.type)}
</Button>
</span>
</Link>
</div>
</div>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
<div className="flex items-center gap-2">
<h3 className="flex gap-2 text-xl font-medium">
{t("workspace_settings.settings.exports.previous_exports")}
</h3>
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
onClick={handleRefresh}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
</button>
</div>
<div className="flex items-center gap-2 text-xs">
<button
disabled={!exporterServices?.prev_page_results}
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.prev_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<MoveLeft className="h-4 w-4" />
<div className="pr-1">{t("prev")}</div>
</button>
<button
disabled={!exporterServices?.next_page_results}
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.next_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<div className="pl-1">{t("next")}</div>
<MoveRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-col">
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div>
<div className="divide-y divide-custom-border-200">
{exporterServices?.results.map((service) => (
<SingleExport key={service.id} service={service} refreshing={refreshing} />
))}
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.exports.title")}
description={t("workspace_settings.empty_state.exports.description")}
assetPath={resolvedPath}
/>
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
</>
{provider && (
<Exporter
isOpen
handleClose={() => handleCsvClose()}
data={null}
user={currentUser || null}
<ExportForm
workspaceSlug={workspaceSlug as string}
provider={provider}
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))}
/>
)}
<PrevExports
workspaceSlug={workspaceSlug as string}
cursor={cursor}
per_page={per_page}
setCursor={setCursor}
/>
</>
</div>
</>
);
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR, { mutate } from "swr";
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IExportData } from "@plane/types";
import { Table } from "@plane/ui";
import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IntegrationService } from "@/services/integrations";
import { DetailedEmptyState } from "../empty-state";
import { ImportExportSettingsLoader } from "../ui";
import { useExportColumns } from "./column";
const integrationService = new IntegrationService();
type Props = {
workspaceSlug: string;
cursor: string | undefined;
per_page: number;
setCursor: (cursor: string) => void;
};
type RowData = IExportData;
export const PrevExports = observer((props: Props) => {
// props
const { workspaceSlug, cursor, per_page, setCursor } = props;
// state
const [refreshing, setRefreshing] = useState(false);
// hooks
const { t } = useTranslation();
const columns = useExportColumns();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" });
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 handleRefresh = () => {
setRefreshing(true);
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
};
useEffect(() => {
const interval = setInterval(() => {
if (exporterServices?.results?.some((service) => service.status === "processing")) {
handleRefresh();
} else {
clearInterval(interval);
}
}, 3000);
return () => clearInterval(interval);
}, [exporterServices]);
return (
<div>
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
<div className="flex items-center gap-2">
<h3 className="flex gap-2 text-xl font-medium">
{t("workspace_settings.settings.exports.previous_exports")}
</h3>
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
onClick={handleRefresh}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
</button>
</div>
<div className="flex items-center gap-2 text-xs">
<button
disabled={!exporterServices?.prev_page_results}
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.prev_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<MoveLeft className="h-4 w-4" />
<div className="pr-1">{t("prev")}</div>
</button>
<button
disabled={!exporterServices?.next_page_results}
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.next_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<div className="pl-1">{t("next")}</div>
<MoveRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-col">
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div>
<div className="divide-y divide-custom-border-200">
<Table
columns={columns}
data={exporterServices?.results ?? []}
keyExtractor={(rowData: RowData) => rowData?.id ?? ""}
tHeadClassName="border-b border-custom-border-100"
thClassName="text-left font-medium divide-x-0 text-custom-text-400"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[50px] text-custom-text-200"
tHeadTrClassName="divide-x-0"
/>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.exports.title")}
description={t("workspace_settings.empty_state.exports.description")}
assetPath={resolvedPath}
/>
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
);
});