Compare commits

...

65 Commits

Author SHA1 Message Date
pablohashescobar
729bad4344 fix: migration 2024-08-14 13:57:59 +05:30
dependabot[bot]
5f26ce2466 chore(deps): bump axios from 1.7.2 to 1.7.4 (#5364)
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 13:41:16 +05:30
guru_sainath
c02a54ef31 [WEB-2214] chore: migration for user favorite, file asset, and deploy board (#5339)
* chore: migrations for user favorite, file asset, and deply boards

* fix: migration fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-08-14 13:07:08 +05:30
Anmol Singh Bhatia
d9c9d85d38 [WEB-2221] fix: app sidebar and favorites improvement (#5357)
* fix: project collapsible toggle

* fix: project favorite redirection

* chore: favorite redirection scroll into view implementation

* fix: use favorite item details project details
2024-08-14 12:53:53 +05:30
pablohashescobar
edb04a33fd chore: issue type migration 2024-08-14 12:46:31 +05:30
NarayanBavisetti
033e7703b4 chore: project issue type migration 2024-08-13 21:53:51 +05:30
Satish Gandham
3f4c95412d Fix the missing eexport in EE folder (#5358) 2024-08-12 19:59:53 +05:30
Aaryan Khandelwal
4792c1cdf5 fix: project modal shortcut (#5353) 2024-08-12 19:17:10 +05:30
Akshita Goyal
041f2b16c3 [WEB-1986] chore: Build Fix, project page import (#5356)
* chore: seperated project components for CE

* chore: splitted the code for project creation form

* fix: code structure optimization

* fix: project page root moved

* fix: synced with preview

* fix: component splitting and refactoring

* fix: build error

* fix: import error
2024-08-12 19:12:35 +05:30
Akshita Goyal
91693b2269 chore: seperated project components for CE (#5324)
* chore: seperated project components for CE

* chore: splitted the code for project creation form

* fix: code structure optimization

* fix: project page root moved

* fix: synced with preview

* fix: component splitting and refactoring

* fix: build error
2024-08-12 18:24:42 +05:30
Aaryan Khandelwal
3ffaa4f2ca [WEB-2217] fix: drag handle positioning and action (#5349)
* fix: drag handle click action

* fix: drag handle positioning
2024-08-12 15:51:23 +05:30
Henit Chobisa
f817d70f78 fix: unable to added issues to a completed cycle (#5348) 2024-08-12 13:04:07 +05:30
Anmol Singh Bhatia
269e6ccd18 [WEB-2204] chore: asset optimization (#5346)
* chore: dashboard empty state asset updated and remove unwanted asset

* chore: workspace active cycle asset updated

* chore: onboarding pages asset updated and remove unwanted asset from web and space app

* chore: onboarding profile setup and create workspace asset updated and remove unwanted asset from web and space app

* chore: code refactor
2024-08-10 12:09:57 +05:30
M. Palanikannan
6e435df613 fix: state creation from external apis (#5345) 2024-08-09 19:29:17 +05:30
Aaryan Khandelwal
85f8fe9247 [WEB-2045] dev: editor variable font sizes and styles support (#5340)
* chore: added variable font size and font style support

* chore: remove font style switcher

* chore: update typography
2024-08-09 19:22:47 +05:30
Anmol Singh Bhatia
6d0cf1b4e9 [WEB-2190] fix: unauthorised delete and redirections (#5342)
* fix: cycle unauthorised delete action redirection

* fix: intake unauthorised delete action redirection
2024-08-09 19:14:38 +05:30
Anmol Singh Bhatia
679b0b6465 [WEB-2189] fix: issue peek overview and issue detail unauthorised delete action (#5341)
* fix: issue peek overview and issue detail delete action

* chore: code refactor

* chore: code refactor
2024-08-09 19:09:25 +05:30
Anmol Singh Bhatia
421bf2abc7 [WEB-2178] fix: empty folder title (#5344)
* fix: empty folder title

* fix: collapsible overflow issue
2024-08-09 19:03:25 +05:30
guru_sainath
f457048644 chore: handling the archived module ids in the issue list and issue detail endpoints (#5343) 2024-08-09 17:16:37 +05:30
Anmol Singh Bhatia
24b1e71cbf [WEB-2211] fix: input autoComplete (#5333)
* fix: input autoComplete

* chore: code refactor

* chore: set autoComplete on for email, password and name
2024-08-09 16:42:31 +05:30
vamsi
0b72bd373b fix: adding signup enabled flag in instance settings endpoint 2024-08-09 16:35:52 +05:30
vamsi
fc205efd6d fix: remove user count from instance settings 2024-08-09 16:23:53 +05:30
dependabot[bot]
f54e1b922d chore(deps): bump django in /apiserver/requirements (#5337)
Bumps [django](https://github.com/django/django) from 4.2.14 to 4.2.15.
- [Commits](https://github.com/django/django/compare/4.2.14...4.2.15)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-08 20:18:05 +05:30
timf34
644d1db44c Fixed typo in manifest.json (#5310) 2024-08-08 20:13:09 +05:30
Manish Gupta
b05d72e29a fixed setup.sh for macos support (#5336)
* fixed setup.sh for macos support

* updated as per coderabbit suggestions
2024-08-08 20:13:01 +05:30
Anmol Singh Bhatia
48cb0f5afc [WEB-2202] chore: user favorites mutation and code refactor (#5330)
* chore: fav item drag and drop improvement

* chore: user favorite type updated

* chore: user favorites helper function added

* dev: favorite item common component added

* dev: favorite item component added and code refactor

* fix: build error

* chore: code refactor

* chore: code refactor

* chore: code refactor
2024-08-08 20:11:18 +05:30
guru_sainath
a2098ffb5e chore: made cursor update on created_by in issue poprities pane in issue deatil, and issue peekoverview (#5331) 2024-08-08 17:13:52 +05:30
rahulramesha
3b21018154 fix issue description in space app's peek overview (#5328) 2024-08-08 17:00:15 +05:30
Anmol Singh Bhatia
1b624ef3ac fix: work log activity validation (#5332) 2024-08-08 16:43:45 +05:30
Aaryan Khandelwal
be82cbb8e8 [WEB-2047] chore: add missing exports (#5334)
* chore: add missing exports

* chore: delete unnecessary files
2024-08-08 16:41:49 +05:30
Aaryan Khandelwal
e805c49e69 [WEB-2047] refactor: editor side menu (#5329)
* refactor: editor side menu

* chore: change editor side menu selector to be id based
2024-08-08 14:48:05 +05:30
Aaryan Khandelwal
943dd593fa dev: editor extensions feature flagging (#5279) 2024-08-07 20:06:15 +05:30
Nikhil
520938ab5c chore: add rate limiting in magic generate endpoint (#5322) 2024-08-07 19:35:00 +05:30
Anmol Singh Bhatia
86909cff14 [WEB-2182] chore: user favorites item enhancements (#5321)
* fix: user favorties item icon type and alignment

* chore: user favorite item clickable area improvement
2024-08-07 17:56:20 +05:30
Anmol Singh Bhatia
598846adc4 [WEB-2182] chore: user favorites improvement (#5318)
* chore: favorite collapsible spacing

* chore: favorite collapsible tooltip added

* chore: user favorites icon improvement and code refactor

* chore: favorites empty state added

* chore: project identifier message updated

* chore: favorties collapsible improvement

* chore: code refactor

* fix: build error

* fix: app sidebar draft issue z-index
2024-08-07 15:28:25 +05:30
rahulramesha
91142659ca [WEB-2192] fix: order of state groups in space app (#5317)
* chore: added sequence in the states endpoint

* fix state grouping order in space app

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-07 13:49:45 +05:30
Akshita Goyal
806eae0139 fix: reloading on favorite action (#5313) 2024-08-07 12:58:24 +05:30
Anmol Singh Bhatia
3279bb6ac9 [WEB-2182] fix: favorite item alignment and redirection (#5316)
* fix: favorite item alignment

* fix: favorite item redirection

* chore: code refactor
2024-08-06 18:21:53 +05:30
Henit Chobisa
976784bc84 feat: added deleted_at as read-only property for the label serializer (#5306) 2024-08-06 17:26:40 +05:30
Henit Chobisa
983769a944 feat: added endpoint for creating service tokens (#5312)
* feat: added endpoint for creating service tokens

* fix: removed filtering of APITokens without being a service token
2024-08-06 17:26:20 +05:30
Anmol Singh Bhatia
3f9523804b fix: delete action mutation (#5315) 2024-08-06 16:42:13 +05:30
guru_sainath
9715922fc1 [WEB-2103] chore: intercom trigger updates from sidebar and command palette helper actions (#5314)
* chore: handled intercom operations programatically.

* fix: app sidebar improvement

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-08-06 16:02:01 +05:30
Nikhil
2fa92fda75 chore: update cache command to delete the cache entry for the cache key (#5309) 2024-08-06 13:34:21 +05:30
Prateek Shourya
95641f31af fix: sidebar help section padding. (#5311) 2024-08-06 13:08:39 +05:30
Akshita Goyal
a93dfc1b8d fix: favorite improvements (#5307) 2024-08-05 20:17:59 +05:30
Bavisetti Narayan
07574b4222 [WEB-2092] chore: favorite delete changes (#5302)
* chore: favorite delete changes

* chore: removed deploy board deletion

* chore: favorite entity deletion
2024-08-05 17:40:49 +05:30
Akshita Goyal
91e4da502a [WEB-1907] Fix/favorite move out of folder (#5305)
* fix: fav feature review changes

* fix: enabled moving out of folder on hovering

* fix: removed consoles
2024-08-05 17:06:53 +05:30
Akshita Goyal
fafa2c06c3 fix: fav feature review changes (#5304) 2024-08-05 16:33:30 +05:30
sriram veeraghanta
86a982e8ce fix: upgrading the turbo version 2024-08-05 15:35:57 +05:30
Aaryan Khandelwal
dd806dfa2f chore: remove yjs resolve (#5301) 2024-08-05 15:30:17 +05:30
rahulramesha
42462c78f7 modify cycle options (#5299) 2024-08-05 15:15:11 +05:30
Anmol Singh Bhatia
21343034c2 [WEB-2173] fix: app sidebar spacing and build error (#5300)
* fix: app sidebar spacing

* fix: build error
2024-08-05 15:13:51 +05:30
Aaryan Khandelwal
f9e7a5826b [WEB-2166] chore: smoother drag experience in the document editor (#5296)
* chore: update drag and drop behaviour

* chore: update drag and drop behaviour

* chore: disable pwa updates on development mode
2024-08-05 13:59:14 +05:30
Aaryan Khandelwal
c99f2fcdbb fix: yjs duplicate import error (#5297) 2024-08-05 13:37:35 +05:30
guru_sainath
0619f1b6d1 [WEB-2103]: chore: Intercom integration (#5295)
* fix: intecom sdk integration

* dev: integrated intercom in god-mode

* dev: intercom default value true

* dev: updated intercom keys in intercom provider

* chore: added restriction values

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-08-05 13:37:11 +05:30
Akshita Goyal
34820eec7a [WEB-1907] Fix: favorites (#5292)
* chore: workspace user favorites

* chore: added project id in entity type

* chore: removed the extra key

* chore: removed the project member filter

* chore: updated the project permission layer

* chore: updated the workspace group favorite filter

* fix: project favorite toggle

* chore: Fav feature

* fix: build errors + added navigation

* fix: added remove entity icon

* fix: nomenclature

* chore: hard delete favorites

* fix: review changes

* fix: added optimistic addition to the store

* chore: user favorite hard delete

* fix: linting fixed

* fix: favorite bugs

* fix: ts bugs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-04 10:15:26 +05:30
rahulramesha
93e6c3b6e0 Optimistically update distribution (#5290) 2024-08-04 10:14:25 +05:30
Aaryan Khandelwal
8f8a97589d fix: casing throughout the platform (#5293) 2024-08-04 10:09:29 +05:30
rahulramesha
3a5c77e8a4 fetch issue activity on peek issue update (#5289) 2024-08-02 19:00:30 +05:30
guru_sainath
79fbcaa2b2 fix: initial fetch filters is not being applied when we have a undefined currentTab in params (#5288) 2024-08-02 18:20:52 +05:30
Bavisetti Narayan
76983a57e9 [WEB-2092] chore: soft delete migration (#5286)
* chore: soft delete migration

* chore: page deletion role check
2024-08-02 13:15:59 +05:30
Anmol Singh Bhatia
e9b1151702 fix: project intake store (#5283) 2024-08-02 12:31:00 +05:30
Akshita Goyal
f4f5e5a0d3 [WEB-1907] feat: Favorites Enhancements (#5262)
* chore: workspace user favorites

* chore: added project id in entity type

* chore: removed the extra key

* chore: removed the project member filter

* chore: updated the project permission layer

* chore: updated the workspace group favorite filter

* fix: project favorite toggle

* chore: Fav feature

* fix: build errors + added navigation

* fix: added remove entity icon

* fix: nomenclature

* chore: hard delete favorites

* fix: review changes

* fix: added optimistic addition to the store

* chore: user favorite hard delete

* fix: linting fixed

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-02 12:25:26 +05:30
sriram veeraghanta
f55c135052 fix: adding icons 2024-08-01 21:29:31 +05:30
sriram veeraghanta
8924e303da fix: PWA related fixes and mainfest added 2024-08-01 21:08:57 +05:30
297 changed files with 7136 additions and 5509 deletions

View File

@@ -9,8 +9,9 @@ import { IInstance, IInstanceAdmin } from "@plane/types";
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -20,11 +21,13 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { updateInstanceInfo } = useInstance();
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance>>({
defaultValues: {
@@ -36,7 +39,16 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
console.log("payload", payload);
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
@@ -74,6 +86,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled
/>
</div>
@@ -93,7 +106,8 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
</div>
<div className="space-y-3">
<div className="text-lg font-medium">Telemetry</div>
<div className="text-lg font-medium">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">

View File

@@ -0,0 +1,82 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<MessageSquare className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -7,7 +7,7 @@ import { GeneralConfigurationForm } from "./form";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
console.log("instance", instance);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">

View File

@@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 min-w-[10rem] ${
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}

View File

@@ -174,6 +174,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -190,6 +191,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
@@ -208,6 +210,7 @@ export const InstanceSetupForm: FC = (props) => {
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
@@ -247,6 +250,7 @@ export const InstanceSetupForm: FC = (props) => {
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button

View File

@@ -57,8 +57,6 @@ export const InstanceSignInForm: FC = (props) => {
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
console.log("csrfToken", csrfToken);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
@@ -129,6 +127,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -147,6 +146,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button

View File

@@ -18,7 +18,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.6.7",
"axios": "^1.7.4",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",

View File

@@ -269,6 +269,7 @@ class LabelSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
@@ -430,4 +431,3 @@ class IssueExpandSerializer(BaseSerializer):
"created_at",
"updated_at",
]

View File

@@ -23,6 +23,7 @@ class StateSerializer(BaseSerializer):
"updated_at",
"workspace",
"project",
"deleted_at",
]

View File

@@ -35,6 +35,7 @@ from plane.db.models import (
IssueAttachment,
IssueLink,
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
@@ -408,6 +409,12 @@ class CycleAPIEndpoint(BaseAPIView):
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -665,17 +672,6 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(

View File

@@ -28,6 +28,7 @@ from plane.db.models import (
ModuleLink,
Project,
ProjectMember,
UserFavorite,
)
from .base import BaseAPIView
@@ -304,6 +305,13 @@ class ModuleAPIEndpoint(BaseAPIView):
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -26,6 +26,7 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
UserFavorite,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -356,6 +357,12 @@ class ProjectAPIEndpoint(BaseAPIView):
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="project",
entity_identifier=pk,
project_id=pk,
).delete()
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -121,3 +121,5 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer

View File

@@ -0,0 +1,101 @@
from rest_framework import serializers
from plane.db.models import (
UserFavorite,
Cycle,
Module,
Issue,
IssueView,
Page,
Project,
)
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["id", "name", "logo_props"]
class PageFavoriteLiteSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
class Meta:
model = Page
fields = ["id", "name", "logo_props", "project_id"]
def get_project_id(self, obj):
project = (
obj.projects.first()
) # This gets the first project related to the Page
return project.id if project else None
class CycleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Cycle
fields = ["id", "name", "logo_props", "project_id"]
class ModuleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Module
fields = ["id", "name", "logo_props", "project_id"]
class ViewFavoriteSerializer(serializers.ModelSerializer):
class Meta:
model = IssueView
fields = ["id", "name", "logo_props", "project_id"]
def get_entity_model_and_serializer(entity_type):
entity_map = {
"cycle": (Cycle, CycleFavoriteLiteSerializer),
"issue": (Issue, None),
"module": (Module, ModuleFavoriteLiteSerializer),
"view": (IssueView, ViewFavoriteSerializer),
"page": (Page, PageFavoriteLiteSerializer),
"project": (Project, ProjectFavoriteLiteSerializer),
"folder": (None, None),
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserFavorite
fields = [
"id",
"entity_type",
"entity_identifier",
"entity_data",
"name",
"is_folder",
"sequence",
"parent",
"workspace_id",
"project_id",
]
read_only_fields = ["workspace", "created_by", "updated_by"]
def get_entity_data(self, obj):
entity_type = obj.entity_type
entity_identifier = obj.entity_identifier
entity_model, entity_serializer = get_entity_model_and_serializer(
entity_type
)
if entity_model and entity_serializer:
try:
entity = entity_model.objects.get(pk=entity_identifier)
return entity_serializer(entity).data
except entity_model.DoesNotExist:
return None
return None

View File

@@ -533,6 +533,7 @@ class IssueReactionSerializer(BaseSerializer):
"project",
"issue",
"actor",
"deleted_at"
]
@@ -551,7 +552,7 @@ class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor"]
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
class IssueVoteSerializer(BaseSerializer):

View File

@@ -1,5 +1,5 @@
from django.urls import path
from plane.app.views import ApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -13,5 +13,10 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]

View File

@@ -25,6 +25,8 @@ from plane.app.views import (
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
@@ -237,4 +239,19 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
path(
"workspaces/<str:slug>/user-favorites/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/group/",
WorkspaceFavoriteGroupEndpoint.as_view(),
name="workspace-user-favorites-groups",
),
]

View File

@@ -40,6 +40,11 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
@@ -169,8 +174,10 @@ from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import ApiTokenEndpoint
from .api import (
ApiTokenEndpoint,
ServiceApiTokenEndpoint,
)
from .page.base import (
PageViewSet,

View File

@@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug
user=request.user, workspace__slug=slug, is_service=False
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -61,6 +61,7 @@ class ApiTokenEndpoint(BaseAPIView):
workspace__slug=slug,
user=request.user,
pk=pk,
is_service=False,
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -78,3 +79,44 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
workspace=workspace,
is_service=True,
).first()
if api_token:
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_200_OK,
)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_201_CREATED,
)

View File

@@ -1086,6 +1086,13 @@ class CycleViewSet(BaseViewSet):
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
user=request.user,
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -160,7 +160,8 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@@ -64,7 +64,6 @@ from plane.utils.user_timezone_converter import user_timezone_converter
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -438,7 +437,8 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -626,7 +626,6 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
project_id=project_id,
is_active=True,
).exists():
return Response(
{"error": "Only admin can perform this action"},
status=status.HTTP_403_FORBIDDEN,

View File

@@ -444,6 +444,12 @@ class ModuleViewSet(BaseViewSet):
)
)
if not queryset.exists():
return Response(
{"error": "Module not found"},
status=status.HTTP_404_NOT_FOUND,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
@@ -776,6 +782,14 @@ class ModuleViewSet(BaseViewSet):
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
user=request.user,
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -333,8 +333,14 @@ class PageViewSet(BaseViewSet):
pk=pk, workspace__slug=slug, projects__id=project_id
)
if not page.owned_by_id != request.user.id and not (
ProjectMember.objects.filter(
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
@@ -347,33 +353,19 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_403_FORBIDDEN,
)
# only the owner and admin can delete the page
if (
ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
is_active=True,
role__gt=20,
).exists()
or request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can delete the page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
status=status.HTTP_400_BAD_REQUEST,
)
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
# Delete the user favorite page
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="page",
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -52,8 +52,14 @@ class IssueSearchEndpoint(BaseAPIView):
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
~Q(
issue_related__issue=issue,
issue_related__deleted_at__isnull=True,
),
~Q(
issue_relation__related_issue=issue,
issue_related__deleted_at__isnull=True,
),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)

View File

@@ -141,6 +141,13 @@ class WorkspaceViewViewSet(BaseViewSet):
or workspace_view.owned_by == request.user
):
workspace_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
workspace__slug=slug,
entity_identifier=pk,
project__isnull=True,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},
@@ -216,7 +223,8 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -437,6 +445,13 @@ class IssueViewViewSet(BaseViewSet):
or project_view.owned_by_id == request.user.id
):
project_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},

View File

@@ -0,0 +1,88 @@
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Django modules
from django.db.models import Q
# Module imports
from plane.app.views.base import BaseAPIView
from plane.db.models import UserFavorite, Workspace
from plane.app.serializers import UserFavoriteSerializer
from plane.app.permissions import WorkspaceEntityPermission
class WorkspaceFavoriteEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent__isnull=True,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
serializer = UserFavoriteSerializer(
favorite, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug, favorite_id):
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent_id=favorite_id,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -29,6 +29,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
class MagicGenerateEndpoint(APIView):
@@ -37,6 +38,10 @@ class MagicGenerateEndpoint(APIView):
AllowAny,
]
throttle_classes = [
AuthenticationThrottle,
]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()

View File

@@ -6,8 +6,23 @@ from django.core.management import BaseCommand
class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values"
def add_arguments(self, parser):
# Positional argument
parser.add_argument(
"--key", type=str, nargs="?", help="Key to clear cache"
)
def handle(self, *args, **options):
try:
if options["key"]:
cache.delete(options["key"])
self.stdout.write(
self.style.SUCCESS(
f"Cache Cleared for key: {options['key']}"
)
)
return
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
return

View File

@@ -1,423 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-26 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0072_issueattachment_external_id_and_more'),
]
operations = [
migrations.AddField(
model_name='analyticview',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='apiactivitylog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='apitoken',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='commentreaction',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='cycle',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='cyclefavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='cycleissue',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='cycleuserproperties',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='dashboard',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='dashboardwidget',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='deployboard',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='emailnotificationlog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='estimate',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='estimatepoint',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='exporterhistory',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='fileasset',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='githubcommentsync',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='githubissuesync',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='githubrepository',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='githubrepositorysync',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='globalview',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='importer',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='inbox',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='inboxissue',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='integration',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issue',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueactivity',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueassignee',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueattachment',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueblocker',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuecomment',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuelabel',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuelink',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuemention',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuereaction',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuerelation',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuesequence',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuesubscriber',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuetype',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueuserproperty',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueview',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issueviewfavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='issuevote',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='label',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='module',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='modulefavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='moduleissue',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='modulelink',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='modulemember',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='moduleuserproperties',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='notification',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='page',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='pageblock',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='pagefavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='pagelabel',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='pagelog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='pageversion',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='project',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectdeployboard',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectfavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectidentifier',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectmember',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectmemberinvite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectpage',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='projectpublicmember',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='slackprojectsync',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='socialloginconnection',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='state',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='team',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='teammember',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='teampage',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='userfavorite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='usernotificationpreference',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='userrecentvisit',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='webhook',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='webhooklog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspace',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspaceintegration',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspacemember',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspacememberinvite',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspacetheme',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='workspaceuserproperties',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
]

View File

@@ -1,75 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-31 12:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"db",
"0073_analyticview_deleted_at_apiactivitylog_deleted_at_and_more",
),
]
operations = [
migrations.AlterUniqueTogether(
name="label",
unique_together={("name", "project", "deleted_at")},
),
migrations.AlterUniqueTogether(
name="module",
unique_together={("name", "project", "deleted_at")},
),
migrations.AlterUniqueTogether(
name="project",
unique_together={
("identifier", "workspace", "deleted_at"),
("name", "workspace", "deleted_at"),
},
),
migrations.AlterUniqueTogether(
name="projectidentifier",
unique_together={("name", "workspace", "deleted_at")},
),
migrations.AddConstraint(
model_name="label",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "project"),
name="label_unique_name_project_when_deleted_at_null",
),
),
migrations.AddConstraint(
model_name="module",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "project"),
name="module_unique_name_project_when_deleted_at_null",
),
),
migrations.AddConstraint(
model_name="project",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("identifier", "workspace"),
name="project_unique_identifier_workspace_when_deleted_at_null",
),
),
migrations.AddConstraint(
model_name="project",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="project_unique_name_workspace_when_deleted_at_null",
),
),
migrations.AddConstraint(
model_name="projectidentifier",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="unique_name_workspace_when_deleted_at_null",
),
),
]

View File

@@ -0,0 +1,203 @@
# Generated by Django 4.2.11 on 2024-08-13 16:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0073_alter_commentreaction_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="deployboard",
name="is_activity_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="fileasset",
name="is_archived",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="userfavorite",
name="sequence",
field=models.FloatField(default=65535),
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.CreateModel(
name="ProjectIssueType",
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,
),
),
("level", models.PositiveIntegerField(default=0)),
("is_default", models.BooleanField(default=False)),
],
options={
"verbose_name": "Project Issue Type",
"verbose_name_plural": "Project Issue Types",
"db_table": "project_issue_types",
"ordering": ("project", "issue_type"),
},
),
migrations.AlterModelOptions(
name="issuetype",
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
},
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_project_when_deleted_at_null",
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.AlterField(
model_name="issuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_types",
to="db.workspace",
),
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together={("workspace", "name", "deleted_at")},
),
migrations.AddConstraint(
model_name="issuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
),
migrations.AddField(
model_name="projectissuetype",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="issue_type",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_issue_types",
to="db.issuetype",
),
),
migrations.AddField(
model_name="projectissuetype",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
migrations.AddField(
model_name="projectissuetype",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
migrations.RemoveField(
model_name="issuetype",
name="is_default",
),
migrations.RemoveField(
model_name="issuetype",
name="project",
),
migrations.RemoveField(
model_name="issuetype",
name="sort_order",
),
migrations.RemoveField(
model_name="issuetype",
name="weight",
),
migrations.AddConstraint(
model_name="projectissuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "issue_type"),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectissuetype",
unique_together={("project", "issue_type", "deleted_at")},
),
migrations.AddField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="issuetype",
name="level",
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -42,6 +42,7 @@ class FileAsset(BaseModel):
related_name="assets",
)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
class Meta:
verbose_name = "File Asset"

View File

@@ -161,7 +161,14 @@ class CycleUserProperties(ProjectBaseModel):
)
class Meta:
unique_together = ["cycle", "user"]
unique_together = ["cycle", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["cycle", "user"],
condition=models.Q(deleted_at__isnull=True),
name="cycle_user_properties_unique_cycle_user_when_deleted_at_null",
)
]
verbose_name = "Cycle User Property"
verbose_name_plural = "Cycle User Properties"
db_table = "cycle_user_properties"

View File

@@ -88,7 +88,14 @@ class DashboardWidget(BaseModel):
return f"{self.dashboard.name} {self.widget.key}"
class Meta:
unique_together = ("widget", "dashboard")
unique_together = ("widget", "dashboard", "deleted_at")
constraints = [
models.UniqueConstraint(
fields=["widget", "dashboard"],
condition=models.Q(deleted_at__isnull=True),
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"

View File

@@ -40,13 +40,21 @@ class DeployBoard(WorkspaceBaseModel):
)
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
is_activity_enabled = models.BooleanField(default=True)
def __str__(self):
"""Return name of the deploy board"""
return f"{self.entity_identifier} <{self.entity_name}>"
class Meta:
unique_together = ["entity_name", "entity_identifier"]
unique_together = ["entity_name", "entity_identifier", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["entity_name", "entity_identifier"],
condition=models.Q(deleted_at__isnull=True),
name="deploy_board_unique_entity_name_entity_identifier_when_deleted_at_null",
)
]
verbose_name = "Deploy Board"
verbose_name_plural = "Deploy Boards"
db_table = "deploy_boards"

View File

@@ -1,6 +1,7 @@
# Django imports
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
@@ -19,7 +20,14 @@ class Estimate(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="estimate_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Estimate"
verbose_name_plural = "Estimates"
db_table = "estimates"

View File

@@ -21,7 +21,7 @@ class UserFavorite(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True, blank=True)
name = models.CharField(max_length=255, blank=True, null=True)
is_folder = models.BooleanField(default=False)
sequence = models.IntegerField(default=65535)
sequence = models.FloatField(default=65535)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
@@ -31,7 +31,19 @@ class UserFavorite(WorkspaceBaseModel):
)
class Meta:
unique_together = ["entity_type", "user", "entity_identifier"]
unique_together = [
"entity_type",
"user",
"entity_identifier",
"deleted_at",
]
constraints = [
models.UniqueConstraint(
fields=["entity_type", "entity_identifier", "user"],
condition=models.Q(deleted_at__isnull=True),
name="user_favorite_unique_entity_type_entity_identifier_user_when_deleted_at_null",
)
]
verbose_name = "User Favorite"
verbose_name_plural = "User Favorites"
db_table = "user_favorites"
@@ -39,9 +51,14 @@ class UserFavorite(WorkspaceBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
largest_sequence = UserFavorite.objects.filter(
workspace=self.project.workspace
).aggregate(largest=models.Max("sequence"))["largest"]
if self.project:
largest_sequence = UserFavorite.objects.filter(
workspace=self.project.workspace
).aggregate(largest=models.Max("sequence"))["largest"]
else:
largest_sequence = UserFavorite.objects.filter(
workspace=self.workspace,
).aggregate(largest=models.Max("sequence"))["largest"]
if largest_sequence is not None:
self.sequence = largest_sequence + 10000

View File

@@ -19,7 +19,14 @@ class Inbox(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=models.Q(deleted_at__isnull=True),
name="inbox_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Inbox"
verbose_name_plural = "Inboxes"
db_table = "inboxes"

View File

@@ -295,7 +295,14 @@ class IssueRelation(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "related_issue"]
unique_together = ["issue", "related_issue", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "related_issue"],
condition=Q(deleted_at__isnull=True),
name="issue_relation_unique_issue_related_issue_when_deleted_at_null",
)
]
verbose_name = "Issue Relation"
verbose_name_plural = "Issue Relations"
db_table = "issue_relations"
@@ -316,7 +323,14 @@ class IssueMention(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "mention"]
unique_together = ["issue", "mention", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "mention"],
condition=Q(deleted_at__isnull=True),
name="issue_mention_unique_issue_mention_when_deleted_at_null",
)
]
verbose_name = "Issue Mention"
verbose_name_plural = "Issue Mentions"
db_table = "issue_mentions"
@@ -337,7 +351,14 @@ class IssueAssignee(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "assignee"]
unique_together = ["issue", "assignee", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "assignee"],
condition=Q(deleted_at__isnull=True),
name="issue_assignee_unique_issue_assignee_when_deleted_at_null",
)
]
verbose_name = "Issue Assignee"
verbose_name_plural = "Issue Assignees"
db_table = "issue_assignees"
@@ -512,7 +533,14 @@ class IssueUserProperty(ProjectBaseModel):
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
unique_together = ["user", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "project"],
condition=Q(deleted_at__isnull=True),
name="issue_user_property_unique_user_project_when_deleted_at_null",
)
]
def __str__(self):
"""Return properties status of the issue"""
@@ -538,9 +566,9 @@ class Label(ProjectBaseModel):
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=['name', 'project'],
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name='label_unique_name_project_when_deleted_at_null'
name="label_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Label"
@@ -610,7 +638,14 @@ class IssueSubscriber(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "subscriber"]
unique_together = ["issue", "subscriber", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "subscriber"],
condition=models.Q(deleted_at__isnull=True),
name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null",
)
]
verbose_name = "Issue Subscriber"
verbose_name_plural = "Issue Subscribers"
db_table = "issue_subscribers"
@@ -632,7 +667,14 @@ class IssueReaction(ProjectBaseModel):
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["issue", "actor", "reaction"]
unique_together = ["issue", "actor", "reaction", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "actor", "reaction"],
condition=models.Q(deleted_at__isnull=True),
name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null",
)
]
verbose_name = "Issue Reaction"
verbose_name_plural = "Issue Reactions"
db_table = "issue_reactions"
@@ -656,7 +698,14 @@ class CommentReaction(ProjectBaseModel):
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["comment", "actor", "reaction"]
unique_together = ["comment", "actor", "reaction", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["comment", "actor", "reaction"],
condition=models.Q(deleted_at__isnull=True),
name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null",
)
]
verbose_name = "Comment Reaction"
verbose_name_plural = "Comment Reactions"
db_table = "comment_reactions"
@@ -687,6 +736,14 @@ class IssueVote(ProjectBaseModel):
unique_together = [
"issue",
"actor",
"deleted_at",
]
constraints = [
models.UniqueConstraint(
fields=["issue", "actor"],
condition=models.Q(deleted_at__isnull=True),
name="issue_vote_unique_issue_actor_when_deleted_at_null",
)
]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"

View File

@@ -1,37 +1,56 @@
# Django imports
from django.db import models
from django.db.models import Q
# Module imports
from .workspace import WorkspaceBaseModel
from .project import ProjectBaseModel
from .base import BaseModel
class IssueType(WorkspaceBaseModel):
class IssueType(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
related_name="issue_types",
on_delete=models.CASCADE,
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
level = models.PositiveIntegerField(default=0)
class Meta:
unique_together = ["project", "name"]
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order
if self._state.adding:
# Get the largest sort order for the project
largest_sort_order = IssueType.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
# If there are issue types, set the sort order to the largest + 10000
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs)
class ProjectIssueType(ProjectBaseModel):
issue_type = models.ForeignKey(
"db.IssueType",
related_name="project_issue_types",
on_delete=models.CASCADE,
)
level = models.PositiveIntegerField(default=0)
is_default = models.BooleanField(default=False)
class Meta:
unique_together = ["project", "issue_type", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "issue_type"],
condition=Q(deleted_at__isnull=True),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
)
]
verbose_name = "Project Issue Type"
verbose_name_plural = "Project Issue Types"
db_table = "project_issue_types"
ordering = ("project", "issue_type")
def __str__(self):
return f"{self.project} - {self.issue_type}"

View File

@@ -130,7 +130,14 @@ class ModuleMember(ProjectBaseModel):
member = models.ForeignKey("db.User", on_delete=models.CASCADE)
class Meta:
unique_together = ["module", "member"]
unique_together = ["module", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["module", "member"],
condition=models.Q(deleted_at__isnull=True),
name="module_member_unique_module_member_when_deleted_at_null",
)
]
verbose_name = "Module Member"
verbose_name_plural = "Module Members"
db_table = "module_members"
@@ -149,7 +156,14 @@ class ModuleIssue(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "module"]
unique_together = ["issue", "module", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "module"],
condition=models.Q(deleted_at__isnull=True),
name="module_issue_unique_issue_module_when_deleted_at_null",
)
]
verbose_name = "Module Issue"
verbose_name_plural = "Module Issues"
db_table = "module_issues"
@@ -222,7 +236,14 @@ class ModuleUserProperties(ProjectBaseModel):
)
class Meta:
unique_together = ["module", "user"]
unique_together = ["module", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["module", "user"],
condition=models.Q(deleted_at__isnull=True),
name="module_user_properties_unique_module_user_when_deleted_at_null",
)
]
verbose_name = "Module User Property"
verbose_name_plural = "Module User Property"
db_table = "module_user_properties"

View File

@@ -234,7 +234,14 @@ class ProjectPage(BaseModel):
)
class Meta:
unique_together = ["project", "page"]
unique_together = ["project", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "page"],
condition=models.Q(deleted_at__isnull=True),
name="project_page_unique_project_page_when_deleted_at_null",
)
]
verbose_name = "Project Page"
verbose_name_plural = "Project Pages"
db_table = "project_pages"
@@ -256,7 +263,14 @@ class TeamPage(BaseModel):
)
class Meta:
unique_together = ["team", "page"]
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"

View File

@@ -214,7 +214,14 @@ class ProjectMember(ProjectBaseModel):
super(ProjectMember, self).save(*args, **kwargs)
class Meta:
unique_together = ["project", "member"]
unique_together = ["project", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "member"],
condition=Q(deleted_at__isnull=True),
name="project_member_unique_project_member_when_deleted_at_null",
)
]
verbose_name = "Project Member"
verbose_name_plural = "Project Members"
db_table = "project_members"
@@ -324,7 +331,14 @@ class ProjectPublicMember(ProjectBaseModel):
)
class Meta:
unique_together = ["project", "member"]
unique_together = ["project", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "member"],
condition=models.Q(deleted_at__isnull=True),
name="project_public_member_unique_project_member_when_deleted_at_null",
)
]
verbose_name = "Project Public Member"
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"

View File

@@ -1,6 +1,7 @@
# Django imports
from django.db import models
from django.template.defaultfilters import slugify
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
@@ -36,7 +37,14 @@ class State(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="state_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "State"
verbose_name_plural = "States"
db_table = "states"

View File

@@ -185,7 +185,14 @@ class WorkspaceMember(BaseModel):
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ["workspace", "member"]
unique_together = ["workspace", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "member"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_member_unique_workspace_member_when_deleted_at_null",
)
]
verbose_name = "Workspace Member"
verbose_name_plural = "Workspace Members"
db_table = "workspace_members"
@@ -210,7 +217,14 @@ class WorkspaceMemberInvite(BaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
class Meta:
unique_together = ["email", "workspace"]
unique_together = ["email", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["email", "workspace"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_member_invite_unique_email_workspace_when_deleted_at_null",
)
]
verbose_name = "Workspace Member Invite"
verbose_name_plural = "Workspace Member Invites"
db_table = "workspace_member_invites"
@@ -240,7 +254,14 @@ class Team(BaseModel):
return f"{self.name} <{self.workspace.name}>"
class Meta:
unique_together = ["name", "workspace"]
unique_together = ["name", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "workspace"],
condition=models.Q(deleted_at__isnull=True),
name="team_unique_name_workspace_when_deleted_at_null",
)
]
verbose_name = "Team"
verbose_name_plural = "Teams"
db_table = "teams"
@@ -264,7 +285,14 @@ class TeamMember(BaseModel):
return self.team.name
class Meta:
unique_together = ["team", "member"]
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"
@@ -287,7 +315,14 @@ class WorkspaceTheme(BaseModel):
return str(self.name) + str(self.actor.email)
class Meta:
unique_together = ["workspace", "name"]
unique_together = ["workspace", "name", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "name"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_theme_unique_workspace_name_when_deleted_at_null",
)
]
verbose_name = "Workspace Theme"
verbose_name_plural = "Workspace Themes"
db_table = "workspace_themes"
@@ -312,7 +347,14 @@ class WorkspaceUserProperties(BaseModel):
)
class Meta:
unique_together = ["workspace", "user"]
unique_together = ["workspace", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_properties_unique_workspace_user_when_deleted_at_null",
)
]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
db_table = "workspace_user_properties"

View File

@@ -13,6 +13,7 @@ class InstanceSerializer(BaseSerializer):
model = Instance
exclude = [
"license_key",
"user_count"
]
read_only_fields = [
"id",

View File

@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
data["is_activated"] = True
# Get all the configuration
(
ENABLE_SIGNUP,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
@@ -66,8 +67,14 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@@ -116,11 +123,21 @@ class InstanceEndpoint(BaseAPIView):
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
@@ -151,6 +168,10 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL

View File

@@ -143,6 +143,19 @@ class Command(BaseCommand):
"category": "UNSPLASH",
"is_encrypted": True,
},
# intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
for item in config_keys:
@@ -265,7 +278,11 @@ class Command(BaseCommand):
]
)
)
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
if (
bool(GITLAB_HOST)
and bool(GITLAB_CLIENT_ID)
and bool(GITLAB_CLIENT_SECRET)
):
value = "1"
else:
value = "0"

View File

@@ -27,14 +27,11 @@ class ProjectStatesEndpoint(BaseAPIView):
status=status.HTTP_404_NOT_FOUND,
)
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
states = State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("name", "group", "color", "id", "sequence")
return Response(
states,

View File

@@ -27,4 +27,9 @@ RESTRICTED_WORKSPACE_SLUGS = [
"channels",
"upgrade",
"billing",
"sign-in",
"sign-up",
"signin",
"signup",
"config",
]

View File

@@ -18,7 +18,6 @@ from plane.db.models import (
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
@@ -30,7 +29,10 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
),
),
}
default_annotations = {
@@ -51,7 +53,6 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",

View File

@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.14
Django==4.2.15
# rest framework
djangorestframework==3.15.2
# postgres

View File

@@ -9,11 +9,20 @@ export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
UPPER_CPU_ARCH=$(tr '[:lower:]' '[:upper:]' <<< "$CPU_ARCH")
mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
SED_PREFIX=()
if [ "$OS_NAME" == "Darwin" ]; then
SED_PREFIX=("-i" "")
else
SED_PREFIX=("-i")
fi
function print_header() {
clear
@@ -51,12 +60,12 @@ function spinner() {
}
function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${CPU_ARCH^^} support." >&2
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
if [ "$CUSTOM_BUILD" == "true" ]; then
echo "" >&2
echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "build"
return 1
fi
@@ -78,7 +87,7 @@ function initialize(){
else
echo "" >&2
echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "" >&2
echo "build"
return 1
@@ -122,7 +131,7 @@ function updateEnvFile() {
return
else
# if key exists, update the value
sed -i "s/^$key=.*/$key=$value/g" "$file"
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
fi
else
echo "File not found: $file"

View File

@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.9"
"turbo": "^2.0.11"
},
"resolutions": {
"@types/react": "18.2.48"

View File

@@ -0,0 +1,13 @@
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
const view = () => {};
const domEvents = {};
return {
view,
domEvents,
};
};

View File

@@ -0,0 +1 @@
export * from "./handle";

View File

@@ -1,10 +1,14 @@
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
};
@@ -12,7 +16,7 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions = [SlashCommand(fileHandler.upload)];
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
return extensions;
};

View File

@@ -1 +1,2 @@
export * from "./ai-features";
export * from "./document-extensions";

View File

@@ -1,18 +1,28 @@
import React, { useState } from "react";
import React from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useDocumentEditor } from "@/hooks/use-document-editor";
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
interface IDocumentEditor {
containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
fileHandler: TFileHandler;
@@ -32,6 +42,8 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
@@ -44,16 +56,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
tabIndex,
value,
} = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
// use document editor
const { editor, isIndexedDbSynced } = useDocumentEditor({
disabledExtensions,
id,
editorClassName,
embedHandler,
@@ -64,7 +70,6 @@ const DocumentEditor = (props: IDocumentEditor) => {
forwardedRef,
mentionHandler,
placeholder,
setHideDragHandleFunction,
tabIndex,
});
@@ -78,9 +83,9 @@ const DocumentEditor = (props: IDocumentEditor) => {
return (
<PageRenderer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
id={id}
tabIndex={tabIndex}
/>

View File

@@ -16,17 +16,19 @@ import { Editor, ReactRenderer } from "@tiptap/react";
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links";
import { BlockMenu } from "@/components/menus";
// types
import { TDisplayConfig } from "@/types";
type IPageRenderer = {
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
hideDragHandle?: () => void;
id: string;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const { editor, editorContainerClassName, hideDragHandle, id, tabIndex } = props;
const { displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
@@ -130,13 +132,13 @@ export const PageRenderer = (props: IPageRenderer) => {
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
hideDragHandle={hideDragHandle}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor && editor.isEditable && <BlockMenu editor={editor} />}
{editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (

View File

@@ -1,6 +1,8 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
@@ -10,12 +12,13 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// plane web types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
interface IDocumentReadOnlyEditor {
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
tabIndex?: number;
@@ -29,6 +32,7 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
id,
@@ -39,17 +43,17 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [
embedHandler?.issue &&
IssueWidget({
widgetCallback: embedHandler?.issue.widgetCallback,
}),
],
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
});
if (!editor) {
@@ -61,7 +65,13 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
});
return (
<PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} id={id} tabIndex={tabIndex} />
<PageRenderer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
tabIndex={tabIndex}
/>
);
};

View File

@@ -1,18 +1,22 @@
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { cn } from "@/helpers/common";
// types
import { TDisplayConfig } from "@/types";
interface EditorContainerProps {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor | null;
editorContainerClassName: string;
hideDragHandle?: () => void;
id: string;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, editor, editorContainerClassName, hideDragHandle, id } = props;
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = () => {
if (!editor) return;
@@ -53,16 +57,25 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
}
};
const handleContainerMouseLeave = () => {
const dragHandleElement = document.querySelector("#editor-side-menu");
if (!dragHandleElement?.classList.contains("side-menu-hidden")) {
dragHandleElement?.classList.add("side-menu-hidden");
}
};
return (
<div
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={hideDragHandle}
onMouseLeave={handleContainerMouseLeave}
className={cn(
"cursor-text relative",
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>

View File

@@ -1,6 +1,8 @@
import { Editor, Extension } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// hooks
import { getEditorClassNames } from "@/helpers/common";
import { useEditor } from "@/hooks/use-editor";
@@ -11,16 +13,15 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[];
hideDragHandleOnMouseLeave: () => void;
};
export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
hideDragHandleOnMouseLeave,
id,
initialValue,
fileHandler,
@@ -57,10 +58,10 @@ export const EditorWrapper: React.FC<Props> = (props) => {
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
hideDragHandle={hideDragHandleOnMouseLeave}
>
{children?.(editor)}
<div className="flex flex-col">

View File

@@ -11,7 +11,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
const extensions = [EnterKeyExtension(onEnterKeyPress)];
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />;
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (

View File

@@ -1,5 +1,7 @@
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -8,12 +10,20 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const { containerClassName, editorClassName = "", id, initialValue, forwardedRef, mentionHandler } = props;
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
id,
initialValue,
forwardedRef,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
forwardedRef,
initialValue,
mentionHandler,
});
@@ -24,7 +34,12 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
if (!editor) return null;
return (
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName} id={id}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
</div>

View File

@@ -1,37 +1,30 @@
import { forwardRef, useCallback, useState } from "react";
import { forwardRef, useCallback } from "react";
// components
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { DragAndDrop, SlashCommand } from "@/extensions";
import { SideMenuExtension, SlashCommand } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const getExtensions = useCallback(() => {
const extensions = [
SlashCommand(fileHandler.upload),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
];
const extensions = [SlashCommand(fileHandler.upload)];
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction));
extensions.push(
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
})
);
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
return (
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}>
<EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper>
);

View File

@@ -14,7 +14,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
if (target.matches("#drag-handle")) {
event.preventDefault();
popup.current?.setProps({

View File

@@ -0,0 +1,7 @@
// types
import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
};

View File

@@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
<NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);

View File

@@ -1,414 +0,0 @@
import { Extension } from "@tiptap/core";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
scrollThreshold: {
up: number;
down: number;
};
}
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
Extension.create({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
scrollThreshold: { up: 300, down: 100 },
setHideDragHandle,
}),
];
},
});
function createDragHandleElement(): HTMLElement {
const dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
const dragHandleContainer = document.createElement("div");
dragHandleContainer.classList.add("drag-handle-container");
dragHandleElement.appendChild(dragHandleContainer);
const dotsContainer = document.createElement("div");
dotsContainer.classList.add("drag-handle-dots");
for (let i = 0; i < 6; i++) {
const spanElement = document.createElement("span");
spanElement.classList.add("drag-handle-dot");
dotsContainer.appendChild(spanElement);
}
dragHandleContainer.appendChild(dotsContainer);
return dragHandleElement;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
}
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView, node: Element) {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
}
function DragHandle(options: DragHandleOptions) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view, node);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view, node);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
listType = node.parentElement!.tagName;
}
if (node.matches("blockquote")) {
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined) return;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hidden");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hidden");
}
}
options.setHideDragHandle?.(hideDragHandle);
return new Plugin({
key: new PluginKey("dragHandle"),
view: (view) => {
dragHandleElement = createDragHandleElement();
dragHandleElement.addEventListener("dragstart", (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("contextmenu", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const a = document.querySelector(".frame-renderer");
if (!a) return;
if (e.clientY < options.scrollThreshold.up) {
a.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
a.scrollBy({ top: 70, behavior: "smooth" });
}
});
hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {
rect.top += 8;
rect.left -= 8;
}
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
rect.left += 8;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
dragenter: (view) => {
view.dom.classList.add("dragging");
hideDragHandle();
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
let isDroppedInsideList = false;
// Traverse up the document tree to find if we're inside a list item
for (let i = resolvedPos.depth; i > 0; i--) {
if (resolvedPos.node(i).type.name === "listItem") {
isDroppedInsideList = true;
break;
}
}
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}

View File

@@ -73,8 +73,7 @@ export const CoreEditorExtensions = ({
horizontalRule: false,
blockquote: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),

View File

@@ -10,7 +10,6 @@ export * from "./typography";
export * from "./core-without-props";
export * from "./document-without-props";
export * from "./custom-code-inline";
export * from "./drag-drop";
export * from "./drop";
export * from "./enter-key-extension";
export * from "./extensions";
@@ -18,4 +17,5 @@ export * from "./horizontal-rule";
export * from "./keymap";
export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./slash-commands";

View File

@@ -0,0 +1,200 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// plane editor extensions
import { AIHandlePlugin } from "@/plane-editor/extensions";
import { DragHandlePlugin } from "@/plugins/drag-handle";
type Props = {
aiEnabled: boolean;
dragDropEnabled: boolean;
};
export type SideMenuPluginProps = {
dragHandleWidth: number;
handlesConfig: {
ai: boolean;
dragDrop: boolean;
};
scrollThreshold: {
up: number;
down: number;
};
};
export type SideMenuHandleOptions = {
view: (view: EditorView, sideMenu: HTMLDivElement | null) => void;
domEvents?: {
[key: string]: (...args: any) => void;
};
};
export const SideMenuExtension = (props: Props) => {
const { aiEnabled, dragDropEnabled } = props;
return Extension.create({
name: "editorSideMenu",
addProseMirrorPlugins() {
return [
SideMenu({
dragHandleWidth: 24,
handlesConfig: {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 300, down: 100 },
}),
];
},
});
};
const absoluteRect = (node: Element) => {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const SideMenu = (options: SideMenuPluginProps) => {
const { handlesConfig } = options;
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
editorSideMenu.id = "editor-side-menu";
// side menu view actions
const hideSideMenu = () => {
if (!editorSideMenu?.classList.contains("side-menu-hidden")) editorSideMenu?.classList.add("side-menu-hidden");
};
const showSideMenu = () => editorSideMenu?.classList.remove("side-menu-hidden");
// side menu elements
const { view: dragHandleView, domEvents: dragHandleDOMEvents } = DragHandlePlugin(options);
const { view: aiHandleView } = AIHandlePlugin(options);
return new Plugin({
key: new PluginKey("sideMenu"),
view: (view) => {
hideSideMenu();
view?.dom.parentElement?.appendChild(editorSideMenu);
// side menu elements' initialization
if (handlesConfig.dragDrop) {
dragHandleView(view, editorSideMenu);
}
if (handlesConfig.ai) {
aiHandleView(view, editorSideMenu);
}
return {
destroy: () => hideSideMenu(),
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideSideMenu();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {
rect.top += 8;
rect.left -= 8;
}
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
rect.left += 8;
}
rect.width = options.dragHandleWidth;
if (!editorSideMenu) return;
editorSideMenu.style.left = `${rect.left - rect.width}px`;
editorSideMenu.style.top = `${rect.top}px`;
showSideMenu();
dragHandleDOMEvents?.mousemove();
},
keydown: () => hideSideMenu(),
mousewheel: () => hideSideMenu(),
dragenter: (view) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.dragenter?.(view);
}
},
drop: (view, event) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.drop?.(view, event);
}
},
dragend: (view) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.dragend?.(view);
}
},
},
},
});
};

View File

@@ -3,9 +3,9 @@ import Collaboration from "@tiptap/extension-collaboration";
import { EditorProps } from "@tiptap/pm/view";
import * as Y from "yjs";
// extensions
import { DragAndDrop, IssueWidget } from "@/extensions";
import { IssueWidget, SideMenuExtension } from "@/extensions";
// hooks
import { TFileHandler, useEditor } from "@/hooks/use-editor";
import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// plane editor provider
@@ -13,9 +13,10 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
type DocumentEditorProps = {
disabledExtensions?: TExtensions[];
editorClassName: string;
editorProps?: EditorProps;
embedHandler?: TEmbedConfig;
@@ -29,13 +30,13 @@ type DocumentEditorProps = {
};
onChange: (updates: Uint8Array) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
tabIndex?: number;
value: Uint8Array;
};
export const useDocumentEditor = (props: DocumentEditorProps) => {
const {
disabledExtensions,
editorClassName,
editorProps = {},
embedHandler,
@@ -46,7 +47,6 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
mentionHandler,
onChange,
placeholder,
setHideDragHandleFunction,
tabIndex,
value,
} = props;
@@ -93,7 +93,10 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
forwardedRef,
mentionHandler,
extensions: [
DragAndDrop(setHideDragHandleFunction),
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
embedHandler?.issue &&
IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback,
@@ -102,6 +105,7 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
document: provider.document,
}),
...DocumentEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
issueEmbedConfig: embedHandler?.issue,
}),
@@ -111,5 +115,8 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
tabIndex,
});
return { editor, isIndexedDbSynced };
return {
editor,
isIndexedDbSynced,
};
};

View File

@@ -1,7 +1,7 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
// components
import { getEditorMenuItems } from "@/components/menus";
// extensions
@@ -14,22 +14,7 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// props
import { CoreEditorProps } from "@/props";
// types
import {
DeleteImage,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
TEditorCommands,
UploadImage,
} from "@/types";
export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types";
export interface CustomEditorProps {
editorClassName: string;
@@ -54,26 +39,30 @@ export interface CustomEditorProps {
value?: string | null | undefined;
}
export const useEditor = ({
editorClassName,
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
forwardedRef,
handleEditorReady,
id = "",
initialValue,
mentionHandler,
onChange,
placeholder,
provider,
tabIndex,
value,
}: CustomEditorProps) => {
const editor = useCustomEditor({
export const useEditor = (props: CustomEditorProps) => {
const {
editorClassName,
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
forwardedRef,
handleEditorReady,
id = "",
initialValue,
mentionHandler,
onChange,
placeholder,
provider,
tabIndex,
value,
} = props;
const editor = useTiptapEditor({
editorProps: {
...CoreEditorProps(editorClassName),
...CoreEditorProps({
editorClassName,
}),
...editorProps,
},
extensions: [
@@ -95,18 +84,10 @@ export const useEditor = ({
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: async () => {
handleEditorReady?.(true);
},
onTransaction: async ({ editor }) => {
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
onChange?.(editor.getJSON(), editor.getHTML());
},
onDestroy: async () => {
handleEditorReady?.(false);
},
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => setSavedSelection(editor.state.selection),
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
const editorRef: MutableRefObject<Editor | null> = useRef(null);

View File

@@ -35,7 +35,9 @@ export const useReadOnlyEditor = ({
editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps(editorClassName),
...CoreReadOnlyEditorProps({
editorClassName,
}),
...editorProps,
},
onCreate: async () => {

View File

@@ -0,0 +1,317 @@
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
const verticalEllipsisIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
const createDragHandleElement = (): HTMLElement => {
const dragHandleElement = document.createElement("button");
dragHandleElement.type = "button";
dragHandleElement.id = "drag-handle";
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.value =
"hidden sm:flex items-center size-5 aspect-square rounded-sm cursor-grab outline-none hover:bg-custom-background-80 active:bg-custom-background-80 active:cursor-grabbing transition-[background-color,_opacity] duration-200 ease-linear";
const iconElement1 = document.createElement("span");
iconElement1.classList.value = "pointer-events-none text-custom-text-300";
iconElement1.innerHTML = verticalEllipsisIcon;
const iconElement2 = document.createElement("span");
iconElement2.classList.value = "pointer-events-none text-custom-text-300 -ml-2.5";
iconElement2.innerHTML = verticalEllipsisIcon;
dragHandleElement.appendChild(iconElement1);
dragHandleElement.appendChild(iconElement2);
return dragHandleElement;
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
};
const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
};
const calcNodePos = (pos: number, view: EditorView, node: Element) => {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
};
export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
let listType = "";
const handleDragStart = (event: DragEvent, view: EditorView) => {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view, node);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view, node);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
listType = node.parentElement!.tagName;
}
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
};
const handleClick = (event: MouseEvent, view: EditorView) => {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined) return;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
// TODO FIX ERROR
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
};
let dragHandleElement: HTMLElement | null = null;
// drag handle view actions
const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
const hideDragHandle = () => {
if (!dragHandleElement?.classList.contains("drag-handle-hidden"))
dragHandleElement?.classList.add("drag-handle-hidden");
};
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
dragHandleElement = createDragHandleElement();
dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const frameRenderer = document.querySelector(".frame-renderer");
if (!frameRenderer) return;
if (e.clientY < options.scrollThreshold.up) {
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
}
});
hideDragHandle();
sideMenu?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
};
const domEvents = {
mousemove: () => showDragHandle(),
dragenter: (view: EditorView) => {
view.dom.classList.add("dragging");
hideDragHandle();
},
drop: (view: EditorView, event: DragEvent) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
let isDroppedInsideList = false;
// Traverse up the document tree to find if we're inside a list item
for (let i = resolvedPos.depth; i > 0; i--) {
if (resolvedPos.node(i).type.name === "listItem") {
isDroppedInsideList = true;
break;
}
}
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view: EditorView) => {
view.dom.classList.remove("dragging");
},
};
return {
view,
domEvents,
};
};

View File

@@ -2,7 +2,13 @@ import { EditorProps } from "@tiptap/pm/view";
// helpers
import { cn } from "@/helpers/common";
export function CoreEditorProps(editorClassName: string): EditorProps {
export type TCoreEditorProps = {
editorClassName: string;
};
export const CoreEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return {
attributes: {
class: cn(
@@ -25,4 +31,4 @@ export function CoreEditorProps(editorClassName: string): EditorProps {
return html.replace(/<img.*?>/g, "");
},
};
}
};

View File

@@ -1,12 +1,18 @@
import { EditorProps } from "@tiptap/pm/view";
// helpers
import { cn } from "@/helpers/common";
// props
import { TCoreEditorProps } from "@/props";
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({
attributes: {
class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName
),
},
});
export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return {
attributes: {
class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName
),
},
};
};

View File

@@ -0,0 +1,17 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
export type TEditorFontSize = "small-font" | "large-font";
export type TDisplayConfig = {
fontStyle?: TEditorFontStyle;
fontSize?: TEditorFontSize;
};

View File

@@ -1,9 +1,7 @@
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// types
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands, TFileHandler } from "@/types";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -26,6 +24,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
export interface IEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
@@ -50,6 +49,7 @@ export interface IRichTextEditor extends IEditorProps {
export interface IReadOnlyEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;

View File

@@ -0,0 +1 @@
export type TExtensions = "ai" | "issue-embed";

View File

@@ -1,5 +1,7 @@
export * from "./config";
export * from "./editor";
export * from "./embed";
export * from "./extensions";
export * from "./image";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";

View File

@@ -20,7 +20,8 @@ export type TEditorCommands =
| "code"
| "table"
| "image"
| "divider";
| "divider"
| "issue-embed";
export type CommandProps = {
editor: Editor;

View File

@@ -34,5 +34,5 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
export type { CustomEditorProps, TFileHandler } from "@/hooks/use-editor";
export type { CustomEditorProps } from "@/hooks/use-editor";
export * from "@/types";

View File

@@ -1,60 +1,31 @@
/* drag handle */
.drag-handle {
/* side menu */
#editor-side-menu {
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
height: 20px;
width: 15px;
display: grid;
place-items: center;
z-index: 5;
cursor: grab;
border-radius: 2px;
transition: background-color 0.2s;
display: flex;
align-items: center;
opacity: 100;
transition:
opacity 0.2s ease 0.2s,
top 0.2s ease,
left 0.2s ease;
transform: translateX(-50%);
&:hover {
background-color: rgba(var(--color-background-80));
}
&:active {
background-color: rgba(var(--color-background-80));
cursor: grabbing;
}
&.hidden {
&.side-menu-hidden {
opacity: 0;
pointer-events: none;
}
}
/* end side menu */
@media screen and (max-width: 600px) {
.drag-handle {
display: none;
/* drag handle */
#drag-handle {
opacity: 100;
&.drag-handle-hidden {
opacity: 0;
pointer-events: none;
}
}
.drag-handle-container {
height: 15px;
width: 15px;
display: grid;
place-items: center;
}
.drag-handle-dots {
height: 100%;
width: 12px;
display: grid;
grid-template-columns: repeat(2, 1fr);
place-items: center;
}
.drag-handle-dot {
height: 2.5px;
width: 2.5px;
background-color: rgba(var(--color-text-300));
border-radius: 50%;
}
/* end drag handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
@@ -62,25 +33,33 @@
cursor: grab;
outline: none !important;
box-shadow: none;
--horizontal-offset: 5px;
&:has(.issue-embed),
&.table-wrapper {
--horizontal-offset: 0px;
}
&::after {
content: "";
position: absolute;
top: 0;
left: calc(-1 * var(--horizontal-offset));
height: 100%;
width: calc(100% + (var(--horizontal-offset) * 2));
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
pointer-events: none;
}
}
.ProseMirror:not(.dragging) .ProseMirror-selectednode::after {
content: "";
position: absolute;
top: 0;
left: -5px;
height: 100%;
width: 100%;
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
}
/* for targetting the taks list items */
/* for targeting the task list items */
li.ProseMirror-selectednode:not(.dragging)[data-checked]::after {
margin-left: -5px;
}
/* for targetting the unordered list items */
/* for targeting the unordered list items */
ul > li.ProseMirror-selectednode:not(.dragging)::after {
margin-left: -10px; /* Adjust as needed */
}
@@ -90,18 +69,18 @@ ol {
counter-reset: item;
}
/* for targetting the ordered list items */
/* for targeting the ordered list items */
ol > li.ProseMirror-selectednode:not(.dragging)::after {
counter-increment: item;
margin-left: -18px;
}
/* for targetting the ordered list items after the 9th item */
/* for targeting the ordered list items after the 9th item */
ol > li:nth-child(n + 10).ProseMirror-selectednode:not(.dragging)::after {
margin-left: -25px;
}
/* for targetting the ordered list items after the 99th item */
/* for targeting the ordered list items after the 99th item */
ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
margin-left: -35px;
}
@@ -118,9 +97,3 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
filter: brightness(90%);
}
}
:not(.dragging) .ProseMirror-selectednode.table-wrapper {
padding: 4px 2px;
background-color: rgba(var(--color-primary-300), 0.1) !important;
box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important;
}

View File

@@ -1,12 +1,82 @@
.editor-container {
&.large-font {
--font-size-h1: 1.75rem;
--font-size-h2: 1.5rem;
--font-size-h3: 1.375rem;
--font-size-h4: 1.25rem;
--font-size-h5: 1.125rem;
--font-size-h6: 1rem;
--font-size-regular: 1rem;
--font-size-list: var(--font-size-regular);
--font-size-code: var(--font-size-regular);
--line-height-h1: 2.25rem;
--line-height-h2: 2rem;
--line-height-h3: 1.75rem;
--line-height-h4: 1.5rem;
--line-height-h5: 1.5rem;
--line-height-h6: 1.5rem;
--line-height-regular: 1.5rem;
--line-height-list: var(--line-height-regular);
--line-height-code: var(--line-height-regular);
}
&.small-font {
--font-size-h1: 1.4rem;
--font-size-h2: 1.2rem;
--font-size-h3: 1.1rem;
--font-size-h4: 1rem;
--font-size-h5: 0.9rem;
--font-size-h6: 0.8rem;
--font-size-regular: 0.8rem;
--font-size-list: var(--font-size-regular);
--font-size-code: var(--font-size-regular);
--line-height-h1: 1.8rem;
--line-height-h2: 1.6rem;
--line-height-h3: 1.4rem;
--line-height-h4: 1.2rem;
--line-height-h5: 1.2rem;
--line-height-h6: 1.2rem;
--line-height-regular: 1.2rem;
--line-height-list: var(--line-height-regular);
--line-height-code: var(--line-height-regular);
}
&.sans-serif {
--font-style: sans-serif;
}
&.serif {
--font-style: serif;
}
&.monospace {
--font-style: monospace;
}
}
.ProseMirror {
--font-size-h1: 1.5rem;
--font-size-h2: 1.3125rem;
--font-size-h3: 1.125rem;
--font-size-h4: 0.9375rem;
--font-size-h5: 0.8125rem;
--font-size-h6: 0.75rem;
--font-size-regular: 0.9375rem;
--font-size-list: var(--font-size-regular);
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
cursor: text;
font-family: var(--font-style);
font-size: var(--font-size-regular);
line-height: 1.2;
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.ProseMirror p.is-editor-empty:first-child::before {
@@ -179,29 +249,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
max-width: 400px !important;
}
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: var(--font-size-regular);
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.fade-in {
opacity: 1;
transition: opacity 0.3s ease-in;
@@ -248,6 +295,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
opacity: 0;
}
/* code block, inline code */
.ProseMirror pre {
font-family: JetBrainsMono, monospace;
tab-size: 2;
@@ -256,10 +304,14 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
.ProseMirror pre code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
.ProseMirror code {
font-size: var(--font-size-code);
}
/* end code block, inline code */
div[data-type="horizontalRule"] {
line-height: 0;
padding: 0.25rem 0;
@@ -342,48 +394,48 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 2rem;
margin-bottom: 4px;
font-size: var(--font-size-h1);
line-height: var(--line-height-h1);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: var(--font-size-h2);
line-height: var(--line-height-h2);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h3);
line-height: var(--line-height-h3);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h4);
line-height: var(--line-height-h4);
font-weight: 600;
line-height: 1.5;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h5);
line-height: var(--line-height-h5);
font-weight: 600;
line-height: 1.5;
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h6);
line-height: var(--line-height-h6);
font-weight: 600;
line-height: 1.5;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
@@ -391,13 +443,13 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
margin-bottom: 1px;
padding: 3px 0;
font-size: var(--font-size-regular);
line-height: 1.5;
line-height: var(--line-height-regular);
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: var(--font-size-list);
line-height: 1.5;
line-height: var(--line-height-list);
}
.prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) {

View File

@@ -12,10 +12,6 @@
width: 100%;
}
.table-wrapper table p {
font-size: 14px;
}
.table-wrapper table td,
.table-wrapper table th {
min-width: 1em;
@@ -115,4 +111,3 @@
opacity: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,29 @@
type TLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};
export type IFavorite = {
id: string;
name: string;
entity_type: string;
entity_data: {
id?: string;
name: string;
logo_props?: TLogoProps | undefined;
};
is_folder: boolean;
sort_order: number;
parent: string | null;
entity_identifier?: string | null;
children: IFavorite[];
project_id: string | null;
sequence: number;
};

View File

@@ -0,0 +1 @@
export * from "./favorite";

View File

@@ -28,3 +28,4 @@ export * from "./common";
export * from "./pragmatic";
export * from "./publish";
export * from "./workspace-notifications";
export * from "./favorite";

View File

@@ -52,6 +52,9 @@ export interface IInstanceConfig {
app_base_url: string | undefined;
space_base_url: string | undefined;
admin_base_url: string | undefined;
// intercom
is_intercom_enabled: boolean;
intercom_app_id: string | undefined;
}
export interface IInstanceAdmin {
@@ -66,11 +69,16 @@ export interface IInstanceAdmin {
user_detail: IUserLite;
}
export type TInstanceIntercomConfigurationKeys =
| "IS_INTERCOM_ENABLED"
| "INTERCOM_APP_ID";
export type TInstanceConfigurationKeys =
| TInstanceAIConfigurationKeys
| TInstanceEmailConfigurationKeys
| TInstanceImageConfigurationKeys
| TInstanceAuthenticationKeys;
| TInstanceAuthenticationKeys
| TInstanceIntercomConfigurationKeys;
export interface IInstanceConfiguration {
id: string;

View File

@@ -38,7 +38,6 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
</Disclosure.Button>
<Transition
show={localIsOpen}
className="overflow-hidden"
enter="transition-max-height duration-400 ease-in-out"
enterFrom="max-h-0"
enterTo="max-h-screen"

View File

@@ -7,10 +7,21 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
inputSize?: "sm" | "md";
hasError?: boolean;
className?: string;
autoComplete?: "on" | "off";
}
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const { id, type, name, mode = "primary", inputSize = "sm", hasError = false, className = "", ...rest } = props;
const {
id,
type,
name,
mode = "primary",
inputSize = "sm",
hasError = false,
className = "",
autoComplete = "off",
...rest
} = props;
return (
<input
@@ -31,6 +42,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
},
className
)}
autoComplete={autoComplete}
{...rest}
/>
);

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const FavoriteFolderIcon: React.FC<ISvgIcons> = ({ className = "text-current", color = "#a3a3a3", ...rest }) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke={color}
className={`${className} stroke-2`}
{...rest}
>
<path
d="M7.33325 13.3334H2.66659C2.31296 13.3334 1.97382 13.1929 1.72378 12.9429C1.47373 12.6928 1.33325 12.3537 1.33325 12.0001V3.3334C1.33325 2.97978 1.47373 2.64064 1.72378 2.39059C1.97382 2.14054 2.31296 2.00006 2.66659 2.00006H5.26659C5.48958 1.99788 5.70955 2.05166 5.90638 2.15648C6.10322 2.2613 6.27061 2.41381 6.39325 2.60006L6.93325 3.40006C7.05466 3.58442 7.21994 3.73574 7.41425 3.84047C7.60857 3.94519 7.82585 4.00003 8.04658 4.00006H13.3333C13.6869 4.00006 14.026 4.14054 14.2761 4.39059C14.5261 4.64064 14.6666 4.97978 14.6666 5.3334V6.3334"
// stroke="#60646C"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.1373 8L13.0038 9.75535L14.9414 10.0386L13.5394 11.4041L13.8702 13.3333L12.1373 12.422L10.4044 13.3333L10.7353 11.4041L9.33325 10.0386L11.2709 9.75535L12.1373 8Z"
stroke-width="1.25"
// stroke="#60646C"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
);

View File

@@ -15,12 +15,16 @@ export * from "./github-icon";
export * from "./gitlab-icon";
export * from "./layer-stack";
export * from "./layers-icon";
export * from "./monospace-icon";
export * from "./photo-filter-icon";
export * from "./priority-icon";
export * from "./related-icon";
export * from "./sans-serif-icon";
export * from "./serif-icon";
export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";
export * from "./dropdown-icon";
export * from "./intake";
export * from "./user-activity-icon";
export * from "./favorite-folder-icon";

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const MonospaceIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} {...rest}>
<path
d="M10.6149 13.0746V11.9267H13.0648C13.4568 11.9267 13.7415 11.838 13.9188 11.6607C14.1055 11.4833 14.1988 11.208 14.1988 10.8347V9.8547L14.2268 8.45473H13.9748L14.2128 8.24474C14.2128 8.80472 14.0261 9.24805 13.6528 9.57471C13.2795 9.90137 12.7802 10.0647 12.1548 10.0647C11.3615 10.0647 10.7362 9.80804 10.2789 9.29472C9.82156 8.77206 9.5929 8.07207 9.5929 7.19476V5.57079C9.5929 4.69347 9.82156 3.99815 10.2789 3.48483C10.7362 2.97151 11.3615 2.71484 12.1548 2.71484C12.7802 2.71484 13.2795 2.87817 13.6528 3.20483C14.0261 3.53149 14.2128 3.97482 14.2128 4.53481L13.9748 4.32481H14.2128V2.85484H15.4588V10.8347C15.4588 11.5253 15.2441 12.0713 14.8148 12.4727C14.3948 12.874 13.8068 13.0746 13.0508 13.0746H10.6149ZM12.5328 8.97272C13.0555 8.97272 13.4662 8.80939 13.7648 8.48273C14.0635 8.15607 14.2128 7.70341 14.2128 7.12476V5.65479C14.2128 5.07613 14.0635 4.62347 13.7648 4.29681C13.4662 3.97015 13.0555 3.80682 12.5328 3.80682C12.0008 3.80682 11.5855 3.96549 11.2869 4.28281C10.9975 4.60014 10.8529 5.05746 10.8529 5.65479V7.12476C10.8529 7.72208 10.9975 8.1794 11.2869 8.49673C11.5855 8.81406 12.0008 8.97272 12.5328 8.97272Z"
fill="currentColor"
/>
<path
d="M0.666626 10.5538L3.32657 0.333984H5.02054L7.66649 10.5538H6.39251L5.72053 7.83784H2.62659L1.9546 10.5538H0.666626ZM2.87858 6.77386H5.45453L4.67055 3.62392C4.52122 3.0266 4.40455 2.52727 4.32055 2.12595C4.23656 1.72462 4.18522 1.46329 4.16656 1.34196C4.14789 1.46329 4.09656 1.72462 4.01256 2.12595C3.92856 2.52727 3.8119 3.02193 3.66257 3.60992L2.87858 6.77386Z"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const SansSerifIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} {...rest}>
<path
d="M12.4877 11.5341C11.9579 11.5341 11.502 11.4646 11.1198 11.3256C10.7406 11.1867 10.4308 11.0028 10.1905 10.7741C9.95021 10.5454 9.77071 10.295 9.65202 10.0228L10.7681 9.56253C10.8462 9.68991 10.9504 9.82453 11.0807 9.96639C11.2139 10.1111 11.3934 10.2342 11.6192 10.3355C11.8479 10.4368 12.1418 10.4875 12.5007 10.4875C12.9929 10.4875 13.3997 10.3674 13.721 10.1271C14.0424 9.88967 14.203 9.51042 14.203 8.98931V7.67785H14.1205C14.0424 7.81971 13.9295 7.97749 13.7818 8.15119C13.6371 8.3249 13.4373 8.47544 13.1825 8.60282C12.9278 8.7302 12.5963 8.79389 12.1881 8.79389C11.6612 8.79389 11.1864 8.67085 10.7637 8.42478C10.3439 8.1758 10.011 7.80958 9.76492 7.3261C9.52174 6.83973 9.40015 6.2419 9.40015 5.53262C9.40015 4.82333 9.52029 4.21537 9.76058 3.70873C10.0038 3.2021 10.3367 2.81416 10.7594 2.54492C11.1821 2.27279 11.6612 2.13672 12.1968 2.13672C12.6108 2.13672 12.9451 2.2062 13.1999 2.34516C13.4547 2.48123 13.653 2.64046 13.7948 2.82285C13.9396 3.00523 14.0511 3.16591 14.1292 3.30487H14.2248V2.22357H15.4971V9.04142C15.4971 9.61464 15.364 10.0851 15.0976 10.4528C14.8313 10.8204 14.4708 11.0926 14.0163 11.2692C13.5647 11.4458 13.0552 11.5341 12.4877 11.5341ZM12.4747 7.71693C12.8482 7.71693 13.1637 7.63008 13.4214 7.45638C13.6819 7.27978 13.8788 7.02791 14.012 6.70077C14.148 6.37073 14.2161 5.97556 14.2161 5.51525C14.2161 5.06651 14.1495 4.67134 14.0163 4.32972C13.8831 3.98811 13.6877 3.72176 13.4301 3.53069C13.1724 3.33672 12.8539 3.23973 12.4747 3.23973C12.0839 3.23973 11.7582 3.34106 11.4976 3.54371C11.2371 3.74347 11.0402 4.01561 10.907 4.36012C10.7767 4.70463 10.7116 5.08967 10.7116 5.51525C10.7116 5.9524 10.7782 6.33599 10.9114 6.66603C11.0445 6.99607 11.2414 7.25373 11.502 7.43901C11.7654 7.62429 12.0897 7.71693 12.4747 7.71693Z"
fill="currentColor"
/>
<path
d="M2.09099 8.8936H0.666626L3.86711 0H5.41741L8.61789 8.8936H7.19353L4.67917 1.61544H4.60969L2.09099 8.8936ZM2.32983 5.41085H6.95034V6.53993H2.32983V5.41085Z"
fill="currentColor"
/>
</svg>
);

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