-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat: workspace management from admin app #6093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| import { useState, useEffect } from "react"; | ||
| import Link from "next/link"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { Controller, useForm } from "react-hook-form"; | ||
| // constants | ||
| import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; | ||
| // types | ||
| import { IWorkspace } from "@plane/types"; | ||
| // components | ||
| import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui"; | ||
| // helpers | ||
| import { WEB_BASE_URL } from "@/helpers/common.helper"; | ||
| // hooks | ||
| import { useWorkspace } from "@/hooks/store"; | ||
| // services | ||
| import { WorkspaceService } from "@/services/workspace.service"; | ||
|
|
||
| const workspaceService = new WorkspaceService(); | ||
|
|
||
| export const WorkspaceCreateForm = () => { | ||
| // router | ||
| const router = useRouter(); | ||
| // states | ||
| const [slugError, setSlugError] = useState(false); | ||
| const [invalidSlug, setInvalidSlug] = useState(false); | ||
| const [defaultValues, setDefaultValues] = useState<Partial<IWorkspace>>({ | ||
| name: "", | ||
| slug: "", | ||
| organization_size: "", | ||
| }); | ||
| // store hooks | ||
| const { createWorkspace } = useWorkspace(); | ||
| // form info | ||
| const { | ||
| handleSubmit, | ||
| control, | ||
| setValue, | ||
| getValues, | ||
| formState: { errors, isSubmitting, isValid }, | ||
| } = useForm<IWorkspace>({ defaultValues, mode: "onChange" }); | ||
|
|
||
| const handleCreateWorkspace = async (formData: IWorkspace) => { | ||
| await workspaceService | ||
| .workspaceSlugCheck(formData.slug) | ||
| .then(async (res) => { | ||
| if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { | ||
| setSlugError(false); | ||
| await createWorkspace(formData) | ||
| .then(async () => { | ||
| setToast({ | ||
| type: TOAST_TYPE.SUCCESS, | ||
| title: "Success!", | ||
| message: "Workspace created successfully.", | ||
| }); | ||
| router.push(`/workspace`); | ||
| }) | ||
| .catch(() => { | ||
| setToast({ | ||
| type: TOAST_TYPE.ERROR, | ||
| title: "Error!", | ||
| message: "Workspace could not be created. Please try again.", | ||
| }); | ||
| }); | ||
| } else setSlugError(true); | ||
| }) | ||
| .catch(() => { | ||
| setToast({ | ||
| type: TOAST_TYPE.ERROR, | ||
| title: "Error!", | ||
| message: "Some error occurred while creating workspace. Please try again.", | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
| useEffect( | ||
| () => () => { | ||
| // when the component unmounts set the default values to whatever user typed in | ||
| setDefaultValues(getValues()); | ||
| }, | ||
| [getValues, setDefaultValues] | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="space-y-8"> | ||
| <div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2"> | ||
| <div className="flex flex-col gap-1"> | ||
| <h4 className="text-sm text-custom-text-300">Name your workspace</h4> | ||
| <div className="flex flex-col gap-1"> | ||
| <Controller | ||
| control={control} | ||
| name="name" | ||
| rules={{ | ||
| required: "This is a required field.", | ||
| validate: (value) => | ||
| /^[\w\s-]*$/.test(value) || | ||
| `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, | ||
| maxLength: { | ||
| value: 80, | ||
| message: "Limit your name to 80 characters.", | ||
| }, | ||
| }} | ||
| render={({ field: { value, ref, onChange } }) => ( | ||
| <Input | ||
| id="workspaceName" | ||
| type="text" | ||
| value={value} | ||
| onChange={(e) => { | ||
| onChange(e.target.value); | ||
| setValue("name", e.target.value); | ||
| setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), { | ||
| shouldValidate: true, | ||
| }); | ||
| }} | ||
| ref={ref} | ||
| hasError={Boolean(errors.name)} | ||
| placeholder="Something familiar and recognizable is always best." | ||
| className="w-full" | ||
| /> | ||
| )} | ||
| /> | ||
| <span className="text-xs text-red-500">{errors?.name?.message}</span> | ||
| </div> | ||
| </div> | ||
| <div className="flex flex-col gap-1"> | ||
| <h4 className="text-sm text-custom-text-300">Set your workspace's URL</h4> | ||
| <div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3"> | ||
| <span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span> | ||
| <Controller | ||
| control={control} | ||
| name="slug" | ||
| rules={{ | ||
| required: "The URL is a required field.", | ||
| maxLength: { | ||
| value: 48, | ||
| message: "Limit your URL to 48 characters.", | ||
| }, | ||
| }} | ||
| render={({ field: { onChange, value, ref } }) => ( | ||
| <Input | ||
| id="workspaceUrl" | ||
| type="text" | ||
| value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} | ||
| onChange={(e) => { | ||
| if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); | ||
| else setInvalidSlug(true); | ||
| onChange(e.target.value.toLowerCase()); | ||
| }} | ||
| ref={ref} | ||
| hasError={Boolean(errors.slug)} | ||
| placeholder="workspace-name" | ||
| className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| {slugError && <p className="text-sm text-red-500">This URL is taken. Try something else.</p>} | ||
| {invalidSlug && ( | ||
| <p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p> | ||
| )} | ||
| {errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>} | ||
| </div> | ||
| <div className="flex flex-col gap-1"> | ||
| <h4 className="text-sm text-custom-text-300">How many people will use this workspace?</h4> | ||
| <div className="w-full"> | ||
| <Controller | ||
| name="organization_size" | ||
| control={control} | ||
| rules={{ required: "This is a required field." }} | ||
| render={({ field: { value, onChange } }) => ( | ||
| <CustomSelect | ||
| value={value} | ||
| onChange={onChange} | ||
| label={ | ||
| ORGANIZATION_SIZE.find((c) => c === value) ?? ( | ||
| <span className="text-custom-text-400">Select a range</span> | ||
| ) | ||
| } | ||
| buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" | ||
| input | ||
| optionsClassName="w-full" | ||
| > | ||
| {ORGANIZATION_SIZE.map((item) => ( | ||
| <CustomSelect.Option key={item} value={item}> | ||
| {item} | ||
| </CustomSelect.Option> | ||
| ))} | ||
| </CustomSelect> | ||
| )} | ||
| /> | ||
| {errors.organization_size && ( | ||
| <span className="text-sm text-red-500">{errors.organization_size.message}</span> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div className="flex max-w-4xl items-center py-1 gap-4"> | ||
| <Button | ||
| variant="primary" | ||
| size="sm" | ||
| onClick={handleSubmit(handleCreateWorkspace)} | ||
| disabled={!isValid} | ||
| loading={isSubmitting} | ||
| > | ||
| {isSubmitting ? "Creating workspace" : "Create workspace"} | ||
| </Button> | ||
| <Link className={getButtonStyling("neutral-primary", "sm")} href="/workspace"> | ||
| Go back | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
Comment on lines
+83
to
+212
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Enhance form validation and type safety. Consider the following improvements:
Consider these enhancements: +const URL_REGEX = /^[a-z0-9][a-z0-9-_]*[a-z0-9]$/;
+const ORGANIZATION_SIZE_OPTIONS = [
+ "1-10",
+ "11-50",
+ "51-200",
+ "201-500",
+ "500+"
+] as const;
+type OrganizationSize = typeof ORGANIZATION_SIZE_OPTIONS[number];
// In the slug Controller
rules={{
required: "The URL is a required field.",
+ pattern: {
+ value: URL_REGEX,
+ message: "URLs must start and end with alphanumeric characters and can contain only (-) and (_)."
+ },
maxLength: {
value: 48,
message: "Limit your URL to 48 characters.",
},
}}
// In the organization size Controller
-name="organization_size"
+name="organization_size" as const
control={control}
rules={{ required: "This is a required field." }}
-render={({ field: { value, onChange } }) => (
+render={({ field: { value, onChange } }: { field: { value: OrganizationSize, onChange: (value: OrganizationSize) => void } }) => (
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| "use client"; | ||
|
|
||
| import { observer } from "mobx-react"; | ||
| // components | ||
| import { WorkspaceCreateForm } from "./form"; | ||
|
|
||
| const WorkspaceCreatePage = observer(() => ( | ||
| <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col"> | ||
| <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0"> | ||
| <div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div> | ||
| <div className="text-sm font-normal text-custom-text-300"> | ||
| You will need to invite users from Workspace Settings after you create this workspace. | ||
| </div> | ||
| </div> | ||
| <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4"> | ||
| <WorkspaceCreateForm /> | ||
| </div> | ||
| </div> | ||
| )); | ||
|
|
||
| export default WorkspaceCreatePage; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { ReactNode } from "react"; | ||
| import { Metadata } from "next"; | ||
| // layouts | ||
| import { AdminLayout } from "@/layouts/admin-layout"; | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "Workspace Management - Plane Web", | ||
| }; | ||
|
|
||
| export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { | ||
| return <AdminLayout>{children}</AdminLayout>; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Enhance error handling and loading state management.
The form submission handler could benefit from:
Consider this enhancement:
const handleCreateWorkspace = async (formData: IWorkspace) => { + try { await workspaceService .workspaceSlugCheck(formData.slug) - .then(async (res) => { - if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { - setSlugError(false); - await createWorkspace(formData) - .then(async () => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Workspace created successfully.", - }); - router.push(`/workspace`); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Workspace could not be created. Please try again.", - }); - }); - } else setSlugError(true); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Some error occurred while creating workspace. Please try again.", - }); - }); + const slugCheckResponse = await workspaceService.workspaceSlugCheck(formData.slug); + + if (!slugCheckResponse.status || RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(true); + return; + } + + setSlugError(false); + await createWorkspace(formData); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Workspace created successfully.", + }); + router.push(`/workspace`); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + } };