Compare commits

...
Author SHA1 Message Date
guru_sainath 2672163c9c fix: implemented module analytics issue burndown chart 2024-06-09 01:03:06 +05:30
dae291fe3b [WEB-412] chore: estimates analytics (#4730)
* chore: estimate points in modules and cycle

* chore: burn down chart analytics

* chore: module serializer change

* dev: handled y-axis estimates in analytics, implemented estimate points on modules

* chore: burn down analytics

* chore: state estimate point analytics

* chore: updated the burn down values

* Remove check mark from estimate point edit field in
create estimate flow

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-06-07 18:19:45 +05:30
23f4d5418f [WEB-1516] refactor: publish project modal and types (#4716)
* refacotr: project publish

* chore: rename service names

* chore: is_deployed changed to anchor

* chore: update is_deployed key

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-06-06 18:12:56 +05:30
pushya22andGitHub df620a5e86 Merge pull request #4717 from makeplane/chore/deploy-board-fields
chore: deploy board fields changes
2024-06-06 15:02:17 +05:30
pushya22andGitHub a59b150266 Merge pull request #4696 from makeplane/revamp-estimates-ce
[WEB-522] feat: Estimates revamp
2024-06-06 13:28:23 +05:30
Satish Gandham 53740fc57a Fix sortable component as the sort order was broken. 2024-06-06 13:19:30 +05:30
NarayanBavisetti a97994bb11 chore: space key changes 2024-06-06 13:16:52 +05:30
guru_sainath 509989279b chore: initial radio select in create estimate 2024-06-06 13:07:45 +05:30
guru_sainath ae9f1a7a94 chore: removed migration file and updated the estimate system order and removed ee banner 2024-06-06 12:55:27 +05:30
guru_sainath 2da91383d4 Merge branch 'develop' of gurusainath:makeplane/plane into revamp-estimates-ce 2024-06-06 12:01:45 +05:30
Satish Gandham 3ab9a233fe Merge develop into revamp-estimates-ce 2024-06-05 20:09:06 +05:30
17f83d6458 [WEB-1516] refactor: space app routing and layouts (#4705)
* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-06-05 20:08:03 +05:30
452e8f39ff chore: squashed migration (#4634)
* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
2024-06-05 17:47:48 +05:30
sriram veeraghanta 028e70c4c1 Merge branch 'develop' of github.com:makeplane/plane into preview 2024-06-05 15:58:49 +05:30
Nikhilandsriram veeraghanta 30fdc1015c fix: cache invalidation on new members invite (#4699) 2024-06-05 15:57:27 +05:30
Henit ChobisaandGitHub 272428b05e fix: build test pull request running on non draft PRs (#4708) 2024-06-05 15:56:36 +05:30
NikhilandGitHub 911832d546 fix: cache invalidation on new members invite (#4699) 2024-06-05 15:17:43 +05:30
NarayanBavisetti ea7a89ef87 chore: estimate point key change 2024-06-05 14:55:07 +05:30
NarayanBavisetti 7f99c95830 chore: estimate point value in graph 2024-06-05 14:35:46 +05:30
NikhilandGitHub 249e71e424 fix: email validation (#4707)
* fix: email validation on complete login or sign up functionality

* dev: add try catch block

* dev: split up code

* dev: empty return
2024-06-05 14:20:57 +05:30
rahulrameshaandGitHub 52d8d6e7ce [WEB-1517] chore: remove drag handle from list drag block (#4698)
* remove drag handle from list drag block

* align list group header with list item

* rearrange chevron for list subissues and rearrange spaces

* adding default draggable property to control link

* remove unnecessary dependencies for useEffect
2024-06-05 14:03:49 +05:30
Aaryan KhandelwalandGitHub 93a22034bd [WEB-1501] chore: update selected entity details on entities list change (#4702)
* chore: update selected entity detials on entities list change

* chore: addd selectionHelpers as a prop
2024-06-05 12:48:50 +05:30
Prateek ShouryaandGitHub 453459d271 [WEB-1459] chore: save users all / favorite project list collapse state into localstorage. (#4701) 2024-06-05 12:47:16 +05:30
M. PalanikannanandGitHub 8c5f693214 regression: focus changing issue with the peek overview editor (#4700) 2024-06-05 12:41:56 +05:30
NarayanBavisetti e53ce860f9 chore: removed the migration file 2024-06-05 05:03:57 +05:30
sriram veeraghanta 4d17616670 fix: docker image build errors 2024-06-04 18:50:28 +05:30
sriram veeraghanta c190bf3a6f fix: regenerating lock file 2024-06-04 18:40:41 +05:30
Prateek ShouryaandGitHub e503c901ae [WEB-1521] chore: add configuration to enable/disable sign-ups. (#4697) 2024-06-04 15:35:20 +05:30
NarayanBavisetti cf1728e38c chore: changed default analytics 2024-06-04 15:26:00 +05:30
Satish GandhamandGitHub 188f8ff83f feat: Add components required for estimates (#4690)
* Add sortable, radio and typography components

* Remove stray css classes

* Prevent drag of items from other draggable

* Minor cleanup

* Update yarn.lock

* Remove radio input component as it was build on
headless ui v2.0.0 and now we are using v1.7.0

* Fix build errors

* Update dependencies in use memo.
2024-06-04 14:17:35 +05:30
guru_sainath 20dbdbc562 chore: removed ee files 2024-06-04 13:47:19 +05:30
guru_sainath 80814978af chore: handled props in create modal 2024-06-04 13:42:17 +05:30
Prateek ShouryaandGitHub 77b73dc032 [WEB-1481] fix: multiple API calls in inbox issues on closed issues tab. (#4691)
* fix: multiple API calls on scroll and closed issues tab.

* fix: pagination loader on initial load.
2024-06-04 13:18:25 +05:30
20acb0dd17 [WEB-1312] fix: trim file name before uploading (#4661)
* fix: trim file name before uploading

* fix: check the cursor position before inserting image

* dev: add trimming for file assets

* dev: add filename validation above if

* dev: make the validation to 50 to support user uploads

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-04 13:14:26 +05:30
Prateek ShouryaandGitHub 7e66e2736b [WEB-1503] chore: add autofocus to name field in inline create/ update state component. (#4685) 2024-06-04 13:12:18 +05:30
Prateek ShouryaandGitHub cad55f3234 [WEB-1498] style: fix comments reaction alignment. (#4686) 2024-06-04 13:11:49 +05:30
guru_sainath 01d1456163 chore: resolved import error in packaged ui 2024-06-04 12:55:34 +05:30
guru_sainath 496479768b chore: removed radio button from ui and updated in the estimates 2024-06-04 12:49:43 +05:30
guru_sainath af7fc035d1 chore: resolved merge conflicts 2024-06-04 12:14:26 +05:30
Prateek ShouryaandGitHub 87582604f7 style: fix overlapping of response container in AI popover. (#4684) 2024-06-04 11:22:40 +05:30
Aaryan KhandelwalandGitHub dd65d03d33 [WEB-1184] feat: issue bulk operations (#4674)
* feat: issue bulk operations

* style: bulk operations action bar

* chore: remove edition separation
2024-06-04 11:12:24 +05:30
guru_sainath a26cf199f7 fix: updated the issue create sorting 2024-06-03 21:53:17 +05:30
Satish Gandham 4025ddca51 Fix sortable spacing issue in create estimate modal 2024-06-03 18:27:38 +05:30
Satish Gandham 21f1c8308f Fix issues with sortable 2024-06-03 17:58:32 +05:30
guru_sainath 84ab12d501 fix: upgrader ee banner 2024-06-03 17:23:35 +05:30
guru_sainath 16fa9c9baa fix: updated and handled the estimate points 2024-06-03 17:06:34 +05:30
Satish Gandham a725385190 Extract the list row actions 2024-06-03 14:34:33 +05:30
guru_sainath 0a72adc2a6 chore: updated alerts 2024-05-31 17:01:50 +05:30
guru_sainath ec228ad02c chore: estimate alerts 2024-05-31 16:21:37 +05:30
guru_sainath 1d9a011797 Merge branch 'revamp-estimates' of gurusainath:makeplane/plane into revamp-estimates 2024-05-31 16:05:16 +05:30
guru_sainath 2adb18f636 chore: estimate dropdown handled in issues 2024-05-31 16:01:37 +05:30
Satish Gandham d0bfd891d4 Add category specific values for custom template 2024-05-31 15:26:05 +05:30
NarayanBavisetti 78db7fe03f chore: added migration 2024-05-31 15:15:59 +05:30
guru_sainath 1794d9e093 chore: handled create and update components in projecr estimates 2024-05-31 12:28:39 +05:30
guru_sainath 0a839cd113 Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-30 18:47:40 +05:30
guru_sainath 42b3607ba1 Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-30 17:04:17 +05:30
guru_sainath dc84b9ac96 chore: handled active and availability vadilation 2024-05-30 17:04:06 +05:30
guru_sainath 2478c7b813 chore: resolved merge conflicts 2024-05-30 14:36:12 +05:30
guru_sainath 8822c8b184 chore: handled inline errors in the estimate switch 2024-05-30 11:27:28 +05:30
guru_sainath c2e07c6b7c chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point 2024-05-29 17:19:14 +05:30
guru_sainath 18c5b2a0a6 chore: resolved build error 2024-05-29 15:06:39 +05:30
guru_sainath cce9ad2a14 Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-29 14:52:59 +05:30
guru_sainath e55801c58b chore: UI and typos 2024-05-29 14:36:04 +05:30
guru_sainath d82c6cc05c Merge branch 'revamp-estimates' of gurusainath:makeplane/plane into revamp-estimates 2024-05-29 13:11:16 +05:30
guru_sainath 9c279a62e0 chore: updated switch estimate 2024-05-29 13:01:59 +05:30
Satish Gandham dc87ac5ec8 Merge branch 'preview' into revamp-estimates 2024-05-29 12:46:10 +05:30
guru_sainath 39482b72ab chore: updated ceate estimare workflow 2024-05-29 11:21:45 +05:30
guru_sainath 87d317ab82 chore: resolved merhe conflicts 2024-05-28 22:35:24 +05:30
guru_sainath 430330bce7 chore: handled close button in estimate edit 2024-05-28 16:59:36 +05:30
guru_sainath e6fa881721 chore: handled estimate edit 2024-05-28 16:48:57 +05:30
guru_sainath 1c1620a822 chore: handled estimates switch 2024-05-28 15:53:56 +05:30
guru_sainath 970517aa16 chore: resolved in merge conflicts 2024-05-28 15:10:14 +05:30
guru_sainath f362afb001 chore: handle estimate update worklfow 2024-05-28 15:06:22 +05:30
guru_sainath 4fa3eda5cd chore: handled current project active estimate 2024-05-28 11:34:58 +05:30
guru_sainath 638662d33a chore: resolved merge conflicts 2024-05-27 23:04:26 +05:30
Satish Gandham 600ad8698f Add custom option in the estimate templates 2024-05-27 19:08:24 +05:30
guru_sainath eb9b60df04 Merge branch 'revamp-estimates' of gurusainath:makeplane/plane into revamp-estimates 2024-05-27 17:14:04 +05:30
guru_sainath 9f329e1346 chore: update sorting in the estimates 2024-05-27 17:13:33 +05:30
NarayanBavisetti a918511b59 chore: update key value pair 2024-05-27 17:07:05 +05:30
guru_sainath 1cd31f4705 chore: integrated new endpoints 2024-05-27 15:51:27 +05:30
NarayanBavisetti be03d90c86 chore: create estimate point 2024-05-27 15:22:27 +05:30
NarayanBavisetti 145968a55b chore: typo changes 2024-05-27 13:58:16 +05:30
NarayanBavisetti d3e579f33c chore: individual endpoint for estimate points 2024-05-27 13:57:16 +05:30
NarayanBavisetti b8a8c82d5f chore: new field added in estimates 2024-05-27 12:43:48 +05:30
NarayanBavisetti 2e341c3f01 chore: response changes of get and post 2024-05-27 12:28:23 +05:30
guru_sainath 5fe135d1ac chore: ui changed 2024-05-27 12:06:18 +05:30
guru_sainath 0a9e16a98a chore: updating the new estinates in update modal 2024-05-27 11:52:54 +05:30
guru_sainath bdb0674d35 chore: editing and deleting the existing estimate updates 2024-05-27 11:35:49 +05:30
guru_sainath 1e59d5b735 chore: create estimate workflow update 2024-05-27 09:47:31 +05:30
guru_sainath a3c9d5639e Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-25 09:11:46 +05:30
guru_sainath 8dbf1d55ab chore: updated create estimate 2024-05-24 17:24:50 +05:30
guru_sainath 3c37bc1943 Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-24 15:24:07 +05:30
NarayanBavisetti d574caa48c chore: migration fixes 2024-05-24 15:17:22 +05:30
guru_sainath 07e905f879 Merge branch 'revamp-estimates' of gurusainath:makeplane/plane into revamp-estimates 2024-05-24 15:07:59 +05:30
guru_sainath 3800f4366c chore: updated create estimate and handled the build error 2024-05-24 15:07:44 +05:30
guru_sainath bc1702647c Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-24 14:06:15 +05:30
NarayanBavisetti 8fa2e9b60b chore: estimate point response change 2024-05-24 13:16:41 +05:30
NarayanBavisetti ca9529cfa6 Merge branch 'revamp-estimates' of github.com:makeplane/plane into revamp-estimates 2024-05-24 12:09:55 +05:30
NarayanBavisetti c811ecaa15 chore: delete an estimate point 2024-05-24 12:09:36 +05:30
guru_sainath 60319ff24e Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-24 10:23:26 +05:30
guru_sainath f0aea50ee8 Merge branch 'revamp-estimates' of gurusainath:makeplane/plane into revamp-estimates 2024-05-23 16:54:03 +05:30
guru_sainath 51a3e2c63c chore: updated empty and loading screens 2024-05-23 16:53:48 +05:30
Satish Gandham 3133207c42 Move sortabla and radio input to packages/ui 2024-05-23 15:58:14 +05:30
guru_sainath d6325320e9 chore: folder structure updates 2024-05-23 15:37:25 +05:30
guru_sainath 67b9d4a8f2 Merge branch 'preview' of gurusainath:makeplane/plane into revamp-estimates 2024-05-23 14:03:43 +05:30
Satish Gandham 0af70136a9 Move code from EE to CE repo 2024-05-23 13:41:30 +05:30
300 changed files with 9198 additions and 6251 deletions
@@ -3,10 +3,11 @@ name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize"]
types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
+32 -3
View File
@@ -7,12 +7,12 @@ import { useTheme } from "next-themes";
import useSWR from "swr";
import { Mails, KeyRound } from "lucide-react";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, setPromiseToast } from "@plane/ui";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
import { cn, resolveGeneralTheme } from "@/helpers/common.helper";
import { useInstance } from "@/hooks/store";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
@@ -45,6 +45,8 @@ const InstanceAuthenticationPage = observer(() => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// derived values
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
@@ -129,7 +131,34 @@ const InstanceAuthenticationPage = observer(() => {
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<div className="space-y-3">
<div className="text-lg font-medium">Authentication modes</div>
<div className="text-lg font-medium pb-1">Sign-up configuration</div>
<div className={cn("w-full flex items-center gap-14 rounded")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className={cn("font-medium leading-5 text-custom-text-100 text-sm")}>
Allow anyone to sign up without invite
</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
Toggling this off will disable self sign ups.
</div>
</div>
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
Boolean(parseInt(enableSignUpConfig)) === true
? updateConfig("ENABLE_SIGNUP", "0")
: updateConfig("ENABLE_SIGNUP", "1");
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className="text-lg font-medium pt-6">Authentication modes</div>
{authenticationMethodsCard.map((method) => (
<AuthenticationMethodCard
key={method.key}
@@ -5,9 +5,11 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
// helpers
import { WEB_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { WEB_BASE_URL } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// assets
import packageJson from "package.json";
@@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => {
return (
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
isSidebarCollapsed ? "flex-col" : ""
}`}
className={cn(
"flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 h-14 flex-shrink-0",
{
"flex-col h-auto py-1.5": isSidebarCollapsed,
}
)}
>
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
+2
View File
@@ -784,6 +784,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id:
return Response(
@@ -865,6 +866,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id,
)
+2 -2
View File
@@ -22,7 +22,7 @@ from plane.db.models import (
IssueProperty,
Module,
Project,
ProjectDeployBoard,
DeployBoard,
ProjectMember,
State,
Workspace,
@@ -99,7 +99,7 @@ class ProjectAPIEndpoint(BaseAPIView):
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
DeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
+1 -1
View File
@@ -30,7 +30,7 @@ from .project import (
ProjectIdentifierSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
DeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
@@ -11,10 +11,6 @@ from rest_framework import serializers
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
@@ -48,10 +44,6 @@ class EstimatePointSerializer(BaseSerializer):
class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
@@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimate_points = serializers.IntegerField(read_only=True)
completed_estimate_points = serializers.IntegerField(read_only=True)
class Meta:
model = Module
@@ -201,6 +203,8 @@ class ModuleSerializer(DynamicBaseSerializer):
"external_id",
"logo_props",
# computed fields
"total_estimate_points",
"completed_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
+5 -5
View File
@@ -13,7 +13,7 @@ from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
ProjectDeployBoard,
DeployBoard,
ProjectPublicMember,
)
@@ -114,7 +114,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
anchor = serializers.CharField(read_only=True)
members = serializers.SerializerMethodField()
def get_members(self, obj):
@@ -148,7 +148,7 @@ class ProjectDetailSerializer(BaseSerializer):
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
anchor = serializers.CharField(read_only=True)
class Meta:
model = Project
@@ -206,14 +206,14 @@ class ProjectMemberLiteSerializer(BaseSerializer):
read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
class DeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
class Meta:
model = ProjectDeployBoard
model = DeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
+20
View File
@@ -4,6 +4,7 @@ from django.urls import path
from plane.app.views import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
EstimatePointEndpoint,
)
@@ -34,4 +35,23 @@ urlpatterns = [
),
name="bulk-create-estimate-points",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointEndpoint.as_view(
{
"post": "create",
}
),
name="estimate-points",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<estimate_point_id>/",
EstimatePointEndpoint.as_view(
{
"patch": "partial_update",
"delete": "destroy",
}
),
name="estimate-points",
),
]
+3 -3
View File
@@ -2,6 +2,7 @@ from django.urls import path
from plane.app.views import (
ProjectViewSet,
DeployBoardViewSet,
ProjectInvitationsViewset,
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
@@ -12,7 +13,6 @@ from plane.app.views import (
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
)
@@ -157,7 +157,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
DeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
@@ -167,7 +167,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
DeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
+2 -1
View File
@@ -4,7 +4,7 @@ from .project.base import (
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
@@ -190,6 +190,7 @@ from .external.base import (
from .estimate.base import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
EstimatePointEndpoint,
)
from .inbox.base import InboxViewSet, InboxIssueViewSet
+3 -3
View File
@@ -33,7 +33,7 @@ class AnalyticsEndpoint(BaseAPIView):
"state__group",
"labels__id",
"assignees__id",
"estimate_point",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
@@ -381,9 +381,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
open_estimate_sum = open_issues_queryset.aggregate(
sum=Sum("estimate_point")
sum=Sum("point")
)["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
"sum"
]
@@ -177,6 +177,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None:
queryset = (
self.get_queryset()
@@ -375,6 +376,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=pk,
)
+137 -1
View File
@@ -17,8 +17,11 @@ from django.db.models import (
UUIDField,
Value,
When,
Subquery,
Sum,
IntegerField,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
@@ -73,6 +76,89 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return self.filter_queryset(
super()
.get_queryset()
@@ -197,12 +283,49 @@ class CycleViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
plot_type = request.GET.get("plot_type", "issues")
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@@ -233,6 +356,12 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -335,6 +464,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=data[0]["id"],
)
)
@@ -359,6 +489,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -527,6 +659,7 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "issues")
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
)
@@ -682,6 +815,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=pk,
)
@@ -798,6 +932,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id:
return Response(
@@ -879,6 +1014,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id,
)
+122 -50
View File
@@ -1,3 +1,6 @@
import random
import string
# Third party imports
from rest_framework.response import Response
from rest_framework import status
@@ -5,7 +8,7 @@ from rest_framework import status
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint
from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.app.serializers import (
EstimateSerializer,
EstimatePointSerializer,
@@ -13,6 +16,12 @@ from plane.app.serializers import (
)
from plane.utils.cache import invalidate_cache
def generate_random_name(length=10):
letters = string.ascii_lowercase
return "".join(random.choice(letters) for i in range(length))
class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
@@ -49,13 +58,17 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
@invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def create(self, request, slug, project_id):
if not request.data.get("estimate", False):
return Response(
{"error": "Estimate is required"},
status=status.HTTP_400_BAD_REQUEST,
)
estimate = request.data.get('estimate')
estimate_name = estimate.get("name", generate_random_name())
estimate_type = estimate.get("type", 'categories')
last_used = estimate.get("last_used", False)
estimate = Estimate.objects.create(
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
)
estimate_points = request.data.get("estimate_points", [])
@@ -67,14 +80,6 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate_serializer = EstimateSerializer(
data=request.data.get("estimate")
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate = estimate_serializer.save(project_id=project_id)
estimate_points = EstimatePoint.objects.bulk_create(
[
EstimatePoint(
@@ -93,17 +98,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
ignore_conflicts=True,
)
estimate_point_serializer = EstimatePointSerializer(
estimate_points, many=True
)
return Response(
{
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
status=status.HTTP_200_OK,
)
serializer = EstimateReadSerializer(estimate)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.get(
@@ -115,13 +111,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_200_OK,
)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
@invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def partial_update(self, request, slug, project_id, estimate_id):
if not request.data.get("estimate", False):
return Response(
{"error": "Estimate is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not len(request.data.get("estimate_points", [])):
return Response(
@@ -131,15 +124,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(pk=estimate_id)
estimate_serializer = EstimateSerializer(
estimate, data=request.data.get("estimate"), partial=True
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate = estimate_serializer.save()
if request.data.get("estimate"):
estimate.name = request.data.get("estimate").get("name", estimate.name)
estimate.type = request.data.get("estimate").get("type", estimate.type)
estimate.save()
estimate_points_data = request.data.get("estimate_points", [])
@@ -165,29 +153,113 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate_point.value = estimate_point_data[0].get(
"value", estimate_point.value
)
estimate_point.key = estimate_point_data[0].get(
"key", estimate_point.key
)
updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update(
updated_estimate_points,
["value"],
["key", "value"],
batch_size=10,
)
estimate_point_serializer = EstimatePointSerializer(
estimate_points, many=True
)
estimate_serializer = EstimateReadSerializer(estimate)
return Response(
{
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
estimate_serializer.data,
status=status.HTTP_200_OK,
)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
@invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def destroy(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id
)
estimate.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class EstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
def create(self, request, slug, project_id, estimate_id):
# TODO: add a key validation if the same key already exists
if not request.data.get("key") or not request.data.get("value"):
return Response(
{"error": "Key and value are required"},
status=status.HTTP_400_BAD_REQUEST,
)
key = request.data.get("key", 0)
value = request.data.get("value", "")
estimate_point = EstimatePoint.objects.create(
estimate_id=estimate_id,
project_id=project_id,
key=key,
value=value,
)
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
# TODO: add a key validation if the same key already exists
estimate_point = EstimatePoint.objects.get(
pk=estimate_point_id,
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
)
serializer = EstimatePointSerializer(
estimate_point, data=request.data, partial=True
)
if not serializer.is_valid():
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
new_estimate_id = request.GET.get("new_estimate_id", None)
estimate_points = EstimatePoint.objects.filter(
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
)
# update all the issues with the new estimate
if new_estimate_id:
_ = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_id=estimate_point_id,
).update(estimate_id=new_estimate_id)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
pk=estimate_point_id
).first()
# rearrange the estimate points
updated_estimate_points = []
for estimate_point in estimate_points:
if estimate_point.key > old_estimate_point.key:
estimate_point.key -= 1
updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update(
updated_estimate_points,
["key"],
batch_size=10,
)
old_estimate_point.delete()
return Response(
EstimatePointSerializer(updated_estimate_points, many=True).data,
status=status.HTTP_200_OK,
)
@@ -165,6 +165,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
@@ -323,6 +324,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
module_id=pk,
)
+50 -1
View File
@@ -16,8 +16,9 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
Sum,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
@@ -128,6 +129,34 @@ class ModuleViewSet(BaseViewSet):
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return (
super()
.get_queryset()
@@ -182,6 +211,18 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()),
)
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
@@ -233,6 +274,8 @@ class ModuleViewSet(BaseViewSet):
"total_issues",
"started_issues",
"unstarted_issues",
"completed_estimate_points",
"total_estimate_points",
"backlog_issues",
"created_at",
"updated_at",
@@ -284,6 +327,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
"completed_estimate_points",
"total_estimate_points",
"total_issues",
"is_favorite",
"cancelled_issues",
@@ -301,6 +346,7 @@ class ModuleViewSet(BaseViewSet):
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "burndown")
queryset = (
self.get_queryset()
.filter(archived_at__isnull=True)
@@ -423,6 +469,7 @@ class ModuleViewSet(BaseViewSet):
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
module_id=pk,
)
@@ -469,6 +516,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"cancelled_issues",
"completed_issues",
+30 -31
View File
@@ -28,7 +28,7 @@ from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
ProjectDeployBoardSerializer,
DeployBoardSerializer,
)
from plane.app.permissions import (
@@ -46,7 +46,7 @@ from plane.db.models import (
Module,
Cycle,
Inbox,
ProjectDeployBoard,
DeployBoard,
IssueProperty,
Issue,
)
@@ -137,12 +137,11 @@ class ProjectViewSet(BaseViewSet):
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
anchor=DeployBoard.objects.filter(
entity_name="project",
entity_identifier=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
).values("anchor")
)
.annotate(sort_order=Subquery(sort_order))
.prefetch_related(
@@ -639,29 +638,28 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
return Response(files, status=status.HTTP_200_OK)
class ProjectDeployBoardViewSet(BaseViewSet):
class DeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
serializer_class = DeployBoardSerializer
model = DeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def list(self, request, slug, project_id):
project_deploy_board = DeployBoard.objects.filter(
entity_name="project",
entity_identifier=project_id,
workspace__slug=slug,
).first()
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
comments = request.data.get("is_comments_enabled", False)
reactions = request.data.get("is_reactions_enabled", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
votes = request.data.get("is_votes_enabled", False)
views = request.data.get(
"views",
{
@@ -673,17 +671,18 @@ class ProjectDeployBoardViewSet(BaseViewSet):
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_deploy_board, _ = DeployBoard.objects.get_or_create(
entity_name="project",
entity_identifier=project_id,
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.view_props = views
project_deploy_board.is_votes_enabled = votes
project_deploy_board.is_comments_enabled = comments
project_deploy_board.is_reactions_enabled = reactions
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
+122 -59
View File
@@ -4,6 +4,8 @@ import uuid
# Django imports
from django.utils import timezone
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third party imports
from zxcvbn import zxcvbn
@@ -46,68 +48,71 @@ class Adapter:
def authenticate(self):
raise NotImplementedError
def complete_login_or_signup(self):
email = self.user_data.get("email")
user = User.objects.filter(email=email).first()
# Check if sign up case or login
is_signup = bool(user)
if not user:
# New user
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "1"),
},
]
)
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
user = User(email=email, username=uuid.uuid4().hex)
if self.user_data.get("user").get("is_password_autoset"):
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.is_email_verified = True
else:
# Validate password
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INVALID_PASSWORD"
],
error_message="INVALID_PASSWORD",
payload={"email": email},
)
user.set_password(self.code)
user.is_password_autoset = False
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
Profile.objects.create(user=user)
if not user.is_active:
def sanitize_email(self, email):
# Check if email is present
if not email:
raise AuthenticationException(
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
error_message="USER_ACCOUNT_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Sanitize email
email = str(email).lower().strip()
# validate email
try:
validate_email(email)
except ValidationError:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
payload={"email": email},
)
# Return email
return email
def validate_password(self, email):
"""Validate password strength"""
results = zxcvbn(self.code)
if results["score"] < 3:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
payload={"email": email},
)
return
def __check_signup(self, email):
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "1"),
},
]
)
# Check if sign up is disabled and invite is present or not
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
# Raise exception
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED",
payload={"email": email},
)
return True
def save_user_data(self, user):
# Update user details
user.last_login_medium = self.provider
user.last_active = timezone.now()
@@ -116,7 +121,63 @@ class Adapter:
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
return user
def complete_login_or_signup(self):
# Get email
email = self.user_data.get("email")
# Sanitize email
email = self.sanitize_email(email)
# Check if the user is present
user = User.objects.filter(email=email).first()
# Check if sign up case or login
is_signup = bool(user)
# If user is not present, create a new user
if not user:
# New user
self.__check_signup(email)
# Initialize user
user = User(email=email, username=uuid.uuid4().hex)
# Check if password is autoset
if self.user_data.get("user").get("is_password_autoset"):
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.is_email_verified = True
# Validate password
else:
# Validate password
self.validate_password(email)
# Set password
user.set_password(self.code)
user.is_password_autoset = False
# Set user details
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
# Create profile
Profile.objects.create(user=user)
if not user.is_active:
raise AuthenticationException(
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
error_message="USER_ACCOUNT_DEACTIVATED",
)
# Save user data
user = self.save_user_data(user=user)
# Call callback if present
if self.callback:
self.callback(
user,
@@ -124,7 +185,9 @@ class Adapter:
self.request,
)
# Create or update account if token data is present
if self.token_data:
self.create_update_account(user=user)
# Return user
return user
@@ -58,6 +58,8 @@ AUTHENTICATION_ERROR_CODES = {
"ADMIN_USER_DEACTIVATED": 5190,
# Rate limit
"RATE_LIMIT_EXCEEDED": 5900,
# Unknown
"AUTHENTICATION_FAILED": 5999,
}
@@ -81,11 +81,11 @@ class OauthAdapter(Adapter):
response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
if self.provider == "google":
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "github":
code = "GITHUB_OAUTH_PROVIDER_ERROR"
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
@@ -4,6 +4,7 @@ from plane.db.models import (
WorkspaceMember,
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache_directly
def process_workspace_project_invitations(user):
@@ -26,6 +27,16 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)
[
invalidate_cache_directly(
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
url_params=False,
user=False,
multiple=True,
)
for workspace_member_invite in workspace_member_invites
]
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
@@ -28,6 +28,7 @@ from plane.db.models import (
Project,
State,
User,
EstimatePoint,
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
@@ -448,21 +449,37 @@ def track_estimate_points(
if current_instance.get("estimate_point") != requested_data.get(
"estimate_point"
):
old_estimate = (
EstimatePoint.objects.filter(
pk=current_instance.get("estimate_point")
).first()
if current_instance.get("estimate_point") is not None
else None
)
new_estimate = (
EstimatePoint.objects.filter(
pk=requested_data.get("estimate_point")
).first()
if requested_data.get("estimate_point") is not None
else None
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
old_value=(
old_identifier=(
current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None
else ""
else None
),
new_value=(
new_identifier=(
requested_data.get("estimate_point")
if requested_data.get("estimate_point") is not None
else ""
else None
),
old_value=old_estimate.value if old_estimate else None,
new_value=new_estimate.value if new_estimate else None,
field="estimate_point",
project_id=project_id,
workspace_id=workspace_id,
@@ -0,0 +1,260 @@
# # Generated by Django 4.2.7 on 2024-05-24 09:47
# Python imports
import uuid
from uuid import uuid4
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import plane.db.models.deploy_board
def issue_estimate_point(apps, schema_editor):
Issue = apps.get_model("db", "Issue")
Project = apps.get_model("db", "Project")
EstimatePoint = apps.get_model("db", "EstimatePoint")
IssueActivity = apps.get_model("db", "IssueActivity")
updated_estimate_point = []
updated_issue_activity = []
# loop through all the projects
for project in Project.objects.filter(estimate__isnull=False):
estimate_points = EstimatePoint.objects.filter(
estimate=project.estimate, project=project
)
for issue_activity in IssueActivity.objects.filter(
field="estimate_point", project=project
):
if issue_activity.new_value:
new_identifier = estimate_points.filter(
key=issue_activity.new_value
).first().id
issue_activity.new_identifier = new_identifier
new_value = estimate_points.filter(
key=issue_activity.new_value
).first().value
issue_activity.new_value = new_value
if issue_activity.old_value:
old_identifier = estimate_points.filter(
key=issue_activity.old_value
).first().id
issue_activity.old_identifier = old_identifier
old_value = estimate_points.filter(
key=issue_activity.old_value
).first().value
issue_activity.old_value = old_value
updated_issue_activity.append(issue_activity)
for issue in Issue.objects.filter(
point__isnull=False, project=project
):
# get the estimate id for the corresponding estimate point in the issue
estimate = estimate_points.filter(key=issue.point).first()
issue.estimate_point = estimate
updated_estimate_point.append(issue)
Issue.objects.bulk_update(
updated_estimate_point, ["estimate_point"], batch_size=1000
)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["new_value", "old_value", "new_identifier", "old_identifier"],
batch_size=1000,
)
def last_used_estimate(apps, schema_editor):
Project = apps.get_model("db", "Project")
Estimate = apps.get_model("db", "Estimate")
# Get all estimate ids used in projects
estimate_ids = Project.objects.filter(estimate__isnull=False).values_list(
"estimate", flat=True
)
# Update all matching estimates
Estimate.objects.filter(id__in=estimate_ids).update(last_used=True)
def populate_deploy_board(apps, schema_editor):
DeployBoard = apps.get_model("db", "DeployBoard")
ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard")
DeployBoard.objects.bulk_create(
[
DeployBoard(
entity_identifier=deploy_board.project_id,
project_id=deploy_board.project_id,
entity_name="project",
anchor=uuid4().hex,
is_comments_enabled=deploy_board.comments,
is_reactions_enabled=deploy_board.reactions,
inbox=deploy_board.inbox,
is_votes_enabled=deploy_board.votes,
view_props=deploy_board.views,
workspace_id=deploy_board.workspace_id,
created_at=deploy_board.created_at,
updated_at=deploy_board.updated_at,
created_by_id=deploy_board.created_by_id,
updated_by_id=deploy_board.updated_by_id,
)
for deploy_board in ProjectDeployBoard.objects.all()
],
batch_size=100,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0066_account_id_token_cycle_logo_props_module_logo_props"),
]
operations = [
migrations.CreateModel(
name="DeployBoard",
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"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("entity_identifier", models.UUIDField(null=True)),
(
"entity_name",
models.CharField(
choices=[
("project", "Project"),
("issue", "Issue"),
("module", "Module"),
("cycle", "Task"),
("page", "Page"),
("view", "View"),
],
max_length=30,
),
),
(
"anchor",
models.CharField(
db_index=True,
default=plane.db.models.deploy_board.get_anchor,
max_length=255,
unique=True,
),
),
("is_comments_enabled", models.BooleanField(default=False)),
("is_reactions_enabled", models.BooleanField(default=False)),
("is_votes_enabled", models.BooleanField(default=False)),
("view_props", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"inbox",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="board_inbox",
to="db.inbox",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Deploy Board",
"verbose_name_plural": "Deploy Boards",
"db_table": "deploy_boards",
"ordering": ("-created_at",),
"unique_together": {("entity_name", "entity_identifier")},
},
),
migrations.AddField(
model_name="estimate",
name="last_used",
field=models.BooleanField(default=False),
),
# Rename the existing field
migrations.RenameField(
model_name="issue",
old_name="estimate_point",
new_name="point",
),
# Add a new field with the original name as a foreign key
migrations.AddField(
model_name="issue",
name="estimate_point",
field=models.ForeignKey(
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_estimates",
to="db.EstimatePoint",
blank=True,
null=True,
),
),
migrations.AlterField(
model_name="estimate",
name="type",
field=models.CharField(default="categories", max_length=255),
),
migrations.AlterField(
model_name="estimatepoint",
name="value",
field=models.CharField(max_length=255),
),
migrations.RunPython(issue_estimate_point),
migrations.RunPython(last_used_estimate),
migrations.RunPython(populate_deploy_board),
]
+2 -1
View File
@@ -4,6 +4,7 @@ from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory
from .importer import Importer
@@ -53,13 +54,13 @@ from .page import Page, PageFavorite, PageLabel, PageLog
from .project import (
Project,
ProjectBaseModel,
ProjectDeployBoard,
ProjectFavorite,
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
+1
View File
@@ -12,6 +12,7 @@ from .base import BaseModel
def get_upload_path(instance, filename):
filename = filename[:50]
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
+53
View File
@@ -0,0 +1,53 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
# Module imports
from .workspace import WorkspaceBaseModel
def get_anchor():
return uuid4().hex
class DeployBoard(WorkspaceBaseModel):
TYPE_CHOICES = (
("project", "Project"),
("issue", "Issue"),
("module", "Module"),
("cycle", "Task"),
("page", "Page"),
("view", "View"),
)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30,
choices=TYPE_CHOICES,
)
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
is_comments_enabled = models.BooleanField(default=False)
is_reactions_enabled = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox",
related_name="board_inbox",
on_delete=models.SET_NULL,
null=True,
)
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
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"]
verbose_name = "Deploy Board"
verbose_name_plural = "Deploy Boards"
db_table = "deploy_boards"
ordering = ("-created_at",)
+3 -2
View File
@@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel):
description = models.TextField(
verbose_name="Estimate Description", blank=True
)
type = models.CharField(max_length=255, default="Categories")
type = models.CharField(max_length=255, default="categories")
last_used = models.BooleanField(default=False)
def __str__(self):
"""Return name of the estimate"""
@@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel):
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
description = models.TextField(blank=True)
value = models.CharField(max_length=20)
value = models.CharField(max_length=255)
def __str__(self):
"""Return name of the estimate"""
+8 -1
View File
@@ -119,11 +119,18 @@ class Issue(ProjectBaseModel):
blank=True,
related_name="state_issue",
)
estimate_point = models.IntegerField(
point = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(12)],
null=True,
blank=True,
)
estimate_point = models.ForeignKey(
"db.EstimatePoint",
on_delete=models.SET_NULL,
related_name="issue_estimates",
null=True,
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
+2
View File
@@ -260,6 +260,8 @@ def get_default_views():
}
# DEPRECATED TODO:
# used to get the old anchors for the project deploy boards
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
+3 -3
View File
@@ -10,7 +10,7 @@ from plane.space.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
"anchor/<str:anchor>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
@@ -20,7 +20,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
"anchor/<str:anchor>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
@@ -31,7 +31,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
+7 -7
View File
@@ -10,12 +10,12 @@ from plane.space.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
@@ -25,7 +25,7 @@ urlpatterns = [
name="issue-comments-project-board",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
@@ -36,7 +36,7 @@ urlpatterns = [
name="issue-comments-project-board",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
@@ -46,7 +46,7 @@ urlpatterns = [
name="issue-reactions-project-board",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
"anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
@@ -55,7 +55,7 @@ urlpatterns = [
name="issue-reactions-project-board",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
"anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
@@ -65,7 +65,7 @@ urlpatterns = [
name="comment-reactions-project-board",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
"anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
+8 -2
View File
@@ -4,17 +4,23 @@ from django.urls import path
from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
"anchor/<str:anchor>/issues/",
ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/anchor/",
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
]
+1
View File
@@ -1,6 +1,7 @@
from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
)
from .issue import (
+34 -34
View File
@@ -18,7 +18,7 @@ from plane.db.models import (
State,
IssueLink,
IssueAttachment,
ProjectDeployBoard,
DeployBoard,
)
from plane.app.serializers import (
IssueSerializer,
@@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
@@ -58,9 +58,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
)
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def list(self, request, anchor, inbox_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if project_deploy_board.inbox is None:
return Response(
@@ -72,8 +72,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
@@ -117,9 +117,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id, inbox_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def create(self, request, anchor, inbox_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if project_deploy_board.inbox is None:
return Response(
@@ -151,7 +151,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
project_id=project_deploy_board.project_id,
color="#ff7700",
)
@@ -163,7 +163,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
project_id=project_deploy_board.project_id,
state=state,
)
@@ -173,14 +173,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
project_id=project_deploy_board.project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
@@ -188,9 +188,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def partial_update(self, request, anchor, inbox_id, pk):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if project_deploy_board.inbox is None:
return Response(
@@ -200,8 +200,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
inbox_id=inbox_id,
)
# Get the project member
@@ -216,8 +216,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
# viewers and guests since only viewers and guests
issue_data = {
@@ -242,7 +242,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
@@ -255,9 +255,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def retrieve(self, request, anchor, inbox_id, pk):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if project_deploy_board.inbox is None:
return Response(
@@ -267,21 +267,21 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
inbox_id=inbox_id,
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def destroy(self, request, anchor, inbox_id, pk):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if project_deploy_board.inbox is None:
return Response(
@@ -291,8 +291,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
inbox_id=inbox_id,
)
+111 -96
View File
@@ -44,7 +44,7 @@ from plane.db.models import (
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
DeployBoard,
IssueVote,
ProjectPublicMember,
)
@@ -76,15 +76,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
def get_queryset(self):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
project_deploy_board = DeployBoard.objects.get(
anchor=self.kwargs.get("anchor"),
entity_name="project",
)
if project_deploy_board.comments:
if project_deploy_board.is_comments_enabled:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL")
.select_related("project")
@@ -93,8 +93,8 @@ class IssueCommentPublicViewSet(BaseViewSet):
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
member_id=self.request.user.id,
is_active=True,
)
@@ -103,15 +103,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
.distinct()
).order_by("created_at")
return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist:
except DeployBoard.DoesNotExist:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def create(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.comments:
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
@@ -120,7 +120,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
project_id=project_deploy_board.project_id,
issue_id=issue_id,
actor=request.user,
access="EXTERNAL",
@@ -132,37 +132,35 @@ class IssueCommentPublicViewSet(BaseViewSet):
),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
if not ProjectMember.objects.filter(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, issue_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def partial_update(self, request, anchor, issue_id, pk):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.comments:
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
comment = IssueComment.objects.get(pk=pk, actor=request.user)
serializer = IssueCommentSerializer(
comment, data=request.data, partial=True
)
@@ -173,7 +171,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
@@ -183,20 +181,18 @@ class IssueCommentPublicViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def destroy(self, request, anchor, issue_id, pk):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.comments:
if not project_deploy_board.is_comments_enabled:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug,
pk=pk,
project_id=project_id,
actor=request.user,
)
issue_activity.delay(
@@ -204,7 +200,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
@@ -221,11 +217,11 @@ class IssueReactionPublicViewSet(BaseViewSet):
def get_queryset(self):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
if project_deploy_board.is_reactions_enabled:
return (
super()
.get_queryset()
@@ -236,15 +232,15 @@ class IssueReactionPublicViewSet(BaseViewSet):
.distinct()
)
return IssueReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
except DeployBoard.DoesNotExist:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def create(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.reactions:
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
@@ -253,16 +249,18 @@ class IssueReactionPublicViewSet(BaseViewSet):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
project_id=project_deploy_board.project_id,
issue_id=issue_id,
actor=request.user,
)
if not ProjectMember.objects.filter(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
)
issue_activity.delay(
@@ -272,25 +270,25 @@ class IssueReactionPublicViewSet(BaseViewSet):
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def destroy(self, request, anchor, issue_id, reaction_code):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.reactions:
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
workspace_id=project_deploy_board.workspace_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
@@ -300,7 +298,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
@@ -319,30 +317,29 @@ class CommentReactionPublicViewSet(BaseViewSet):
def get_queryset(self):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
project_deploy_board = DeployBoard.objects.get(
anchor=self.kwargs.get("anchor"), entity_name="project"
)
if project_deploy_board.reactions:
if project_deploy_board.is_reactions_enabled:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_deploy_board.project_id)
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
return CommentReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
except DeployBoard.DoesNotExist:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def create(self, request, anchor, comment_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.reactions:
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
@@ -351,18 +348,18 @@ class CommentReactionPublicViewSet(BaseViewSet):
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
project_id=project_deploy_board.project_id,
comment_id=comment_id,
actor=request.user,
)
if not ProjectMember.objects.filter(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
)
issue_activity.delay(
@@ -379,19 +376,19 @@ class CommentReactionPublicViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def destroy(self, request, anchor, comment_id, reaction_code):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
if not project_deploy_board.reactions:
if not project_deploy_board.is_reactions_enabled:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
@@ -401,7 +398,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
@@ -421,36 +418,42 @@ class IssueVotePublicViewSet(BaseViewSet):
def get_queryset(self):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("anchor"),
entity_name="project",
)
if project_deploy_board.votes:
if project_deploy_board.is_votes_enabled:
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_deploy_board.project_id)
)
return IssueVote.objects.none()
except ProjectDeployBoard.DoesNotExist:
except DeployBoard.DoesNotExist:
return IssueVote.objects.none()
def create(self, request, slug, project_id, issue_id):
def create(self, request, anchor, issue_id):
print("hite")
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
print("awer")
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
project_id=project_deploy_board.project_id,
issue_id=issue_id,
)
print("AWer")
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
is_active=True,
).exists():
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
project_id=project_deploy_board.project_id,
member=request.user,
)
issue_vote.vote = request.data.get("vote", 1)
@@ -462,26 +465,29 @@ class IssueVotePublicViewSet(BaseViewSet):
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, issue_id):
def destroy(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
)
issue_activity.delay(
type="issue_vote.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
project_id=str(project_deploy_board.project_id),
current_instance=json.dumps(
{
"vote": str(issue_vote.vote),
@@ -499,9 +505,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
AllowAny,
]
def get(self, request, slug, project_id, issue_id):
def get(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=issue_id
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
pk=issue_id,
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -512,14 +523,17 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
AllowAny,
]
def get(self, request, slug, project_id):
if not ProjectDeployBoard.objects.filter(
workspace__slug=slug, project_id=project_id
def get(self, request, anchor):
if not DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).exists():
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
filters = issue_filters(request.query_params, "GET")
@@ -544,8 +558,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(project_id=project_deploy_board.project_id)
.filter(workspace_id=project_deploy_board.workspace_id)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
@@ -652,8 +666,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
.annotate(
custom_order=Case(
@@ -670,7 +684,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
)
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
).values("id", "name", "color", "parent")
## Grouping the results
+26 -10
View File
@@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.app.serializers import ProjectDeployBoardSerializer
from plane.app.serializers import DeployBoardSerializer
from plane.db.models import (
Project,
ProjectDeployBoard,
DeployBoard,
)
@@ -23,11 +23,11 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
AllowAny,
]
def get(self, request, slug, project_id):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
def get(self, request, anchor):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -36,13 +36,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
AllowAny,
]
def get(self, request, slug):
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
projects = (
Project.objects.filter(workspace__slug=slug)
Project.objects.filter(workspace=deploy_board.workspace)
.annotate(
is_public=Exists(
ProjectDeployBoard.objects.filter(
workspace__slug=slug, project_id=OuterRef("pk")
DeployBoard.objects.filter(
anchor=anchor,
project_id=OuterRef("pk"),
entity_name="project",
)
)
)
@@ -58,3 +61,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
)
return Response(projects, status=status.HTTP_200_OK)
class WorkspaceProjectAnchorEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
+115 -39
View File
@@ -4,18 +4,28 @@ from itertools import groupby
# Django import
from django.db import models
from django.db.models import Case, CharField, Count, F, Sum, Value, When
from django.db.models import (
Case,
CharField,
Count,
F,
Sum,
Value,
When,
IntegerField,
)
from django.db.models.functions import (
Coalesce,
Concat,
ExtractMonth,
ExtractYear,
TruncDate,
Cast,
)
from django.utils import timezone
# Module imports
from plane.db.models import Issue
from plane.db.models import Issue, Project
def annotate_with_monthly_dimension(queryset, field_name, attribute):
@@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate
else:
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(
x_axis
)
queryset = queryset.annotate(
estimate=Sum(Cast("estimate_point__value", IntegerField()))
).order_by(x_axis)
queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset
)
@@ -110,9 +120,33 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
return sort_data(grouped_data, temp_axis)
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
def burndown_plot(
queryset,
slug,
project_id,
plot_type,
cycle_id=None,
module_id=None,
):
# Total Issues in Cycle or Module
total_issues = queryset.total_issues
# check whether the estimate is a point or not
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type and plot_type == "points":
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
).values_list("estimate_point__value", flat=True)
issue_estimates = [int(value) for value in issue_estimates]
total_estimate_points = sum(issue_estimates)
if cycle_id:
if queryset.end_date and queryset.start_date:
@@ -128,18 +162,32 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
if module_id:
# Get all dates between the two dates
@@ -152,31 +200,59 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
for date in date_range:
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
if plot_type == "points":
cumulative_pending_issues = total_estimate_points
total_completed = 0
total_completed = sum(
int(item["estimate_point__value"])
for item in completed_issues_estimate_point_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
else:
chart_data[str(date)] = cumulative_pending_issues
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
return chart_data
+1 -1
View File
@@ -66,7 +66,7 @@ def invalidate_cache_directly(
custom_path = path if path is not None else request.get_full_path()
auth_header = (
None
if request.user.is_anonymous
if request and request.user.is_anonymous
else str(request.user.id) if user else None
)
key = generate_cache_key(custom_path, auth_header)
@@ -112,7 +112,7 @@ export const useEditor = ({
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
try {
editor.commands.setContent(value);
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
const docLength = editor.state.doc.content.size;
@@ -50,9 +50,25 @@ export async function startImageUpload(
};
try {
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const resolvedPos = view.state.doc.resolve(pos ?? 0);
const nodeBefore = resolvedPos.nodeBefore;
// if the image is at the start of the line i.e. when nodeBefore is null
if (nodeBefore === null) {
if (pos) {
// so that the image is not inserted at the next line, else incase the
// image is inserted at any line where there's some content, the
// position is kept as it is to be inserted at the next line
pos -= 1;
}
}
view.focus();
const src = await uploadAndValidateImage(file, uploadFile);
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
@@ -112,3 +128,14 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom
throw error;
}
}
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export type TXAxisValues =
| "state__group"
| "labels__id"
| "assignees__id"
| "estimate_point"
| "estimate_point__value"
| "issue_cycle__cycle_id"
| "issue_module__module_id"
| "priority"
+13
View File
@@ -24,3 +24,16 @@ export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
}
// estimates
export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export enum EEstimateUpdateStages {
CREATE = "create",
EDIT = "edit",
SWITCH = "switch",
}
+66 -29
View File
@@ -1,40 +1,77 @@
export interface IEstimate {
created_at: Date;
created_by: string;
description: string;
id: string;
name: string;
project: string;
project_detail: IProject;
updated_at: Date;
updated_by: string;
points: IEstimatePoint[];
workspace: string;
workspace_detail: IWorkspace;
}
import { EEstimateSystem, EEstimateUpdateStages } from "./enums";
export interface IEstimatePoint {
created_at: string;
created_by: string;
description: string;
estimate: string;
id: string;
key: number;
project: string;
updated_at: string;
updated_by: string;
value: string;
workspace: string;
id: string | undefined;
key: number | undefined;
value: string | undefined;
description: string | undefined;
workspace: string | undefined;
project: string | undefined;
estimate: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
created_by: string | undefined;
updated_by: string | undefined;
}
export type TEstimateSystemKeys =
| EEstimateSystem.POINTS
| EEstimateSystem.CATEGORIES
| EEstimateSystem.TIME;
export interface IEstimate {
id: string | undefined;
name: string | undefined;
description: string | undefined;
type: TEstimateSystemKeys | undefined; // categories, points, time
points: IEstimatePoint[] | undefined;
workspace: string | undefined;
project: string | undefined;
last_used: boolean | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
created_by: string | undefined;
updated_by: string | undefined;
}
export interface IEstimateFormData {
estimate: {
name: string;
description: string;
estimate?: {
name?: string;
type?: string;
last_used?: boolean;
};
estimate_points: {
id?: string;
id?: string | undefined;
key: number;
value: string;
}[];
}
export type TEstimatePointsObject = {
id?: string | undefined;
key: number;
value: string;
};
export type TTemplateValues = {
title: string;
values: TEstimatePointsObject[];
hide?: boolean;
};
export type TEstimateSystem = {
name: string;
templates: Record<string, TTemplateValues>;
is_available: boolean;
is_ee: boolean;
};
export type TEstimateSystems = {
[K in TEstimateSystemKeys]: TEstimateSystem;
};
// update estimates
export type TEstimateUpdateStageKeys =
| EEstimateUpdateStages.CREATE
| EEstimateUpdateStages.EDIT
| EEstimateUpdateStages.SWITCH;
+1 -1
View File
@@ -15,7 +15,6 @@ export * from "./importer";
export * from "./inbox";
export * from "./analytics";
export * from "./api_token";
export * from "./app";
export * from "./auth";
export * from "./calendar";
export * from "./instance";
@@ -28,3 +27,4 @@ export * from "./webhook";
export * from "./workspace-views";
export * from "./common";
export * from "./pragmatic";
export * from "./publish";
+1 -1
View File
@@ -15,7 +15,7 @@ export type TIssue = {
priority: TIssuePriorities;
label_ids: string[];
assignee_ids: string[];
estimate_point: number | null;
estimate_point: string | null;
sub_issues_count: number;
attachment_count: number;
+2
View File
@@ -44,6 +44,8 @@ export interface IModule {
target_date: string | null;
total_issues: number;
unstarted_issues: number;
total_estimate_points?: number;
completed_estimate_points?: number;
updated_at: string;
updated_by?: string;
archived_at: string | null;
+1 -1
View File
@@ -32,7 +32,7 @@ export interface IProject {
estimate: string | null;
id: string;
identifier: string;
is_deployed: boolean;
anchor: string | null;
is_favorite: boolean;
is_member: boolean;
logo_props: TLogoProps;
+41
View File
@@ -0,0 +1,41 @@
import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types";
export type TPublishEntityType = "project";
export type TProjectPublishLayouts =
| "calendar"
| "gantt"
| "kanban"
| "list"
| "spreadsheet";
export type TPublishViewProps = {
calendar?: boolean;
gantt?: boolean;
kanban?: boolean;
list?: boolean;
spreadsheet?: boolean;
};
export type TProjectDetails = IProjectLite &
Pick<IProject, "cover_image" | "logo_props" | "description">;
export type TPublishSettings = {
anchor: string | undefined;
is_comments_enabled: boolean;
created_at: string | undefined;
created_by: string | undefined;
entity_identifier: string | undefined;
entity_name: TPublishEntityType | undefined;
id: string | undefined;
inbox: unknown;
project: string | undefined;
project_details: TProjectDetails | undefined;
is_reactions_enabled: boolean;
updated_at: string | undefined;
updated_by: string | undefined;
view_props: TViewProps | undefined;
is_votes_enabled: boolean;
workspace: string | undefined;
workspace_detail: IWorkspaceLite | undefined;
};
+5 -2
View File
@@ -20,17 +20,20 @@
"postcss": "postcss styles/globals.css -o styles/output.css --watch"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.17",
"@headlessui/react": "^2.0.3",
"@popperjs/core": "^2.11.8",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
"react-popper": "^2.3.0",
"sonner": "^1.4.2",
"sonner": "^1.4.41",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
+11 -2
View File
@@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
target?: string;
disabled?: boolean;
className?: string;
draggable?: boolean;
};
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
if (disabled) return <>{children}</>;
return (
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
<a
href={href}
target={target}
onClick={handleOnClick}
{...rest}
ref={ref}
className={className}
draggable={draggable}
>
{children}
</a>
);
+3 -1
View File
@@ -13,4 +13,6 @@ export * from "./loader";
export * from "./control-link";
export * from "./toast";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./typography";
export * from "./drop-indicator";
export * from "./sortable";
+62
View File
@@ -0,0 +1,62 @@
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { isEqual } from "lodash";
import { cn } from "../../helpers";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "../drop-indicator";
type Props = {
children: React.ReactNode;
data: any; //@todo make this generic
className?: string;
};
const Draggable = ({ children, data, className }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<boolean>(false); // NEW
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
useEffect(() => {
const el = ref.current;
if (el) {
combine(
draggable({
element: el,
onDragStart: () => setDragging(true), // NEW
onDrop: () => setDragging(false), // NEW
getInitialData: () => data,
}),
dropTargetForElements({
element: el,
onDragEnter: (args) => {
setIsDraggedOver(true);
setClosestEdge(extractClosestEdge(args.self.data));
},
onDragLeave: () => setIsDraggedOver(false),
onDrop: () => {
setIsDraggedOver(false);
},
canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__,
getData: ({ input, element }) =>
attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
}),
})
);
}
}, [data]);
return (
<div ref={ref} className={cn(dragging && "opacity-25", className)}>
{<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />}
{children}
{<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />}
</div>
);
};
export { Draggable };
+2
View File
@@ -0,0 +1,2 @@
export * from "./sortable";
export * from "./draggable";
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Sortable } from "./sortable";
const meta: Meta<typeof Sortable> = {
title: "Sortable",
component: Sortable,
};
export default meta;
type Story = StoryObj<typeof Sortable>;
const data = [
{ id: "1", name: "John Doe" },
{ id: "2", name: "Satish" },
{ id: "3", name: "Alice" },
{ id: "4", name: "Bob" },
{ id: "5", name: "Charlie" },
];
export const Default: Story = {
args: {
data,
render: (item: any) => (
// <Draggable data={item} className="rounded-lg">
<div className="border ">{item.name}</div>
// </Draggable>
),
onChange: (data) => console.log(data.map(({ id }) => id)),
keyExtractor: (item: any) => item.id,
},
};
+79
View File
@@ -0,0 +1,79 @@
import React, { Fragment, useEffect, useMemo } from "react";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { Draggable } from "./draggable";
type Props<T> = {
data: T[];
render: (item: T, index: number) => React.ReactNode;
onChange: (data: T[]) => void;
keyExtractor: (item: T, index: number) => string;
containerClassName?: string;
id?: string;
};
const moveItem = <T,>(
data: T[],
source: T,
destination: T & Record<symbol, string>,
keyExtractor: (item: T, index: number) => string
) => {
const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
if (sourceIndex === -1) return data;
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
if (destinationIndex === -1) return data;
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
const newData = [...data];
const [movedItem] = newData.splice(sourceIndex, 1);
let adjustedDestinationIndex = destinationIndex;
if (position === "bottom") {
adjustedDestinationIndex++;
}
// Prevent moving item out of bounds
if (adjustedDestinationIndex > newData.length) {
adjustedDestinationIndex = newData.length;
}
newData.splice(adjustedDestinationIndex, 0, movedItem);
return newData;
};
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
useEffect(() => {
const unsubscribe = monitorForElements({
onDrop({ source, location }) {
const destination = location?.current?.dropTargets[0];
if (!destination) return;
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
},
});
// Clean up the subscription on unmount
return () => {
if (unsubscribe) unsubscribe();
};
}, [data, keyExtractor, onChange]);
const enhancedData = useMemo(() => {
const uuid = id ? id : Math.random().toString(36).substring(7);
return data.map((item) => ({ ...item, __uuid__: uuid }));
}, [data, id]);
return (
<>
{enhancedData.map((item, index) => (
<Draggable key={keyExtractor(item, index)} data={item} className={containerClassName}>
<Fragment>{render(item, index)} </Fragment>
</Draggable>
))}
</>
);
};
export default Sortable;
+1
View File
@@ -0,0 +1 @@
export * from "./sub-heading";
@@ -0,0 +1,15 @@
import React from "react";
import { cn } from "../../helpers";
type Props = {
children: React.ReactNode;
className?: string;
noMargin?: boolean;
};
const SubHeading = ({ children, className, noMargin }: Props) => (
<h3 className={cn("text-xl font-medium text-custom-text-200 block leading-7", !noMargin && "mb-2", className)}>
{children}
</h3>
);
export { SubHeading };
@@ -0,0 +1,42 @@
import { notFound, redirect } from "next/navigation";
// types
import { TPublishSettings } from "@plane/types";
// services
import PublishService from "@/services/publish.service";
const publishService = new PublishService();
type Props = {
params: {
workspaceSlug: string;
projectId: string;
};
searchParams: any;
};
export default async function IssuesPage(props: Props) {
const { params, searchParams } = props;
// query params
const { workspaceSlug, projectId } = params;
const { board, peekId } = searchParams;
let response: TPublishSettings | undefined = undefined;
try {
response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId);
} catch (error) {
// redirect to 404 page on error
notFound();
}
let url = "";
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
if (board) params.append("board", board);
if (peekId) params.append("peekId", peekId);
if (params.toString()) url += `?${params.toString()}`;
redirect(url);
} else {
notFound();
}
}
@@ -1,16 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
// components
import { ProjectDetailsView } from "@/components/views";
export default function WorkspaceProjectPage({ params }: { params: { workspace_slug: any; project_id: any } }) {
const { workspace_slug, project_id } = params;
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
if (!workspace_slug || !project_id) return <></>;
return <ProjectDetailsView workspaceSlug={workspace_slug} projectId={project_id} peekId={peekId} />;
}
+31 -22
View File
@@ -1,38 +1,47 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
// ui
import { Button } from "@plane/ui";
// assets
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export default function InstanceError() {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
const ErrorPage = () => {
const handleRetry = () => {
window.location.reload();
};
return (
<div className="relative h-screen overflow-x-hidden overflow-y-auto container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<Image src={instanceImage} alt="Plane instance failure image" />
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3>
<p className="font-medium text-base text-center">
We were unable to fetch the details of the instance. <br />
Fret not, it might just be a connectivity issue.
<div className="grid h-screen place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Exception Detected!</h3>
<p className="mx-auto w-1/2 text-sm text-custom-text-200">
We{"'"}re Sorry! An exception has been detected, and our engineering team has been notified. We apologize
for any inconvenience this may have caused. Please reach out to our engineering team at{" "}
<a href="mailto:support@plane.so" className="text-custom-primary">
support@plane.so
</a>{" "}
or on our{" "}
<a
href="https://discord.com/invite/A92xrEGCge"
target="_blank"
className="text-custom-primary"
rel="noopener noreferrer"
>
Discord
</a>{" "}
server for further assistance.
</p>
</div>
<div className="flex justify-center">
<Button size="md" onClick={handleRetry}>
Retry
<div className="flex items-center justify-center gap-2">
<Button variant="primary" size="md" onClick={handleRetry}>
Refresh
</Button>
{/* <Button variant="neutral-primary" size="md" onClick={() => {}}>
Sign out
</Button> */}
</div>
</div>
</div>
);
}
};
export default ErrorPage;
@@ -1,25 +1,39 @@
"use client";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import { notFound } from "next/navigation";
import useSWR from "swr";
// components
import IssueNavbar from "@/components/issues/navbar";
import { LogoSpinner } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
// hooks
import { usePublish, usePublishList } from "@/hooks/store";
// assets
import planeLogo from "public/plane-logo.svg";
import planeLogo from "@/public/plane-logo.svg";
export default async function ProjectLayout({
children,
params,
}: {
type Props = {
children: React.ReactNode;
params: { workspace_slug: string; project_id: string };
}) {
const { workspace_slug, project_id } = params;
params: {
anchor: string;
};
};
if (!workspace_slug || !project_id) notFound();
const IssuesLayout = observer((props: Props) => {
const { children, params } = props;
// params
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
// fetch publish settings
useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null);
if (!publishSettings) return <LogoSpinner />;
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssueNavbar workspaceSlug={workspace_slug} projectId={project_id} />
<IssuesNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<a
@@ -37,4 +51,6 @@ export default async function ProjectLayout({
</a>
</div>
);
}
});
export default IssuesLayout;
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { observer } from "mobx-react-lite";
import { useSearchParams } from "next/navigation";
// components
import { IssuesLayoutsRoot } from "@/components/issues";
// hooks
import { usePublish } from "@/hooks/store";
type Props = {
params: {
anchor: string;
};
};
const IssuesPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
const publishSettings = usePublish(anchor);
if (!publishSettings) return null;
return <IssuesLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
});
export default IssuesPage;
+12 -14
View File
@@ -4,20 +4,18 @@ import Image from "next/image";
// assets
import UserLoggedInImage from "public/user-logged-in.svg";
export default function NotFound() {
return (
<div className="flex h-screen w-screen flex-col">
<div className="grid h-full w-full place-items-center p-6">
<div className="text-center">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
<div className="h-32 w-32">
<Image src={UserLoggedInImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Not Found</h1>
<p className="mt-4">Please enter the appropriate project URL to view the issue board.</p>
const NotFound = () => (
<div className="h-screen w-screen grid place-items-center">
<div className="text-center">
<div className="mx-auto size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="size-32">
<Image src={UserLoggedInImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Not Found</h1>
<p className="mt-4">Please enter the appropriate project URL to view the issue board.</p>
</div>
);
}
</div>
);
export default NotFound;
+19 -11
View File
@@ -1,36 +1,44 @@
"use client";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import { useTheme } from "next-themes";
// components
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
import { UserAvatar } from "@/components/issues";
// hooks
import { useUser } from "@/hooks/store";
// assets
import PlaneLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = () => {
export const UserLoggedIn = observer(() => {
// store hooks
const { data: user } = useUser();
// next-themes
const { resolvedTheme } = useTheme();
const logo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
if (!user) return null;
return (
<div className="flex h-screen w-screen flex-col">
<div className="flex flex-col h-screen w-screen">
<div className="relative flex w-full items-center justify-between gap-4 border-b border-custom-border-200 px-6 py-5">
<div>
<Image src={PlaneLogo} alt="User already logged in" />
<div className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</div>
<UserAvatar />
</div>
<div className="grid h-full w-full place-items-center p-6">
<div className="size-full grid place-items-center p-6">
<div className="text-center">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
<div className="h-32 w-32">
<div className="mx-auto size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="size-32">
<Image src={UserLoggedInImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Logged in Successfully!</h1>
<h1 className="mt-12 text-3xl font-semibold">Logged in successfully!</h1>
<p className="mt-4">
You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board.
</p>
@@ -38,4 +46,4 @@ export const UserLoggedIn = () => {
</div>
</div>
);
};
});
-1
View File
@@ -1,3 +1,2 @@
export * from "./latest-feature-block";
export * from "./project-logo";
export * from "./logo-spinner";
@@ -1,40 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// icons
import { Lightbulb } from "lucide-react";
// images
import latestFeatures from "public/onboarding/onboarding-pages.svg";
export const LatestFeatureBlock = () => {
const { resolvedTheme } = useTheme();
return (
<>
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-onboarding-border-200 bg-onboarding-background-100 py-2 sm:w-96">
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
<p className="text-left text-sm text-onboarding-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div
className={`mx-auto mt-8 overflow-hidden rounded-md border border-onboarding-border-200 object-cover sm:h-52 sm:w-96 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
}`}
>
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`-mt-2 ml-10 h-full rounded-md ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
</div>
</>
);
};
-1
View File
@@ -1,2 +1 @@
export * from "./not-ready-view";
export * from "./instance-failure-view";
@@ -1,62 +0,0 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// ui
import { Button } from "@plane/ui";
// helper
import { GOD_MODE_URL, SPACE_BASE_PATH } from "@/helpers/common.helper";
// images
import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png";
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
export const InstanceNotReady: FC = () => {
const { resolvedTheme } = useTheme();
const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return (
<div className="relative">
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
<div className="flex items-center gap-x-2 py-10">
<Link href={`${SPACE_BASE_PATH}/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
</div>
<div className="absolute inset-0 z-0">
<Image src={patternBackground} className="w-screen h-full object-cover" alt="Plane background pattern" />
</div>
<div className="relative z-10 mb-[110px] flex-grow">
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-onboarding-text-400">
Get started by setting up your instance and workspace
</p>
</div>
<div>
<a href={GOD_MODE_URL}>
<Button size="lg" className="w-full">
Get started
</Button>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,10 +0,0 @@
"use client";
export const IssueBlockDownVotes = ({ number }: { number: number }) => (
<div className="flex h-6 items-center rounded border-[0.5px] border-custom-border-300 px-1.5 py-1 pl-1 text-xs text-custom-text-300">
<span className="material-symbols-rounded !m-0 rotate-180 !p-0 text-base text-custom-text-300">
arrow_upward_alt
</span>
{number}
</div>
);
@@ -1,59 +0,0 @@
"use client";
// helpers
import { renderFullDate } from "@/helpers/date-time.helper";
export const dueDateIconDetails = (
date: string,
stateGroup: string
): {
iconName: string;
className: string;
} => {
let iconName = "calendar_today";
let className = "";
if (!date || ["completed", "cancelled"].includes(stateGroup)) {
iconName = "calendar_today";
className = "";
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const targetDate = new Date(date);
targetDate.setHours(0, 0, 0, 0);
const timeDifference = targetDate.getTime() - today.getTime();
if (timeDifference < 0) {
iconName = "event_busy";
className = "text-red-500";
} else if (timeDifference === 0) {
iconName = "today";
className = "text-red-500";
} else if (timeDifference === 24 * 60 * 60 * 1000) {
iconName = "event";
className = "text-yellow-500";
} else {
iconName = "calendar_today";
className = "";
}
}
return {
iconName,
className,
};
};
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
const iconDetails = dueDateIconDetails(due_date, group);
return (
<div className="flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100">
<span className={`material-symbols-rounded -my-0.5 text-sm ${iconDetails.className}`}>
{iconDetails.iconName}
</span>
{renderFullDate(due_date)}
</div>
);
};
@@ -1,19 +0,0 @@
"use client";
export const IssueBlockLabels = ({ labels }: any) => (
<div className="relative flex flex-wrap items-center gap-1">
{labels &&
labels.length > 0 &&
labels.map((_label: any) => (
<div
key={_label?.id}
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-xs">{_label?.name}</div>
</div>
</div>
))}
</div>
);
@@ -1,18 +0,0 @@
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
export const IssueBlockState = ({ state }: any) => {
const stateGroup = issueGroupFilter(state.group);
if (stateGroup === null) return <></>;
return (
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
<div className="flex w-full items-center gap-1.5 text-custom-text-200">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<div className="text-xs">{state?.name}</div>
</div>
</div>
);
};
@@ -1,8 +0,0 @@
"use client";
export const IssueBlockUpVotes = ({ number }: { number: number }) => (
<div className="flex h-6 items-center rounded border-[0.5px] border-custom-border-300 px-1.5 py-1 pl-1 text-xs text-custom-text-300">
<span className="material-symbols-rounded !m-0 !p-0 text-base text-custom-text-300">arrow_upward_alt</span>
{number}
</div>
);
@@ -1 +0,0 @@
export const IssueCalendarView = () => <div> </div>;
@@ -1 +0,0 @@
export const IssueGanttView = () => <div> </div>;
@@ -1,82 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
// components
import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, useProject } from "@/hooks/store";
// interfaces
import { IIssue } from "@/types/issue";
type IssueKanBanBlockProps = {
issue: IIssue;
workspaceSlug: string;
projectId: string;
params: any;
};
export const IssueKanBanBlock: FC<IssueKanBanBlockProps> = observer((props) => {
const router = useRouter();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// props
const { workspaceSlug, projectId, issue } = props;
// hooks
const { project } = useProject();
const { setPeekId } = useIssueDetails();
const handleBlockClick = () => {
setPeekId(issue.id);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (
<div className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs">
{/* id */}
<div className="break-words text-xs text-custom-text-300">
{project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<h6
onClick={handleBlockClick}
role="button"
className="line-clamp-2 cursor-pointer break-words text-sm font-medium"
>
{issue.name}
</h6>
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</div>
);
});
@@ -1,28 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
// mobx hook
// import { useIssue } from "@/hooks/store";
// interfaces
import { IIssueState } from "@/types/issue";
export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => {
// const { getCountOfIssuesByState } = useIssue();
const stateGroup = issueGroupFilter(state.group);
if (stateGroup === null) return <></>;
return (
<div className="flex items-center gap-2 px-2 pb-2">
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
</div>
<div className="mr-1 truncate font-semibold capitalize text-custom-text-200">{state?.name}</div>
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
</div>
);
});
@@ -1,58 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueKanBanBlock } from "@/components/issues/board-views/kanban/block";
import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header";
// ui
import { Icon } from "@/components/ui";
// mobx hook
import { useIssue } from "@/hooks/store";
// interfaces
import { IIssueState, IIssue } from "@/types/issue";
type IssueKanbanViewProps = {
workspaceSlug: string;
projectId: string;
};
export const IssueKanbanView: FC<IssueKanbanViewProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
{states &&
states.length > 0 &&
states.map((_state: IIssueState) => (
<div key={_state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader state={_state} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
{getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueKanBanBlock
key={_issue.id}
issue={_issue}
workspaceSlug={workspaceSlug}
projectId={projectId}
params={{}}
/>
))}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
))}
</div>
);
});
@@ -1,42 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueListBlock } from "@/components/issues/board-views/list/block";
import { IssueListHeader } from "@/components/issues/board-views/list/header";
// mobx hook
import { useIssue } from "@/hooks/store";
// types
import { IIssueState, IIssue } from "@/types/issue";
type IssueListViewProps = {
workspaceSlug: string;
projectId: string;
};
export const IssueListView: FC<IssueListViewProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<>
{states &&
states.length > 0 &&
states.map((_state: IIssueState) => (
<div key={_state.id} className="relative w-full">
<IssueListHeader state={_state} />
{getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="divide-y divide-custom-border-200">
{getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock key={_issue.id} issue={_issue} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
))}
</>
);
});
@@ -1 +0,0 @@
export const IssueSpreadsheetView = () => <div> </div>;
@@ -1,10 +1,10 @@
"use client";
// icons
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// types
import { IIssueLabel, IIssueState, TFilters } from "@/types/issue";
import { IStateLite } from "@plane/types";
import { IIssueLabel, TFilters } from "@/types/issue";
// components
import { AppliedPriorityFilters } from "./priority";
import { AppliedStateFilters } from "./state";
@@ -14,7 +14,7 @@ type Props = {
handleRemoveAllFilters: () => void;
handleRemoveFilter: (key: keyof TFilters, value: string | null) => void;
labels?: IIssueLabel[] | undefined;
states?: IIssueState[] | undefined;
states?: IStateLite[] | undefined;
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
@@ -12,18 +12,18 @@ import { TIssueQueryFilters } from "@/types/issue";
import { AppliedFiltersList } from "./filters-list";
type TIssueAppliedFilters = {
workspaceSlug: string;
projectId: string;
anchor: string;
};
export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();
// props
const { workspaceSlug, projectId } = props;
// hooks
const { issueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
// store hooks
const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
const { states, labels } = useIssue();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const userFilters = issueFilters?.filters || {};
@@ -46,30 +46,26 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
if (labels.length > 0) params = { ...params, labels: labels.join(",") };
params = new URLSearchParams(params).toString();
router.push(`/${workspaceSlug}/${projectId}?${params}`);
router.push(`/issues/${anchor}?${params}`);
},
[workspaceSlug, projectId, activeLayout, issueFilters, router]
[activeLayout, anchor, issueFilters, router]
);
const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string | null) => {
if (!projectId) return;
let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (value === null) newValues = [];
else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
updateIssueFilters(projectId, "filters", key, newValues);
updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues);
},
[projectId, issueFilters, updateIssueFilters, updateRouteParams]
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
);
const handleRemoveAllFilters = () => {
if (!projectId) return;
initIssueFilters(projectId, {
initIssueFilters(anchor, {
display_filters: { layout: activeLayout || "list" },
filters: {
state: [],
@@ -78,13 +74,13 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
},
});
router.push(`/${workspaceSlug}/${projectId}?${`board=${activeLayout || "list"}`}`);
router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`);
};
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="border-b border-custom-border-200 p-5 py-3">
<div className="border-b border-custom-border-200 bg-custom-background-100 p-4">
<AppliedFiltersList
appliedFilters={appliedFilters || {}}
handleRemoveFilter={handleFilters as any}
@@ -2,13 +2,14 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
import { StateGroupIcon } from "@plane/ui";
// types
import { IIssueState } from "@/types/issue";
import { IStateLite } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
type Props = {
handleRemove: (val: string) => void;
states: IIssueState[];
states: IStateLite[];
values: string[];
};
+11 -10
View File
@@ -17,17 +17,18 @@ import { useIssue, useIssueFilter } from "@/hooks/store";
import { TIssueQueryFilters } from "@/types/issue";
type IssueFiltersDropdownProps = {
workspaceSlug: string;
projectId: string;
anchor: string;
};
export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = props;
// hooks
const { issueFilters, updateIssueFilters } = useIssueFilter();
const { getIssueFilters, updateIssueFilters } = useIssueFilter();
const { states, labels } = useIssue();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const updateRouteParams = useCallback(
@@ -37,24 +38,24 @@ export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((pro
const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? [];
const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
router.push(`/issues/${anchor}?${queryParam}`);
},
[workspaceSlug, projectId, activeLayout, issueFilters, router]
[anchor, activeLayout, issueFilters, router]
);
const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string) => {
if (!projectId || !value) return;
if (!value) return;
const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
updateIssueFilters(projectId, "filters", key, newValues);
updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues);
},
[projectId, issueFilters, updateIssueFilters, updateRouteParams]
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
);
return (
@@ -4,7 +4,8 @@ import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react";
// types
import { IIssueState, IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
import { IStateLite } from "@plane/types";
import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
// components
import { FilterPriority, FilterState } from "./";
@@ -13,7 +14,7 @@ type Props = {
handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
layoutDisplayFiltersOptions: TIssueFilterKeys[];
labels?: IIssueLabel[] | undefined;
states?: IIssueState[] | undefined;
states?: IStateLite[] | undefined;
};
export const FilterSelection: React.FC<Props> = observer((props) => {
+4 -3
View File
@@ -1,17 +1,18 @@
"use client";
import React, { useState } from "react";
// types
import { IStateLite } from "@plane/types";
// ui
import { Loader, StateGroupIcon } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
// types
import { IIssueState } from "@/types/issue";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
states: IIssueState[] | undefined;
states: IStateLite[] | undefined;
};
export const FilterState: React.FC<Props> = (props) => {
+2
View File
@@ -0,0 +1,2 @@
export * from "./issue-layouts";
export * from "./navbar";
@@ -0,0 +1,4 @@
export * from "./kanban";
export * from "./list";
export * from "./properties";
export * from "./root";
@@ -0,0 +1,78 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
// components
import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, usePublish } from "@/hooks/store";
// interfaces
import { IIssue } from "@/types/issue";
type Props = {
anchor: string;
issue: IIssue;
params: any;
};
export const IssueKanBanBlock: FC<Props> = observer((props) => {
const { anchor, issue } = props;
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board");
const state = searchParams.get("state");
const priority = searchParams.get("priority");
const labels = searchParams.get("labels");
// store hooks
const { project_details } = usePublish(anchor);
const { setPeekId } = useIssueDetails();
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
const handleBlockClick = () => {
setPeekId(issue.id);
};
return (
<Link
href={`/issues/${anchor}?${queryParam}`}
onClick={handleBlockClick}
className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs select-none"
>
{/* id */}
<div className="break-words text-xs text-custom-text-300">
{project_details?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<h6 role="button" className="line-clamp-2 cursor-pointer break-words text-sm">
{issue.name}
</h6>
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</Link>
);
});
@@ -0,0 +1,25 @@
"use client";
import { observer } from "mobx-react-lite";
// types
import { IStateLite } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
type Props = {
state: IStateLite;
};
export const IssueKanBanHeader: React.FC<Props> = observer((props) => {
const { state } = props;
return (
<div className="flex items-center gap-2 px-2 pb-2">
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
</div>
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name}</div>
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
</div>
);
});
@@ -0,0 +1,3 @@
export * from "./block";
export * from "./header";
export * from "./root";
@@ -0,0 +1,50 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues";
// ui
import { Icon } from "@/components/ui";
// mobx hook
import { useIssue } from "@/hooks/store";
type Props = {
anchor: string;
};
export const IssueKanbanLayoutRoot: FC<Props> = observer((props) => {
const { anchor } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
{states?.map((state) => {
const issues = getFilteredIssuesByState(state.id);
return (
<div key={state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader state={state} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
{issues && issues.length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{issues.map((issue) => (
<IssueKanBanBlock key={issue.id} anchor={anchor} issue={issue} params={{}} />
))}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
);
})}
</div>
);
});
@@ -1,56 +1,52 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
// components
import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date";
import { IssueBlockLabels } from "@/components/issues/board-views/block-labels";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hook
import { useIssueDetails, useProject } from "@/hooks/store";
// interfaces
import { useIssueDetails, usePublish } from "@/hooks/store";
// types
import { IIssue } from "@/types/issue";
// store
type IssueListBlockProps = {
anchor: string;
issue: IIssue;
workspaceSlug: string;
projectId: string;
};
export const IssueListBlock: FC<IssueListBlockProps> = observer((props) => {
const { workspaceSlug, projectId, issue } = props;
const searchParams = useSearchParams();
export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) => {
const { anchor, issue } = props;
// query params
const searchParams = useSearchParams();
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// store
const { project } = useProject();
// store hooks
const { setPeekId } = useIssueDetails();
// router
const router = useRouter();
const { project_details } = usePublish(anchor);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
const handleBlockClick = () => {
setPeekId(issue.id);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (
<div className="relative flex items-center gap-10 bg-custom-background-100 p-3">
<Link
href={`/issues/${anchor}?${queryParam}`}
onClick={handleBlockClick}
className="relative flex items-center gap-10 bg-custom-background-100 p-3"
>
<div className="relative flex w-full flex-grow items-center gap-3 overflow-hidden">
{/* id */}
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{project?.identifier}-{issue?.sequence_id}
{project_details?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm font-medium">
<div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm">
{issue.name}
</div>
</div>
@@ -84,6 +80,6 @@ export const IssueListBlock: FC<IssueListBlockProps> = observer((props) => {
</div>
)}
</div>
</div>
</Link>
);
});
@@ -1,20 +1,18 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
// types
import { IStateLite } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
// mobx hook
// import { useIssue } from "@/hooks/store";
// types
import { IIssueState } from "@/types/issue";
export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
// const { getCountOfIssuesByState } = useIssue();
const stateGroup = issueGroupFilter(state.group);
// const count = getCountOfIssuesByState(state.id);
type Props = {
state: IStateLite;
};
if (stateGroup === null) return <></>;
export const IssueListLayoutHeader: React.FC<Props> = observer((props) => {
const { state } = props;
return (
<div className="flex items-center gap-2 p-3">
@@ -0,0 +1,3 @@
export * from "./block";
export * from "./header";
export * from "./root";
@@ -0,0 +1,40 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues";
// mobx hook
import { useIssue } from "@/hooks/store";
type Props = {
anchor: string;
};
export const IssuesListLayoutRoot: FC<Props> = observer((props) => {
const { anchor } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<>
{states?.map((state) => {
const issues = getFilteredIssuesByState(state.id);
return (
<div key={state.id} className="relative w-full">
<IssueListLayoutHeader state={state} />
{issues && issues.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{issues.map((issue) => (
<IssueListLayoutBlock key={issue.id} anchor={anchor} issue={issue} />
))}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
);
})}
</>
);
});
@@ -0,0 +1,32 @@
"use client";
import { CalendarCheck2 } from "lucide-react";
// types
import { TStateGroups } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
type Props = {
due_date: string;
group: TStateGroups;
};
export const IssueBlockDueDate = (props: Props) => {
const { due_date, group } = props;
return (
<div
className={cn(
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
{
"text-red-500": shouldHighlightIssueDueDate(due_date, group),
}
)}
>
<CalendarCheck2 className="size-3 flex-shrink-0" />
{renderFormattedDate(due_date)}
</div>
);
};
@@ -0,0 +1,4 @@
export * from "./due-date";
export * from "./labels";
export * from "./priority";
export * from "./state";

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