mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
65 Commits
fix-pwa-in
...
fix-migrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
729bad4344 | ||
|
|
5f26ce2466 | ||
|
|
c02a54ef31 | ||
|
|
d9c9d85d38 | ||
|
|
edb04a33fd | ||
|
|
033e7703b4 | ||
|
|
3f4c95412d | ||
|
|
4792c1cdf5 | ||
|
|
041f2b16c3 | ||
|
|
91693b2269 | ||
|
|
3ffaa4f2ca | ||
|
|
f817d70f78 | ||
|
|
269e6ccd18 | ||
|
|
6e435df613 | ||
|
|
85f8fe9247 | ||
|
|
6d0cf1b4e9 | ||
|
|
679b0b6465 | ||
|
|
421bf2abc7 | ||
|
|
f457048644 | ||
|
|
24b1e71cbf | ||
|
|
0b72bd373b | ||
|
|
fc205efd6d | ||
|
|
f54e1b922d | ||
|
|
644d1db44c | ||
|
|
b05d72e29a | ||
|
|
48cb0f5afc | ||
|
|
a2098ffb5e | ||
|
|
3b21018154 | ||
|
|
1b624ef3ac | ||
|
|
be82cbb8e8 | ||
|
|
e805c49e69 | ||
|
|
943dd593fa | ||
|
|
520938ab5c | ||
|
|
86909cff14 | ||
|
|
598846adc4 | ||
|
|
91142659ca | ||
|
|
806eae0139 | ||
|
|
3279bb6ac9 | ||
|
|
976784bc84 | ||
|
|
983769a944 | ||
|
|
3f9523804b | ||
|
|
9715922fc1 | ||
|
|
2fa92fda75 | ||
|
|
95641f31af | ||
|
|
a93dfc1b8d | ||
|
|
07574b4222 | ||
|
|
91e4da502a | ||
|
|
fafa2c06c3 | ||
|
|
86a982e8ce | ||
|
|
dd806dfa2f | ||
|
|
42462c78f7 | ||
|
|
21343034c2 | ||
|
|
f9e7a5826b | ||
|
|
c99f2fcdbb | ||
|
|
0619f1b6d1 | ||
|
|
34820eec7a | ||
|
|
93e6c3b6e0 | ||
|
|
8f8a97589d | ||
|
|
3a5c77e8a4 | ||
|
|
79fbcaa2b2 | ||
|
|
76983a57e9 | ||
|
|
e9b1151702 | ||
|
|
f4f5e5a0d3 | ||
|
|
f55c135052 | ||
|
|
8924e303da |
@@ -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">
|
||||
|
||||
82
admin/app/general/intercom.tsx
Normal file
82
admin/app/general/intercom.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class StateSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -121,3 +121,5 @@ from .exporter import ExporterHistorySerializer
|
||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||
|
||||
from .favorite import UserFavoriteSerializer
|
||||
|
||||
101
apiserver/plane/app/serializers/favorite.py
Normal file
101
apiserver/plane/app/serializers/favorite.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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())),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
88
apiserver/plane/app/views/workspace/favorite.py
Normal file
88
apiserver/plane/app/views/workspace/favorite.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,6 +13,7 @@ class InstanceSerializer(BaseSerializer):
|
||||
model = Instance
|
||||
exclude = [
|
||||
"license_key",
|
||||
"user_count"
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,4 +27,9 @@ RESTRICTED_WORKSPACE_SLUGS = [
|
||||
"channels",
|
||||
"upgrade",
|
||||
"billing",
|
||||
"sign-in",
|
||||
"sign-up",
|
||||
"signin",
|
||||
"signup",
|
||||
"config",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.14
|
||||
Django==4.2.15
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
13
packages/editor/src/ce/extensions/ai-features/handle.ts
Normal file
13
packages/editor/src/ce/extensions/ai-features/handle.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
1
packages/editor/src/ce/extensions/ai-features/index.ts
Normal file
1
packages/editor/src/ce/extensions/ai-features/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./handle";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./ai-features";
|
||||
export * from "./document-extensions";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
7
packages/editor/src/core/constants/config.ts
Normal file
7
packages/editor/src/core/constants/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
|
||||
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||
fontSize: "large-font",
|
||||
fontStyle: "sans-serif",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
200
packages/editor/src/core/extensions/side-menu.tsx
Normal file
200
packages/editor/src/core/extensions/side-menu.tsx
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
317
packages/editor/src/core/plugins/drag-handle.ts
Normal file
317
packages/editor/src/core/plugins/drag-handle.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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, "");
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
17
packages/editor/src/core/types/config.ts
Normal file
17
packages/editor/src/core/types/config.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
1
packages/editor/src/core/types/extensions.ts
Normal file
1
packages/editor/src/core/types/extensions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type TExtensions = "ai" | "issue-embed";
|
||||
@@ -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";
|
||||
|
||||
@@ -20,7 +20,8 @@ export type TEditorCommands =
|
||||
| "code"
|
||||
| "table"
|
||||
| "image"
|
||||
| "divider";
|
||||
| "divider"
|
||||
| "issue-embed";
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] *)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
29
packages/types/src/favorite/favorite.d.ts
vendored
Normal file
29
packages/types/src/favorite/favorite.d.ts
vendored
Normal 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;
|
||||
};
|
||||
1
packages/types/src/favorite/index.d.ts
vendored
Normal file
1
packages/types/src/favorite/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./favorite";
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -28,3 +28,4 @@ export * from "./common";
|
||||
export * from "./pragmatic";
|
||||
export * from "./publish";
|
||||
export * from "./workspace-notifications";
|
||||
export * from "./favorite";
|
||||
|
||||
10
packages/types/src/instance/base.d.ts
vendored
10
packages/types/src/instance/base.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
32
packages/ui/src/icons/favorite-folder-icon.tsx
Normal file
32
packages/ui/src/icons/favorite-folder-icon.tsx
Normal 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>
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
16
packages/ui/src/icons/monospace-icon.tsx
Normal file
16
packages/ui/src/icons/monospace-icon.tsx
Normal 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>
|
||||
);
|
||||
16
packages/ui/src/icons/sans-serif-icon.tsx
Normal file
16
packages/ui/src/icons/sans-serif-icon.tsx
Normal 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
Reference in New Issue
Block a user