Allow project slug edition and refactor frontend components to reduce duplicated code

This commit is contained in:
carlosmonastyrski
2025-03-07 17:49:30 -03:00
parent 0f42fcd688
commit 699e03c1a9
26 changed files with 133 additions and 752 deletions

View File

@@ -459,7 +459,8 @@ export const PROJECTS = {
workspaceId: "The ID of the project to update.", workspaceId: "The ID of the project to update.",
name: "The new name of the project.", name: "The new name of the project.",
projectDescription: "An optional description label for the project.", projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project." autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the server)"
}, },
GET_KEY: { GET_KEY: {
workspaceId: "The ID of the project to get the key from." workspaceId: "The ID of the project to get the key from."

View File

@@ -307,7 +307,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
.max(256, { message: "Description must be 256 or fewer characters" }) .max(256, { message: "Description must be 256 or fewer characters" })
.optional() .optional()
.describe(PROJECTS.UPDATE.projectDescription), .describe(PROJECTS.UPDATE.projectDescription),
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization),
slug: z
.string()
.trim()
.max(64, { message: "Slug must be 64 characters or fewer" })
.optional()
.describe(PROJECTS.UPDATE.slug)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -325,7 +331,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
update: { update: {
name: req.body.name, name: req.body.name,
description: req.body.description, description: req.body.description,
autoCapitalization: req.body.autoCapitalization autoCapitalization: req.body.autoCapitalization,
slug: req.body.slug
}, },
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id, actorId: req.permission.id,

View File

@@ -563,11 +563,24 @@ export const projectServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
if (update.slug) {
const existingProject = await projectDAL.findOne({
slug: update.slug,
orgId: actorOrgId
});
if (existingProject && existingProject.id !== project.id) {
throw new BadRequestError({
message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.`
});
}
}
const updatedProject = await projectDAL.updateById(project.id, { const updatedProject = await projectDAL.updateById(project.id, {
name: update.name, name: update.name,
description: update.description, description: update.description,
autoCapitalization: update.autoCapitalization, autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization enforceCapitalization: update.autoCapitalization,
slug: update.slug
}); });
return updatedProject; return updatedProject;

View File

@@ -82,6 +82,7 @@ export type TUpdateProjectDTO = {
name?: string; name?: string;
description?: string; description?: string;
autoCapitalization?: boolean; autoCapitalization?: boolean;
slug?: string;
}; };
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -9,9 +9,7 @@ import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api"; import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton"; const baseFormSchema = z.object({
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z description: z
.string() .string()
@@ -20,33 +18,59 @@ const formSchema = z.object({
.optional() .optional()
}); });
type FormData = z.infer<typeof formSchema>; const formSchemaWithSlug = baseFormSchema.extend({
slug: z
.string()
.min(1, "Required")
.max(64, "Too long, maximum length is 64 characters")
.regex(/^[a-zA-Z0-9-]+$/, "Only letters, numbers and hyphens are allowed")
});
export const ProjectOverviewChangeSection = () => { type BaseFormData = z.infer<typeof baseFormSchema>;
type FormDataWithSlug = z.infer<typeof formSchemaWithSlug>;
type Props = {
showSlugField?: boolean;
};
export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const [currentSlug, setCurrentSlug] = useState(currentWorkspace?.slug);
const { mutateAsync, isPending } = useUpdateProject(); const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) }); const { handleSubmit, control, reset } = useForm<BaseFormData | FormDataWithSlug>({
resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema)
});
useEffect(() => { useEffect(() => {
if (currentWorkspace) { if (currentWorkspace) {
reset({ reset({
name: currentWorkspace.name, name: currentWorkspace.name,
description: currentWorkspace.description ?? "" description: currentWorkspace.description ?? "",
...(showSlugField && { slug: currentWorkspace.slug })
}); });
setCurrentSlug(currentWorkspace.slug);
} }
}, [currentWorkspace]); }, [currentWorkspace, showSlugField]);
const onFormSubmit = async ({ name, description }: FormData) => { const onFormSubmit = async (data: BaseFormData | FormDataWithSlug) => {
try { try {
if (!currentWorkspace?.id) return; if (!currentWorkspace?.id) return;
await mutateAsync({ await mutateAsync({
projectID: currentWorkspace.id, projectID: currentWorkspace.id,
newProjectName: name, newProjectName: data.name,
newProjectDescription: description newProjectDescription: data.description,
...(showSlugField &&
"slug" in data && {
newSlug: data.slug !== currentWorkspace.slug ? data.slug : undefined
})
}); });
if (showSlugField && "slug" in data) {
setCurrentSlug(data.slug);
}
createNotification({ createNotification({
text: "Successfully updated project overview", text: "Successfully updated project overview",
type: "success" type: "success"
@@ -65,20 +89,34 @@ export const ProjectOverviewChangeSection = () => {
<div className="justify-betweens flex"> <div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2> <h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2"> <div className="space-x-2">
<CopyButton <Button
value={currentWorkspace?.slug || ""} variant="outline_bg"
hoverText="Click to project slug" size="sm"
notificationText="Copied project slug to clipboard" onClick={() => {
navigator.clipboard.writeText(currentSlug || "");
createNotification({
text: "Copied project slug to clipboard",
type: "success"
});
}}
title="Click to copy project slug"
> >
Copy Project Slug Copy Project Slug
</CopyButton> </Button>
<CopyButton <Button
value={currentWorkspace?.id || ""} variant="outline_bg"
hoverText="Click to project ID" size="sm"
notificationText="Copied project ID to clipboard" onClick={() => {
navigator.clipboard.writeText(currentWorkspace?.id || "");
createNotification({
text: "Copied project ID to clipboard",
type: "success"
});
}}
title="Click to copy project ID"
> >
Copy Project ID Copy Project ID
</CopyButton> </Button>
</div> </div>
</div> </div>
<div> <div>
@@ -113,6 +151,38 @@ export const ProjectOverviewChangeSection = () => {
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> </div>
{showSlugField && (
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project slug"
>
<Input
placeholder="Project slug"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="slug"
/>
)}
</ProjectPermissionCan>
</div>
</div>
)}
<div className="flex w-full flex-row items-end gap-4"> <div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<ProjectPermissionCan <ProjectPermissionCan

View File

@@ -251,12 +251,13 @@ export const useUpdateProject = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Workspace, object, UpdateProjectDTO>({ return useMutation<Workspace, object, UpdateProjectDTO>({
mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => { mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
const { data } = await apiRequest.patch<{ workspace: Workspace }>( const { data } = await apiRequest.patch<{ workspace: Workspace }>(
`/api/v1/workspace/${projectID}`, `/api/v1/workspace/${projectID}`,
{ {
name: newProjectName, name: newProjectName,
description: newProjectDescription description: newProjectDescription,
slug: newSlug
} }
); );
return data.workspace; return data.workspace;

View File

@@ -74,6 +74,7 @@ export type UpdateProjectDTO = {
projectID: string; projectID: string;
newProjectName: string; newProjectName: string;
newProjectDescription?: string; newProjectDescription?: string;
newSlug?: string;
}; };
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };

View File

@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>

View File

@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};

View File

@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};

View File

@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>

View File

@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};

View File

@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -1,3 +1,4 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
@@ -7,7 +8,6 @@ import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSect
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { EnvironmentSection } from "../EnvironmentSection"; import { EnvironmentSection } from "../EnvironmentSection";
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection"; import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection"; import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
import { SecretTagsSection } from "../SecretTagsSection"; import { SecretTagsSection } from "../SecretTagsSection";
@@ -17,7 +17,7 @@ export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
{isSecretManager && <EnvironmentSection />} {isSecretManager && <EnvironmentSection />}
{isSecretManager && <SecretTagsSection />} {isSecretManager && <SecretTagsSection />}
{isSecretManager && <AutoCapitalizationSection />} {isSecretManager && <AutoCapitalizationSection />}

View File

@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};

View File

@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};

View File

@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -2,5 +2,4 @@ export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection"; export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection";
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { EnvironmentSection } from "./EnvironmentSection"; export { EnvironmentSection } from "./EnvironmentSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
export { SecretTagsSection } from "./SecretTagsSection"; export { SecretTagsSection } from "./SecretTagsSection";

View File

@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>

View File

@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};

View File

@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};

View File

@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";

View File

@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";