mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
7 Commits
add-caddy-
...
timeline-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5c963fa0 | ||
|
|
cb055d566b | ||
|
|
447af2e05a | ||
|
|
2fbe22d689 | ||
|
|
17043a6c7f | ||
|
|
66673279d1 | ||
|
|
389ee74ff5 |
@@ -13,7 +13,6 @@ from .user import (
|
||||
from .workspace import (
|
||||
WorkSpaceSerializer,
|
||||
WorkSpaceMemberSerializer,
|
||||
TeamSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
|
||||
@@ -9,8 +9,6 @@ from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
@@ -99,57 +97,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class TeamSerializer(BaseSerializer):
|
||||
members_detail = UserLiteSerializer(
|
||||
read_only=True, source="members", many=True
|
||||
)
|
||||
members = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data, **kwargs):
|
||||
if "members" in validated_data:
|
||||
members = validated_data.pop("members")
|
||||
workspace = self.context["workspace"]
|
||||
team = Team.objects.create(**validated_data, workspace=workspace)
|
||||
team_members = [
|
||||
TeamMember(member=member, team=team, workspace=workspace)
|
||||
for member in members
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return team
|
||||
team = Team.objects.create(**validated_data)
|
||||
return team
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if "members" in validated_data:
|
||||
members = validated_data.pop("members")
|
||||
TeamMember.objects.filter(team=instance).delete()
|
||||
team_members = [
|
||||
TeamMember(
|
||||
member=member, team=instance, workspace=instance.workspace
|
||||
)
|
||||
for member in members
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return super().update(instance, validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkspaceThemeSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceTheme
|
||||
|
||||
@@ -7,7 +7,6 @@ from plane.app.views import (
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
@@ -116,11 +115,6 @@ urlpatterns = [
|
||||
),
|
||||
name="project-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
|
||||
AddTeamToProjectEndpoint.as_view(),
|
||||
name="projects",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
|
||||
ProjectUserViewsEndpoint.as_view(),
|
||||
|
||||
@@ -10,7 +10,6 @@ from plane.app.views import (
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
TeamMemberViewSet,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
@@ -127,28 +126,6 @@ urlpatterns = [
|
||||
),
|
||||
name="leave-workspace-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/",
|
||||
TeamMemberViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-team-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/<uuid:pk>/",
|
||||
TeamMemberViewSet.as_view(
|
||||
{
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
}
|
||||
),
|
||||
name="workspace-team-members",
|
||||
),
|
||||
path(
|
||||
"users/last-visited-workspace/",
|
||||
UserLastProjectWithWorkspaceEndpoint.as_view(),
|
||||
|
||||
@@ -16,7 +16,6 @@ from .project.invite import (
|
||||
|
||||
from .project.member import (
|
||||
ProjectMemberViewSet,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
)
|
||||
@@ -49,7 +48,6 @@ from .workspace.favorite import (
|
||||
|
||||
from .workspace.member import (
|
||||
WorkSpaceMemberViewSet,
|
||||
TeamMemberViewSet,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
|
||||
@@ -21,7 +21,6 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
TeamMember,
|
||||
IssueUserProperty,
|
||||
WorkspaceMember,
|
||||
)
|
||||
@@ -342,54 +341,6 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
team_members = TeamMember.objects.filter(
|
||||
workspace__slug=slug, team__in=request.data.get("teams", [])
|
||||
).values_list("member", flat=True)
|
||||
|
||||
if len(team_members) == 0:
|
||||
return Response(
|
||||
{"error": "No such team exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project_members = []
|
||||
issue_props = []
|
||||
for member in team_members:
|
||||
project_members.append(
|
||||
ProjectMember(
|
||||
project_id=project_id,
|
||||
member_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
issue_props.append(
|
||||
IssueUserProperty(
|
||||
project_id=project_id,
|
||||
user_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
ProjectMember.objects.bulk_create(
|
||||
project_members, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
_ = IssueUserProperty.objects.bulk_create(
|
||||
issue_props, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
|
||||
@@ -24,7 +24,6 @@ from plane.app.permissions import (
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
ProjectMemberRoleSerializer,
|
||||
TeamSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
@@ -34,7 +33,6 @@ from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Team,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
@@ -351,63 +349,4 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_members_dict[str(project_id)] = []
|
||||
project_members_dict[str(project_id)].append(project_member)
|
||||
|
||||
return Response(project_members_dict, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
model = Team
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.prefetch_related("members")
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
members = list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__id__in=request.data.get("members", []),
|
||||
is_active=True,
|
||||
)
|
||||
.annotate(member_str_id=Cast("member", output_field=CharField()))
|
||||
.distinct()
|
||||
.values_list("member_str_id", flat=True)
|
||||
)
|
||||
|
||||
if len(members) != len(request.data.get("members", [])):
|
||||
users = list(
|
||||
set(request.data.get("members", [])).difference(members)
|
||||
)
|
||||
users = User.objects.filter(pk__in=users)
|
||||
|
||||
serializer = UserLiteSerializer(users, many=True)
|
||||
return Response(
|
||||
{
|
||||
"error": f"{len(users)} of the member(s) are not a part of the workspace",
|
||||
"members": serializer.data,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = TeamSerializer(
|
||||
data=request.data, context={"workspace": workspace}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(project_members_dict, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.15 on 2024-11-08 14:24
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0085_intake_intakeissue_remove_inboxissue_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='teammember',
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teammember',
|
||||
name='created_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teammember',
|
||||
name='member',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teammember',
|
||||
name='team',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teammember',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teammember',
|
||||
name='workspace',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='teampage',
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teampage',
|
||||
name='created_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teampage',
|
||||
name='page',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teampage',
|
||||
name='team',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teampage',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='teampage',
|
||||
name='workspace',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='page',
|
||||
name='teams',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Team',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TeamMember',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TeamPage',
|
||||
),
|
||||
]
|
||||
@@ -77,8 +77,6 @@ from .user import Account, Profile, User
|
||||
from .view import IssueView
|
||||
from .webhook import Webhook, WebhookLog
|
||||
from .workspace import (
|
||||
Team,
|
||||
TeamMember,
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceMember,
|
||||
|
||||
@@ -52,9 +52,6 @@ class Page(BaseModel):
|
||||
projects = models.ManyToManyField(
|
||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||
)
|
||||
teams = models.ManyToManyField(
|
||||
"db.Team", related_name="pages", through="db.TeamPage"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@@ -169,33 +166,6 @@ class ProjectPage(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.project.name} {self.page.name}"
|
||||
|
||||
|
||||
class TeamPage(BaseModel):
|
||||
team = models.ForeignKey(
|
||||
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team", "page", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "page"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_page_unique_team_page_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team Page"
|
||||
verbose_name_plural = "Team Pages"
|
||||
db_table = "team_pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class PageVersion(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
||||
@@ -259,71 +259,6 @@ class WorkspaceMemberInvite(BaseModel):
|
||||
return f"{self.workspace.name} {self.email} {self.accepted}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||
members = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="members",
|
||||
through="TeamMember",
|
||||
through_fields=("team", "member"),
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
|
||||
)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the team"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_unique_name_workspace_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team"
|
||||
verbose_name_plural = "Teams"
|
||||
db_table = "teams"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class TeamMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
Workspace, on_delete=models.CASCADE, related_name="team_member"
|
||||
)
|
||||
team = models.ForeignKey(
|
||||
Team, on_delete=models.CASCADE, related_name="team_member"
|
||||
)
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="team_member",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.team.name
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "member"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_member_unique_team_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team Member"
|
||||
verbose_name_plural = "Team Members"
|
||||
db_table = "team_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceTheme(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
|
||||
|
||||
@@ -5,7 +5,11 @@ import { computedFn } from "mobx-utils";
|
||||
// components
|
||||
import { ChartDataType, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@/components/gantt-chart";
|
||||
import { currentViewDataWithView } from "@/components/gantt-chart/data";
|
||||
import { getDateFromPositionOnGantt, getItemPositionWidth } from "@/components/gantt-chart/views/helpers";
|
||||
import {
|
||||
getDateFromPositionOnGantt,
|
||||
getItemPositionWidth,
|
||||
getPositionFromDate,
|
||||
} from "@/components/gantt-chart/views/helpers";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// store
|
||||
@@ -47,6 +51,7 @@ export interface IBaseTimelineStore {
|
||||
initGantt: () => void;
|
||||
|
||||
getDateFromPositionOnGantt: (position: number, offsetDays: number) => Date | undefined;
|
||||
getPositionFromDateOnGantt: (date: string | Date, offSetWidth: number) => number | undefined;
|
||||
}
|
||||
|
||||
export class BaseTimeLineStore implements IBaseTimelineStore {
|
||||
@@ -186,7 +191,7 @@ export class BaseTimeLineStore implements IBaseTimelineStore {
|
||||
start_date: blockData?.start_date ?? undefined,
|
||||
target_date: blockData?.target_date ?? undefined,
|
||||
};
|
||||
if (this.currentViewData && this.currentViewData?.data?.startDate && this.currentViewData?.data?.dayWidth) {
|
||||
if (this.currentViewData && (this.currentViewData?.data?.startDate || this.currentViewData?.data?.dayWidth)) {
|
||||
block.position = getItemPositionWidth(this.currentViewData, block);
|
||||
}
|
||||
|
||||
@@ -227,6 +232,15 @@ export class BaseTimeLineStore implements IBaseTimelineStore {
|
||||
return Math.round(position / this.currentViewData.data.dayWidth);
|
||||
};
|
||||
|
||||
/**
|
||||
* returns position of the date on chart
|
||||
*/
|
||||
getPositionFromDateOnGantt = computedFn((date: string | Date, offSetWidth: number) => {
|
||||
if (!this.currentViewData) return;
|
||||
|
||||
return getPositionFromDate(this.currentViewData, date, offSetWidth);
|
||||
});
|
||||
|
||||
/**
|
||||
* returns the date at which the position corresponds to on the timeline chart
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,7 @@ type Props = {
|
||||
export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
const {
|
||||
applyButtonText = "Apply changes",
|
||||
bothRequired = true,
|
||||
bothRequired = false,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonFromDateClassName,
|
||||
|
||||
@@ -68,7 +68,7 @@ export const BlockRow: React.FC<Props> = observer((props) => {
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!block || !block.data || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
const isBlockVisibleOnChart = block.start_date || block.target_date;
|
||||
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
|
||||
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
|
||||
const isBlockHoveredOn = isBlockActive(block.id);
|
||||
|
||||
@@ -46,10 +46,11 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
|
||||
const { isMoving, handleBlockDrag } = useGanttResizable(block, resizableRef, ganttContainerRef, updateBlockDates);
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!block || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
|
||||
const isBlockVisibleOnChart = block?.start_date || block?.target_date;
|
||||
const isBlockComplete = block?.start_date && block?.target_date;
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!block || (!showAllBlocks && !isBlockVisibleOnChart)) return null;
|
||||
|
||||
if (!block.data) return null;
|
||||
|
||||
@@ -63,7 +64,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
ref={resizableRef}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
transform: `translateX(${block.position?.marginLeft}px)`,
|
||||
marginLeft: `${block.position?.marginLeft}px`,
|
||||
width: `${block.position?.width}px`,
|
||||
}}
|
||||
>
|
||||
@@ -88,7 +89,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
handleBlockDrag={handleBlockDrag}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableBlockMove={enableBlockMove && !!isBlockComplete}
|
||||
isMoving={isMoving}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { IssueBulkOperationsRoot } from "@/plane-web/components/issues";
|
||||
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
|
||||
//
|
||||
import { GanttChartRowList } from "../blocks/block-row-list";
|
||||
import { GANTT_SELECT_GROUP, HEADER_HEIGHT } from "../constants";
|
||||
import { DEFAULT_BLOCK_WIDTH, GANTT_SELECT_GROUP, HEADER_HEIGHT } from "../constants";
|
||||
import { getItemPositionWidth } from "../views";
|
||||
import { TimelineDragHelper } from "./timeline-drag-helper";
|
||||
|
||||
@@ -108,14 +108,20 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
|
||||
const approxRangeLeft = scrollLeft;
|
||||
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
|
||||
const calculatedRangeRight = itemsContainerWidth - (scrollLeft + clientWidth);
|
||||
|
||||
if (approxRangeRight < clientWidth) updateCurrentViewRenderPayload("right", currentView);
|
||||
if (approxRangeLeft < clientWidth) updateCurrentViewRenderPayload("left", currentView);
|
||||
if (approxRangeRight < clientWidth || calculatedRangeRight < clientWidth) {
|
||||
updateCurrentViewRenderPayload("right", currentView);
|
||||
}
|
||||
if (approxRangeLeft < clientWidth) {
|
||||
updateCurrentViewRenderPayload("left", currentView);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollToBlock = (block: IGanttBlock) => {
|
||||
const scrollContainer = ganttContainerRef.current as HTMLDivElement;
|
||||
const scrollToDate = getDate(block.start_date);
|
||||
const scrollToEndDate = !block.start_date && block.target_date;
|
||||
const scrollToDate = block.start_date ? getDate(block.start_date) : getDate(block.target_date);
|
||||
let chartData;
|
||||
|
||||
if (!scrollContainer || !currentViewData || !scrollToDate) return;
|
||||
@@ -129,7 +135,8 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
const updatedPosition = getItemPositionWidth(chartData ?? currentViewData, block);
|
||||
|
||||
setTimeout(() => {
|
||||
if (updatedPosition) scrollContainer.scrollLeft = updatedPosition.marginLeft - 4;
|
||||
if (updatedPosition)
|
||||
scrollContainer.scrollLeft = updatedPosition.marginLeft - 4 - (scrollToEndDate ? DEFAULT_BLOCK_WIDTH : 0);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ export const GANTT_BREADCRUMBS_HEIGHT = 40;
|
||||
|
||||
export const SIDEBAR_WIDTH = 360;
|
||||
|
||||
export const DEFAULT_BLOCK_WIDTH = 60;
|
||||
|
||||
export const GANTT_SELECT_GROUP = "gantt-issues";
|
||||
|
||||
@@ -71,7 +71,7 @@ export const useGanttResizable = (
|
||||
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
|
||||
marginLeft = Math.round(mouseX / dayWidth) * dayWidth;
|
||||
// get Dimensions from dom's style
|
||||
const prevMarginLeft = parseFloat(resizableDiv.style.transform.slice(11, -3));
|
||||
const prevMarginLeft = parseFloat(resizableDiv.style.marginLeft.slice(0, -2));
|
||||
const prevWidth = parseFloat(resizableDiv.style.width.slice(0, -2));
|
||||
// calculate new width
|
||||
const marginDelta = prevMarginLeft - marginLeft;
|
||||
@@ -88,7 +88,7 @@ export const useGanttResizable = (
|
||||
if (width < dayWidth) return;
|
||||
|
||||
resizableDiv.style.width = `${width}px`;
|
||||
resizableDiv.style.transform = `translateX(${marginLeft}px)`;
|
||||
resizableDiv.style.marginLeft = `${marginLeft}px`;
|
||||
|
||||
const deltaLeft = Math.round((marginLeft - (block.position?.marginLeft ?? 0)) / dayWidth) * dayWidth;
|
||||
const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth;
|
||||
|
||||
@@ -27,8 +27,8 @@ export const IssuesSidebarBlock = observer((props: Props) => {
|
||||
const { updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
|
||||
const { getIsIssuePeeked } = useIssueDetail();
|
||||
|
||||
const isBlockVisibleOnChart = !!block?.start_date && !!block?.target_date;
|
||||
const duration = isBlockVisibleOnChart ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
|
||||
const isBlockComplete = !!block?.start_date && !!block?.target_date;
|
||||
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
|
||||
|
||||
if (!block?.data) return null;
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
|
||||
if (!block) return <></>;
|
||||
|
||||
const isBlockVisibleOnChart = !!block.start_date && !!block.target_date;
|
||||
const duration = isBlockVisibleOnChart ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
|
||||
const isBlockComplete = !!block.start_date && !!block.target_date;
|
||||
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { addDaysToDate, findTotalDaysInRange, getDate } from "@/helpers/date-time.helper";
|
||||
import { DEFAULT_BLOCK_WIDTH } from "../constants";
|
||||
import { ChartDataType, IGanttBlock } from "../types";
|
||||
import { IMonthBlock, IMonthView, monthView } from "./month-view";
|
||||
import { quarterView } from "./quarter-view";
|
||||
import { IWeekBlock, weekView } from "./week-view";
|
||||
|
||||
/**
|
||||
* Generates Date by using Day, month and Year
|
||||
@@ -84,7 +82,7 @@ export const getDateFromPositionOnGantt = (position: number, chartData: ChartDat
|
||||
*/
|
||||
export const getItemPositionWidth = (chartData: ChartDataType, itemData: IGanttBlock) => {
|
||||
let scrollPosition: number = 0;
|
||||
let scrollWidth: number = 0;
|
||||
let scrollWidth: number = DEFAULT_BLOCK_WIDTH;
|
||||
|
||||
const { startDate: chartStartDate } = chartData.data;
|
||||
const { start_date, target_date } = itemData;
|
||||
@@ -92,24 +90,42 @@ export const getItemPositionWidth = (chartData: ChartDataType, itemData: IGanttB
|
||||
const itemStartDate = getDate(start_date);
|
||||
const itemTargetDate = getDate(target_date);
|
||||
|
||||
if (!itemStartDate || !itemTargetDate) return;
|
||||
|
||||
chartStartDate.setHours(0, 0, 0, 0);
|
||||
itemStartDate.setHours(0, 0, 0, 0);
|
||||
itemTargetDate.setHours(0, 0, 0, 0);
|
||||
itemStartDate?.setHours(0, 0, 0, 0);
|
||||
itemTargetDate?.setHours(0, 0, 0, 0);
|
||||
|
||||
// get number of days from chart start date to block's start date
|
||||
const positionDaysDifference = Math.round(findTotalDaysInRange(chartStartDate, itemStartDate, false) ?? 0);
|
||||
|
||||
if (!positionDaysDifference) return;
|
||||
if (!itemStartDate && !itemTargetDate) return;
|
||||
|
||||
// get scroll position from the number of days and width of each day
|
||||
scrollPosition = positionDaysDifference * chartData.data.dayWidth;
|
||||
scrollPosition = itemStartDate
|
||||
? getPositionFromDate(chartData, itemStartDate, 0)
|
||||
: getPositionFromDate(chartData, itemTargetDate!, -1 * DEFAULT_BLOCK_WIDTH);
|
||||
|
||||
// get width of block
|
||||
const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime();
|
||||
const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)));
|
||||
scrollWidth = (widthDaysDifference + 1) * chartData.data.dayWidth;
|
||||
if (itemStartDate && itemTargetDate) {
|
||||
// get width of block
|
||||
const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime();
|
||||
const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)));
|
||||
scrollWidth = (widthDaysDifference + 1) * chartData.data.dayWidth;
|
||||
}
|
||||
|
||||
return { marginLeft: scrollPosition, width: scrollWidth };
|
||||
};
|
||||
|
||||
export const getPositionFromDate = (chartData: ChartDataType, date: string | Date, offsetWidth: number) => {
|
||||
const currDate = getDate(date);
|
||||
|
||||
const { startDate: chartStartDate } = chartData.data;
|
||||
|
||||
if (!currDate || !chartStartDate) return 0;
|
||||
|
||||
chartStartDate.setHours(0, 0, 0, 0);
|
||||
currDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// get number of days from chart start date to block's start date
|
||||
const positionDaysDifference = Math.round(findTotalDaysInRange(chartStartDate, currDate, false) ?? 0);
|
||||
|
||||
if (!positionDaysDifference) return 0;
|
||||
|
||||
// get scroll position from the number of days and width of each day
|
||||
return positionDaysDifference * chartData.data.dayWidth + offsetWidth;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Tooltip, ControlLink } from "@plane/ui";
|
||||
// components
|
||||
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
@@ -13,7 +15,8 @@ import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-red
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
// local types
|
||||
//
|
||||
import { getBlockViewDetails } from "../utils";
|
||||
import { GanttStoreType } from "./base-gantt-root";
|
||||
|
||||
type Props = {
|
||||
@@ -39,36 +42,37 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
|
||||
const stateDetails =
|
||||
issueDetails && getProjectStates(issueDetails?.project_id)?.find((state) => state?.id == issueDetails?.state_id);
|
||||
|
||||
const { message, blockStyle } = getBlockViewDetails(issueDetails, stateDetails?.color ?? "");
|
||||
|
||||
const handleIssuePeekOverview = () => handleRedirection(workspaceSlug, issueDetails, isMobile);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`issue-${issueId}`}
|
||||
className="relative flex h-full w-full cursor-pointer items-center rounded"
|
||||
style={{
|
||||
backgroundColor: stateDetails?.color,
|
||||
}}
|
||||
onClick={handleIssuePeekOverview}
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{issueDetails?.name}</h5>
|
||||
<div>{message}</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
disabled={!message}
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{issueDetails?.name}</h5>
|
||||
<div>
|
||||
{renderFormattedDate(issueDetails?.start_date ?? "")} to{" "}
|
||||
{renderFormattedDate(issueDetails?.target_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
<div
|
||||
id={`issue-${issueId}`}
|
||||
className="relative flex h-full w-full cursor-pointer items-center rounded"
|
||||
style={blockStyle}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100">
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||
<div
|
||||
className="sticky w-auto overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100"
|
||||
style={{ left: `${SIDEBAR_WIDTH}px` }}
|
||||
>
|
||||
{issueDetails?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,7 +96,11 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
const handleIssuePeekOverview = () => handleRedirection(workspaceSlug, issueDetails, isMobile);
|
||||
const handleIssuePeekOverview = (e: any) => {
|
||||
e.stopPropagation(true);
|
||||
e.preventDefault();
|
||||
handleRedirection(workspaceSlug, issueDetails, isMobile);
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties } from "react";
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import clone from "lodash/clone";
|
||||
import concat from "lodash/concat";
|
||||
@@ -32,6 +33,7 @@ import { Logo } from "@/components/common";
|
||||
import { ISSUE_PRIORITIES, EIssuesStoreType } from "@/constants/issue";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// store
|
||||
import { ICycleStore } from "@/store/cycle.store";
|
||||
@@ -672,3 +674,39 @@ export function getApproximateCardHeight(displayProperties: IIssueDisplayPropert
|
||||
|
||||
return cardHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* This Method is used to get Block view details, that returns block style and tooltip message
|
||||
* @param block
|
||||
* @param backgroundColor
|
||||
* @returns
|
||||
*/
|
||||
export const getBlockViewDetails = (
|
||||
block: { start_date: string | undefined | null; target_date: string | undefined | null } | undefined | null,
|
||||
backgroundColor: string
|
||||
) => {
|
||||
const isBlockVisibleOnChart = block?.start_date || block?.target_date;
|
||||
const isBlockComplete = block?.start_date && block?.target_date;
|
||||
|
||||
let message;
|
||||
const blockStyle: CSSProperties = {
|
||||
backgroundColor,
|
||||
};
|
||||
|
||||
if (isBlockVisibleOnChart && !isBlockComplete) {
|
||||
if (block?.start_date) {
|
||||
message = `From ${renderFormattedDate(block.start_date)}`;
|
||||
blockStyle.maskImage = `linear-gradient(to right, ${backgroundColor} 60%, transparent 100%)`;
|
||||
} else if (block?.target_date) {
|
||||
message = `Till ${renderFormattedDate(block.target_date)}`;
|
||||
blockStyle.maskImage = `linear-gradient(to left, ${backgroundColor} 60%, transparent 100%)`;
|
||||
}
|
||||
} else if (isBlockComplete) {
|
||||
message = `${renderFormattedDate(block?.start_date)} to ${renderFormattedDate(block?.target_date)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
blockStyle,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
// ui
|
||||
import { Tooltip, ModuleStatusIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// components
|
||||
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
|
||||
import { getBlockViewDetails } from "@/components/issues/issue-layouts/utils";
|
||||
// constants
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
@@ -30,31 +31,40 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => {
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const { message, blockStyle } = getBlockViewDetails(
|
||||
moduleDetails,
|
||||
MODULE_STATUS.find((s) => s.value === moduleDetails?.status)?.color ?? ""
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full w-full items-center rounded"
|
||||
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === moduleDetails?.status)?.color }}
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug?.toString()}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`)
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{moduleDetails?.name}</h5>
|
||||
<div>{message}</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{moduleDetails?.name}</h5>
|
||||
<div>
|
||||
{renderFormattedDate(moduleDetails?.start_date ?? "")} to{" "}
|
||||
{renderFormattedDate(moduleDetails?.target_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex h-full w-full cursor-pointer items-center rounded"
|
||||
style={blockStyle}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${workspaceSlug?.toString()}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`
|
||||
)
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{moduleDetails?.name}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||
<div
|
||||
className="sticky w-auto overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100"
|
||||
style={{ left: `${SIDEBAR_WIDTH}px` }}
|
||||
>
|
||||
{moduleDetails?.name}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user