Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c71d7c87b | ||
|
|
d6f27f7019 | ||
|
|
97ff3832fd | ||
|
|
d29ab80762 | ||
|
|
ff8bbed6f9 | ||
|
|
d04619477b | ||
|
|
547c138084 | ||
|
|
5c907db0e2 | ||
|
|
a85e592ada | ||
|
|
b21d190ce0 | ||
|
|
cba41e0755 | ||
|
|
02308eeb15 | ||
|
|
9ee41ece98 | ||
|
|
666ddf73b6 | ||
|
|
4499a5fa25 | ||
|
|
727dd4002e | ||
|
|
4b5a2bc4e5 | ||
|
|
b1c340b199 | ||
|
|
a612a17d28 | ||
|
|
d55ee6d5b8 | ||
|
|
aa1e192a50 | ||
|
|
6cd8af1092 | ||
|
|
66652a5d71 | ||
|
|
3bccda0c86 | ||
|
|
fb3295f5f4 | ||
|
|
fa3aa362a9 | ||
|
|
b73ea37798 | ||
|
|
d537e560e3 | ||
|
|
1b92a18ef8 | ||
|
|
31b6d52417 | ||
|
|
a153de34d6 | ||
|
|
64a44f4fce | ||
|
|
bb8a156bdd | ||
|
|
f02a2b04a5 | ||
|
|
b6ab853c57 | ||
|
|
fe43300aa7 | ||
|
|
849d9891d2 | ||
|
|
2768f560ad | ||
|
|
fe5999ceff | ||
|
|
da0071256f | ||
|
|
3c6006d04a | ||
|
|
8c04aa6f51 | ||
|
|
9f14167ef5 | ||
|
|
11bfbe560a | ||
|
|
fc52936024 | ||
|
|
5150c661ab | ||
|
|
63bc01f385 | ||
|
|
1953d6fe3a | ||
|
|
1b9033993d | ||
|
|
75ada1bfac | ||
|
|
d0f9a4d245 | ||
|
|
05894c5b9c | ||
|
|
5926c9e8e9 | ||
|
|
5aeedd1e5a | ||
|
|
7725b200f7 | ||
|
|
2c69538617 | ||
|
|
41bd98dd63 | ||
|
|
bf1c326b44 | ||
|
|
3d1485461d | ||
|
|
4251b114c3 | ||
|
|
712339a638 | ||
|
|
1c9162e1f1 | ||
|
|
f1e6f59716 | ||
|
|
69f235ed24 | ||
|
|
4aa01ffebe | ||
|
|
41c0ba502c | ||
|
|
378e896bf0 | ||
|
|
e3799c8a40 | ||
|
|
0d70397639 | ||
|
|
d2758fe5e6 | ||
|
|
1420b7e7d3 | ||
|
|
e5ebee664b | ||
|
|
05d3e3ae45 | ||
|
|
c68658d877 |
@@ -0,0 +1,20 @@
|
|||||||
|
### Description
|
||||||
|
<!-- Provide a detailed description of the changes in this PR -->
|
||||||
|
|
||||||
|
### Type of Change
|
||||||
|
<!-- Put an 'x' in the boxes that apply -->
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] Feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Improvement (change that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] Code refactoring
|
||||||
|
- [ ] Performance improvements
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
### Screenshots and Media (if applicable)
|
||||||
|
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
<!-- Please describe the tests that you ran to verify your changes -->
|
||||||
|
|
||||||
|
### References
|
||||||
|
<!-- Link related issues if there are any -->
|
||||||
@@ -314,8 +314,8 @@ jobs:
|
|||||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||||
|
|
||||||
attach_assets_to_build:
|
attach_assets_to_build:
|
||||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
|
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||||
name: Attach Assets to Build
|
name: Attach Assets to Release
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -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">
|
<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" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const InstanceGitlabConfigForm: FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/authentication"
|
href="/authentication"
|
||||||
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
|
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
|
|||||||
@@ -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="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-xl font-medium text-custom-text-100">Manage authentication modes for your instance</div>
|
||||||
<div className="text-sm font-normal text-custom-text-300">
|
<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>
|
</div>
|
||||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||||
@@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => {
|
|||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
value={Boolean(parseInt(enableSignUpConfig))}
|
value={Boolean(parseInt(enableSignUpConfig))}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
Boolean(parseInt(enableSignUpConfig)) === true
|
if (Boolean(parseInt(enableSignUpConfig)) === true) {
|
||||||
? updateConfig("ENABLE_SIGNUP", "0")
|
updateConfig("ENABLE_SIGNUP", "0");
|
||||||
: updateConfig("ENABLE_SIGNUP", "1");
|
} else {
|
||||||
|
updateConfig("ENABLE_SIGNUP", "1");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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} />
|
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
|||||||
{
|
{
|
||||||
key: "EMAIL_FROM",
|
key: "EMAIL_FROM",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Sender email address",
|
label: "Sender's email address",
|
||||||
description:
|
description:
|
||||||
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
|
"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",
|
placeholder: "no-reply@projectplane.so",
|
||||||
@@ -174,12 +174,12 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
|
<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="mr-8 flex items-center gap-10 pt-4">
|
||||||
<div className="grow">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
|
|||||||
</div>
|
</div>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div className="text-sm font-medium text-custom-text-100 leading-5">
|
<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>
|
||||||
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
<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
|
<a
|
||||||
href="https://docs.plane.so/self-hosting/telemetry"
|
href="https://docs.plane.so/self-hosting/telemetry"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-custom-primary-100 hover:underline"
|
className="text-custom-primary-100 hover:underline"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Know more.
|
our Telemetry Policy.
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grow">
|
<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">
|
<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.
|
automatically.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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" });
|
||||||
|
// derived values
|
||||||
|
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
|
||||||
|
|
||||||
|
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||||
|
await workspaceService
|
||||||
|
.workspaceSlugCheck(formData.slug)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
|
||||||
|
setSlugError(false);
|
||||||
|
await createWorkspace(formData)
|
||||||
|
.then(async () => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Success!",
|
||||||
|
message: "Workspace created successfully.",
|
||||||
|
});
|
||||||
|
router.push(`/workspace`);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Error!",
|
||||||
|
message: "Workspace could not be created. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else setSlugError(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Error!",
|
||||||
|
message: "Some error occurred while creating workspace. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
// when the component unmounts set the default values to whatever user typed in
|
||||||
|
setDefaultValues(getValues());
|
||||||
|
},
|
||||||
|
[getValues, setDefaultValues]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Name your workspace</h4>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
rules={{
|
||||||
|
required: "This is a required field.",
|
||||||
|
validate: (value) =>
|
||||||
|
/^[\w\s-]*$/.test(value) ||
|
||||||
|
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
|
||||||
|
maxLength: {
|
||||||
|
value: 80,
|
||||||
|
message: "Limit your name to 80 characters.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { value, ref, onChange } }) => (
|
||||||
|
<Input
|
||||||
|
id="workspaceName"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setValue("name", e.target.value);
|
||||||
|
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.name)}
|
||||||
|
placeholder="Something familiar and recognizable is always best."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Set your workspace's URL</h4>
|
||||||
|
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||||
|
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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'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";
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
export const UpgradeButton: React.FC = () => (
|
export const UpgradeButton: React.FC = () => (
|
||||||
<a href="https://plane.so/one" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
||||||
Available on One
|
Upgrade
|
||||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ export const HelpSection: FC = observer(() => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
<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
|
<a
|
||||||
href={redirectionLink}
|
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`}
|
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} />
|
<ExternalLink size={14} />
|
||||||
{!isSidebarCollapsed && "Redirect to plane"}
|
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { FC, useEffect, useRef } from "react";
|
import { FC, useEffect, useRef } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane helpers
|
// plane helpers
|
||||||
import { useOutsideClickDetector } from "@plane/helpers";
|
import { useOutsideClickDetector } from "@plane/hooks";
|
||||||
// components
|
// components
|
||||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||||
// hooks
|
// hooks
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip, WorkspaceIcon } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { useTheme } from "@/hooks/store";
|
import { useTheme } from "@/hooks/store";
|
||||||
@@ -14,31 +14,37 @@ const INSTANCE_ADMIN_LINKS = [
|
|||||||
{
|
{
|
||||||
Icon: Cog,
|
Icon: Cog,
|
||||||
name: "General",
|
name: "General",
|
||||||
description: "Identify your instances and get key details",
|
description: "Identify your instances and get key details.",
|
||||||
href: `/general/`,
|
href: `/general/`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Icon: WorkspaceIcon,
|
||||||
|
name: "Workspaces",
|
||||||
|
description: "Manage all workspaces on this instance.",
|
||||||
|
href: `/workspace/`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Icon: Mail,
|
Icon: Mail,
|
||||||
name: "Email",
|
name: "Email",
|
||||||
description: "Set up emails to your users",
|
description: "Configure your SMTP controls.",
|
||||||
href: `/email/`,
|
href: `/email/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: Lock,
|
Icon: Lock,
|
||||||
name: "Authentication",
|
name: "Authentication",
|
||||||
description: "Configure authentication modes",
|
description: "Configure authentication modes.",
|
||||||
href: `/authentication/`,
|
href: `/authentication/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: BrainCog,
|
Icon: BrainCog,
|
||||||
name: "Artificial intelligence",
|
name: "Artificial intelligence",
|
||||||
description: "Configure your OpenAI creds",
|
description: "Configure your OpenAI creds.",
|
||||||
href: `/ai/`,
|
href: `/ai/`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: Image,
|
Icon: Image,
|
||||||
name: "Images in Plane",
|
name: "Images in Plane",
|
||||||
description: "Allow third-party image libraries",
|
description: "Allow third-party image libraries.",
|
||||||
href: `/image/`,
|
href: `/image/`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export const InstanceHeader: FC = observer(() => {
|
|||||||
return "Github";
|
return "Github";
|
||||||
case "gitlab":
|
case "gitlab":
|
||||||
return "GitLab";
|
return "GitLab";
|
||||||
|
case "workspace":
|
||||||
|
return "Workspace";
|
||||||
|
case "create":
|
||||||
|
return "Create";
|
||||||
default:
|
default:
|
||||||
return pathName.toUpperCase();
|
return pathName.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { resolveGeneralTheme } from "helpers/common.helper";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { useTheme as nextUseTheme } from "next-themes";
|
import { useTheme as nextUseTheme } from "next-themes";
|
||||||
// ui
|
// ui
|
||||||
import { Button, getButtonStyling } from "@plane/ui";
|
import { Button, getButtonStyling } from "@plane/ui";
|
||||||
// helpers
|
|
||||||
import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useTheme } from "@/hooks/store";
|
import { useTheme } from "@/hooks/store";
|
||||||
// icons
|
// icons
|
||||||
@@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => {
|
|||||||
// theme
|
// theme
|
||||||
const { resolvedTheme } = nextUseTheme();
|
const { resolvedTheme } = nextUseTheme();
|
||||||
|
|
||||||
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
|
|
||||||
|
|
||||||
if (!isNewUserPopup) return <></>;
|
if (!isNewUserPopup) return <></>;
|
||||||
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">
|
<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="text-base font-semibold">Create workspace</div>
|
||||||
<div className="py-2 text-sm font-medium text-custom-text-300">
|
<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
|
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>
|
||||||
<div className="flex items-center gap-4 pt-2">
|
<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
|
Create workspace
|
||||||
</a>
|
</Link>
|
||||||
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
|
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./list-item";
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
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 (
|
||||||
|
<a
|
||||||
|
key={workspaceId}
|
||||||
|
href={`${WEB_BASE_URL}/${encodeURIComponent(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>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./use-theme";
|
export * from "./use-theme";
|
||||||
export * from "./use-instance";
|
export * from "./use-instance";
|
||||||
export * from "./use-user";
|
export * from "./use-user";
|
||||||
|
export * from "./use-workspace";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 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> {
|
||||||
|
const params = new URLSearchParams({ slug });
|
||||||
|
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react";
|
|||||||
import { IInstanceStore, InstanceStore } from "./instance.store";
|
import { IInstanceStore, InstanceStore } from "./instance.store";
|
||||||
import { IThemeStore, ThemeStore } from "./theme.store";
|
import { IThemeStore, ThemeStore } from "./theme.store";
|
||||||
import { IUserStore, UserStore } from "./user.store";
|
import { IUserStore, UserStore } from "./user.store";
|
||||||
|
import { IWorkspaceStore, WorkspaceStore } from "./workspace.store";
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
|
|
||||||
@@ -10,17 +11,20 @@ export abstract class CoreRootStore {
|
|||||||
theme: IThemeStore;
|
theme: IThemeStore;
|
||||||
instance: IInstanceStore;
|
instance: IInstanceStore;
|
||||||
user: IUserStore;
|
user: IUserStore;
|
||||||
|
workspace: IWorkspaceStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
this.instance = new InstanceStore(this);
|
this.instance = new InstanceStore(this);
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
|
this.workspace = new WorkspaceStore(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
hydrate(initialData: any) {
|
hydrate(initialData: any) {
|
||||||
this.theme.hydrate(initialData.theme);
|
this.theme.hydrate(initialData.theme);
|
||||||
this.instance.hydrate(initialData.instance);
|
this.instance.hydrate(initialData.instance);
|
||||||
this.user.hydrate(initialData.user);
|
this.user.hydrate(initialData.user);
|
||||||
|
this.workspace.hydrate(initialData.workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetOnSignOut() {
|
resetOnSignOut() {
|
||||||
@@ -28,5 +32,6 @@ export abstract class CoreRootStore {
|
|||||||
this.instance = new InstanceStore(this);
|
this.instance = new InstanceStore(this);
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
|
this.workspace = new WorkspaceStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: Record<string, IWorkspace>) => 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 - Record<string, IWorkspace>
|
||||||
|
*/
|
||||||
|
hydrate = (data: Record<string, IWorkspace>) => {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run develop",
|
"dev": "turbo run develop",
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.19",
|
"@headlessui/react": "^1.7.19",
|
||||||
"@plane/constants": "*",
|
"@plane/constants": "*",
|
||||||
"@plane/helpers": "*",
|
"@plane/hooks": "*",
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
|
"@plane/utils": "*",
|
||||||
"@sentry/nextjs": "^8.32.0",
|
"@sentry/nextjs": "^8.32.0",
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/lodash": "^4.17.0",
|
"@types/lodash": "^4.17.0",
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
"lucide-react": "^0.356.0",
|
"lucide-react": "^0.356.0",
|
||||||
"mobx": "^6.12.0",
|
"mobx": "^6.12.0",
|
||||||
"mobx-react": "^9.1.1",
|
"mobx-react": "^9.1.1",
|
||||||
"next": "^14.2.12",
|
"next": "^14.2.20",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.23.1"
|
"version": "0.24.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,9 +258,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
|||||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||||
)
|
)
|
||||||
|
|
||||||
intake_view = request.data.get(
|
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||||
"inbox_view", request.data.get("intake_view", False)
|
|
||||||
)
|
|
||||||
|
|
||||||
if project.archived_at:
|
if project.archived_at:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from .user import (
|
|||||||
from .workspace import (
|
from .workspace import (
|
||||||
WorkSpaceSerializer,
|
WorkSpaceSerializer,
|
||||||
WorkSpaceMemberSerializer,
|
WorkSpaceMemberSerializer,
|
||||||
TeamSerializer,
|
|
||||||
WorkSpaceMemberInviteSerializer,
|
WorkSpaceMemberInviteSerializer,
|
||||||
WorkspaceLiteSerializer,
|
WorkspaceLiteSerializer,
|
||||||
WorkspaceThemeSerializer,
|
WorkspaceThemeSerializer,
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer
|
|||||||
from .user import UserLiteSerializer, UserAdminLiteSerializer
|
from .user import UserLiteSerializer, UserAdminLiteSerializer
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
Team,
|
|
||||||
TeamMember,
|
|
||||||
WorkspaceMemberInvite,
|
WorkspaceMemberInvite,
|
||||||
WorkspaceTheme,
|
WorkspaceTheme,
|
||||||
WorkspaceUserProperties,
|
WorkspaceUserProperties,
|
||||||
@@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class TeamSerializer(BaseSerializer):
|
|
||||||
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
|
|
||||||
members = serializers.ListField(
|
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
|
||||||
write_only=True,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Team
|
|
||||||
fields = "__all__"
|
|
||||||
read_only_fields = [
|
|
||||||
"workspace",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
def create(self, validated_data, **kwargs):
|
|
||||||
if "members" in validated_data:
|
|
||||||
members = validated_data.pop("members")
|
|
||||||
workspace = self.context["workspace"]
|
|
||||||
team = Team.objects.create(**validated_data, workspace=workspace)
|
|
||||||
team_members = [
|
|
||||||
TeamMember(member=member, team=team, workspace=workspace)
|
|
||||||
for member in members
|
|
||||||
]
|
|
||||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
|
||||||
return team
|
|
||||||
team = Team.objects.create(**validated_data)
|
|
||||||
return team
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
if "members" in validated_data:
|
|
||||||
members = validated_data.pop("members")
|
|
||||||
TeamMember.objects.filter(team=instance).delete()
|
|
||||||
team_members = [
|
|
||||||
TeamMember(member=member, team=instance, workspace=instance.workspace)
|
|
||||||
for member in members
|
|
||||||
]
|
|
||||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceThemeSerializer(BaseSerializer):
|
class WorkspaceThemeSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkspaceTheme
|
model = WorkspaceTheme
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from plane.app.views import (
|
|||||||
ProjectMemberViewSet,
|
ProjectMemberViewSet,
|
||||||
ProjectMemberUserEndpoint,
|
ProjectMemberUserEndpoint,
|
||||||
ProjectJoinEndpoint,
|
ProjectJoinEndpoint,
|
||||||
AddTeamToProjectEndpoint,
|
|
||||||
ProjectUserViewsEndpoint,
|
ProjectUserViewsEndpoint,
|
||||||
ProjectIdentifierEndpoint,
|
ProjectIdentifierEndpoint,
|
||||||
ProjectFavoritesViewSet,
|
ProjectFavoritesViewSet,
|
||||||
@@ -83,11 +82,6 @@ urlpatterns = [
|
|||||||
ProjectMemberViewSet.as_view({"post": "leave"}),
|
ProjectMemberViewSet.as_view({"post": "leave"}),
|
||||||
name="project-member",
|
name="project-member",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
|
|
||||||
AddTeamToProjectEndpoint.as_view(),
|
|
||||||
name="projects",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
|
||||||
ProjectUserViewsEndpoint.as_view(),
|
ProjectUserViewsEndpoint.as_view(),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from plane.app.views import (
|
|||||||
WorkspaceMemberUserEndpoint,
|
WorkspaceMemberUserEndpoint,
|
||||||
WorkspaceMemberUserViewsEndpoint,
|
WorkspaceMemberUserViewsEndpoint,
|
||||||
WorkSpaceAvailabilityCheckEndpoint,
|
WorkSpaceAvailabilityCheckEndpoint,
|
||||||
TeamMemberViewSet,
|
|
||||||
UserLastProjectWithWorkspaceEndpoint,
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
WorkspaceThemeViewSet,
|
WorkspaceThemeViewSet,
|
||||||
WorkspaceUserProfileStatsEndpoint,
|
WorkspaceUserProfileStatsEndpoint,
|
||||||
@@ -100,23 +99,6 @@ urlpatterns = [
|
|||||||
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
|
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
|
||||||
name="leave-workspace-members",
|
name="leave-workspace-members",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/teams/",
|
|
||||||
TeamMemberViewSet.as_view({"get": "list", "post": "create"}),
|
|
||||||
name="workspace-team-members",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/teams/<uuid:pk>/",
|
|
||||||
TeamMemberViewSet.as_view(
|
|
||||||
{
|
|
||||||
"put": "update",
|
|
||||||
"patch": "partial_update",
|
|
||||||
"delete": "destroy",
|
|
||||||
"get": "retrieve",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="workspace-team-members",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"users/last-visited-workspace/",
|
"users/last-visited-workspace/",
|
||||||
UserLastProjectWithWorkspaceEndpoint.as_view(),
|
UserLastProjectWithWorkspaceEndpoint.as_view(),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from .project.invite import (
|
|||||||
|
|
||||||
from .project.member import (
|
from .project.member import (
|
||||||
ProjectMemberViewSet,
|
ProjectMemberViewSet,
|
||||||
AddTeamToProjectEndpoint,
|
|
||||||
ProjectMemberUserEndpoint,
|
ProjectMemberUserEndpoint,
|
||||||
UserProjectRolesEndpoint,
|
UserProjectRolesEndpoint,
|
||||||
)
|
)
|
||||||
@@ -49,7 +48,6 @@ from .workspace.favorite import (
|
|||||||
|
|
||||||
from .workspace.member import (
|
from .workspace.member import (
|
||||||
WorkSpaceMemberViewSet,
|
WorkSpaceMemberViewSet,
|
||||||
TeamMemberViewSet,
|
|
||||||
WorkspaceMemberUserEndpoint,
|
WorkspaceMemberUserEndpoint,
|
||||||
WorkspaceProjectMemberEndpoint,
|
WorkspaceProjectMemberEndpoint,
|
||||||
WorkspaceMemberUserViewsEndpoint,
|
WorkspaceMemberUserViewsEndpoint,
|
||||||
@@ -88,8 +86,6 @@ from .cycle.base import (
|
|||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
CycleViewSet,
|
|
||||||
TransferCycleIssueEndpoint,
|
|
||||||
CycleAnalyticsEndpoint,
|
CycleAnalyticsEndpoint,
|
||||||
CycleProgressEndpoint,
|
CycleProgressEndpoint,
|
||||||
)
|
)
|
||||||
@@ -206,6 +202,5 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
|||||||
|
|
||||||
from .error_404 import custom_404_view
|
from .error_404 import custom_404_view
|
||||||
|
|
||||||
from .exporter.base import ExportIssuesEndpoint
|
|
||||||
from .notification.base import MarkAllReadNotificationViewSet
|
from .notification.base import MarkAllReadNotificationViewSet
|
||||||
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
|
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ from django.db.models import (
|
|||||||
UUIDField,
|
UUIDField,
|
||||||
Value,
|
Value,
|
||||||
Subquery,
|
Subquery,
|
||||||
Case,
|
|
||||||
When,
|
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -445,12 +443,10 @@ class IssueViewSet(BaseViewSet):
|
|||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
.annotate(
|
.annotate(
|
||||||
cycle_id=Case(
|
cycle_id=Subquery(
|
||||||
When(
|
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||||
issue_cycle__cycle__deleted_at__isnull=True,
|
:1
|
||||||
then=F("issue_cycle__cycle_id"),
|
]
|
||||||
),
|
|
||||||
default=None,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from ..base import BaseAPIView, BaseViewSet
|
|||||||
from plane.bgtasks.page_transaction_task import page_transaction
|
from plane.bgtasks.page_transaction_task import page_transaction
|
||||||
from plane.bgtasks.page_version_task import page_version
|
from plane.bgtasks.page_version_task import page_version
|
||||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
|
|
||||||
|
|
||||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||||
@@ -114,7 +115,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
serializer = PageSerializer(
|
serializer = PageSerializer(
|
||||||
data=request.data,
|
data=request.data,
|
||||||
@@ -134,7 +135,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
try:
|
try:
|
||||||
page = Page.objects.get(
|
page = Page.objects.get(
|
||||||
@@ -234,7 +235,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def lock(self, request, slug, project_id, pk):
|
def lock(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.filter(
|
page = Page.objects.filter(
|
||||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||||
@@ -244,7 +245,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
page.save()
|
page.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def unlock(self, request, slug, project_id, pk):
|
def unlock(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.filter(
|
page = Page.objects.filter(
|
||||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||||
@@ -255,7 +256,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def access(self, request, slug, project_id, pk):
|
def access(self, request, slug, project_id, pk):
|
||||||
access = request.data.get("access", 0)
|
access = request.data.get("access", 0)
|
||||||
page = Page.objects.filter(
|
page = Page.objects.filter(
|
||||||
@@ -296,7 +297,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
pages = PageSerializer(queryset, many=True).data
|
pages = PageSerializer(queryset, many=True).data
|
||||||
return Response(pages, status=status.HTTP_200_OK)
|
return Response(pages, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def archive(self, request, slug, project_id, pk):
|
def archive(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||||
|
|
||||||
@@ -323,7 +324,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
|
|
||||||
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def unarchive(self, request, slug, project_id, pk):
|
def unarchive(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||||
|
|
||||||
@@ -348,7 +349,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
|
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||||
def destroy(self, request, slug, project_id, pk):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||||
|
|
||||||
@@ -470,6 +471,8 @@ class SubPagesEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PagesDescriptionViewSet(BaseViewSet):
|
class PagesDescriptionViewSet(BaseViewSet):
|
||||||
|
parser_classes = [MultiPartParser]
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
page = (
|
page = (
|
||||||
@@ -526,30 +529,33 @@ class PagesDescriptionViewSet(BaseViewSet):
|
|||||||
existing_instance = json.dumps(
|
existing_instance = json.dumps(
|
||||||
{"description_html": page.description_html}, cls=DjangoJSONEncoder
|
{"description_html": page.description_html}, cls=DjangoJSONEncoder
|
||||||
)
|
)
|
||||||
|
print("before the variables")
|
||||||
|
|
||||||
# Get the base64 data from the request
|
# capture the page transaction
|
||||||
base64_data = request.data.get("description_binary")
|
if request.data.get("description_html"):
|
||||||
|
page_transaction.delay(
|
||||||
# If base64 data is provided
|
new_value=request.data, old_value=existing_instance, page_id=pk
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return Response({"message": "Updated successfully"})
|
|
||||||
else:
|
if not request.data.get("description_binary"):
|
||||||
return Response({"error": "No binary data provided"})
|
return Response({"error": "No binary data provided"})
|
||||||
|
|
||||||
|
# Store the updated binary data
|
||||||
|
page.description_html = request.data.get(
|
||||||
|
"description_html", page.description_html
|
||||||
|
)
|
||||||
|
page.description = request.data.get("description", page.description)
|
||||||
|
page.description_binary = request.POST.get("description_binary")
|
||||||
|
|
||||||
|
page.description_binary = base64.b64decode(
|
||||||
|
request.POST.get("description_binary")
|
||||||
|
)
|
||||||
|
print("before the ssssave")
|
||||||
|
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"})
|
||||||
|
|||||||
@@ -384,11 +384,9 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
intake_view = request.data.get(
|
|
||||||
"inbox_view", request.data.get("intake_view", False)
|
|
||||||
)
|
|
||||||
|
|
||||||
project = Project.objects.get(pk=pk)
|
project = Project.objects.get(pk=pk)
|
||||||
|
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||||
current_instance = json.dumps(
|
current_instance = json.dumps(
|
||||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,20 +11,12 @@ from plane.app.serializers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectBasePermission,
|
|
||||||
ProjectMemberPermission,
|
ProjectMemberPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
WorkspaceUserPermission,
|
WorkspaceUserPermission,
|
||||||
)
|
)
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
|
||||||
Project,
|
|
||||||
ProjectMember,
|
|
||||||
Workspace,
|
|
||||||
TeamMember,
|
|
||||||
IssueUserProperty,
|
|
||||||
WorkspaceMember,
|
|
||||||
)
|
|
||||||
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
from plane.app.permissions.base import allow_permission, ROLE
|
from plane.app.permissions.base import allow_permission, ROLE
|
||||||
@@ -309,53 +301,6 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class AddTeamToProjectEndpoint(BaseAPIView):
|
|
||||||
permission_classes = [ProjectBasePermission]
|
|
||||||
|
|
||||||
def post(self, request, slug, project_id):
|
|
||||||
team_members = TeamMember.objects.filter(
|
|
||||||
workspace__slug=slug, team__in=request.data.get("teams", [])
|
|
||||||
).values_list("member", flat=True)
|
|
||||||
|
|
||||||
if len(team_members) == 0:
|
|
||||||
return Response(
|
|
||||||
{"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
|
||||||
|
|
||||||
project_members = []
|
|
||||||
issue_props = []
|
|
||||||
for member in team_members:
|
|
||||||
project_members.append(
|
|
||||||
ProjectMember(
|
|
||||||
project_id=project_id,
|
|
||||||
member_id=member,
|
|
||||||
workspace=workspace,
|
|
||||||
created_by=request.user,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
issue_props.append(
|
|
||||||
IssueUserProperty(
|
|
||||||
project_id=project_id,
|
|
||||||
user_id=member,
|
|
||||||
workspace=workspace,
|
|
||||||
created_by=request.user,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
ProjectMember.objects.bulk_create(
|
|
||||||
project_members, batch_size=10, ignore_conflicts=True
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = IssueUserProperty.objects.bulk_create(
|
|
||||||
issue_props, batch_size=10, ignore_conflicts=True
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||||
def get(self, request, slug, project_id):
|
def get(self, request, slug, project_id):
|
||||||
project_member = ProjectMember.objects.get(
|
project_member = ProjectMember.objects.get(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@@ -38,6 +39,7 @@ from django.utils.decorators import method_decorator
|
|||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.vary import vary_on_cookie
|
from django.views.decorators.vary import vary_on_cookie
|
||||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||||
|
from plane.license.utils.instance_value import get_configuration_value
|
||||||
|
|
||||||
|
|
||||||
class WorkSpaceViewSet(BaseViewSet):
|
class WorkSpaceViewSet(BaseViewSet):
|
||||||
@@ -80,6 +82,21 @@ class WorkSpaceViewSet(BaseViewSet):
|
|||||||
|
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
try:
|
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)
|
serializer = WorkSpaceSerializer(data=request.data)
|
||||||
|
|
||||||
slug = request.data.get("slug", False)
|
slug = request.data.get("slug", False)
|
||||||
@@ -337,6 +354,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
created_at__date=request.data.get("date"),
|
created_at__date=request.data.get("date"),
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
).select_related("actor", "workspace", "issue", "project")[:10000]
|
).select_related("actor", "workspace", "issue", "project")[:10000]
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField
|
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.functions import Cast
|
|
||||||
|
|
||||||
# Third party modules
|
# Third party modules
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
||||||
WorkSpaceAdminPermission,
|
|
||||||
WorkspaceEntityPermission,
|
|
||||||
allow_permission,
|
|
||||||
ROLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
ProjectMemberRoleSerializer,
|
ProjectMemberRoleSerializer,
|
||||||
TeamSerializer,
|
|
||||||
UserLiteSerializer,
|
|
||||||
WorkspaceMemberAdminSerializer,
|
WorkspaceMemberAdminSerializer,
|
||||||
WorkspaceMemberMeSerializer,
|
WorkspaceMemberMeSerializer,
|
||||||
WorkSpaceMemberSerializer,
|
WorkSpaceMemberSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.views.base import BaseAPIView
|
from plane.app.views.base import BaseAPIView
|
||||||
from plane.db.models import (
|
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
|
||||||
Project,
|
|
||||||
ProjectMember,
|
|
||||||
Team,
|
|
||||||
User,
|
|
||||||
Workspace,
|
|
||||||
WorkspaceMember,
|
|
||||||
DraftIssue,
|
|
||||||
)
|
|
||||||
from plane.utils.cache import invalidate_cache
|
from plane.utils.cache import invalidate_cache
|
||||||
|
|
||||||
from .. import BaseViewSet
|
from .. import BaseViewSet
|
||||||
@@ -284,53 +268,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
|||||||
project_members_dict[str(project_id)].append(project_member)
|
project_members_dict[str(project_id)].append(project_member)
|
||||||
|
|
||||||
return Response(project_members_dict, status=status.HTTP_200_OK)
|
return Response(project_members_dict, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberViewSet(BaseViewSet):
|
|
||||||
serializer_class = TeamSerializer
|
|
||||||
model = Team
|
|
||||||
permission_classes = [WorkSpaceAdminPermission]
|
|
||||||
|
|
||||||
search_fields = ["member__display_name", "member__first_name"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return self.filter_queryset(
|
|
||||||
super()
|
|
||||||
.get_queryset()
|
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.select_related("workspace", "workspace__owner")
|
|
||||||
.prefetch_related("members")
|
|
||||||
)
|
|
||||||
|
|
||||||
def create(self, request, slug):
|
|
||||||
members = list(
|
|
||||||
WorkspaceMember.objects.filter(
|
|
||||||
workspace__slug=slug,
|
|
||||||
member__id__in=request.data.get("members", []),
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
.annotate(member_str_id=Cast("member", output_field=CharField()))
|
|
||||||
.distinct()
|
|
||||||
.values_list("member_str_id", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(members) != len(request.data.get("members", [])):
|
|
||||||
users = list(set(request.data.get("members", [])).difference(members))
|
|
||||||
users = User.objects.filter(pk__in=users)
|
|
||||||
|
|
||||||
serializer = UserLiteSerializer(users, many=True)
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"error": f"{len(users)} of the member(s) are not a part of the workspace",
|
|
||||||
"members": serializer.data,
|
|
||||||
},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
|
||||||
|
|
||||||
serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
|
|||||||
)
|
)
|
||||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Lower the email
|
||||||
|
email = str(email).lower().strip()
|
||||||
|
|
||||||
# Validate email
|
# Validate email
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
|
|||||||
)
|
)
|
||||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
email = str(email).lower().strip()
|
||||||
# Validate email
|
# Validate email
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from django.utils import timezone
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.db.models.fields.related import OneToOneRel
|
||||||
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@@ -11,31 +12,98 @@ from celery import shared_task
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||||
|
"""
|
||||||
|
Soft delete related objects for a given model instance
|
||||||
|
"""
|
||||||
|
# Get the model class using app registry
|
||||||
model_class = apps.get_model(app_label, model_name)
|
model_class = apps.get_model(app_label, model_name)
|
||||||
instance = model_class.all_objects.get(pk=instance_pk)
|
|
||||||
related_fields = instance._meta.get_fields()
|
|
||||||
for field in related_fields:
|
|
||||||
if field.one_to_many or field.one_to_one:
|
|
||||||
try:
|
|
||||||
# Check if the field has CASCADE on delete
|
|
||||||
if (
|
|
||||||
not hasattr(field.remote_field, "on_delete")
|
|
||||||
or field.remote_field.on_delete == models.CASCADE
|
|
||||||
):
|
|
||||||
if field.one_to_many:
|
|
||||||
related_objects = getattr(instance, field.name).all()
|
|
||||||
elif field.one_to_one:
|
|
||||||
related_object = getattr(instance, field.name)
|
|
||||||
related_objects = (
|
|
||||||
[related_object] if related_object is not None else []
|
|
||||||
)
|
|
||||||
|
|
||||||
for obj in related_objects:
|
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
|
||||||
if obj:
|
try:
|
||||||
obj.deleted_at = timezone.now()
|
instance = model_class.all_objects.get(pk=instance_pk)
|
||||||
obj.save(using=using)
|
except model_class.DoesNotExist:
|
||||||
except ObjectDoesNotExist:
|
return
|
||||||
pass
|
|
||||||
|
# Get all related fields that are reverse relationships
|
||||||
|
all_related = [
|
||||||
|
f
|
||||||
|
for f in instance._meta.get_fields()
|
||||||
|
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
|
||||||
|
]
|
||||||
|
|
||||||
|
# Handle each related field
|
||||||
|
for relation in all_related:
|
||||||
|
related_name = relation.get_accessor_name()
|
||||||
|
|
||||||
|
# Skip if the relation doesn't exist
|
||||||
|
if not hasattr(instance, related_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the on_delete behavior name
|
||||||
|
on_delete_name = (
|
||||||
|
relation.on_delete.__name__
|
||||||
|
if hasattr(relation.on_delete, "__name__")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if on_delete_name == "DO_NOTHING":
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif on_delete_name == "SET_NULL":
|
||||||
|
# Handle SET_NULL relationships
|
||||||
|
if isinstance(relation, OneToOneRel):
|
||||||
|
# For OneToOne relationships
|
||||||
|
related_obj = getattr(instance, related_name, None)
|
||||||
|
if related_obj and isinstance(related_obj, models.Model):
|
||||||
|
setattr(related_obj, relation.remote_field.name, None)
|
||||||
|
related_obj.save(update_fields=[relation.remote_field.name])
|
||||||
|
else:
|
||||||
|
# For other relationships
|
||||||
|
related_queryset = getattr(instance, related_name).all()
|
||||||
|
related_queryset.update(**{relation.remote_field.name: None})
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Handle CASCADE and other delete behaviors
|
||||||
|
try:
|
||||||
|
if relation.one_to_one:
|
||||||
|
# Handle OneToOne relationships
|
||||||
|
related_obj = getattr(instance, related_name, None)
|
||||||
|
if related_obj:
|
||||||
|
if hasattr(related_obj, "deleted_at"):
|
||||||
|
if not related_obj.deleted_at:
|
||||||
|
related_obj.deleted_at = timezone.now()
|
||||||
|
related_obj.save()
|
||||||
|
# Recursively handle related objects
|
||||||
|
soft_delete_related_objects(
|
||||||
|
related_obj._meta.app_label,
|
||||||
|
related_obj._meta.model_name,
|
||||||
|
related_obj.pk,
|
||||||
|
using,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Handle other relationships
|
||||||
|
related_queryset = getattr(instance, related_name).all()
|
||||||
|
for related_obj in related_queryset:
|
||||||
|
if hasattr(related_obj, "deleted_at"):
|
||||||
|
if not related_obj.deleted_at:
|
||||||
|
related_obj.deleted_at = timezone.now()
|
||||||
|
related_obj.save()
|
||||||
|
# Recursively handle related objects
|
||||||
|
soft_delete_related_objects(
|
||||||
|
related_obj._meta.app_label,
|
||||||
|
related_obj._meta.model_name,
|
||||||
|
related_obj.pk,
|
||||||
|
using,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error or handle as needed
|
||||||
|
print(f"Error handling relation {related_name}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Finally, soft delete the instance itself if it hasn't been deleted yet
|
||||||
|
if hasattr(instance, "deleted_at") and not instance.deleted_at:
|
||||||
|
instance.deleted_at = timezone.now()
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
# @shared_task
|
# @shared_task
|
||||||
|
|||||||
@@ -162,8 +162,7 @@ def generate_table_row(issue):
|
|||||||
issue["priority"],
|
issue["priority"],
|
||||||
(
|
(
|
||||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||||
if issue["created_by__first_name"]
|
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||||
and issue["created_by__last_name"]
|
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -197,8 +196,7 @@ def generate_json_row(issue):
|
|||||||
"Priority": issue["priority"],
|
"Priority": issue["priority"],
|
||||||
"Created By": (
|
"Created By": (
|
||||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||||
if issue["created_by__first_name"]
|
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||||
and issue["created_by__last_name"]
|
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
"Assignee": (
|
"Assignee": (
|
||||||
@@ -208,17 +206,11 @@ def generate_json_row(issue):
|
|||||||
),
|
),
|
||||||
"Labels": issue["labels__name"] if issue["labels__name"] else "",
|
"Labels": issue["labels__name"] if issue["labels__name"] else "",
|
||||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||||
"Cycle Start Date": dateConverter(
|
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||||
issue["issue_cycle__cycle__start_date"]
|
|
||||||
),
|
|
||||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||||
"Module Name": issue["issue_module__module__name"],
|
"Module Name": issue["issue_module__module__name"],
|
||||||
"Module Start Date": dateConverter(
|
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||||
issue["issue_module__module__start_date"]
|
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||||
),
|
|
||||||
"Module Target Date": dateConverter(
|
|
||||||
issue["issue_module__module__target_date"]
|
|
||||||
),
|
|
||||||
"Created At": dateTimeConverter(issue["created_at"]),
|
"Created At": dateTimeConverter(issue["created_at"]),
|
||||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@@ -16,8 +18,9 @@ from plane.db.models import (
|
|||||||
IssueComment,
|
IssueComment,
|
||||||
IssueActivity,
|
IssueActivity,
|
||||||
UserNotificationPreference,
|
UserNotificationPreference,
|
||||||
ProjectMember
|
ProjectMember,
|
||||||
)
|
)
|
||||||
|
from django.db.models import Subquery
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@@ -95,7 +98,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
|||||||
).exists()
|
).exists()
|
||||||
and not Issue.objects.filter(
|
and not Issue.objects.filter(
|
||||||
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
||||||
).exists() and ProjectMember.objects.filter(
|
).exists()
|
||||||
|
and ProjectMember.objects.filter(
|
||||||
project_id=project_id, member_id=mention_id, is_active=True
|
project_id=project_id, member_id=mention_id, is_active=True
|
||||||
).exists()
|
).exists()
|
||||||
):
|
):
|
||||||
@@ -242,14 +246,21 @@ def notifications(
|
|||||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# get the list of active project members
|
||||||
|
project_members = ProjectMember.objects.filter(
|
||||||
|
project_id=project_id, is_active=True
|
||||||
|
).values_list("member_id", flat=True)
|
||||||
|
|
||||||
# Get new mentions from the newer instance
|
# Get new mentions from the newer instance
|
||||||
new_mentions = get_new_mentions(
|
new_mentions = get_new_mentions(
|
||||||
requested_instance=requested_data, current_instance=current_instance
|
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
|
new_mentions = [
|
||||||
).values_list("member_id", flat=True))
|
str(mention)
|
||||||
new_mentions = [str(member_id) for member_id in new_mentions]
|
for mention in new_mentions
|
||||||
|
if mention in set(project_members)
|
||||||
|
]
|
||||||
removed_mention = get_removed_mentions(
|
removed_mention = get_removed_mentions(
|
||||||
requested_instance=requested_data, current_instance=current_instance
|
requested_instance=requested_data, current_instance=current_instance
|
||||||
)
|
)
|
||||||
@@ -280,6 +291,11 @@ def notifications(
|
|||||||
new_value=issue_comment_new_value,
|
new_value=issue_comment_new_value,
|
||||||
)
|
)
|
||||||
comment_mentions = comment_mentions + new_comment_mentions
|
comment_mentions = comment_mentions + new_comment_mentions
|
||||||
|
comment_mentions = [
|
||||||
|
mention
|
||||||
|
for mention in comment_mentions
|
||||||
|
if UUID(mention) in set(project_members)
|
||||||
|
]
|
||||||
|
|
||||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||||
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
||||||
@@ -293,7 +309,11 @@ def notifications(
|
|||||||
|
|
||||||
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
|
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
|
||||||
issue_subscribers = list(
|
issue_subscribers = list(
|
||||||
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
|
IssueSubscriber.objects.filter(
|
||||||
|
project_id=project_id,
|
||||||
|
issue_id=issue_id,
|
||||||
|
subscriber__in=Subquery(project_members),
|
||||||
|
)
|
||||||
.exclude(
|
.exclude(
|
||||||
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
|
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
|
||||||
)
|
)
|
||||||
@@ -314,7 +334,9 @@ def notifications(
|
|||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
issue_assignees = IssueAssignee.objects.filter(
|
issue_assignees = IssueAssignee.objects.filter(
|
||||||
issue_id=issue_id, project_id=project_id
|
issue_id=issue_id,
|
||||||
|
project_id=project_id,
|
||||||
|
assignee__in=Subquery(project_members),
|
||||||
).values_list("assignee", flat=True)
|
).values_list("assignee", flat=True)
|
||||||
|
|
||||||
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
||||||
|
|||||||
@@ -13,28 +13,14 @@ from plane.db.models import (
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
||||||
help = "Add a member to a project. If present in the workspace"
|
help = "Add a member to a project. If present in the workspace"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
# Positional argument
|
# 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(
|
parser.add_argument(
|
||||||
"--project_id",
|
"--role", type=int, nargs="?", help="Role of the user in the project"
|
||||||
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):
|
def handle(self, *args: Any, **options: Any):
|
||||||
@@ -67,9 +53,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Get the smallest sort order
|
# Get the smallest sort order
|
||||||
smallest_sort_order = (
|
smallest_sort_order = (
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(workspace_id=project.workspace_id)
|
||||||
workspace_id=project.workspace_id,
|
|
||||||
)
|
|
||||||
.order_by("sort_order")
|
.order_by("sort_order")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -79,22 +63,15 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
sort_order = 65535
|
sort_order = 65535
|
||||||
|
|
||||||
if ProjectMember.objects.filter(
|
if ProjectMember.objects.filter(project=project, member=user).exists():
|
||||||
project=project,
|
|
||||||
member=user,
|
|
||||||
).exists():
|
|
||||||
# Update the project member
|
# Update the project member
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(project=project, member=user).update(
|
||||||
project=project,
|
is_active=True, sort_order=sort_order, role=role
|
||||||
member=user,
|
)
|
||||||
).update(is_active=True, sort_order=sort_order, role=role)
|
|
||||||
else:
|
else:
|
||||||
# Create the project member
|
# Create the project member
|
||||||
ProjectMember.objects.create(
|
ProjectMember.objects.create(
|
||||||
project=project,
|
project=project, member=user, role=role, sort_order=sort_order
|
||||||
member=user,
|
|
||||||
role=role,
|
|
||||||
sort_order=sort_order,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Issue Property
|
# Issue Property
|
||||||
@@ -102,9 +79,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
|
||||||
f"User {user_email} added to project {project_id}"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except CommandError as e:
|
except CommandError as e:
|
||||||
|
|||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-11-27 09:07
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import plane.db.models.webhook
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="IssueVersion",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True, verbose_name="Last Modified At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"deleted_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="Deleted At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("parent", models.UUIDField(blank=True, null=True)),
|
||||||
|
("state", models.UUIDField(blank=True, null=True)),
|
||||||
|
("estimate_point", models.UUIDField(blank=True, null=True)),
|
||||||
|
("name", models.CharField(max_length=255, verbose_name="Issue Name")),
|
||||||
|
("description", models.JSONField(blank=True, default=dict)),
|
||||||
|
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||||
|
("description_stripped", models.TextField(blank=True, null=True)),
|
||||||
|
("description_binary", models.BinaryField(null=True)),
|
||||||
|
(
|
||||||
|
"priority",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("urgent", "Urgent"),
|
||||||
|
("high", "High"),
|
||||||
|
("medium", "Medium"),
|
||||||
|
("low", "Low"),
|
||||||
|
("none", "None"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
max_length=30,
|
||||||
|
verbose_name="Issue Priority",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("start_date", models.DateField(blank=True, null=True)),
|
||||||
|
("target_date", models.DateField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"sequence_id",
|
||||||
|
models.IntegerField(default=1, verbose_name="Issue Sequence ID"),
|
||||||
|
),
|
||||||
|
("sort_order", models.FloatField(default=65535)),
|
||||||
|
("completed_at", models.DateTimeField(null=True)),
|
||||||
|
("archived_at", models.DateField(null=True)),
|
||||||
|
("is_draft", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"external_source",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"external_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("type", models.UUIDField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"last_saved_at",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("owned_by", models.UUIDField()),
|
||||||
|
(
|
||||||
|
"assignees",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.UUIDField(),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"labels",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.UUIDField(),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("cycle", models.UUIDField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"modules",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.UUIDField(),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("properties", models.JSONField(default=dict)),
|
||||||
|
("meta", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_created_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Created By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"issue",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="versions",
|
||||||
|
to="db.issue",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"project",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="project_%(class)s",
|
||||||
|
to="db.project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_updated_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Last Modified By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="workspace_%(class)s",
|
||||||
|
to="db.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Issue Version",
|
||||||
|
"verbose_name_plural": "Issue Versions",
|
||||||
|
"db_table": "issue_versions",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="teampage",
|
||||||
|
unique_together=None,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="teampage",
|
||||||
|
name="created_by",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="teampage",
|
||||||
|
name="page",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="teampage",
|
||||||
|
name="team",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="teampage",
|
||||||
|
name="updated_by",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="teampage",
|
||||||
|
name="workspace",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="page",
|
||||||
|
name="teams",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="members",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="fileasset",
|
||||||
|
name="entity_identifier",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="webhook",
|
||||||
|
name="is_internal",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="fileasset",
|
||||||
|
name="entity_type",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="webhook",
|
||||||
|
name="url",
|
||||||
|
field=models.URLField(
|
||||||
|
max_length=1024,
|
||||||
|
validators=[
|
||||||
|
plane.db.models.webhook.validate_schema,
|
||||||
|
plane.db.models.webhook.validate_domain,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="TeamMember",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="TeamPage",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -53,7 +53,6 @@ from .project import (
|
|||||||
ProjectMemberInvite,
|
ProjectMemberInvite,
|
||||||
ProjectPublicMember,
|
ProjectPublicMember,
|
||||||
)
|
)
|
||||||
from .deploy_board import DeployBoard
|
|
||||||
from .session import Session
|
from .session import Session
|
||||||
from .social_connection import SocialLoginConnection
|
from .social_connection import SocialLoginConnection
|
||||||
from .state import State
|
from .state import State
|
||||||
@@ -61,8 +60,6 @@ from .user import Account, Profile, User
|
|||||||
from .view import IssueView
|
from .view import IssueView
|
||||||
from .webhook import Webhook, WebhookLog
|
from .webhook import Webhook, WebhookLog
|
||||||
from .workspace import (
|
from .workspace import (
|
||||||
Team,
|
|
||||||
TeamMember,
|
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceBaseModel,
|
WorkspaceBaseModel,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
@@ -71,23 +68,14 @@ from .workspace import (
|
|||||||
WorkspaceUserProperties,
|
WorkspaceUserProperties,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .importer import Importer
|
|
||||||
|
|
||||||
from .page import Page, PageLog, PageLabel
|
|
||||||
|
|
||||||
from .estimate import Estimate, EstimatePoint
|
|
||||||
|
|
||||||
from .intake import Intake, IntakeIssue
|
|
||||||
|
|
||||||
from .analytic import AnalyticView
|
|
||||||
|
|
||||||
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
|
||||||
|
|
||||||
from .exporter import ExporterHistory
|
|
||||||
|
|
||||||
from .webhook import Webhook, WebhookLog
|
|
||||||
|
|
||||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
|
||||||
|
|
||||||
from .favorite import UserFavorite
|
from .favorite import UserFavorite
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ class FileAsset(BaseModel):
|
|||||||
page = models.ForeignKey(
|
page = models.ForeignKey(
|
||||||
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
|
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||||
)
|
)
|
||||||
entity_type = models.CharField(
|
entity_type = models.CharField(max_length=255, null=True, blank=True)
|
||||||
max_length=255, choices=EntityTypeContext.choices, null=True, blank=True
|
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
|
||||||
)
|
|
||||||
is_deleted = models.BooleanField(default=False)
|
is_deleted = models.BooleanField(default=False)
|
||||||
is_archived = models.BooleanField(default=False)
|
is_archived = models.BooleanField(default=False)
|
||||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django import apps
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.utils.html_processor import strip_tags
|
from plane.utils.html_processor import strip_tags
|
||||||
from plane.db.mixins import SoftDeletionManager
|
from plane.db.mixins import SoftDeletionManager
|
||||||
|
from plane.utils.exception_logger import log_exception
|
||||||
from .project import ProjectBaseModel
|
from .project import ProjectBaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -656,3 +657,103 @@ class IssueVote(ProjectBaseModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.issue.name} {self.actor.email}"
|
return f"{self.issue.name} {self.actor.email}"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueVersion(ProjectBaseModel):
|
||||||
|
issue = models.ForeignKey(
|
||||||
|
"db.Issue", on_delete=models.CASCADE, related_name="versions"
|
||||||
|
)
|
||||||
|
PRIORITY_CHOICES = (
|
||||||
|
("urgent", "Urgent"),
|
||||||
|
("high", "High"),
|
||||||
|
("medium", "Medium"),
|
||||||
|
("low", "Low"),
|
||||||
|
("none", "None"),
|
||||||
|
)
|
||||||
|
parent = models.UUIDField(blank=True, null=True)
|
||||||
|
state = models.UUIDField(blank=True, null=True)
|
||||||
|
estimate_point = models.UUIDField(blank=True, null=True)
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||||
|
description = models.JSONField(blank=True, default=dict)
|
||||||
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
description_stripped = models.TextField(blank=True, null=True)
|
||||||
|
description_binary = models.BinaryField(null=True)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PRIORITY_CHOICES,
|
||||||
|
verbose_name="Issue Priority",
|
||||||
|
default="none",
|
||||||
|
)
|
||||||
|
start_date = models.DateField(null=True, blank=True)
|
||||||
|
target_date = models.DateField(null=True, blank=True)
|
||||||
|
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||||
|
sort_order = models.FloatField(default=65535)
|
||||||
|
completed_at = models.DateTimeField(null=True)
|
||||||
|
archived_at = models.DateField(null=True)
|
||||||
|
is_draft = models.BooleanField(default=False)
|
||||||
|
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
type = models.UUIDField(blank=True, null=True)
|
||||||
|
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||||
|
owned_by = models.UUIDField()
|
||||||
|
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||||
|
labels = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||||
|
cycle = models.UUIDField(null=True, blank=True)
|
||||||
|
modules = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||||
|
properties = models.JSONField(default=dict)
|
||||||
|
meta = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Issue Version"
|
||||||
|
verbose_name_plural = "Issue Versions"
|
||||||
|
db_table = "issue_versions"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} <{self.project.name}>"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def log_issue_version(cls, issue, user):
|
||||||
|
try:
|
||||||
|
"""
|
||||||
|
Log the issue version
|
||||||
|
"""
|
||||||
|
|
||||||
|
Module = apps.get_model("db.Module")
|
||||||
|
CycleIssue = apps.get_model("db.CycleIssue")
|
||||||
|
|
||||||
|
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
|
||||||
|
|
||||||
|
cls.objects.create(
|
||||||
|
issue=issue,
|
||||||
|
parent=issue.parent,
|
||||||
|
state=issue.state,
|
||||||
|
point=issue.point,
|
||||||
|
estimate_point=issue.estimate_point,
|
||||||
|
name=issue.name,
|
||||||
|
description=issue.description,
|
||||||
|
description_html=issue.description_html,
|
||||||
|
description_stripped=issue.description_stripped,
|
||||||
|
description_binary=issue.description_binary,
|
||||||
|
priority=issue.priority,
|
||||||
|
start_date=issue.start_date,
|
||||||
|
target_date=issue.target_date,
|
||||||
|
sequence_id=issue.sequence_id,
|
||||||
|
sort_order=issue.sort_order,
|
||||||
|
completed_at=issue.completed_at,
|
||||||
|
archived_at=issue.archived_at,
|
||||||
|
is_draft=issue.is_draft,
|
||||||
|
external_source=issue.external_source,
|
||||||
|
external_id=issue.external_id,
|
||||||
|
type=issue.type,
|
||||||
|
last_saved_at=issue.last_saved_at,
|
||||||
|
assignees=issue.assignees,
|
||||||
|
labels=issue.labels,
|
||||||
|
cycle=cycle_issue.cycle if cycle_issue else None,
|
||||||
|
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
|
||||||
|
owned_by=user,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log_exception(e)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ class Page(BaseModel):
|
|||||||
projects = models.ManyToManyField(
|
projects = models.ManyToManyField(
|
||||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||||
)
|
)
|
||||||
teams = models.ManyToManyField(
|
|
||||||
"db.Team", related_name="pages", through="db.TeamPage"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Page"
|
verbose_name = "Page"
|
||||||
@@ -160,32 +157,6 @@ class ProjectPage(BaseModel):
|
|||||||
return f"{self.project.name} {self.page.name}"
|
return f"{self.project.name} {self.page.name}"
|
||||||
|
|
||||||
|
|
||||||
class TeamPage(BaseModel):
|
|
||||||
team = models.ForeignKey(
|
|
||||||
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
|
|
||||||
)
|
|
||||||
page = models.ForeignKey(
|
|
||||||
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
|
|
||||||
)
|
|
||||||
workspace = models.ForeignKey(
|
|
||||||
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ["team", "page", "deleted_at"]
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["team", "page"],
|
|
||||||
condition=models.Q(deleted_at__isnull=True),
|
|
||||||
name="team_page_unique_team_page_when_deleted_at_null",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
verbose_name = "Team Page"
|
|
||||||
verbose_name_plural = "Team Pages"
|
|
||||||
db_table = "team_pages"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
|
|
||||||
|
|
||||||
class PageVersion(BaseModel):
|
class PageVersion(BaseModel):
|
||||||
workspace = models.ForeignKey(
|
workspace = models.ForeignKey(
|
||||||
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
|
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class Webhook(BaseModel):
|
|||||||
workspace = models.ForeignKey(
|
workspace = models.ForeignKey(
|
||||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
|
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
|
||||||
)
|
)
|
||||||
url = models.URLField(validators=[validate_schema, validate_domain])
|
url = models.URLField(
|
||||||
|
validators=[validate_schema, validate_domain], max_length=1024
|
||||||
|
)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
secret_key = models.CharField(max_length=255, default=generate_token)
|
secret_key = models.CharField(max_length=255, default=generate_token)
|
||||||
project = models.BooleanField(default=False)
|
project = models.BooleanField(default=False)
|
||||||
@@ -39,6 +41,7 @@ class Webhook(BaseModel):
|
|||||||
module = models.BooleanField(default=False)
|
module = models.BooleanField(default=False)
|
||||||
cycle = models.BooleanField(default=False)
|
cycle = models.BooleanField(default=False)
|
||||||
issue_comment = models.BooleanField(default=False)
|
issue_comment = models.BooleanField(default=False)
|
||||||
|
is_internal = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.workspace.slug} {self.url}"
|
return f"{self.workspace.slug} {self.url}"
|
||||||
|
|||||||
@@ -239,13 +239,6 @@ class WorkspaceMemberInvite(BaseModel):
|
|||||||
class Team(BaseModel):
|
class Team(BaseModel):
|
||||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||||
members = models.ManyToManyField(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
blank=True,
|
|
||||||
related_name="members",
|
|
||||||
through="TeamMember",
|
|
||||||
through_fields=("team", "member"),
|
|
||||||
)
|
|
||||||
workspace = models.ForeignKey(
|
workspace = models.ForeignKey(
|
||||||
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
|
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
|
||||||
)
|
)
|
||||||
@@ -270,33 +263,6 @@ class Team(BaseModel):
|
|||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(BaseModel):
|
|
||||||
workspace = models.ForeignKey(
|
|
||||||
Workspace, on_delete=models.CASCADE, related_name="team_member"
|
|
||||||
)
|
|
||||||
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member")
|
|
||||||
member = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.team.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ["team", "member", "deleted_at"]
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["team", "member"],
|
|
||||||
condition=models.Q(deleted_at__isnull=True),
|
|
||||||
name="team_member_unique_team_member_when_deleted_at_null",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
verbose_name = "Team Member"
|
|
||||||
verbose_name_plural = "Team Members"
|
|
||||||
db_table = "team_members"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceTheme(BaseModel):
|
class WorkspaceTheme(BaseModel):
|
||||||
workspace = models.ForeignKey(
|
workspace = models.ForeignKey(
|
||||||
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
|
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ from .instance import InstanceSerializer
|
|||||||
|
|
||||||
from .configuration import InstanceConfigurationSerializer
|
from .configuration import InstanceConfigurationSerializer
|
||||||
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
||||||
|
from .workspace import WorkspaceSerializer
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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,37 @@
|
|||||||
|
# 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")
|
||||||
|
# Check uniqueness case-insensitively
|
||||||
|
if Workspace.objects.filter(slug__iexact=value).exists():
|
||||||
|
raise serializers.ValidationError("Slug is already in use")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Workspace
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"owner",
|
||||||
|
"logo_url",
|
||||||
|
]
|
||||||
@@ -13,4 +13,8 @@ from .admin import (
|
|||||||
InstanceAdminUserSessionEndpoint,
|
InstanceAdminUserSessionEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .changelog import ChangeLogEndpoint
|
|
||||||
|
from .workspace import (
|
||||||
|
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||||
|
InstanceWorkSpaceEndpoint,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Python imports
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Third party imports
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
|
|
||||||
# plane imports
|
|
||||||
from .base import BaseAPIView
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeLogEndpoint(BaseAPIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
def fetch_change_logs(self):
|
|
||||||
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
# Fetch the changelog
|
|
||||||
if settings.INSTANCE_CHANGELOG_URL:
|
|
||||||
data = self.fetch_change_logs()
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
|
||||||
else:
|
|
||||||
return Response(
|
|
||||||
{"error": "could not fetch changelog please try again later"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
@@ -45,6 +45,7 @@ class InstanceEndpoint(BaseAPIView):
|
|||||||
# Get all the configuration
|
# Get all the configuration
|
||||||
(
|
(
|
||||||
ENABLE_SIGNUP,
|
ENABLE_SIGNUP,
|
||||||
|
DISABLE_WORKSPACE_CREATION,
|
||||||
IS_GOOGLE_ENABLED,
|
IS_GOOGLE_ENABLED,
|
||||||
IS_GITHUB_ENABLED,
|
IS_GITHUB_ENABLED,
|
||||||
GITHUB_APP_NAME,
|
GITHUB_APP_NAME,
|
||||||
@@ -65,6 +66,10 @@ class InstanceEndpoint(BaseAPIView):
|
|||||||
"key": "ENABLE_SIGNUP",
|
"key": "ENABLE_SIGNUP",
|
||||||
"default": os.environ.get("ENABLE_SIGNUP", "0"),
|
"default": os.environ.get("ENABLE_SIGNUP", "0"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "DISABLE_WORKSPACE_CREATION",
|
||||||
|
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "IS_GOOGLE_ENABLED",
|
"key": "IS_GOOGLE_ENABLED",
|
||||||
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
|
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
|
||||||
@@ -125,6 +130,7 @@ class InstanceEndpoint(BaseAPIView):
|
|||||||
data = {}
|
data = {}
|
||||||
# Authentication
|
# Authentication
|
||||||
data["enable_signup"] = ENABLE_SIGNUP == "1"
|
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_google_enabled"] = IS_GOOGLE_ENABLED == "1"
|
||||||
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
|
||||||
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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__iexact=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,
|
||||||
|
)
|
||||||
@@ -29,6 +29,12 @@ class Command(BaseCommand):
|
|||||||
"category": "AUTHENTICATION",
|
"category": "AUTHENTICATION",
|
||||||
"is_encrypted": False,
|
"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",
|
"key": "ENABLE_EMAIL_PASSWORD",
|
||||||
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ from plane.license.api.views import (
|
|||||||
InstanceAdminUserMeEndpoint,
|
InstanceAdminUserMeEndpoint,
|
||||||
InstanceAdminSignOutEndpoint,
|
InstanceAdminSignOutEndpoint,
|
||||||
InstanceAdminUserSessionEndpoint,
|
InstanceAdminUserSessionEndpoint,
|
||||||
ChangeLogEndpoint,
|
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||||
|
InstanceWorkSpaceEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", InstanceEndpoint.as_view(), name="instance"),
|
path("", InstanceEndpoint.as_view(), name="instance"),
|
||||||
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
|
|
||||||
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
|
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
|
||||||
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
|
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
|
||||||
path(
|
path(
|
||||||
@@ -55,4 +55,10 @@ urlpatterns = [
|
|||||||
EmailCredentialCheckEndpoint.as_view(),
|
EmailCredentialCheckEndpoint.as_view(),
|
||||||
name="email-credential-check",
|
name="email-credential-check",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspace-slug-check/",
|
||||||
|
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||||
|
name="instance-workspace-availability",
|
||||||
|
),
|
||||||
|
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ from plane.space.views import (
|
|||||||
ProjectStatesEndpoint,
|
ProjectStatesEndpoint,
|
||||||
ProjectLabelsEndpoint,
|
ProjectLabelsEndpoint,
|
||||||
ProjectMembersEndpoint,
|
ProjectMembersEndpoint,
|
||||||
|
ProjectMetaDataEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"anchor/<str:anchor>/meta/",
|
||||||
|
ProjectMetaDataEndpoint.as_view(),
|
||||||
|
name="project-meta",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"anchor/<str:anchor>/settings/",
|
"anchor/<str:anchor>/settings/",
|
||||||
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
|
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
|
|||||||
from .label import ProjectLabelsEndpoint
|
from .label import ProjectLabelsEndpoint
|
||||||
|
|
||||||
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
||||||
|
|
||||||
|
from .meta import ProjectMetaDataEndpoint
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# third party
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from plane.db.models import DeployBoard, Project
|
||||||
|
|
||||||
|
from .base import BaseAPIView
|
||||||
|
from plane.space.serializer.project import ProjectLiteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMetaDataEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, anchor):
|
||||||
|
try:
|
||||||
|
deploy_board = DeployBoard.objects.filter(
|
||||||
|
anchor=anchor, entity_name="project"
|
||||||
|
).first()
|
||||||
|
except DeployBoard.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_id = deploy_board.entity_identifier
|
||||||
|
project = Project.objects.get(id=project_id)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ProjectLiteSerializer(project)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
|
# Python imports
|
||||||
|
import os
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
from opentelemetry.sdk.resources import Resource
|
from opentelemetry.sdk.resources import Resource
|
||||||
from opentelemetry.instrumentation.django import DjangoInstrumentor
|
from opentelemetry.instrumentation.django import DjangoInstrumentor
|
||||||
import os
|
|
||||||
|
# Global variable to track initialization
|
||||||
|
_TRACER_PROVIDER = None
|
||||||
|
|
||||||
|
|
||||||
def init_tracer():
|
def init_tracer():
|
||||||
"""Initialize OpenTelemetry with proper shutdown handling"""
|
"""Initialize OpenTelemetry with proper shutdown handling"""
|
||||||
|
global _TRACER_PROVIDER
|
||||||
|
|
||||||
# Check if already initialized to prevent double initialization
|
# If already initialized, return existing provider
|
||||||
if trace.get_tracer_provider().__class__.__name__ == "TracerProvider":
|
if _TRACER_PROVIDER is not None:
|
||||||
return
|
return _TRACER_PROVIDER
|
||||||
|
|
||||||
# Configure the tracer provider
|
# Configure the tracer provider
|
||||||
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
|
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
|
||||||
resource = Resource.create({"service.name": service_name})
|
resource = Resource.create({"service.name": service_name})
|
||||||
tracer_provider = TracerProvider(resource=resource)
|
tracer_provider = TracerProvider(resource=resource)
|
||||||
|
|
||||||
|
# Set as global tracer provider
|
||||||
trace.set_tracer_provider(tracer_provider)
|
trace.set_tracer_provider(tracer_provider)
|
||||||
|
|
||||||
# Configure the OTLP exporter
|
# Configure the OTLP exporter
|
||||||
@@ -29,12 +39,20 @@ def init_tracer():
|
|||||||
# Initialize Django instrumentation
|
# Initialize Django instrumentation
|
||||||
DjangoInstrumentor().instrument()
|
DjangoInstrumentor().instrument()
|
||||||
|
|
||||||
|
# Store provider globally
|
||||||
|
_TRACER_PROVIDER = tracer_provider
|
||||||
|
|
||||||
|
# Register shutdown handler
|
||||||
|
atexit.register(shutdown_tracer)
|
||||||
|
|
||||||
return tracer_provider
|
return tracer_provider
|
||||||
|
|
||||||
|
|
||||||
def shutdown_tracer():
|
def shutdown_tracer():
|
||||||
"""Shutdown OpenTelemetry tracers and processors"""
|
"""Shutdown OpenTelemetry tracers and processors"""
|
||||||
provider = trace.get_tracer_provider()
|
global _TRACER_PROVIDER
|
||||||
|
|
||||||
if hasattr(provider, "shutdown"):
|
if _TRACER_PROVIDER is not None:
|
||||||
provider.shutdown()
|
if hasattr(_TRACER_PROVIDER, "shutdown"):
|
||||||
|
_TRACER_PROVIDER.shutdown()
|
||||||
|
_TRACER_PROVIDER = None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# base requirements
|
# base requirements
|
||||||
|
|
||||||
# django
|
# django
|
||||||
Django==4.2.16
|
Django==4.2.17
|
||||||
# rest framework
|
# rest framework
|
||||||
djangorestframework==3.15.2
|
djangorestframework==3.15.2
|
||||||
# postgres
|
# postgres
|
||||||
|
|||||||
+5
-5
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "live",
|
"name": "live",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./src/server.ts",
|
"main": "./src/server.ts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||||
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
|
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"lint": "eslint . --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.20.0",
|
"express": "^4.21.2",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getSchema } from "@tiptap/core";
|
import { getSchema } from "@tiptap/core";
|
||||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
import {
|
||||||
import * as Y from "yjs"
|
prosemirrorJSONToYDoc,
|
||||||
|
yXmlFragmentToProseMirrorRootNode,
|
||||||
|
} from "y-prosemirror";
|
||||||
|
import * as Y from "yjs";
|
||||||
// plane editor
|
// plane editor
|
||||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
import {
|
||||||
|
CoreEditorExtensionsWithoutProps,
|
||||||
|
DocumentEditorExtensionsWithoutProps,
|
||||||
|
} from "@plane/editor/lib";
|
||||||
|
|
||||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||||
...CoreEditorExtensionsWithoutProps,
|
...CoreEditorExtensionsWithoutProps,
|
||||||
@@ -11,7 +17,9 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
|
|||||||
];
|
];
|
||||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||||
|
|
||||||
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
export const getAllDocumentFormatsFromBinaryData = (
|
||||||
|
description: Uint8Array,
|
||||||
|
): {
|
||||||
contentBinaryEncoded: string;
|
contentBinaryEncoded: string;
|
||||||
contentJSON: object;
|
contentJSON: object;
|
||||||
contentHTML: string;
|
contentHTML: string;
|
||||||
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
|||||||
const type = yDoc.getXmlFragment("default");
|
const type = yDoc.getXmlFragment("default");
|
||||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||||
type,
|
type,
|
||||||
documentEditorSchema
|
documentEditorSchema,
|
||||||
).toJSON();
|
).toJSON();
|
||||||
// convert to HTML
|
// convert to HTML
|
||||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||||
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
|||||||
contentJSON,
|
contentJSON,
|
||||||
contentHTML,
|
contentHTML,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
|
export const getBinaryDataFromHTMLString = (
|
||||||
contentBinary: Uint8Array
|
descriptionHTML: string,
|
||||||
|
): {
|
||||||
|
contentBinary: Uint8Array;
|
||||||
} => {
|
} => {
|
||||||
// convert HTML to JSON
|
// convert HTML to JSON
|
||||||
const contentJSON = generateJSON(
|
const contentJSON = generateJSON(
|
||||||
descriptionHTML ?? "<p></p>",
|
descriptionHTML ?? "<p></p>",
|
||||||
DOCUMENT_EDITOR_EXTENSIONS
|
DOCUMENT_EDITOR_EXTENSIONS,
|
||||||
);
|
);
|
||||||
// convert JSON to Y.Doc format
|
// convert JSON to Y.Doc format
|
||||||
const transformedData = prosemirrorJSONToYDoc(
|
const transformedData = prosemirrorJSONToYDoc(
|
||||||
documentEditorSchema,
|
documentEditorSchema,
|
||||||
contentJSON,
|
contentJSON,
|
||||||
"default"
|
"default",
|
||||||
);
|
);
|
||||||
// convert Y.Doc to Uint8Array format
|
// convert Y.Doc to Uint8Array format
|
||||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentBinary: encodedData
|
contentBinary: encodedData,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||||
// extensions
|
// extensions
|
||||||
import { getExtensions } from "@/core/extensions/index.js";
|
import { getExtensions } from "@/core/extensions/index.js";
|
||||||
|
import {
|
||||||
|
DocumentCollaborativeEvents,
|
||||||
|
TDocumentEventsServer,
|
||||||
|
} from "@plane/editor/lib";
|
||||||
// editor types
|
// editor types
|
||||||
import { TUserDetails } from "@plane/editor";
|
import { TUserDetails } from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
|
|||||||
throw Error("Authentication unsuccessful!");
|
throw Error("Authentication unsuccessful!");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async onStateless({ payload, document }) {
|
||||||
|
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||||
|
const response =
|
||||||
|
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||||
|
if (response) {
|
||||||
|
document.broadcastStateless(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
extensions,
|
extensions,
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,18 +26,41 @@ export const updatePageDescription = async (
|
|||||||
|
|
||||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||||
getAllDocumentFormatsFromBinaryData(updatedDescription);
|
getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
// Generate a unique boundary
|
||||||
description_binary: contentBinaryEncoded,
|
const boundary = `----FormBoundary${Date.now().toString()}`;
|
||||||
description_html: contentHTML,
|
|
||||||
description: contentJSON,
|
// Construct the multipart form data manually
|
||||||
};
|
let formData = "";
|
||||||
|
|
||||||
|
// Add binary content
|
||||||
|
formData += `--${boundary}\r\n`;
|
||||||
|
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
|
||||||
|
formData += "Content-Type: application/octet-stream\r\n\r\n";
|
||||||
|
formData += updatedDescription + "\r\n";
|
||||||
|
|
||||||
|
// Add HTML content
|
||||||
|
formData += `--${boundary}\r\n`;
|
||||||
|
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
|
||||||
|
formData += "Content-Type: text/html\r\n\r\n";
|
||||||
|
formData += contentHTML + "\r\n";
|
||||||
|
|
||||||
|
// Add JSON content
|
||||||
|
formData += `--${boundary}\r\n`;
|
||||||
|
formData += 'Content-Disposition: form-data; name="description"\r\n';
|
||||||
|
formData += "Content-Type: application/json\r\n\r\n";
|
||||||
|
formData += JSON.stringify(contentJSON) + "\r\n";
|
||||||
|
|
||||||
|
// End boundary
|
||||||
|
formData += `--${boundary}--\r\n`;
|
||||||
|
|
||||||
await pageService.updateDescription(
|
await pageService.updateDescription(
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
projectId,
|
projectId,
|
||||||
pageId,
|
pageId,
|
||||||
payload,
|
formData,
|
||||||
|
boundary,
|
||||||
cookie,
|
cookie,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -46,6 +69,8 @@ export const updatePageDescription = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update the service method
|
||||||
|
|
||||||
const fetchDescriptionHTMLAndTransform = async (
|
const fetchDescriptionHTMLAndTransform = async (
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -90,7 +115,9 @@ export const fetchPageDescriptionBinary = async (
|
|||||||
pageId,
|
pageId,
|
||||||
cookie,
|
cookie,
|
||||||
);
|
);
|
||||||
|
console.log("response", response);
|
||||||
const binaryData = new Uint8Array(response);
|
const binaryData = new Uint8Array(response);
|
||||||
|
console.log("binaryData", binaryData);
|
||||||
|
|
||||||
if (binaryData.byteLength === 0) {
|
if (binaryData.byteLength === 0) {
|
||||||
const binary = await fetchDescriptionHTMLAndTransform(
|
const binary = await fetchDescriptionHTMLAndTransform(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class PageService extends APIService {
|
|||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
cookie: string
|
cookie: string,
|
||||||
): Promise<TPage> {
|
): Promise<TPage> {
|
||||||
return this.get(
|
return this.get(
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||||
@@ -20,7 +20,7 @@ export class PageService extends APIService {
|
|||||||
headers: {
|
headers: {
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -32,7 +32,7 @@ export class PageService extends APIService {
|
|||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
cookie: string
|
cookie: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return this.get(
|
return this.get(
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||||
@@ -42,7 +42,7 @@ export class PageService extends APIService {
|
|||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
},
|
},
|
||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -54,21 +54,19 @@ export class PageService extends APIService {
|
|||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
data: {
|
formData: string,
|
||||||
description_binary: string;
|
boundary: string,
|
||||||
description_html: string;
|
cookie: string,
|
||||||
description: object;
|
|
||||||
},
|
|
||||||
cookie: string
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return this.patch(
|
return this.patch(
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||||
data,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "latest",
|
"prettier": "latest",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||||
"turbo": "^2.3.2"
|
"turbo": "^2.3.3"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.22",
|
"packageManager": "yarn@1.22.22",
|
||||||
"name": "plane"
|
"name": "plane"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./auth";
|
|
||||||
export * from "./issue";
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/constants",
|
"name": "@plane/constants",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts"
|
"main": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
||||||
|
// PI Base Url
|
||||||
|
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
|
||||||
|
// God Mode Admin App Base Url
|
||||||
|
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||||
|
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||||
|
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
|
||||||
|
// Publish App Base Url
|
||||||
|
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||||
|
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||||
|
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
|
||||||
|
// Live App Base Url
|
||||||
|
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
|
||||||
|
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
|
||||||
|
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
|
||||||
|
// plane website url
|
||||||
|
export const WEBSITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./auth";
|
||||||
|
export * from "./endpoints";
|
||||||
|
export * from "./issue";
|
||||||
|
export * from "./workspace";
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export const ORGANIZATION_SIZE = [
|
||||||
|
"Just myself",
|
||||||
|
"2-10",
|
||||||
|
"11-50",
|
||||||
|
"51-200",
|
||||||
|
"201-500",
|
||||||
|
"500+",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RESTRICTED_URLS = [
|
||||||
|
"404",
|
||||||
|
"accounts",
|
||||||
|
"api",
|
||||||
|
"create-workspace",
|
||||||
|
"god-mode",
|
||||||
|
"installations",
|
||||||
|
"invitations",
|
||||||
|
"onboarding",
|
||||||
|
"profile",
|
||||||
|
"spaces",
|
||||||
|
"workspace-invitations",
|
||||||
|
"password",
|
||||||
|
"flags",
|
||||||
|
"monitor",
|
||||||
|
"monitoring",
|
||||||
|
"ingest",
|
||||||
|
"plane-pro",
|
||||||
|
"plane-ultimate",
|
||||||
|
"enterprise",
|
||||||
|
"plane-enterprise",
|
||||||
|
"disco",
|
||||||
|
"silo",
|
||||||
|
"chat",
|
||||||
|
"calendar",
|
||||||
|
"drive",
|
||||||
|
"channels",
|
||||||
|
"upgrade",
|
||||||
|
"billing",
|
||||||
|
"sign-in",
|
||||||
|
"sign-up",
|
||||||
|
"signin",
|
||||||
|
"signup",
|
||||||
|
"config",
|
||||||
|
"live",
|
||||||
|
"admin",
|
||||||
|
"m",
|
||||||
|
"import",
|
||||||
|
"importers",
|
||||||
|
"integrations",
|
||||||
|
"integration",
|
||||||
|
"configuration",
|
||||||
|
"initiatives",
|
||||||
|
"initiative",
|
||||||
|
"config",
|
||||||
|
"workflow",
|
||||||
|
"workflows",
|
||||||
|
"epics",
|
||||||
|
"epic",
|
||||||
|
"story",
|
||||||
|
"mobile",
|
||||||
|
"dashboard",
|
||||||
|
"desktop",
|
||||||
|
"onload",
|
||||||
|
"real-time",
|
||||||
|
"one",
|
||||||
|
"pages",
|
||||||
|
"mobile",
|
||||||
|
"business",
|
||||||
|
"pro",
|
||||||
|
"settings",
|
||||||
|
"monitor",
|
||||||
|
"license",
|
||||||
|
"licenses",
|
||||||
|
"instances",
|
||||||
|
"instance",
|
||||||
|
];
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor",
|
"name": "@plane/editor",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -36,8 +37,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.26.4",
|
"@floating-ui/react": "^0.26.4",
|
||||||
"@hocuspocus/provider": "^2.13.5",
|
"@hocuspocus/provider": "^2.13.5",
|
||||||
"@plane/helpers": "*",
|
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
|
"@plane/utils": "*",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
"@tiptap/extension-blockquote": "^2.1.13",
|
"@tiptap/extension-blockquote": "^2.1.13",
|
||||||
"@tiptap/extension-character-count": "^2.6.5",
|
"@tiptap/extension-character-count": "^2.6.5",
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
|
// types
|
||||||
|
import { TExtensions } from "@/types";
|
||||||
|
|
||||||
export const CoreEditorAdditionalExtensions = (): Extensions => [];
|
type Props = {
|
||||||
|
disabledExtensions: TExtensions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||||
|
const {} = props;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
|
// types
|
||||||
|
import { TExtensions } from "@/types";
|
||||||
|
|
||||||
export const CoreReadOnlyEditorAdditionalExtensions = (): Extensions => [];
|
type Props = {
|
||||||
|
disabledExtensions: TExtensions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||||
|
const {} = props;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ type Props = {
|
|||||||
|
|
||||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||||
const { disabledExtensions } = _props;
|
const { disabledExtensions } = _props;
|
||||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
|
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
SlashCommands({
|
||||||
|
disabledExtensions,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./core";
|
export * from "./core";
|
||||||
export * from "./document-extensions";
|
export * from "./document-extensions";
|
||||||
|
export * from "./slash-commands";
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// extensions
|
||||||
|
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||||
|
// types
|
||||||
|
import { TExtensions } from "@/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
disabledExtensions: TExtensions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||||
|
const {} = props;
|
||||||
|
const options: TSlashCommandAdditionalOption[] = [];
|
||||||
|
return options;
|
||||||
|
};
|
||||||
@@ -1 +0,0 @@
|
|||||||
export type TEditorAdditionalCommands = never;
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
export * from "./editor";
|
|
||||||
export * from "./issue-embed";
|
export * from "./issue-embed";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
// components
|
// components
|
||||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||||
// constants
|
// constants
|
||||||
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
|||||||
containerClassName,
|
containerClassName,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||||
|
editable,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
embedHandler,
|
embedHandler,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
@@ -43,23 +44,25 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// use document editor
|
// use document editor
|
||||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
|
||||||
onTransaction,
|
useCollaborativeEditor({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editorClassName,
|
editable,
|
||||||
embedHandler,
|
editorClassName,
|
||||||
extensions,
|
embedHandler,
|
||||||
fileHandler,
|
extensions,
|
||||||
forwardedRef,
|
fileHandler,
|
||||||
handleEditorReady,
|
forwardedRef,
|
||||||
id,
|
handleEditorReady,
|
||||||
mentionHandler,
|
id,
|
||||||
placeholder,
|
mentionHandler,
|
||||||
realtimeConfig,
|
onTransaction,
|
||||||
serverHandler,
|
placeholder,
|
||||||
tabIndex,
|
realtimeConfig,
|
||||||
user,
|
serverHandler,
|
||||||
});
|
tabIndex,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
const editorContainerClassNames = getEditorClassNames({
|
const editorContainerClassNames = getEditorClassNames({
|
||||||
noBorder: true,
|
noBorder: true,
|
||||||
@@ -67,9 +70,30 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
|||||||
containerClassName,
|
containerClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function documentIndexedDbEntry(dbName: string) {
|
||||||
|
try {
|
||||||
|
const databases = await indexedDB.databases();
|
||||||
|
const hasEntry = databases.some((db) => db.name === dbName);
|
||||||
|
setHasIndexedDbEntry(hasEntry);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking database existence:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentIndexedDbEntry(id);
|
||||||
|
}, [id, localProvider]);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
|
// Wait until we know about IndexedDB status
|
||||||
|
if (hasIndexedDbEntry === null) return null;
|
||||||
|
|
||||||
|
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
|
||||||
|
return <DocumentContentLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
|
|||||||
+2
@@ -15,6 +15,7 @@ import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/ty
|
|||||||
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
||||||
const {
|
const {
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
disabledExtensions,
|
||||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
embedHandler,
|
embedHandler,
|
||||||
@@ -37,6 +38,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||||
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
extensions,
|
extensions,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
[editor, cleanup]
|
[editor, cleanup]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("rendered");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||||
@@ -139,12 +140,12 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
id={id}
|
id={id}
|
||||||
>
|
>
|
||||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||||
{editor.isEditable && (
|
{/* {editor.isEditable && ( */}
|
||||||
<>
|
{/* <> */}
|
||||||
<BlockMenu editor={editor} />
|
{/* <BlockMenu editor={editor} /> */}
|
||||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
|
||||||
</>
|
{/* </> */}
|
||||||
)}
|
{/* )} */}
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && linkViewProps && coordinates && (
|
{isOpen && linkViewProps && coordinates && (
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
|
|||||||
// hooks
|
// hooks
|
||||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||||
// types
|
// types
|
||||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
|
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
|
||||||
|
|
||||||
interface IDocumentReadOnlyEditor {
|
interface IDocumentReadOnlyEditor {
|
||||||
|
disabledExtensions: TExtensions[];
|
||||||
id: string;
|
id: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
containerClassName: string;
|
containerClassName: string;
|
||||||
@@ -31,6 +32,7 @@ interface IDocumentReadOnlyEditor {
|
|||||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||||
const {
|
const {
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
disabledExtensions,
|
||||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
embedHandler,
|
embedHandler,
|
||||||
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
extensions,
|
extensions,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
|||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
disabledExtensions,
|
||||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||||
|
editable,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
extensions,
|
extensions,
|
||||||
id,
|
id,
|
||||||
@@ -37,6 +39,8 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
editable,
|
||||||
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
enableHistory: true,
|
enableHistory: true,
|
||||||
extensions,
|
extensions,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types";
|
|||||||
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||||
const {
|
const {
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
disabledExtensions,
|
||||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
fileHandler,
|
fileHandler,
|
||||||
@@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
|
|||||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||||
|
|
||||||
const RichTextEditor = (props: IRichTextEditor) => {
|
const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
const {
|
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
|
||||||
disabledExtensions,
|
|
||||||
dragDropEnabled,
|
|
||||||
bubbleMenuEnabled = true,
|
|
||||||
extensions: externalExtensions = [],
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
const extensions = [
|
const extensions = [
|
||||||
@@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
if (!disabledExtensions?.includes("slash-commands")) {
|
if (!disabledExtensions?.includes("slash-commands")) {
|
||||||
extensions.push(SlashCommands());
|
extensions.push(
|
||||||
|
SlashCommands({
|
||||||
|
disabledExtensions,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const DocumentCollaborativeEvents = {
|
||||||
|
lock: { client: "locked", server: "lock" },
|
||||||
|
unlock: { client: "unlocked", server: "unlock" },
|
||||||
|
archive: { client: "archived", server: "archive" },
|
||||||
|
unarchive: { client: "unarchived", server: "unarchive" },
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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];
|
||||||
|
},
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user