Compare commits

...
Author SHA1 Message Date
NarayanBavisetti e5ebee664b chore: removed binary decoding 2024-11-27 00:41:34 +05:30
Prateek ShouryaandGitHub 05d3e3ae45 feat: workspace management from admin app (#6093)
* feat: workspace management from admin app

* chore: UI and UX copy improvements

* chore: ux copy improvements
2024-11-26 23:57:41 +05:30
Prateek ShouryaandGitHub 9dbb2b26c3 fix: issue activity sort order componenet import (#6098) 2024-11-26 20:49:39 +05:30
Vamsi KrishnaandGitHub fa2e60101f [WEB-2774] Chore: re-ordering functionality for entities in favorites. (#6078)
* fixed re order for favorites

* fixed lint errors

* added reorder

* fixed reorder inside folder

* fixed lint issues

* memoized reorder

* removed unnecessary comments

* seprated duplicate logic to a common file

* removed code comments
2024-11-26 19:15:21 +05:30
Satish GandhamandGitHub 6376a09318 - Change batch size to 50 for inserting issues (#6085)
- Fallback to server when mentions filter is used
- Split load workspace into multiple transactions
2024-11-26 19:12:39 +05:30
Vamsi KrishnaandGitHub 32048be26f [WEB-2432]fix: project not found state and error page alignment (#6095)
* fixed error page alignment and projects empty page

* spelling corrected

* spelling corrected
2024-11-26 19:11:35 +05:30
Vamsi KrishnaandGitHub f09e37fed8 [WEB - 2779] feat: Added sort order for issue activity (#6087)
* added sort order for issue activity

* fixed invalid date generation issue

* fixed lint errors, optimized code
2024-11-26 18:58:01 +05:30
sriram veeraghantaandGitHub 31c761db25 fix: nivo charts update fixes (#6080) 2024-11-26 18:52:42 +05:30
Aaryan KhandelwalandGitHub f7b2cee418 fix: misalignment of swimlanes group header (#6077) 2024-11-26 18:51:46 +05:30
Vamsi KrishnaandGitHub 1d9b02b085 [WEB-2724] fix: custom properties issue while moving to project (#6090)
* fixed custom properties adding issue

* added error handling to function
2024-11-26 18:50:28 +05:30
sriram veeraghanta 84c5e70181 chore: upgrade turbo repo version 2024-11-26 18:14:28 +05:30
sriram veeraghanta 234513278f fix: refactor editor extensions code spliting 2024-11-26 18:08:32 +05:30
NikhilandGitHub 76fe136d85 fix: project join for admin and members (#6097)
* chore: add enum role comparison

* chore: add member also to join a project
2024-11-26 16:58:41 +05:30
sriram veeraghanta c4a5c5973f fix: tracer error handling 2024-11-26 15:30:53 +05:30
sriram veeraghanta 89819a9473 fix: workflow fixes 2024-11-26 15:13:58 +05:30
sriram veeraghanta 182aa58f6c fix: tracer init fixes 2024-11-26 15:11:54 +05:30
Anmol Singh BhatiaandGitHub 7469e67b71 fix: project view application error (#6091) 2024-11-25 20:05:03 +05:30
sriram veeraghanta 1cb16bf176 fix: email error handling on magic auth 2024-11-25 15:02:50 +05:30
Bavisetti NarayanandGitHub ca88675dbf chore: added dates in issue export (#6088)
* chore: added dates in issue export

* chore: added date converter
2024-11-22 19:59:08 +05:30
NikhilandGitHub 86f8743ade chore: remove exists checks (#6086) 2024-11-22 17:00:20 +05:30
NikhilandGitHub 1a6ec7034a chore: management command to add user to a project (#6084) 2024-11-22 16:05:58 +05:30
42d6078f60 [WEB-2776] fix: restrict notifications (#6081)
* chore: restrict notifications

* chore: handled the issue filter duplicates

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-11-22 16:02:11 +05:30
6ef62820fa [WEB-2778] chore: private project join restriction (#6082)
* chore: private project join restriction

* chore: update project not found container layout

* chore: restrict other users to join private project

* chore: add check condition using enum

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-11-22 16:00:19 +05:30
116 changed files with 2302 additions and 1259 deletions
+1 -3
View File
@@ -25,9 +25,6 @@ on:
required: false
default: false
type: boolean
# push:
# branches:
# - master
env:
TARGET_BRANCH: ${{ github.ref_name }}
@@ -354,6 +351,7 @@ jobs:
branch_build_push_live,
branch_build_push_apiserver,
branch_build_push_proxy,
attach_assets_to_build,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
+6 -1
View File
@@ -121,7 +121,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
<div className="relative inline-flex items-center gap-2 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 px-4 py-2 text-xs text-custom-primary-200">
<Lightbulb height="14" width="14" />
<div>If you have a preferred AI models vendor, please get in touch with us.</div>
<div>
If you have a preferred AI models vendor, please get in{" "}
<a className="underline font-medium" href="https://plane.so/contact">
touch with us.
</a>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -195,7 +195,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
+1 -1
View File
@@ -191,7 +191,7 @@ export const InstanceGitlabConfigForm: FC<Props> = (props) => {
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
+1 -1
View File
@@ -192,7 +192,7 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
+7 -5
View File
@@ -60,7 +60,7 @@ const InstanceAuthenticationPage = observer(() => {
<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">Manage authentication modes for your instance</div>
<div className="text-sm font-normal text-custom-text-300">
Configure authentication modes for your team and restrict sign ups to be invite only.
Configure authentication modes for your team and restrict sign-ups to be invite only.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
@@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => {
<ToggleSwitch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
Boolean(parseInt(enableSignUpConfig)) === true
? updateConfig("ENABLE_SIGNUP", "0")
: updateConfig("ENABLE_SIGNUP", "1");
if (Boolean(parseInt(enableSignUpConfig)) === true) {
updateConfig("ENABLE_SIGNUP", "0");
} else {
updateConfig("ENABLE_SIGNUP", "1");
}
}}
size="sm"
disabled={isSubmitting}
@@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
</div>
</div>
</div>
<div className="text-lg font-medium pt-6">Authentication modes</div>
<div className="text-lg font-medium pt-6">Available authentication modes</div>
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
</div>
) : (
+4 -4
View File
@@ -72,7 +72,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
{
key: "EMAIL_FROM",
type: "text",
label: "Sender email address",
label: "Sender's email address",
description:
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
placeholder: "no-reply@projectplane.so",
@@ -174,12 +174,12 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
</div>
</div>
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
<div className="flex w-full max-w-md flex-col gap-y-10 px-1">
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
<div className="mr-8 flex items-center gap-10 pt-4">
<div className="grow">
<div className="text-sm font-medium text-custom-text-100">Authentication (optional)</div>
<div className="text-sm font-medium text-custom-text-100">Authentication</div>
<div className="text-xs font-normal text-custom-text-300">
We recommend setting up a username password for your SMTP server
This is optional, but we recommend setting up a username and a password for your SMTP server.
</div>
</div>
</div>
+4 -3
View File
@@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">
Allow Plane to collect anonymous usage events
Let Plane collect anonymous usage data
</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
We collect usage events without any PII to analyse and improve Plane.{" "}
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
in line with{" "}
<a
href="https://docs.plane.so/self-hosting/telemetry"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Know more.
our Telemetry Policy.
</a>
</div>
</div>
+2 -2
View File
@@ -60,9 +60,9 @@ export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
<div className="text-sm font-medium text-custom-text-100 leading-5">Chat with us</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
+212
View File
@@ -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&apos;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>
);
};
+21
View File
@@ -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;
+12
View File
@@ -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>;
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { TInstanceConfigurationKeys } from "@plane/types";
// ui
import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui";
// components
import { WorkspaceListItem } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
const WorkspaceManagementPage = observer(() => {
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance();
const {
workspaceIds,
loader: workspaceLoader,
paginationInfo,
fetchWorkspaces,
fetchNextWorkspaces,
} = useWorkspace();
// derived values
const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? "";
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
// fetch data
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces());
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving configuration",
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="flex flex-col gap-1">
<div className="text-xl font-medium text-custom-text-100">Workspaces on this instance</div>
<div className="text-sm font-normal text-custom-text-300">
See all workspaces and control who can create them.
</div>
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<div className="space-y-3">
{formattedConfig ? (
<div className={cn("w-full flex items-center gap-14 rounded")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="text-lg font-medium pb-1">Prevent anyone else from creating a workspace.</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
Toggling this on will let only you create workspaces. You will have to invite users to new
workspaces.
</div>
</div>
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
value={Boolean(parseInt(disableWorkspaceCreation))}
onChange={() => {
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
} else {
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
}
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
</div>
) : (
<Loader>
<Loader.Item height="50px" width="100%" />
</Loader>
)}
{workspaceLoader !== "init-loader" ? (
<>
<div className="pt-6 flex items-center justify-between gap-2">
<div className="flex flex-col items-start gap-x-2">
<div className="flex items-center gap-2 text-lg font-medium">
All workspaces on this instance{" "}
<span className="text-custom-text-300"> {workspaceIds.length}</span>
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
<LoaderIcon className="w-4 h-4 animate-spin" />
)}
</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
You can&apos;t yet delete workspaces and you can only go to the workspace if you are an Admin or a
Member.
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "sm")}>
Create workspace
</Link>
</div>
</div>
<div className="flex flex-col gap-4 py-2">
{workspaceIds.map((workspaceId) => (
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
))}
</div>
{hasNextPage && (
<div className="flex justify-center">
<Button
variant="link-primary"
onClick={() => fetchNextWorkspaces()}
disabled={workspaceLoader === "pagination"}
>
Load more
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
</Button>
</div>
)}
</>
) : (
<Loader className="space-y-10 py-8">
<Loader.Item height="24px" width="20%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
</Loader>
)}
</div>
</div>
</div>
);
});
export default WorkspaceManagementPage;
@@ -9,8 +9,8 @@ import { getButtonStyling } from "@plane/ui";
import { cn } from "@/helpers/common.helper";
export const UpgradeButton: React.FC = () => (
<a href="https://plane.so/one" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
Available on One
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
Upgrade
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
</a>
);
@@ -52,13 +52,13 @@ export const HelpSection: FC = observer(() => {
)}
>
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
<a
href={redirectionLink}
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-xs text-custom-primary-200 whitespace-nowrap`}
>
<ExternalLink size={14} />
{!isSidebarCollapsed && "Redirect to plane"}
{!isSidebarCollapsed && "Redirect to Plane"}
</a>
</Tooltip>
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
import { Tooltip } from "@plane/ui";
import { Tooltip, WorkspaceIcon } from "@plane/ui";
// hooks
import { cn } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
@@ -14,31 +14,37 @@ const INSTANCE_ADMIN_LINKS = [
{
Icon: Cog,
name: "General",
description: "Identify your instances and get key details",
description: "Identify your instances and get key details.",
href: `/general/`,
},
{
Icon: WorkspaceIcon,
name: "Workspaces",
description: "Manage all workspaces on this instance.",
href: `/workspace/`,
},
{
Icon: Mail,
name: "Email",
description: "Set up emails to your users",
description: "Configure your SMTP controls.",
href: `/email/`,
},
{
Icon: Lock,
name: "Authentication",
description: "Configure authentication modes",
description: "Configure authentication modes.",
href: `/authentication/`,
},
{
Icon: BrainCog,
name: "Artificial intelligence",
description: "Configure your OpenAI creds",
description: "Configure your OpenAI creds.",
href: `/ai/`,
},
{
Icon: Image,
name: "Images in Plane",
description: "Allow third-party image libraries",
description: "Allow third-party image libraries.",
href: `/image/`,
},
];
+4
View File
@@ -33,6 +33,10 @@ export const InstanceHeader: FC = observer(() => {
return "Github";
case "gitlab":
return "GitLab";
case "workspace":
return "Workspace";
case "create":
return "Create";
default:
return pathName.toUpperCase();
}
+5 -7
View File
@@ -1,13 +1,13 @@
"use client";
import React from "react";
import { resolveGeneralTheme } from "helpers/common.helper";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme as nextUseTheme } from "next-themes";
// ui
import { Button, getButtonStyling } from "@plane/ui";
// helpers
import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper";
// hooks
import { useTheme } from "@/hooks/store";
// icons
@@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => {
// theme
const { resolvedTheme } = nextUseTheme();
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
if (!isNewUserPopup) return <></>;
return (
<div className="absolute bottom-8 right-8 p-6 w-96 border border-custom-border-100 shadow-md rounded-lg bg-custom-background-100">
@@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
<div className="text-base font-semibold">Create workspace</div>
<div className="py-2 text-sm font-medium text-custom-text-300">
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
workspace, you will need to login again.
workspace.
</div>
<div className="flex items-center gap-4 pt-2">
<a href={redirectionLink} className={getButtonStyling("primary", "sm")}>
<Link href="/workspace/create" className={getButtonStyling("primary", "sm")}>
Create workspace
</a>
</Link>
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
Close
</Button>
+1
View File
@@ -0,0 +1 @@
export * from "./list-item";
@@ -0,0 +1,82 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
// helpers
import { Tooltip } from "@plane/ui";
import { WEB_BASE_URL } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
type TWorkspaceListItemProps = {
workspaceId: string;
};
export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => {
// store hooks
const { getWorkspaceById } = useWorkspace();
// derived values
const workspace = getWorkspaceById(workspaceId);
if (!workspace) return null;
return (
<Link
key={workspaceId}
href={encodeURI(WEB_BASE_URL + "/" + workspace.slug)}
target="_blank"
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
>
<div className="flex items-start gap-4">
<span
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-xs uppercase ${
!workspace?.logo_url && "rounded bg-custom-primary-500 text-white"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt="Workspace Logo"
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-wrap w-full items-center gap-2.5">
<h3 className={`text-base font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="The unique URL of your workspace">
<h4 className="text-sm text-custom-text-300">[{workspace.slug}]</h4>
</Tooltip>
</div>
{workspace.owner.email && (
<div className="flex items-center gap-1 text-xs">
<h3 className="text-custom-text-200 font-medium">Owned by:</h3>
<h4 className="text-custom-text-300">{workspace.owner.email}</h4>
</div>
)}
<div className="flex items-center gap-2.5 text-xs">
{workspace.total_projects !== null && (
<span className="flex items-center gap-1">
<h3 className="text-custom-text-200 font-medium">Total projects:</h3>
<h4 className="text-custom-text-300">{workspace.total_projects}</h4>
</span>
)}
{workspace.total_members !== null && (
<>
<span className="flex items-center gap-1">
<h3 className="text-custom-text-200 font-medium">Total members:</h3>
<h4 className="text-custom-text-300">{workspace.total_members}</h4>
</span>
</>
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
</div>
</Link>
);
});
+1
View File
@@ -1,3 +1,4 @@
export * from "./use-theme";
export * from "./use-instance";
export * from "./use-user";
export * from "./use-workspace";
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-provider";
import { IWorkspaceStore } from "@/store/workspace.store";
export const useWorkspace = (): IWorkspaceStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider");
return context.workspace;
};
+52
View File
@@ -0,0 +1,52 @@
// types
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class WorkspaceService extends APIService {
constructor() {
super(API_BASE_URL);
}
/**
* @description Fetches all workspaces
* @returns Promise<TWorkspacePaginationInfo>
*/
async getWorkspaces(nextPageCursor?: string): Promise<TWorkspacePaginationInfo> {
return this.get<TWorkspacePaginationInfo>("/api/instances/workspaces/", {
cursor: nextPageCursor,
})
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Checks if a slug is available
* @param slug - string
* @returns Promise<any>
*/
async workspaceSlugCheck(slug: string): Promise<any> {
return this.get(`/api/instances/workspace-slug-check/?slug=${slug}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Creates a new workspace
* @param data - IWorkspace
* @returns Promise<IWorkspace>
*/
async createWorkspace(data: IWorkspace): Promise<IWorkspace> {
return this.post<IWorkspace, IWorkspace>("/api/instances/workspaces/", data)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+5
View File
@@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react";
import { IInstanceStore, InstanceStore } from "./instance.store";
import { IThemeStore, ThemeStore } from "./theme.store";
import { IUserStore, UserStore } from "./user.store";
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
enableStaticRendering(typeof window === "undefined");
@@ -10,17 +11,20 @@ export abstract class CoreRootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
workspace: IWorkspaceStore;
constructor() {
this.theme = new ThemeStore(this);
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
this.workspace = new WorkspaceStore(this);
}
hydrate(initialData: any) {
this.theme.hydrate(initialData.theme);
this.instance.hydrate(initialData.instance);
this.user.hydrate(initialData.user);
this.workspace.hydrate(initialData.workspace);
}
resetOnSignOut() {
@@ -28,5 +32,6 @@ export abstract class CoreRootStore {
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
this.theme = new ThemeStore(this);
this.workspace = new WorkspaceStore(this);
}
}
+150
View File
@@ -0,0 +1,150 @@
import set from "lodash/set";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// services
import { WorkspaceService } from "@/services/workspace.service";
// root store
import { CoreRootStore } from "@/store/root.store";
export interface IWorkspaceStore {
// observables
loader: TLoader;
workspaces: Record<string, IWorkspace>;
paginationInfo: TPaginationInfo | undefined;
// computed
workspaceIds: string[];
// helper actions
hydrate: (data: any) => void;
getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
// fetch actions
fetchWorkspaces: () => Promise<IWorkspace[]>;
fetchNextWorkspaces: () => Promise<IWorkspace[]>;
// curd actions
createWorkspace: (data: IWorkspace) => Promise<IWorkspace>;
}
export class WorkspaceStore implements IWorkspaceStore {
// observables
loader: TLoader = "init-loader";
workspaces: Record<string, IWorkspace> = {};
paginationInfo: TPaginationInfo | undefined = undefined;
// services
workspaceService;
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
loader: observable,
workspaces: observable,
paginationInfo: observable,
// computed
workspaceIds: computed,
// helper actions
hydrate: action,
getWorkspaceById: action,
// fetch actions
fetchWorkspaces: action,
fetchNextWorkspaces: action,
// curd actions
createWorkspace: action,
});
this.workspaceService = new WorkspaceService();
}
// computed
get workspaceIds() {
return Object.keys(this.workspaces);
}
// helper actions
/**
* @description Hydrates the workspaces
* @param data - any
*/
hydrate = (data: any) => {
if (data) this.workspaces = data;
};
/**
* @description Gets a workspace by id
* @param workspaceId - string
* @returns IWorkspace | undefined
*/
getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId];
// fetch actions
/**
* @description Fetches all workspaces
* @returns Promise<>
*/
fetchWorkspaces = async (): Promise<IWorkspace[]> => {
try {
if (this.workspaceIds.length > 0) {
this.loader = "mutation";
} else {
this.loader = "init-loader";
}
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces();
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
set(this.workspaces, [workspace.id], workspace);
});
set(this, "paginationInfo", paginationInfo);
});
return paginatedWorkspaceData.results;
} catch (error) {
console.error("Error fetching workspaces", error);
throw error;
} finally {
this.loader = "loaded";
}
};
/**
* @description Fetches the next page of workspaces
* @returns Promise<IWorkspace[]>
*/
fetchNextWorkspaces = async (): Promise<IWorkspace[]> => {
if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
try {
this.loader = "pagination";
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor);
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
set(this.workspaces, [workspace.id], workspace);
});
set(this, "paginationInfo", paginationInfo);
});
return paginatedWorkspaceData.results;
} catch (error) {
console.error("Error fetching next workspaces", error);
throw error;
} finally {
this.loader = "loaded";
}
};
// curd actions
/**
* @description Creates a new workspace
* @param data - IWorkspace
* @returns Promise<IWorkspace>
*/
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
try {
this.loader = "mutation";
const workspace = await this.workspaceService.createWorkspace(data);
runInAction(() => {
set(this.workspaces, [workspace.id], workspace);
});
return workspace;
} catch (error) {
console.error("Error creating workspace", error);
throw error;
} finally {
this.loader = "loaded";
}
};
}
+21 -24
View File
@@ -527,29 +527,26 @@ class PagesDescriptionViewSet(BaseViewSet):
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
return Response({"message": "Updated successfully"})
else:
if not request.data.get("description_binary"):
return Response({"error": "No binary data provided"})
# Store the updated binary data
page.description_binary = request.data.get(
"description_binary", page.description_binary
)
page.description_html = request.data.get("description_html", page.description_html)
page.description = request.data.get("description", page.description)
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
@@ -176,6 +176,10 @@ class ProjectViewSet(BaseViewSet):
def retrieve(self, request, slug, pk):
project = (
self.get_queryset()
.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
.annotate(
@@ -136,6 +136,12 @@ class UserProjectInvitationsViewset(BaseViewSet):
member=request.user, workspace__slug=slug, is_active=True
)
if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
return Response(
{"error": "You do not have permission to join the project"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_role = workspace_member.role
workspace = workspace_member.workspace
+17 -1
View File
@@ -1,6 +1,7 @@
# Python imports
import csv
import io
import os
from datetime import date
from dateutil.relativedelta import relativedelta
@@ -38,7 +39,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
@@ -80,6 +81,21 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
]
)
if DISABLE_WORKSPACE_CREATION == "1":
return Response(
{"error": "Workspace creation is not allowed"},
status=status.HTTP_403_FORBIDDEN,
)
serializer = WorkSpaceSerializer(data=request.data)
slug = request.data.get("slug", False)
@@ -44,10 +44,8 @@ class MagicGenerateEndpoint(APIView):
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = request.META.get("HTTP_ORIGIN", "/")
email = request.data.get("email", False)
email = request.data.get("email", "").strip().lower()
try:
# Clean up the email
email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
@@ -39,10 +39,8 @@ class MagicGenerateSpaceEndpoint(APIView):
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = base_host(request=request, is_space=True)
email = request.data.get("email", False)
email = request.data.get("email", "").strip().lower()
try:
# Clean up the email
email = email.strip().lower()
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
+17 -7
View File
@@ -157,12 +157,13 @@ def generate_table_row(issue):
issue["name"],
issue["description_stripped"],
issue["state__name"],
dateTimeConverter(issue["start_date"]),
dateTimeConverter(issue["target_date"]),
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
if issue["created_by__first_name"]
and issue["created_by__last_name"]
else ""
),
(
@@ -191,10 +192,13 @@ def generate_json_row(issue):
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
if issue["created_by__first_name"]
and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -204,11 +208,17 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
+8 -1
View File
@@ -16,6 +16,7 @@ from plane.db.models import (
IssueComment,
IssueActivity,
UserNotificationPreference,
ProjectMember
)
# Third Party imports
@@ -94,6 +95,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
).exists()
and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
).exists() and ProjectMember.objects.filter(
project_id=project_id, member_id=mention_id, is_active=True
).exists()
):
project = Project.objects.get(pk=project_id)
@@ -243,6 +246,10 @@ def notifications(
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
new_mentions = list(ProjectMember.objects.filter(
project_id=project_id, member_id__in=new_mentions, is_active=True
).values_list("member_id", flat=True))
new_mentions = [str(member_id) for member_id in new_mentions]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
)
@@ -286,7 +293,7 @@ def notifications(
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
.exclude(
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
)
@@ -0,0 +1,112 @@
# Django imports
from typing import Any
from django.core.management import BaseCommand, CommandError
# Module imports
from plane.db.models import (
User,
WorkspaceMember,
ProjectMember,
Project,
IssueUserProperty,
)
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument(
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
)
def handle(self, *args: Any, **options: Any):
try:
if not options["project_id"]:
raise CommandError("Project ID is required")
if not options["user_email"]:
raise CommandError("User Email is required")
project_id = options["project_id"]
user_email = options["user_email"]
role = options.get("role", 20)
print(f"Role: {role}")
user = User.objects.filter(email=user_email).first()
if not user:
raise CommandError("User not found")
# Check if the project exists
project = Project.objects.filter(pk=project_id).first()
if not project:
raise CommandError("Project not found")
# Check if the user exists in the workspace
if not WorkspaceMember.objects.filter(
workspace=project.workspace, member=user, is_active=True
).exists():
raise CommandError("User not member in workspace")
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
.order_by("sort_order")
.first()
)
if smallest_sort_order:
sort_order = smallest_sort_order.sort_order - 1000
else:
sort_order = 65535
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
# Update the project member
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
else:
# Create the project member
ProjectMember.objects.create(
project=project,
member=user,
role=role,
sort_order=sort_order,
)
# Issue Property
IssueUserProperty.objects.get_or_create(user=user, project=project)
# Success message
self.stdout.write(
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
)
return
except CommandError as e:
self.stdout.write(self.style.ERROR(e))
return
@@ -2,3 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
@@ -0,0 +1,6 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name",]
@@ -0,0 +1,34 @@
# Third Party Imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Workspace
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
class WorkspaceSerializer(BaseSerializer):
owner = UserLiteSerializer(read_only=True)
logo_url = serializers.CharField(read_only=True)
total_projects = serializers.IntegerField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
return value
class Meta:
model = Workspace
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"owner",
"logo_url",
]
@@ -14,3 +14,5 @@ from .admin import (
)
from .changelog import ChangeLogEndpoint
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
@@ -45,6 +45,7 @@ class InstanceEndpoint(BaseAPIView):
# Get all the configuration
(
ENABLE_SIGNUP,
DISABLE_WORKSPACE_CREATION,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
@@ -65,6 +66,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@@ -125,6 +130,7 @@ class InstanceEndpoint(BaseAPIView):
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
@@ -0,0 +1,115 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F
# Module imports
from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import Workspace, WorkspaceMember, Project
from plane.license.api.serializers import WorkspaceSerializer
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def get(self, request):
slug = request.GET.get("slug", False)
if not slug or slug == "":
return Response(
{"error": "Workspace Slug is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = (
Workspace.objects.filter(slug=slug).exists()
or slug in RESTRICTED_WORKSPACE_SLUGS
)
return Response({"status": not workspace}, status=status.HTTP_200_OK)
class InstanceWorkSpaceEndpoint(BaseAPIView):
model = Workspace
serializer_class = WorkspaceSerializer
permission_classes = [InstanceAdminPermission]
def get(self, request):
project_count = (
Project.objects.filter(workspace_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
).select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count,
total_members=member_count,
)
# Add search functionality
search = request.query_params.get("search", None)
if search:
workspaces = workspaces.filter(name__icontains=search)
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer (data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
if not name or not slug:
return Response(
{"error": "Both name and slug are required"},
status=status.HTTP_400_BAD_REQUEST,
)
if len(name) > 80 or len(slug) > 48:
return Response(
{"error": "The maximum length for name is 80 and for slug is 48"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
_ = WorkspaceMember.objects.create(
workspace_id=serializer.data["id"],
member=request.user,
role=20,
company_role=request.data.get("company_role", ""),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_410_GONE,
)
+77 -64
View File
@@ -16,76 +16,55 @@ from plane.db.models import (
Page,
WorkspaceMember,
)
from plane.utils.telemetry import init_tracer, shutdown_tracer
@shared_task
def instance_traces():
# Get the tracer
tracer = trace.get_tracer(__name__)
try:
init_tracer()
# Check if the instance is registered
instance = Instance.objects.first()
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then return
if instance is None:
return
# If instance is None then return
if instance is None:
return
if instance.is_telemetry_enabled:
# Get the tracer
tracer = trace.get_tracer(__name__)
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
if instance.is_telemetry_enabled:
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute("is_telemetry_enabled", instance.is_telemetry_enabled)
span.set_attribute("is_support_required", instance.is_support_required)
span.set_attribute("is_setup_done", instance.is_setup_done)
span.set_attribute(
"is_signup_screen_visited", instance.is_signup_screen_visited
)
span.set_attribute("is_verified", instance.is_verified)
span.set_attribute("edition", instance.edition)
span.set_attribute("domain", instance.domain)
span.set_attribute("is_test", instance.is_test)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(workspace=workspace).count()
module_issue_count = ModuleIssue.objects.filter(workspace=workspace).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(workspace=workspace).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute(
"is_telemetry_enabled", instance.is_telemetry_enabled
)
span.set_attribute("is_support_required", instance.is_support_required)
span.set_attribute("is_setup_done", instance.is_setup_done)
span.set_attribute(
"is_signup_screen_visited", instance.is_signup_screen_visited
)
span.set_attribute("is_verified", instance.is_verified)
span.set_attribute("edition", instance.edition)
span.set_attribute("domain", instance.domain)
span.set_attribute("is_test", instance.is_test)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
@@ -93,6 +72,40 @@ def instance_traces():
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(
workspace=workspace
).count()
module_issue_count = ModuleIssue.objects.filter(
workspace=workspace
).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(
workspace=workspace
).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
finally:
# Shutdown the tracer
shutdown_tracer()
@@ -29,6 +29,12 @@ class Command(BaseCommand):
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "DISABLE_WORKSPACE_CREATION",
"value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
"category": "WORKSPACE_MANAGEMENT",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
+12
View File
@@ -12,6 +12,8 @@ from plane.license.api.views import (
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
@@ -55,4 +57,14 @@ urlpatterns = [
EmailCredentialCheckEndpoint.as_view(),
name="email-credential-check",
),
path(
"workspace-slug-check/",
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
]
-21
View File
@@ -16,14 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
# OpenTelemetry
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -33,19 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
trace.set_tracer_provider(TracerProvider(resource=resource))
# Configure the OTLP exporter
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
span_processor = BatchSpanProcessor(otlp_exporter)
trace.get_tracer_provider().add_span_processor(span_processor)
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
+1
View File
@@ -185,6 +185,7 @@ def filter_labels(params, issue_filter, method, prefix=""):
and params.get("labels") != "null"
):
issue_filter[f"{prefix}labels__in"] = params.get("labels")
issue_filter[f"{prefix}label_issue__deleted_at__isnull"] = True
return issue_filter
+40
View File
@@ -0,0 +1,40 @@
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
import os
def init_tracer():
"""Initialize OpenTelemetry with proper shutdown handling"""
# Check if already initialized to prevent double initialization
if trace.get_tracer_provider().__class__.__name__ == "TracerProvider":
return
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
trace.set_tracer_provider(tracer_provider)
# Configure the OTLP exporter
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
span_processor = BatchSpanProcessor(otlp_exporter)
tracer_provider.add_span_processor(span_processor)
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
return tracer_provider
def shutdown_tracer():
"""Shutdown OpenTelemetry tracers and processors"""
provider = trace.get_tracer_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.3.0"
"turbo": "^2.3.2"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
+2 -1
View File
@@ -1,2 +1,3 @@
export * from "./auth";
export * from "./issue";
export * from "./issue";
export * from "./workspace";
+23
View File
@@ -0,0 +1,23 @@
export const ORGANIZATION_SIZE = [
"Just myself",
"2-10",
"11-50",
"51-200",
"201-500",
"500+",
];
export const RESTRICTED_URLS = [
"404",
"accounts",
"api",
"create-workspace",
"error",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
];
@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensions = (): Extensions => [];
@@ -0,0 +1,2 @@
export * from "./extensions";
export * from "./read-only-extensions";
@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const CoreReadOnlyEditorAdditionalExtensions = (): Extensions => [];
@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
@@ -1 +1,2 @@
export * from "./core";
export * from "./document-extensions";
+1
View File
@@ -0,0 +1 @@
export type TEditorAdditionalCommands = never;
+1
View File
@@ -1 +1,2 @@
export * from "./editor";
export * from "./issue-embed";
@@ -1,56 +0,0 @@
import React, { useState } from "react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<Props> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
return (
<NodeViewWrapper
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}
handleOpen={(val) => setIsEmojiPickerOpen(val)}
updateAttributes={updateAttributes}
/>
<CalloutBlockColorSelector
disabled={!editor.isEditable}
isOpen={isColorPickerOpen}
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}
/>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};
@@ -1,75 +0,0 @@
import { Ban, ChevronDown } from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common";
type Props = {
disabled: boolean;
isOpen: boolean;
onSelect: (color: string | null) => void;
toggleDropdown: () => void;
};
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
"opacity-100 pointer-events-auto": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
{
"bg-white/10": isOpen,
}
)}
disabled={disabled}
>
<span>Color</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
};
@@ -1,72 +0,0 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
calloutComponent: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig = Node.create({
name: "calloutComponent",
group: "block",
content: "block+",
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
if (logoInUse === "emoji") {
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);
},
},
};
},
parseHTML() {
return [
{
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
},
];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes), 0];
},
});
@@ -1,68 +0,0 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
// utils
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertCallout:
() =>
({ commands }) => {
// get stored logo values and background color from the local storage
const storedLogoValues = getStoredLogo();
const storedBackgroundValue = getStoredBackgroundColor();
return commands.insertContent({
type: this.name,
content: [
{
type: "paragraph",
},
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
},
});
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
try {
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
// Check if selection is empty and at the beginning of the callout
if (empty && parentNodeDetails) {
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
return true;
}
}
} catch (error) {
console.error("Error in performing backspace action on callout", error);
}
return false; // Allow the default behavior if conditions are not met
},
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,3 +0,0 @@
export * from "./block";
export * from "./extension";
export * from "./read-only-extension";
@@ -1,97 +0,0 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/helpers";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
type Props = {
blockAttributes: TCalloutBlockAttributes;
disabled: boolean;
handleOpen: (val: boolean) => void;
isOpen: boolean;
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
name: blockAttributes["data-icon-name"],
},
emoji: {
value: blockAttributes["data-emoji-unicode"]?.toString(),
url: blockAttributes["data-emoji-url"],
},
};
return (
<div contentEditable={false}>
<EmojiIconPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
className="flex-shrink-0 grid place-items-center"
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
"hover:bg-white/10": !disabled,
})}
label={<Logo logo={logoValue} size={18} type="lucide" />}
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
},
};
} else if (val.type === "icon") {
newLogoValue = {
"data-icon-name": val.value.name,
"data-icon-color": val.value.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: val.value.name,
color: val.value.color,
},
};
}
// update node attributes
updateAttributes({
"data-logo-in-use": val.type,
...newLogoValue,
});
// update stored logo in local storage
updateStoredLogo(newLogoValueToStoreInLocalStorage);
handleOpen(false);
}}
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
disabled={disabled}
searchDisabled
/>
</div>
);
};
@@ -1,14 +0,0 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
selectable: false,
draggable: false,
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,26 +0,0 @@
export enum EAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
BLOCK_TYPE = "data-block-type",
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
@@ -1,85 +0,0 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": null,
"data-icon-name": null,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": null,
"data-block-type": "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
"data-logo-in-use": "emoji",
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");
return fallBackValues;
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
"data-logo-in-use": "emoji",
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
"data-logo-in-use": "icon",
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
};
}
}
}
// fallback values
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
}
return null;
};
// function to update the stored background color on local storage
export const updateStoredBackgroundColor = (value: string | null): void => {
if (typeof window === "undefined") return;
if (value === null) {
localStorage.removeItem("editor-calloutComponent-background");
return;
} else {
localStorage.setItem("editor-calloutComponent-background", value);
}
};
@@ -1,3 +1,4 @@
import { Extensions } from "@tiptap/core";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -17,10 +18,10 @@ import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
export const CoreEditorExtensionsWithoutProps = [
export const CoreEditorExtensionsWithoutProps: Extensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -87,8 +88,8 @@ export const CoreEditorExtensionsWithoutProps = [
TableRow,
CustomMentionWithoutProps(),
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
@@ -1,3 +1,4 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
@@ -8,7 +9,6 @@ import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomCalloutExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
@@ -33,6 +33,8 @@ import {
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
type TArguments = {
enableHistory: boolean;
@@ -45,7 +47,7 @@ type TArguments = {
tabIndex?: number;
};
export const CoreEditorExtensions = (args: TArguments) => {
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
return [
@@ -160,7 +162,7 @@ export const CoreEditorExtensions = (args: TArguments) => {
}),
CharacterCount,
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
...CoreEditorAdditionalExtensions(),
];
};
@@ -1,4 +1,3 @@
export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
@@ -1,3 +1,4 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
@@ -22,13 +23,14 @@ import {
HeadingListExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, TFileHandler } from "@/types";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
type Props = {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
@@ -37,7 +39,7 @@ type Props = {
};
};
export const CoreReadOnlyEditorExtensions = (props: Props) => {
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { fileHandler, mentionConfig } = props;
return [
@@ -127,6 +129,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => {
CustomColorExtension,
HeadingListExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
...CoreReadOnlyEditorAdditionalExtensions(),
];
};
@@ -12,7 +12,6 @@ import {
List,
ListOrdered,
ListTodo,
MessageSquareText,
MinusSquare,
Table,
TextQuote,
@@ -35,20 +34,20 @@ import {
toggleTextColor,
toggleBackgroundColor,
insertImage,
insertCallout,
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
import { TSlashCommandAdditionalOption } from "./root";
export type TSlashCommandSection = {
key: string;
key: TSlashCommandSectionKeys;
title?: string;
items: ISlashCommandItem[];
};
export const getSlashCommandFilteredSections =
(additionalOptions?: ISlashCommandItem[]) =>
(additionalOptions?: TSlashCommandAdditionalOption[]) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
@@ -180,15 +179,6 @@ export const getSlashCommandFilteredSections =
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
commandKey: "callout",
key: "callout",
title: "Callout",
icon: <MessageSquareText className="size-3.5" />,
description: "Insert callout",
searchTerms: ["callout", "comment", "message", "info", "alert"],
command: ({ editor, range }: CommandProps) => insertCallout(editor, range),
},
{
commandKey: "divider",
key: "divider",
@@ -201,7 +191,7 @@ export const getSlashCommandFilteredSections =
],
},
{
key: "text-color",
key: "text-colors",
title: "Colors",
items: [
{
@@ -242,7 +232,7 @@ export const getSlashCommandFilteredSections =
],
},
{
key: "background-color",
key: "background-colors",
title: "Background colors",
items: [
{
@@ -279,8 +269,18 @@ export const getSlashCommandFilteredSections =
},
];
additionalOptions?.map((item) => {
SLASH_COMMAND_SECTIONS?.[0]?.items.push(item);
additionalOptions?.forEach((item) => {
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
const itemIndexToPushAfter = sectionToPushTo.items.findIndex((i) => i.commandKey === item.pushAfter);
if (itemIndexToPushAfter !== undefined) {
const resolvedIndex =
itemIndexToPushAfter + 1 < sectionToPushTo.items.length
? itemIndexToPushAfter + 1
: sectionToPushTo.items.length - 1;
sectionToPushTo.items.splice(resolvedIndex, 0, item);
} else {
sectionToPushTo.items.push(item);
}
});
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
@@ -3,7 +3,7 @@ import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import tippy from "tippy.js";
// types
import { ISlashCommandItem } from "@/types";
import { ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -12,6 +12,11 @@ export type SlashCommandOptions = {
suggestion: Omit<SuggestionOptions, "editor">;
};
export type TSlashCommandAdditionalOption = ISlashCommandItem & {
section: TSlashCommandSectionKeys;
pushAfter: TEditorCommands;
};
const Command = Extension.create<SlashCommandOptions>({
name: "slash-command",
addOptions() {
@@ -102,7 +107,7 @@ const renderItems = () => {
};
};
export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
export const SlashCommands = (additionalOptions?: TSlashCommandAdditionalOption[]) =>
Command.configure({
suggestion: {
items: getSlashCommandFilteredSections(additionalOptions),
@@ -189,7 +189,3 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
else editor.chain().focus().setHorizontalRule().run();
};
export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();
};
+4 -2
View File
@@ -14,6 +14,8 @@ import {
TServerHandler,
} from "@/types";
import { TTextAlign } from "@/extensions";
// plane editor types
import { TEditorAdditionalCommands } from "@/plane-editor/types";
export type TEditorCommands =
| "text"
@@ -39,7 +41,7 @@ export type TEditorCommands =
| "text-color"
| "background-color"
| "text-align"
| "callout";
| TEditorAdditionalCommands;
export type TCommandExtraProps = {
image: {
@@ -121,7 +123,7 @@ export interface IEditorProps {
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];
@@ -8,6 +8,8 @@ export type CommandProps = {
range: Range;
};
export type TSlashCommandSectionKeys = "general" | "text-colors" | "background-colors";
export type ISlashCommandItem = {
commandKey: TEditorCommands;
key: string;
+4 -1
View File
@@ -4,6 +4,7 @@ import {
TInstanceEmailConfigurationKeys,
TInstanceImageConfigurationKeys,
TInstanceAuthenticationKeys,
TInstanceWorkspaceConfigurationKeys,
} from "./";
export interface IInstanceInfo {
@@ -36,6 +37,7 @@ export interface IInstance {
}
export interface IInstanceConfig {
is_workspace_creation_disabled: boolean;
is_google_enabled: boolean;
is_github_enabled: boolean;
is_gitlab_enabled: boolean;
@@ -78,7 +80,8 @@ export type TInstanceConfigurationKeys =
| TInstanceEmailConfigurationKeys
| TInstanceImageConfigurationKeys
| TInstanceAuthenticationKeys
| TInstanceIntercomConfigurationKeys;
| TInstanceIntercomConfigurationKeys
| TInstanceWorkspaceConfigurationKeys;
export interface IInstanceConfiguration {
id: string;
+1
View File
@@ -3,3 +3,4 @@ export * from "./auth";
export * from "./base";
export * from "./email";
export * from "./image";
export * from "./workspace";
+1
View File
@@ -0,0 +1 @@
export type TInstanceWorkspaceConfigurationKeys = "DISABLE_WORKSPACE_CREATION";
+5
View File
@@ -21,6 +21,7 @@ export interface IWorkspace {
readonly updated_by: string;
organization_size: string;
total_issues: number;
total_projects?: number;
}
export interface IWorkspaceLite {
@@ -222,3 +223,7 @@ export interface IWorkspaceProgressResponse {
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
}
export type TWorkspacePaginationInfo = TPaginationInfo & {
results: IWorkspace[];
};
+1
View File
@@ -33,3 +33,4 @@ export * from "./in-progress-icon";
export * from "./done-icon";
export * from "./pending-icon";
export * from "./pi-chat";
export * from "./workspace-icon";
+14
View File
@@ -0,0 +1,14 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const WorkspaceIcon: React.FC<ISvgIcons> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75V6.75H21C21.4142 6.75 21.75 7.08579 21.75 7.5C21.75 7.91421 21.4142 8.25 21 8.25V20.25H21.75C22.1642 20.25 22.5 20.5858 22.5 21C22.5 21.4142 22.1642 21.75 21.75 21.75H2.25C1.83579 21.75 1.5 21.4142 1.5 21C1.5 20.5858 1.83579 20.25 2.25 20.25H3V3.75C2.58579 3.75 2.25 3.41421 2.25 3ZM4.5 3.75V20.25H6V17.625C6 16.5898 6.83979 15.75 7.875 15.75H10.125C11.1602 15.75 12 16.5898 12 17.625V20.25H13.5V3.75H4.5ZM15 8.25V20.25H19.5V8.25H15ZM10.5 20.25V17.625C10.5 17.4182 10.3318 17.25 10.125 17.25H7.875C7.66821 17.25 7.5 17.4182 7.5 17.625V20.25H10.5ZM6 6.75C6 6.33579 6.33579 6 6.75 6H7.5C7.91421 6 8.25 6.33579 8.25 6.75C8.25 7.16421 7.91421 7.5 7.5 7.5H6.75C6.33579 7.5 6 7.16421 6 6.75ZM9.75 6.75C9.75 6.33579 10.0858 6 10.5 6H11.25C11.6642 6 12 6.33579 12 6.75C12 7.16421 11.6642 7.5 11.25 7.5H10.5C10.0858 7.5 9.75 7.16421 9.75 6.75ZM6 9.75C6 9.33579 6.33579 9 6.75 9H7.5C7.91421 9 8.25 9.33579 8.25 9.75C8.25 10.1642 7.91421 10.5 7.5 10.5H6.75C6.33579 10.5 6 10.1642 6 9.75ZM9.75 9.75C9.75 9.33579 10.0858 9 10.5 9H11.25C11.6642 9 12 9.33579 12 9.75C12 10.1642 11.6642 10.5 11.25 10.5H10.5C10.0858 10.5 9.75 10.1642 9.75 9.75ZM16.5 11.25C16.5 10.8358 16.8358 10.5 17.25 10.5H17.258C17.6722 10.5 18.008 10.8358 18.008 11.25V11.258C18.008 11.6722 17.6722 12.008 17.258 12.008H17.25C16.8358 12.008 16.5 11.6722 16.5 11.258V11.25ZM6 12.75C6 12.3358 6.33579 12 6.75 12H7.5C7.91421 12 8.25 12.3358 8.25 12.75C8.25 13.1642 7.91421 13.5 7.5 13.5H6.75C6.33579 13.5 6 13.1642 6 12.75ZM9.75 12.75C9.75 12.3358 10.0858 12 10.5 12H11.25C11.6642 12 12 12.3358 12 12.75C12 13.1642 11.6642 13.5 11.25 13.5H10.5C10.0858 13.5 9.75 13.1642 9.75 12.75ZM16.5 14.25C16.5 13.8358 16.8358 13.5 17.25 13.5H17.258C17.6722 13.5 18.008 13.8358 18.008 14.25V14.258C18.008 14.6722 17.6722 15.008 17.258 15.008H17.25C16.8358 15.008 16.5 14.6722 16.5 14.258V14.25ZM16.5 17.25C16.5 16.8358 16.8358 16.5 17.25 16.5H17.258C17.6722 16.5 18.008 16.8358 18.008 17.25V17.258C18.008 17.6722 17.6722 18.008 17.258 18.008H17.25C16.8358 18.008 16.5 17.6722 16.5 17.258V17.25Z"
fill="currentColor"
/>
</svg>
);
@@ -28,18 +28,6 @@ const ProjectViewsPage = observer(() => {
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
if (!workspaceSlug || !projectId) return <></>;
// No access to
if (currentProjectDetails?.issue_views_view === false)
return (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={EmptyStateType.DISABLED_PROJECT_VIEW}
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
/>
</div>
);
const handleRemoveFilter = useCallback(
(key: keyof TViewFilterProps, value: string | EViewAccess | null) => {
let newValues = filters.filters?.[key];
@@ -59,6 +47,19 @@ const ProjectViewsPage = observer(() => {
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
if (!workspaceSlug || !projectId) return <></>;
// No access to
if (currentProjectDetails?.issue_views_view === false)
return (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={EmptyStateType.DISABLED_PROJECT_VIEW}
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
/>
</div>
);
return (
<>
<PageHead title={pageTitle} />
+37 -9
View File
@@ -7,15 +7,19 @@ import Link from "next/link";
import { useTheme } from "next-themes";
import { IWorkspace } from "@plane/types";
// components
import { Button, getButtonStyling } from "@plane/ui";
import { CreateWorkspaceForm } from "@/components/workspace";
// hooks
import { useUser, useUserProfile } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// wrappers
import { AuthenticationWrapper } from "@/lib/wrappers";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// images
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png";
const CreateWorkspacePage = observer(() => {
// router
@@ -31,6 +35,8 @@ const CreateWorkspacePage = observer(() => {
});
// hooks
const { resolvedTheme } = useTheme();
// derived values
const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled();
const onSubmit = async (workspace: IWorkspace) => {
await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`));
@@ -56,16 +62,38 @@ const CreateWorkspacePage = observer(() => {
</div>
</div>
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
<div className="w-full space-y-7 sm:space-y-10">
<h4 className="text-2xl font-semibold">Create your workspace</h4>
<div className="sm:w-3/4 md:w-2/5">
<CreateWorkspaceForm
onSubmit={onSubmit}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues as any}
/>
{isWorkspaceCreationDisabled ? (
<div className="w-4/5 h-full flex flex-col items-center justify-center text-lg font-medium gap-1">
<Image src={WorkspaceCreationDisabled} width={200} alt="Workspace creation disabled" className="mb-4" />
<div className="text-lg font-medium text-center">Only your instance admin can create workspaces</div>
<p className="text-sm text-custom-text-300 text-center">
If you know your instance admin&apos;s email address, <br /> click the button below to get in touch with
them.
</p>
<div className="flex gap-4 mt-6">
<Button variant="primary" onClick={() => router.back()}>
Go back
</Button>
<a
href={`mailto:?subject=${encodeURIComponent("Requesting a new workspace")}&body=${encodeURIComponent(`Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n${currentUser?.first_name} ${currentUser?.last_name}\n${currentUser?.email}`)}`}
className={getButtonStyling("outline-primary", "md")}
>
Request instance admin
</a>
</div>
</div>
</div>
) : (
<div className="w-full space-y-7 sm:space-y-10">
<h4 className="text-2xl font-semibold">Create your workspace</h4>
<div className="sm:w-3/4 md:w-2/5">
<CreateWorkspaceForm
onSubmit={onSubmit}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues as any}
/>
</div>
</div>
)}
</div>
</div>
</AuthenticationWrapper>
+1 -1
View File
@@ -39,7 +39,7 @@ export default function CustomErrorComponent() {
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<div className="space-y-2 relative flex flex-col justify-center items-center">
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
+7
View File
@@ -0,0 +1,7 @@
import { store } from "@/lib/store-context";
export const getIsWorkspaceCreationDisabled = () => {
const instanceConfig = store.instance.config;
return instanceConfig?.is_workspace_creation_disabled;
};
@@ -1,8 +1,8 @@
/* eslint-disable no-useless-catch */
import concat from "lodash/concat";
import orderBy from "lodash/orderBy";
import set from "lodash/set";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { action, makeObservable, observable, runInAction } from "mobx";
@@ -29,6 +29,7 @@ export interface IIssueActivityStoreActions {
export interface IIssueActivityStore extends IIssueActivityStoreActions {
// observables
sortOrder: 'asc' | 'desc'
loader: TActivityLoader;
activities: TIssueActivityIdMap;
activityMap: TIssueActivityMap;
@@ -36,10 +37,12 @@ export interface IIssueActivityStore extends IIssueActivityStoreActions {
getActivitiesByIssueId: (issueId: string) => string[] | undefined;
getActivityById: (activityId: string) => TIssueActivity | undefined;
getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined;
toggleSortOrder: ()=>void;
}
export class IssueActivityStore implements IIssueActivityStore {
// observables
sortOrder: "asc" | "desc" = 'asc';
loader: TActivityLoader = "fetch";
activities: TIssueActivityIdMap = {};
activityMap: TIssueActivityMap = {};
@@ -50,11 +53,13 @@ export class IssueActivityStore implements IIssueActivityStore {
constructor(protected store: CoreRootStore) {
makeObservable(this, {
// observables
sortOrder: observable.ref,
loader: observable.ref,
activities: observable,
activityMap: observable,
// actions
fetchActivities: action,
toggleSortOrder: action
});
// services
this.issueActivityService = new IssueActivityService();
@@ -99,11 +104,15 @@ export class IssueActivityStore implements IIssueActivityStore {
});
});
activityComments = sortBy(activityComments, "created_at");
activityComments = orderBy(activityComments, (e)=>new Date(e.created_at || 0), this.sortOrder);
return activityComments;
});
toggleSortOrder = ()=>{
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
}
// actions
public async fetchActivities(
workspaceSlug: string,
@@ -21,30 +21,30 @@ type TAuthHeader = {
const Titles = {
[EAuthModes.SIGN_IN]: {
[EAuthSteps.EMAIL]: {
header: "Log in or Sign up",
header: "Log in or sign up",
subHeader: "",
},
[EAuthSteps.PASSWORD]: {
header: "Log in or Sign up",
subHeader: "Log in using your password.",
header: "Log in or sign up",
subHeader: "Use your email-password combination to log in.",
},
[EAuthSteps.UNIQUE_CODE]: {
header: "Log in or Sign up",
subHeader: "Log in using your unique code.",
subHeader: "Log in using a unique code sent to the email address above.",
},
},
[EAuthModes.SIGN_UP]: {
[EAuthSteps.EMAIL]: {
header: "Sign up or Log in",
header: "Sign up",
subHeader: "",
},
[EAuthSteps.PASSWORD]: {
header: "Sign up or Log in",
subHeader: "Sign up using your password",
header: "Sign up",
subHeader: "Sign up using an email-password combination.",
},
[EAuthSteps.UNIQUE_CODE]: {
header: "Sign up or Log in",
subHeader: "Sign up using your unique code",
header: "Sign up",
subHeader: "Sign up using a unique code sent to the email address above.",
},
},
};
@@ -64,7 +64,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
placeholder="name@company.com"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
autoComplete="on"
autoFocus
@@ -8,3 +8,6 @@ export * from "./activity-filter";
// issue comment
export * from "./comments";
// sort
export * from "./sort-root";
@@ -9,7 +9,7 @@ import { EFileAssetType } from "@plane/types/src/enums";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { IssueCommentCreate } from "@/components/issues";
import { IssueActivityCommentRoot } from "@/components/issues/issue-detail";
import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// hooks
import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web components
@@ -43,6 +43,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
// hooks
const {
issue: { getIssueById },
activity: { sortOrder, toggleSortOrder },
createComment,
updateComment,
removeComment,
@@ -162,6 +163,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
disabled={disabled}
/>
)}
<ActivitySortRoot sortOrder={sortOrder} toggleSort={toggleSortOrder} />
<ActivityFilterRoot
selectedFilters={selectedFilters}
toggleFilter={toggleFilter}
@@ -0,0 +1,28 @@
"use client";
import { FC, memo } from "react";
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
import { getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
export type TActivitySortRoot = {
sortOrder: "asc" | "desc";
toggleSort: () => void;
};
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
<div
className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300 cursor-pointer")}
onClick={() => {
props.toggleSort();
}}
>
{props.sortOrder === "asc" ? (
<ArrowUpWideNarrow className="size-4 " />
) : (
<ArrowDownWideNarrow className="size-4 " />
)}
</div>
));
ActivitySortRoot.displayName = "ActivitySortRoot";
@@ -54,7 +54,7 @@ const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGro
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
({ getGroupIssueCount, sub_group_by, group_by, list, collapsedGroups, handleCollapsedGroups, showEmptyGroup }) => (
<div className="relative flex h-max min-h-full w-full items-center gap-2">
<div className="relative flex h-max min-h-full w-full items-center gap-4">
{list &&
list.length > 0 &&
list.map((_list: IGroupByColumn) => {
+37 -11
View File
@@ -101,6 +101,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const [labelModal, setLabelModal] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [isMoving, setIsMoving] = useState<boolean>(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
@@ -113,8 +114,12 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
// store hooks
const { getProjectById } = useProject();
const { getIssueTypeIdOnProjectChange, getActiveAdditionalPropertiesLength, handlePropertyValuesValidation } =
useIssueModal();
const {
getIssueTypeIdOnProjectChange,
getActiveAdditionalPropertiesLength,
handlePropertyValuesValidation,
handleCreateUpdatePropertyValues,
} = useIssueModal();
const { isMobile } = usePlatformOS();
const { moveIssue } = useWorkspaceDraftIssues();
@@ -236,6 +241,33 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
});
};
const handleMoveToProjects = async () => {
if (!data?.id || !data?.project_id || !data) return;
setIsMoving(true);
try {
await handleCreateUpdatePropertyValues({
issueId: data.id,
issueTypeId: data.type_id,
projectId: data.project_id,
workspaceSlug: workspaceSlug.toString(),
isDraft: true,
});
await moveIssue(workspaceSlug.toString(), data.id, {
...data,
...getValues(),
} as TWorkspaceDraftIssue);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move issue to project. Please try again.",
});
} finally {
setIsMoving(false);
}
};
const condition =
(watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "<p></p>");
@@ -492,15 +524,9 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
variant="primary"
type="button"
size="sm"
loading={isSubmitting}
onClick={() => {
if (data?.id && data) {
moveIssue(workspaceSlug.toString(), data?.id, {
...data,
...getValues(),
} as TWorkspaceDraftIssue);
}
}}
loading={isMoving}
onClick={handleMoveToProjects}
disabled={isMoving}
>
Add to project
</Button>
@@ -4,11 +4,14 @@ import Image from "next/image";
// icons
import { useTheme } from "next-themes";
// types
import { OctagonAlert } from "lucide-react";
import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types";
// components
import { Invitations, OnboardingHeader, SwitchAccountDropdown, CreateWorkspace } from "@/components/onboarding";
// hooks
import { useUser } from "@/hooks/store";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// assets
import CreateJoinWorkspaceDark from "@/public/onboarding/create-join-workspace-dark.webp";
import CreateJoinWorkspace from "@/public/onboarding/create-join-workspace-light.webp";
@@ -34,6 +37,8 @@ export const CreateOrJoinWorkspaces: React.FC<Props> = observer((props) => {
const { data: user } = useUser();
// hooks
const { resolvedTheme } = useTheme();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
useEffect(() => {
if (invitations.length > 0) {
@@ -66,12 +71,25 @@ export const CreateOrJoinWorkspaces: React.FC<Props> = observer((props) => {
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE)}
/>
) : currentView === ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE ? (
<CreateWorkspace
stepChange={stepChange}
user={user ?? undefined}
invitedWorkspaces={invitations.length}
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)}
/>
isWorkspaceCreationEnabled ? (
<CreateWorkspace
stepChange={stepChange}
user={user ?? undefined}
invitedWorkspaces={invitations.length}
handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)}
/>
) : (
<div className="flex h-96 w-full items-center justify-center">
<div className="flex gap-2.5 w-full items-start justify-center text-sm leading-5 mt-4 px-6 py-4 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-custom-primary-200">
<OctagonAlert className="flex-shrink-0 size-5 mt-1" />
<span>
You don&apos;t seem to have any invites to a workspace and your instance admin has restricted
creation of new workspaces. Please ask a workspace owner or admin to invite you to a workspace first
and come back to this screen to join.
</span>
</div>
</div>
)
) : (
<div className="flex h-96 w-full items-center justify-center">
<LogoSpinner />
@@ -3,13 +3,14 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// constants
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
// types
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
// ui
import { Button, CustomSelect, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { E_ONBOARDING, WORKSPACE_CREATED } from "@/constants/event-tracker";
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
// hooks
import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
// services
@@ -154,18 +155,19 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="name"
>
Workspace name
Name your workspace
</label>
<Controller
control={control}
name="name"
rules={{
required: "Workspace name is required",
required: "This is a required field.",
validate: (value) =>
/^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`,
/^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
message: "Limit your name to 80 characters.",
},
}}
render={({ field: { value, ref, onChange } }) => (
@@ -182,7 +184,7 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
shouldValidate: true,
});
}}
placeholder="Enter workspace name..."
placeholder="Something familiar and recognizable is always best."
ref={ref}
hasError={Boolean(errors.name)}
className="w-full border-onboarding-border-100 placeholder:text-custom-text-400"
@@ -198,16 +200,16 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="slug"
>
Workspace URL
Set your workspace&apos;s URL
</label>
<Controller
control={control}
name="slug"
rules={{
required: "Workspace slug is required",
required: "This is a required field.",
maxLength: {
value: 48,
message: "Workspace slug should not exceed 48 characters",
message: "Limit your URL to 48 characters.",
},
}}
render={({ field: { value, ref, onChange } }) => (
@@ -223,20 +225,22 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
/^[a-zA-Z0-9_-]+$/.test(e.target.value) ? setInvalidSlug(false) : setInvalidSlug(true);
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="w-full border-none !px-0"
/>
</div>
)}
/>
<p className="text-sm text-onboarding-text-300">You can only edit the slug of the URL</p>
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
{slugError && <p className="-mt-3 text-sm text-red-500">This URL is taken. Try something else.</p>}
{invalidSlug && (
<p className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}</p>
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
)}
{errors.slug && <span className="text-sm text-red-500">{errors.slug.message}</span>}
</div>
@@ -246,20 +250,20 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="organization_size"
>
Company size
How many people will use this workspace?
</label>
<div className="w-full">
<Controller
name="organization_size"
control={control}
rules={{ required: "This field is required" }}
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 organization size</span>
<span className="text-custom-text-400">Select a range</span>
)
}
buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md"
@@ -3,13 +3,14 @@
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// constants
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
// types
import { IWorkspace } from "@plane/types";
// ui
import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { WORKSPACE_CREATED } from "@/constants/event-tracker";
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
// hooks
import { useEventTracker, useWorkspace } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -40,8 +41,8 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
setDefaultValues,
secondaryButton,
primaryButtonText = {
loading: "Creating...",
default: "Create Workspace",
loading: "Creating workspace",
default: "Create workspace",
},
} = props;
// states
@@ -124,7 +125,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
<div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm">
<label htmlFor="workspaceName">
Workspace Name
Name your workspace
<span className="ml-0.5 text-red-500">*</span>
</label>
<div className="flex flex-col gap-1">
@@ -132,12 +133,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control}
name="name"
rules={{
required: "Workspace name is required",
required: "This is a required field.",
validate: (value) =>
/^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`,
/^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
message: "Limit your name to 80 characters.",
},
}}
render={({ field: { value, ref, onChange } }) => (
@@ -154,7 +156,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Enter workspace name..."
placeholder="Something familiar and recognizable is always best."
className="w-full"
/>
)}
@@ -164,7 +166,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</div>
<div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl">
Workspace URL
Set your workspace&apos;s URL
<span className="ml-0.5 text-red-500">*</span>
</label>
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
@@ -173,10 +175,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control}
name="slug"
rules={{
required: "Workspace slug is required",
required: "This is a required field.",
maxLength: {
value: 48,
message: "Workspace slug should not exceed 48 characters",
message: "Limit your URL to 48 characters.",
},
}}
render={({ field: { onChange, value, ref } }) => (
@@ -185,12 +187,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
/^[a-zA-Z0-9_-]+$/.test(e.target.value) ? setInvalidSlug(false) : setInvalidSlug(true);
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="Enter workspace url..."
placeholder="workspace-name"
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
/>
)}
@@ -198,26 +201,26 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</div>
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
{invalidSlug && (
<p className="text-sm text-red-500">{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}</p>
<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="space-y-1 text-sm">
<span>
What size is your organization?<span className="ml-0.5 text-red-500">*</span>
How many people will use this workspace?<span className="ml-0.5 text-red-500">*</span>
</span>
<div className="w-full">
<Controller
name="organization_size"
control={control}
rules={{ required: "This field is required" }}
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 organization size</span>
<span className="text-custom-text-400">Select a range</span>
)
}
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
@@ -4,6 +4,9 @@ import { useEffect, useState, FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Pencil } from "lucide-react";
// constants
import { ORGANIZATION_SIZE } from "@plane/constants";
// types
import { IWorkspace } from "@plane/types";
// ui
import { Button, CustomSelect, Input, TOAST_TYPE, setToast } from "@plane/ui";
@@ -12,7 +15,6 @@ import { LogoSpinner } from "@/components/common";
import { WorkspaceImageUploadModal } from "@/components/core";
// constants
import { WORKSPACE_UPDATED } from "@/constants/event-tracker";
import { ORGANIZATION_SIZE } from "@/constants/workspace";
// helpers
import { getFileURL } from "@/helpers/file.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
@@ -20,6 +20,9 @@ import { getFileURL } from "@/helpers/file.helper";
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// components
import { WorkspaceLogo } from "../logo";
// Static Data
@@ -53,6 +56,8 @@ export const SidebarDropdown = observer(() => {
} = useUser();
const { updateUserProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false;
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
@@ -205,15 +210,17 @@ export const SidebarDropdown = observer(() => {
)}
</div>
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
Create workspace
</Menu.Item>
</Link>
{isWorkspaceCreationEnabled && (
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
Create workspace
</Menu.Item>
</Link>
)}
{userLinks(workspaceSlug?.toString() ?? "").map(
(link, index) =>
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && (
@@ -2,27 +2,31 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { DragLocationHistory, ElementDragPayload, DropTargetRecord } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import uniqBy from "lodash/uniqBy";
import orderBy from "lodash/orderBy";
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// ui
import { IFavorite } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, setToast, TOAST_TYPE, FavoriteFolderIcon, DragHandle } from "@plane/ui";
import { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence } from "./favorites.helpers";
import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
type Props = {
@@ -30,132 +34,95 @@ type Props = {
favorite: IFavorite;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
handleReorder: (favoriteId: string, sequence: number) => void;
handleDrop: (self: DropTargetRecord,source: ElementDragPayload, location: DragLocationHistory) => void;
};
export const FavoriteFolder: React.FC<Props> = (props) => {
const { favorite, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props;
const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites, groupedFavorites, moveFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
!favorite.children && getGroupedFavorites(workspaceSlug.toString(), favorite.id);
const handleOnDrop = (source: string, destination: string) => {
moveFavorite(workspaceSlug.toString(), source, {
parent: destination,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
});
});
};
const handleOnDropFolder = (payload: Partial<IFavorite>) => {
moveFavoriteFolder(workspaceSlug.toString(), favorite.id, payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Folder moved successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move folder.",
});
});
};
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { type: "PARENT", id: favorite.id, is_folder: favorite.is_folder };
const initialData = { id: favorite.id, isGroup: true, isChild: false };
return combine(
draggable({
element,
getInitialData: () => initialData,
onDragStart: () => setIsDragging(true),
onDrop: (data) => {
setIsDraggedOver(false);
if (!data.location.current.dropTargets[0]) return;
const destinationData = data.location.current.dropTargets[0].data;
if (favorite.id && destinationData) {
const edge = extractClosestEdge(destinationData) || undefined;
const payload = {
id: favorite.id,
sequence: Math.round(
getDestinationStateSequence(groupedFavorites, destinationData.id as string, edge) || 0
),
};
handleOnDropFolder(payload);
}
onGenerateDragPreview: ({ nativeSetDragImage }) =>{
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="rounded flex gap-1 bg-custom-background-100 text-sm p-1 pr-2">
<div className="size-5 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
onDrop: () => {
setIsDragging(false)
}, // canDrag: () => isDraggable,
}),
dropTargetForElements({
element,
getData: ({ input, element }) =>
attachClosestEdge(initialData, {
canDrop: ({ source }) => getCanDrop(source, favorite, false),
getData: ({ input, element }) =>{
const blockedStates: InstructionType[] = [];
if(!isLastChild){
blockedStates.push('reorder-below');
}
return attachInstruction(initialData,{
input,
element,
allowedEdges: ["top", "bottom"],
}),
onDragEnter: (args) => {
setIsDragging(true);
setIsDraggedOver(true);
args.source.data.is_folder && setClosestEdge(extractClosestEdge(args.self.data));
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? 'last-in-group' : 'standard',
block: blockedStates
})
},
onDrag: ({source, self, location}) => {
const instruction = getInstructionFromPayload(self,source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setIsDragging(false);
setIsDraggedOver(false);
setClosestEdge(null);
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ self, source }) => {
setIsDragging(false);
setIsDraggedOver(false);
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (source.data.is_folder) return;
if (sourceId === destinationId) return;
if (!sourceId || !destinationId) return;
if (groupedFavorites[sourceId].parent === destinationId) return;
handleOnDrop(sourceId, destinationId);
setInstruction(undefined);
},
onDrop: ({ self, source, location})=>{
setInstruction(undefined);
handleDrop(self, source,location);
}
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging, favorite.id, handleOnDrop]);
}, [isDragging, favorite.id ]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
@@ -174,10 +141,11 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
// id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", {
"bg-custom-sidebar-background-80 opacity-60": isDragging,
"border-[2px] border-custom-primary-100" : instruction === 'make-child'
})}
>
{/* draggable drop top indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />
<DropIndicator isVisible={instruction === "reorder-above"}/>
<div
className={cn(
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
@@ -316,21 +284,22 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"px-2": !isSidebarCollapsed,
})}
>
{uniqBy(favorite.children, "id").map((child) => (
{orderBy(favorite.children,'sequence','desc').map((child,index) => (
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
isLastChild={index === favorite.children.length - 1}
parentId={favorite.id}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={groupedFavorites}
handleDrop={handleDrop}
/>
))}
</Disclosure.Panel>
</Transition>
)}
{/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />{" "}
{ isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
)}
</Disclosure>
@@ -2,13 +2,20 @@
import React, { FC, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { draggable, dropTargetForElements, ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
// plane helpers
import { createRoot } from "react-dom/client";
import { useOutsideClickDetector } from "@plane/helpers";
// ui
import { IFavorite } from "@plane/types";
import { IFavorite, InstructionType } from "@plane/types";
// components
import { DropIndicator } from "@plane/ui";
import {
FavoriteItemDragHandle,
FavoriteItemQuickAction,
@@ -18,67 +25,114 @@ import {
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
//helpers
import { getCanDrop, getInstructionFromPayload} from "../favorites.helpers";
type Props = {
isLastChild: boolean;
parentId: string | undefined;
workspaceSlug: string;
favorite: IFavorite;
favoriteMap: Record<string, IFavorite>;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
handleDrop: (self: DropTargetRecord,source: ElementDragPayload, location: DragLocationHistory) => void;
};
export const FavoriteRoot: FC<Props> = observer((props) => {
// props
const { workspaceSlug, favorite, favoriteMap, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props;
const {
isLastChild,
parentId,
workspaceSlug,
favorite,
handleRemoveFromFavorites,
handleDrop,
} = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
//state
const [isDragging, setIsDragging] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
//ref
const elementRef = useRef<HTMLDivElement>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleQuickAction = (value: boolean) => setIsMenuActive(value);
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
// drag and drop
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { id: favorite.id, isGroup: false, isChild: !!parentId, parentId };
return combine(
draggable({
element,
dragHandle: elementRef.current,
canDrag: () => true,
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">
<FavoriteItemTitle
href={itemLink}
icon={itemIcon}
title={itemTitle}
isSidebarCollapsed={!!sidebarCollapsed}
/>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => getCanDrop(source, favorite, !!parentId),
onDragStart: () => {
setIsDragging(true);
},
onDragEnter: () => {
setIsDragging(true);
getData: ({ input, element }) =>{
const blockedStates: InstructionType[] = ['make-child'];
if(!isLastChild){
blockedStates.push('reorder-below');
}
return attachInstruction(initialData,{
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? 'last-in-group' : 'standard',
block: blockedStates
})
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setIsDragging(false);
setInstruction(undefined);
},
onDrop: ({ source }) => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self,source,location)
},
})
);
@@ -89,6 +143,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
return (
<>
<DropIndicator isVisible={instruction === "reorder-above"}/>
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive} sidebarCollapsed={sidebarCollapsed}>
{!sidebarCollapsed && <FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />}
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} isSidebarCollapsed={!!sidebarCollapsed} />
@@ -102,6 +157,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
/>
)}
</FavoriteItemWrapper>
{ isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</>
);
});
@@ -1,7 +1,12 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
DragLocationHistory,
DropTargetRecord,
ElementDragPayload,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
@@ -23,6 +28,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { FavoriteFolder } from "./favorite-folder";
import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence, getInstructionFromPayload, TargetData } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
export const SidebarFavoritesMenu = observer(() => {
@@ -33,7 +39,14 @@ export const SidebarFavoritesMenu = observer(() => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { favoriteIds, groupedFavorites, deleteFavorite, removeFromFavoriteFolder } = useFavorite();
const {
favoriteIds,
groupedFavorites,
deleteFavorite,
removeFromFavoriteFolder,
reOrderFavorite,
moveFavoriteToFolder,
} = useFavorite();
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
@@ -46,6 +59,66 @@ export const SidebarFavoritesMenu = observer(() => {
const containerRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef(null);
const handleMoveToFolder = (sourceId: string, destinationId: string) => {
moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
parent: destinationId,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
});
});
};
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
const isFolder = self.data?.isGroup;
const dropTargets = location?.current?.dropTargets ?? [];
if (!dropTargets || dropTargets.length <= 0) return;
const dropTarget =
dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0];
const dropTargetData = dropTarget?.data as TargetData;
if (!dropTarget || !dropTargetData) return;
const instruction = getInstructionFromPayload(dropTarget, source, location);
const parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
const droppedFavId = instruction !== "make-child" ? dropTargetData.id : undefined;
const sourceData = source.data as TargetData;
if (!sourceData.id) return;
if (isFolder) {
// handle move to a new parent folder if dropped on a folder
if (parentId && parentId !== sourceData.parentId) {
handleMoveToFolder(sourceData.id, parentId);
}
//handle remove from folder if dropped outside of the folder
if (parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
// handle reordering at root level
if (droppedFavId) {
if (instruction != "make-child") {
const destinationSequence = getDestinationStateSequence(groupedFavorites, droppedFavId, instruction);
handleReorder(sourceData.id, destinationSequence || 0);
}
}
} else {
//handling reordering for favorites
if (droppedFavId) {
const destinationSequence = getDestinationStateSequence(groupedFavorites, droppedFavId, instruction);
handleReorder(sourceData.id, destinationSequence || 0);
}
// handle removal from folder if dropped outside a folder
if (!parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
}
};
const handleRemoveFromFavorites = (favorite: IFavorite) => {
deleteFavorite(workspaceSlug.toString(), favorite.id)
.then(() => {
@@ -64,25 +137,30 @@ export const SidebarFavoritesMenu = observer(() => {
});
};
const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId, {
id: favoriteId,
parent: null,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
})
.catch(() => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
});
});
};
const handleReorder = useCallback(
(favoriteId: string, sequence: number) => {
reOrderFavorite(workspaceSlug.toString(), favoriteId, {
sequence: sequence,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
message: "Failed reorder favorite",
});
});
};
},
[workspaceSlug, reOrderFavorite]
);
useEffect(() => {
if (sidebarCollapsed) toggleFavoriteMenu(true);
}, [sidebarCollapsed, toggleFavoriteMenu]);
@@ -109,7 +187,6 @@ export const SidebarFavoritesMenu = observer(() => {
const sourceId = source?.data?.id as string | undefined;
console.log({ sourceId });
if (!sourceId || !groupedFavorites[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
);
@@ -138,7 +215,7 @@ export const SidebarFavoritesMenu = observer(() => {
<FolderPlus
onClick={() => {
setCreateNewFolder(true);
!isFavoriteMenuOpen && toggleFavoriteMenu(!isFavoriteMenuOpen);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}}
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")}
/>
@@ -179,7 +256,7 @@ export const SidebarFavoritesMenu = observer(() => {
) : (
orderBy(Object.values(groupedFavorites), "sequence", "desc")
.filter((fav) => !fav.parent)
.map((fav, index) => (
.map((fav, index, { length }) => (
<Tooltip
key={fav.id}
tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name}
@@ -191,17 +268,20 @@ export const SidebarFavoritesMenu = observer(() => {
{fav.is_folder ? (
<FavoriteFolder
favorite={fav}
isLastChild={index === favoriteIds.length - 1}
isLastChild={index === length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
handleReorder={handleReorder}
handleDrop={handleDrop}
/>
) : (
<FavoriteRoot
workspaceSlug={workspaceSlug.toString()}
favorite={fav}
isLastChild={index === length - 1}
parentId={undefined}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={groupedFavorites}
handleDrop={handleDrop}
/>
)}
</Tooltip>

Some files were not shown because too many files have changed in this diff Show More