mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
21 Commits
fix/error-
...
fix/kanban
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9efc606acf | ||
|
|
9870be61bb | ||
|
|
f5b7964c6b | ||
|
|
15c7deb2db | ||
|
|
e60ef36bfe | ||
|
|
bc2c97b9c3 | ||
|
|
7f99b9a554 | ||
|
|
b74a0ea4d3 | ||
|
|
d9f11733ad | ||
|
|
87aab74579 | ||
|
|
d5dd971fb4 | ||
|
|
1789b8ddeb | ||
|
|
1caa109c16 | ||
|
|
b711fedb65 | ||
|
|
deaa63488b | ||
|
|
87737dbfbe | ||
|
|
fc1cffd524 | ||
|
|
6e574515e0 | ||
|
|
d87edede79 | ||
|
|
196724214b | ||
|
|
695892d66e |
2
.github/workflows/create-sync-pr.yml
vendored
2
.github/workflows/create-sync-pr.yml
vendored
@@ -11,7 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
@@ -6,9 +6,15 @@ from plane.api.views import (
|
||||
IssueLinkAPIEndpoint,
|
||||
IssueCommentAPIEndpoint,
|
||||
IssueActivityAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueAPIEndpoint.as_view(),
|
||||
|
||||
@@ -3,6 +3,7 @@ from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
|
||||
from .state import StateAPIEndpoint
|
||||
|
||||
from .issue import (
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAPIEndpoint,
|
||||
LabelAPIEndpoint,
|
||||
IssueLinkAPIEndpoint,
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.api.serializers import (
|
||||
LabelSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
ProjectMemberPermission,
|
||||
@@ -51,6 +52,65 @@ from plane.db.models import (
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
"""
|
||||
This viewset provides `retrieveByIssueId` on workspace level
|
||||
|
||||
"""
|
||||
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
permission_classes = [
|
||||
ProjectEntityPermission
|
||||
]
|
||||
serializer_class = IssueSerializer
|
||||
|
||||
|
||||
@property
|
||||
def project__identifier(self):
|
||||
return self.kwargs.get("project__identifier", None)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__identifier=self.kwargs.get("project__identifier"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
).distinct()
|
||||
|
||||
def get(self, request, slug, project__identifier=None, issue__identifier=None):
|
||||
if issue__identifier and project__identifier:
|
||||
issue = Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier)
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
@@ -282,7 +342,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
request.data.get("external_id")
|
||||
and (issue.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
|
||||
@@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
# Handle requests based on project__identifier
|
||||
if hasattr(view, "project__identifier") and view.project__identifier:
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project__identifier=view.project__identifier,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
|
||||
@@ -151,8 +151,8 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
|
||||
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
|
||||
@invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
is_triage=False,
|
||||
)
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -6,10 +6,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
children: React.ReactNode;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
|
||||
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -23,8 +24,8 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest}>
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -27,9 +27,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
: mode === "true-transparent"
|
||||
? "rounded border-none bg-transparent ring-0"
|
||||
: ""
|
||||
} ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${
|
||||
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
|
||||
}`,
|
||||
} ${hasError ? "border-red-500" : ""} ${inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""}`,
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -46,10 +46,10 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
}`}`}
|
||||
target={activity.issue === null ? "_self" : "_blank"}
|
||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||
className="font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
||||
<span className="font-normal break-all">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
|
||||
@@ -105,7 +105,7 @@ const EstimatePoint = observer((props: { point: string }) => {
|
||||
const estimateValue = getEstimatePointValue(Number(point), null);
|
||||
|
||||
return (
|
||||
<span className="font-medium text-custom-text-100">
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{areEstimatesEnabledForCurrentProject
|
||||
? estimateValue
|
||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
||||
@@ -300,11 +300,13 @@ const activityDetails: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
<span className="overflow-hidden">
|
||||
added a new label{" "}
|
||||
<span className="inline-flex w-min items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.new_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||
{activity.new_value}
|
||||
</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<span className="">
|
||||
@@ -312,15 +314,17 @@ const activityDetails: {
|
||||
to <IssueLink activity={activity} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the label{" "}
|
||||
<span className="inline-flex w-min items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.old_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||
{activity.old_value}
|
||||
</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<span>
|
||||
@@ -404,29 +408,30 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
<span className="flex-shrink-0">
|
||||
added {showIssue ? <IssueLink activity={activity} /> : "this issue"} to the cycle{" "}
|
||||
added {showIssue ? <IssueLink activity={activity} /> : "this issue"}{" "}
|
||||
<span className="whitespace-nowrap">to the cycle</span>{" "}
|
||||
</span>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "updated")
|
||||
return (
|
||||
<>
|
||||
<span className="flex-shrink-0">set the cycle to </span>
|
||||
<span className="flex-shrink-0 whitespace-nowrap">set the cycle to </span>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -438,9 +443,9 @@ const activityDetails: {
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.old_value}</span>
|
||||
<span className="break-all">{activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -457,9 +462,9 @@ const activityDetails: {
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -471,9 +476,9 @@ const activityDetails: {
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -485,9 +490,9 @@ const activityDetails: {
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.old_value}</span>
|
||||
<span className="break-all">{activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
@@ -497,7 +502,7 @@ const activityDetails: {
|
||||
name: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
<span className="truncate">set the name to {activity.new_value}</span>
|
||||
set the name to <span className="break-all">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -513,7 +518,8 @@ const activityDetails: {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the parent <span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
removed the parent{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -525,7 +531,8 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the parent to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the parent to{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -560,13 +567,14 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
marked that {showIssue ? <IssueLink activity={activity} /> : "this issue"} relates to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the relation from <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
removed the relation from{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -578,13 +586,14 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is blocking issue{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the blocking issue <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
removed the blocking issue{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -596,14 +605,14 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is being blocked by{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} being blocked by issue{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -615,14 +624,14 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} as duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} as a duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -631,7 +640,7 @@ const activityDetails: {
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the state to <span className="font-medium text-custom-text-100 break-all">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -660,7 +669,9 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
set the start date to{" "}
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{renderFormattedDate(activity.new_value)}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -690,7 +701,9 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
set the due date to{" "}
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{renderFormattedDate(activity.new_value)}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
<IssueLink activity={activity} />
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
workspaceLevelToggle = false,
|
||||
} = props;
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
@@ -72,7 +73,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
search: debouncedSearchTerm,
|
||||
@@ -80,7 +81,10 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
workspace_search: isWorkspaceLevel,
|
||||
})
|
||||
.then((res) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
.finally(() => {
|
||||
setIsSearching(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
|
||||
|
||||
return (
|
||||
@@ -194,14 +198,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
</h5>
|
||||
)}
|
||||
|
||||
<IssueSearchModalEmptyState
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
isSearching={isSearching}
|
||||
issues={issues}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
|
||||
{isSearching ? (
|
||||
{isSearching || isLoading ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
@@ -209,48 +206,59 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<ul className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}>
|
||||
{issues.map((issue) => {
|
||||
const selected = selectedIssues.some((i) => i.id === issue.id);
|
||||
<>
|
||||
{issues.length === 0 ? (
|
||||
<IssueSearchModalEmptyState
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
isSearching={isSearching}
|
||||
issues={issues}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<ul className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}>
|
||||
{issues.map((issue) => {
|
||||
const selected = selectedIssues.some((i) => i.id === issue.id);
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue}
|
||||
className={({ active }) =>
|
||||
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</div>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
className="z-1 relative hidden text-custom-text-200 hover:text-custom-text-100 group-hover:block"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</a>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue}
|
||||
className={({ active }) =>
|
||||
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</div>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
className="z-1 relative hidden text-custom-text-200 hover:text-custom-text-100 group-hover:block"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</a>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
|
||||
type TSingleProgressStatsProps = {
|
||||
title: any;
|
||||
completed: number;
|
||||
@@ -26,9 +24,6 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
<div className="w-1/2">{title}</div>
|
||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||
<div className="flex h-5 items-center justify-center gap-1">
|
||||
<span className="h-4 w-4">
|
||||
<CircularProgressIndicator percentage={(completed / total) * 100} size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="w-8 text-right">
|
||||
{isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}%
|
||||
</span>
|
||||
|
||||
@@ -149,7 +149,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -231,23 +231,23 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-4 bottom-3.5 z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
@@ -85,7 +85,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
@@ -103,6 +103,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
|
||||
@@ -154,7 +154,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onClick={(e) => {
|
||||
@@ -199,58 +199,61 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 md:w-auto md:flex-shrink-0 md:justify-end">
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||
: `${currentCycle.label}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="h-6 w-52 flex-shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
<div className="absolute right-5 bottom-8 z-[5] flex items-center gap-1.5">
|
||||
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 md:w-auto md:flex-shrink-0 md:justify-end">
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: currentCycle.color,
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||
: `${currentCycle.label}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export const DashboardWidgets = observer(() => {
|
||||
if (!workspaceSlug || !homeDashboardId) return null;
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-2 gap-7">
|
||||
<div className="relative flex flex-col lg:grid lg:grid-cols-2 gap-7">
|
||||
{Object.entries(WIDGETS_LIST).map(([key, widget]) => {
|
||||
const WidgetComponent = widget.component;
|
||||
// if the widget doesn't exist, return null
|
||||
|
||||
@@ -68,8 +68,8 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-1 break-words">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<div className="-mt-2 break-words">
|
||||
<p className="inline text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "}
|
||||
</span>
|
||||
@@ -81,7 +81,9 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-custom-text-200">{calculateTimeAgo(activity.created_at)}</p>
|
||||
<p className="text-xs text-custom-text-200 whitespace-nowrap">
|
||||
{calculateTimeAgo(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -23,7 +24,7 @@ export type EmptyStateProps = {
|
||||
secondaryButtonOnClick?: () => void;
|
||||
};
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = (props) => {
|
||||
export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
const {
|
||||
type,
|
||||
size = "lg",
|
||||
@@ -173,4 +174,4 @@ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -51,16 +51,19 @@ export const ProjectsHeader = observer(() => {
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TProjectFilters, value: string | string[]) => {
|
||||
if (!workspaceSlug) return;
|
||||
const newValues = filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value))
|
||||
let newValues = filters?.[key] ?? [];
|
||||
if (Array.isArray(value)) {
|
||||
if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = [];
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
else {
|
||||
} else {
|
||||
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
else {
|
||||
if (key === "created_at") newValues = [value];
|
||||
else newValues.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, { [key]: newValues });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Avatar, Loader } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||
// hooks
|
||||
import { useMember, useProjectInbox } from "@/hooks/store";
|
||||
import { useMember, useProjectInbox, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
filterKey: TInboxIssueFilterMemberKeys;
|
||||
@@ -20,6 +20,7 @@ export const FilterMember: FC<Props> = observer((props: Props) => {
|
||||
// hooks
|
||||
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||
const { getUserDetails } = useMember();
|
||||
const { currentUser } = useUser();
|
||||
// states
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
@@ -34,6 +35,7 @@ export const FilterMember: FC<Props> = observer((props: Props) => {
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(memberId) => !filterValue.includes(memberId),
|
||||
(memberId) => memberId !== currentUser?.id,
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -71,7 +73,7 @@ export const FilterMember: FC<Props> = observer((props: Props) => {
|
||||
isChecked={filterValue?.includes(member.id) ? true : false}
|
||||
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))}
|
||||
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
|
||||
title={member.display_name}
|
||||
title={currentUser?.id === member.id ? "You" : member?.display_name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -116,10 +116,16 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
setFormSubmitting(false);
|
||||
};
|
||||
|
||||
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
|
||||
|
||||
if (!workspaceSlug || !projectId || !workspaceId) return <></>;
|
||||
return (
|
||||
<form className="relative space-y-4" onSubmit={handleFormSubmit}>
|
||||
<InboxIssueTitle data={formData} handleData={handleFormData} />
|
||||
<InboxIssueTitle
|
||||
data={formData}
|
||||
handleData={handleFormData}
|
||||
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
|
||||
/>
|
||||
<InboxIssueDescription
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
@@ -138,7 +144,13 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={formSubmitting}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="submit"
|
||||
loading={formSubmitting}
|
||||
disabled={isTitleLengthMoreThan255Character}
|
||||
>
|
||||
{formSubmitting ? "Adding Issue..." : "Add Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -121,10 +121,16 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
|
||||
setFormSubmitting(false);
|
||||
};
|
||||
|
||||
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
|
||||
|
||||
if (!workspaceSlug || !projectId || !workspaceId || !formData) return <></>;
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
<InboxIssueTitle data={formData} handleData={handleFormData} />
|
||||
<InboxIssueTitle
|
||||
data={formData}
|
||||
handleData={handleFormData}
|
||||
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
|
||||
/>
|
||||
<InboxIssueDescription
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
@@ -138,7 +144,14 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
|
||||
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="button" loading={formSubmitting} onClick={handleFormSubmit}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
loading={formSubmitting}
|
||||
disabled={isTitleLengthMoreThan255Character}
|
||||
onClick={handleFormSubmit}
|
||||
>
|
||||
{formSubmitting ? "Adding..." : "Add to project"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Input } from "@plane/ui";
|
||||
type TInboxIssueTitle = {
|
||||
data: Partial<TIssue>;
|
||||
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
|
||||
isTitleLengthMoreThan255Character?: boolean;
|
||||
};
|
||||
|
||||
export const InboxIssueTitle: FC<TInboxIssueTitle> = observer((props) => {
|
||||
const { data, handleData } = props;
|
||||
const { data, handleData, isTitleLengthMoreThan255Character } = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap gap-2 items-center">
|
||||
@@ -21,9 +22,11 @@ export const InboxIssueTitle: FC<TInboxIssueTitle> = observer((props) => {
|
||||
onChange={(e) => handleData("name", e.target.value)}
|
||||
placeholder="Title"
|
||||
className="w-full resize-none text-xl"
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
{isTitleLengthMoreThan255Character && (
|
||||
<span className="text-xs text-red-500">Title should be less than 255 characters</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CopyPlus,
|
||||
CalendarClock,
|
||||
CalendarCheck2,
|
||||
UserCircle2,
|
||||
} from "lucide-react";
|
||||
// hooks
|
||||
// components
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
} from "@/components/dropdowns";
|
||||
// ui
|
||||
// helpers
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import {
|
||||
DeleteIssueModal,
|
||||
IssueLinkRoot,
|
||||
@@ -54,7 +56,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// types
|
||||
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { useEstimate, useIssueDetail, useMember, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
@@ -88,9 +90,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getUserDetails } = useMember();
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const createdByDetails = getUserDetails(issue.created_by);
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
|
||||
@@ -257,6 +262,21 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createdByDetails && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Created by</span>
|
||||
</div>
|
||||
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
|
||||
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
|
||||
<ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />
|
||||
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CalendarClock className="h-4 w-4 flex-shrink-0" />
|
||||
@@ -460,12 +480,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueLinkRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<IssueLinkRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!isEditable} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -50,7 +50,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof IIssueFilterOptions;
|
||||
|
||||
@@ -60,9 +60,9 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<div
|
||||
key={filterKey}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize w-full"
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize truncate"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5 w-full">
|
||||
<div className="flex flex-wrap items-center gap-1.5 truncate">
|
||||
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
{membersFilters.includes(filterKey) && (
|
||||
<AppliedMembersFilters
|
||||
@@ -141,7 +141,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAllFilters}
|
||||
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
|
||||
className="flex items-center gap-2 flex-shrink-0 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
|
||||
>
|
||||
Clear all
|
||||
<X size={12} strokeWidth={2} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { Avatar, Loader } from "@plane/ui";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { useMember, useUser } from "@/hooks/store";
|
||||
// components
|
||||
// ui
|
||||
|
||||
@@ -22,16 +22,18 @@ export const FilterAssignees: React.FC<Props> = observer((props: Props) => {
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const filteredOptions = (memberIds || []).filter(
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const filteredOptions = (memberIds || []).filter((memberId) =>
|
||||
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(memberId) => !(appliedFilters ?? []).includes(memberId),
|
||||
(memberId) => memberId !== currentUser?.id,
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -66,7 +68,7 @@ export const FilterAssignees: React.FC<Props> = observer((props: Props) => {
|
||||
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||
onClick={() => handleUpdate(member.id)}
|
||||
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
|
||||
title={member.display_name}
|
||||
title={currentUser?.id === member.id ? "You" : member?.display_name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { Avatar, Loader } from "@plane/ui";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { useMember, useUser } from "@/hooks/store";
|
||||
// components
|
||||
// ui
|
||||
|
||||
@@ -22,14 +22,16 @@ export const FilterCreatedBy: React.FC<Props> = observer((props: Props) => {
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const filteredOptions = (memberIds || []).filter(
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const filteredOptions = (memberIds || []).filter((memberId) =>
|
||||
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(memberId) => !(appliedFilters ?? []).includes(memberId),
|
||||
(memberId) => memberId !== currentUser?.id,
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -66,7 +68,7 @@ export const FilterCreatedBy: React.FC<Props> = observer((props: Props) => {
|
||||
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||
onClick={() => handleUpdate(member.id)}
|
||||
icon={<Avatar name={member.display_name} src={member.avatar} size="md" />}
|
||||
title={member.display_name}
|
||||
title={currentUser?.id === member.id ? "You" : member?.display_name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { Loader, Avatar } from "@plane/ui";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { useMember, useUser } from "@/hooks/store";
|
||||
// components
|
||||
// ui
|
||||
|
||||
@@ -22,16 +22,18 @@ export const FilterMentions: React.FC<Props> = observer((props: Props) => {
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const filteredOptions = (memberIds || []).filter(
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const filteredOptions = (memberIds || []).filter((memberId) =>
|
||||
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return sortBy(filteredOptions, [
|
||||
(memberId) => !(appliedFilters ?? []).includes(memberId),
|
||||
(memberId) => memberId !== currentUser?.id,
|
||||
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -66,7 +68,7 @@ export const FilterMentions: React.FC<Props> = observer((props: Props) => {
|
||||
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||
onClick={() => handleUpdate(member.id)}
|
||||
icon={<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} size={"md"} />}
|
||||
title={member.display_name}
|
||||
title={currentUser?.id === member.id ? "You" : member?.display_name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -76,6 +76,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
const sub_group_by = displayFilters?.sub_group_by;
|
||||
const group_by = displayFilters?.group_by;
|
||||
|
||||
const orderBy = displayFilters?.order_by;
|
||||
|
||||
const userDisplayFilters = displayFilters || null;
|
||||
|
||||
const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan;
|
||||
@@ -158,7 +160,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
issues.getIssueIds,
|
||||
updateIssue,
|
||||
group_by,
|
||||
sub_group_by
|
||||
sub_group_by,
|
||||
orderBy !== "sort_order"
|
||||
).catch((err) => {
|
||||
setToast({
|
||||
title: "Error",
|
||||
@@ -259,6 +262,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
orderBy={orderBy}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={renderQuickActions}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
|
||||
@@ -60,7 +60,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
className="absolute -top-1 right-0 hidden group-hover/kanban-block:block"
|
||||
onClick={handleEventPropagation}
|
||||
>
|
||||
{quickActions(issue)}
|
||||
{!isReadOnly && quickActions(issue)}
|
||||
</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
@@ -103,7 +103,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
issueIds,
|
||||
} = props;
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
@@ -138,6 +138,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
canDrag: () => isDragAllowed,
|
||||
getInitialData: () => ({ id: issue?.id, type: "ISSUE" }),
|
||||
onDragStart: () => {
|
||||
@@ -151,7 +152,6 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: (payload) => payload.source?.data?.id !== issue?.id,
|
||||
getData: () => ({ id: issue?.id, type: "ISSUE" }),
|
||||
onDragEnter: () => {
|
||||
setIsDraggingOverBlock(true);
|
||||
@@ -184,32 +184,29 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
disabled={!!issue?.tempId}
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "hover:cursor-pointer": isDragAllowed },
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
|
||||
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "hover:cursor-pointer": isDragAllowed },
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
|
||||
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
|
||||
)}
|
||||
ref={cardRef}
|
||||
<RenderIfVisible
|
||||
classNames="space-y-2 px-3 py-2"
|
||||
root={scrollableContainerRef}
|
||||
defaultHeight="100px"
|
||||
horizontalOffset={50}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<RenderIfVisible
|
||||
classNames="space-y-2 px-3 py-2"
|
||||
root={scrollableContainerRef}
|
||||
defaultHeight="100px"
|
||||
horizontalOffset={50}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<KanbanIssueDetailsBlock
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
</div>
|
||||
<KanbanIssueDetailsBlock
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
</ControlLink>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
// hooks
|
||||
@@ -39,6 +40,7 @@ export interface IGroupByKanBan {
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: TIssueGroupByOptions | undefined;
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
@@ -87,6 +89,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
handleOnDrop,
|
||||
showEmptyGroup = true,
|
||||
subGroupIssueHeaderCount,
|
||||
orderBy,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
@@ -180,6 +183,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
orderBy={orderBy}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={isDragDisabled}
|
||||
updateIssue={updateIssue}
|
||||
@@ -206,6 +210,7 @@ export interface IKanBan {
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: TIssueGroupByOptions | undefined;
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
sub_group_id?: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
@@ -252,6 +257,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
handleOnDrop,
|
||||
showEmptyGroup,
|
||||
subGroupIssueHeaderCount,
|
||||
orderBy,
|
||||
} = props;
|
||||
|
||||
const issueKanBanView = useKanbanView();
|
||||
@@ -263,6 +269,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
orderBy={orderBy}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={!issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by)}
|
||||
updateIssue={updateIssue}
|
||||
|
||||
@@ -11,13 +11,20 @@ import {
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
import { ISSUE_ORDER_BY_OPTIONS } from "@/constants/issue";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
//components
|
||||
import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils";
|
||||
import {
|
||||
KanbanDropLocation,
|
||||
getSourceFromDropPayload,
|
||||
getDestinationFromDropPayload,
|
||||
highlightIssueOnDrop,
|
||||
} from "./utils";
|
||||
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
|
||||
|
||||
interface IKanbanGroup {
|
||||
@@ -45,6 +52,7 @@ interface IKanbanGroup {
|
||||
groupByVisibilityToggle?: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
}
|
||||
|
||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
@@ -52,6 +60,7 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
groupId,
|
||||
sub_group_id,
|
||||
group_by,
|
||||
orderBy,
|
||||
sub_group_by,
|
||||
issuesMap,
|
||||
displayProperties,
|
||||
@@ -102,13 +111,15 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
if (!source || !destination) return;
|
||||
|
||||
handleOnDrop(source, destination);
|
||||
|
||||
highlightIssueOnDrop(payload.source.element.id, orderBy !== "sort_order");
|
||||
},
|
||||
}),
|
||||
autoScrollForElements({
|
||||
element,
|
||||
})
|
||||
);
|
||||
}, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn]);
|
||||
}, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn, orderBy]);
|
||||
|
||||
const prePopulateQuickAddData = (
|
||||
groupByKey: string | undefined,
|
||||
@@ -162,16 +173,31 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
return preloadedData;
|
||||
};
|
||||
|
||||
const shouldOverlay = isDraggingOverColumn && orderBy !== "sort_order";
|
||||
const readableOrderBy = ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.title;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${groupId}__${sub_group_id}`}
|
||||
className={cn(
|
||||
"relative h-full transition-all min-h-[50px]",
|
||||
{ "bg-custom-background-80": isDraggingOverColumn },
|
||||
{ "vertical-scrollbar scrollbar-md": !sub_group_by }
|
||||
{ "vertical-scrollbar scrollbar-md": !sub_group_by && !shouldOverlay }
|
||||
)}
|
||||
ref={columnRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 h-full w-full justify-center items-center text-sm text-custom-text-100",
|
||||
{
|
||||
"flex flex-col bg-custom-primary-10 border-[2px] border-custom-primary-40 rounded z-[2]": shouldOverlay,
|
||||
},
|
||||
{ hidden: !shouldOverlay }
|
||||
)}
|
||||
>
|
||||
{readableOrderBy && <span>The layout is ordered by {readableOrderBy}.</span>}
|
||||
<span>Drop here to move the issue.</span>
|
||||
</div>
|
||||
<KanbanIssueBlocksList
|
||||
sub_group_id={sub_group_id}
|
||||
columnId={groupId}
|
||||
@@ -183,7 +209,7 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
scrollableContainerRef={sub_group_by ? scrollableContainerRef : columnRef}
|
||||
/>
|
||||
|
||||
{enableQuickIssueCreate && !disableIssueCreation && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
@@ -113,6 +114,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
disableIssueCreation?: boolean;
|
||||
storeType: KanbanStoreType;
|
||||
enableQuickIssueCreate: boolean;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
quickAddCallback?: (
|
||||
@@ -145,6 +147,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
handleOnDrop,
|
||||
orderBy,
|
||||
} = props;
|
||||
|
||||
const calculateIssueCount = (column_id: string) => {
|
||||
@@ -180,7 +183,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||
return (
|
||||
<div key={_list.id} className="flex flex-shrink-0 flex-col">
|
||||
<div className="sticky top-[50px] z-[1] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||
<div className="sticky left-0 flex-shrink-0">
|
||||
<HeaderSubGroupByCard
|
||||
column_id={_list.id}
|
||||
@@ -215,6 +218,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
handleOnDrop={handleOnDrop}
|
||||
orderBy={orderBy}
|
||||
subGroupIssueHeaderCount={(groupByListId: string) =>
|
||||
getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId)
|
||||
}
|
||||
@@ -253,6 +257,7 @@ export interface IKanBanSwimLanes {
|
||||
viewId?: string;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
@@ -262,6 +267,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
displayProperties,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
orderBy,
|
||||
updateIssue,
|
||||
storeType,
|
||||
quickActions,
|
||||
@@ -312,7 +318,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="sticky top-0 z-[2] h-[50px] bg-custom-background-90 px-2">
|
||||
<div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
|
||||
<SubGroupSwimlaneHeader
|
||||
issueIds={issueIds}
|
||||
group_by={group_by}
|
||||
@@ -333,6 +339,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
orderBy={orderBy}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
kanbanFilters={kanbanFilters}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pull from "lodash/pull";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types";
|
||||
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store";
|
||||
|
||||
@@ -87,14 +88,17 @@ export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): K
|
||||
const handleSortOrder = (
|
||||
destinationIssues: string[],
|
||||
destinationIssueId: string | undefined,
|
||||
getIssueById: (issueId: string) => TIssue | undefined
|
||||
getIssueById: (issueId: string) => TIssue | undefined,
|
||||
shouldAddIssueAtTop = false
|
||||
) => {
|
||||
const sortOrderDefaultValue = 65535;
|
||||
let currentIssueState = {};
|
||||
|
||||
const destinationIndex = destinationIssueId
|
||||
? destinationIssues.indexOf(destinationIssueId)
|
||||
: destinationIssues.length;
|
||||
: shouldAddIssueAtTop
|
||||
? 0
|
||||
: destinationIssues.length;
|
||||
|
||||
if (destinationIssues && destinationIssues.length > 0) {
|
||||
if (destinationIndex === 0) {
|
||||
@@ -145,7 +149,8 @@ export const handleDragDrop = async (
|
||||
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined,
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined,
|
||||
groupBy: TIssueGroupByOptions | undefined,
|
||||
subGroupBy: TIssueGroupByOptions | undefined
|
||||
subGroupBy: TIssueGroupByOptions | undefined,
|
||||
shouldAddIssueAtTop = false
|
||||
) => {
|
||||
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
|
||||
|
||||
@@ -165,7 +170,7 @@ export const handleDragDrop = async (
|
||||
// for both horizontal and vertical dnd
|
||||
updatedIssue = {
|
||||
...updatedIssue,
|
||||
...handleSortOrder(destinationIssues, destination.id, getIssueById),
|
||||
...handleSortOrder(destinationIssues, destination.id, getIssueById, shouldAddIssueAtTop),
|
||||
};
|
||||
|
||||
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
|
||||
@@ -207,3 +212,18 @@ export const handleDragDrop = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const highlightIssueOnDrop = (elementId: string | undefined, shouldScrollIntoView = true) => {
|
||||
setTimeout(async () => {
|
||||
const sourceElementId = elementId ?? "";
|
||||
const sourceElement = document.getElementById(sourceElementId);
|
||||
if (shouldScrollIntoView && sourceElement)
|
||||
await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });
|
||||
sourceElement?.classList?.add("highlight");
|
||||
setTimeout(() => {
|
||||
const sourceElementId = elementId ?? "";
|
||||
const sourceElement = document.getElementById(sourceElementId);
|
||||
sourceElement?.classList?.remove("highlight");
|
||||
}, 2000);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
||||
key={`${issueId}`}
|
||||
defaultHeight="3rem"
|
||||
root={containerRef}
|
||||
classNames={"relative border-b last:border-b-transparent"}
|
||||
classNames="relative border-b border-b-custom-border-200 last:border-b-transparent"
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<IssueBlock
|
||||
|
||||
@@ -391,6 +391,8 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
|
||||
<div className="relative">
|
||||
{data?.description_html === undefined ? (
|
||||
<Loader className="min-h-[7rem] space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-2 py-2">
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
projectId,
|
||||
issueId,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
@@ -56,6 +57,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setIsLoading(true);
|
||||
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
@@ -65,7 +67,10 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
workspace_search: isWorkspaceLevel,
|
||||
})
|
||||
.then((res) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
.finally(() => {
|
||||
setIsSearching(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [debouncedSearchTerm, isOpen, issueId, isWorkspaceLevel, projectId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
@@ -153,14 +158,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
</h5>
|
||||
)}
|
||||
|
||||
<IssueSearchModalEmptyState
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
isSearching={isSearching}
|
||||
issues={issues}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
|
||||
{isSearching ? (
|
||||
{isSearching || isLoading ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
@@ -168,41 +166,52 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<ul className={`text-sm ${issues.length > 0 ? "p-2" : ""}`}>
|
||||
{issues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
value={issue}
|
||||
className={({ active, selected }) =>
|
||||
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
className="z-1 relative hidden flex-shrink-0 text-custom-text-200 hover:text-custom-text-100 group-hover:block"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</a>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{issues.length === 0 ? (
|
||||
<IssueSearchModalEmptyState
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
isSearching={isSearching}
|
||||
issues={issues}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<ul className={`text-sm ${issues.length > 0 ? "p-2" : ""}`}>
|
||||
{issues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
value={issue}
|
||||
className={({ active, selected }) =>
|
||||
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
className="z-1 relative hidden flex-shrink-0 text-custom-text-200 hover:text-custom-text-100 group-hover:block"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</a>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
XCircle,
|
||||
CalendarClock,
|
||||
CalendarCheck2,
|
||||
UserCircle2,
|
||||
} from "lucide-react";
|
||||
// hooks
|
||||
// ui icons
|
||||
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
|
||||
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
DateDropdown,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
MemberDropdown,
|
||||
StateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import {
|
||||
IssueLinkRoot,
|
||||
IssueCycleSelect,
|
||||
@@ -35,7 +37,8 @@ import {
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
|
||||
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
workspaceSlug: string;
|
||||
@@ -53,9 +56,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
const createdByDetails = getUserDetails(issue?.created_by);
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const isEstimateEnabled = projectDetails?.estimate;
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
@@ -131,6 +137,22 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* created by */}
|
||||
{createdByDetails && (
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Created by</span>
|
||||
</div>
|
||||
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
|
||||
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
|
||||
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
|
||||
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* start date */}
|
||||
<div className="flex w-full items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
|
||||
@@ -91,9 +91,9 @@ export const DeleteLabelModal: React.FC<Props> = observer((props) => {
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to delete label-{" "}
|
||||
<span className="font-medium text-custom-text-100">{data?.name}</span>? The label will be
|
||||
removed from all the issues.
|
||||
Are you sure you wish to delete{" "}
|
||||
<span className="font-medium text-custom-text-100">{data?.name}</span>? This will remove the
|
||||
label from all the issue and from any views where the label is being filtered upon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const ModuleForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -109,13 +109,14 @@ export const ModuleForm: React.FC<Props> = (props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
hasError={Boolean(errors?.name)}
|
||||
placeholder="Module Title"
|
||||
className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400"
|
||||
tabIndex={1}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
|
||||
@@ -144,106 +144,107 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
: "0 Issue";
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!moduleDetails.is_favorite}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-between py-0.5">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-4 bottom-3.5 z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!moduleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -135,56 +135,60 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
const completedModuleCheck = moduleDetails.status === "completed";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
|
||||
onClick={(e) => {
|
||||
if (isArchived) {
|
||||
openModuleOverview(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row">
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
|
||||
onClick={(e) => {
|
||||
if (isArchived) {
|
||||
openModuleOverview(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row">
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="h-6 w-52 flex-shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-5 bottom-8 z-[5] flex items-center gap-1.5">
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-between gap-2.5 sm:w-auto sm:flex-shrink-0 sm:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && (
|
||||
@@ -232,6 +236,6 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
// components
|
||||
import { Spinner } from "@plane/ui";
|
||||
import { DashboardWidgets } from "@/components/dashboard";
|
||||
@@ -12,7 +11,11 @@ import { UserGreetingsView } from "@/components/user";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useApplication, useEventTracker, useDashboard, useProject, useUser } from "@/hooks/store";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
|
||||
export const WorkspaceDashboardView = observer(() => {
|
||||
// store hooks
|
||||
@@ -25,6 +28,8 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const [windowWidth] = useSize();
|
||||
|
||||
const handleTourCompleted = () => {
|
||||
updateTourCompleted()
|
||||
.then(() => {
|
||||
@@ -57,7 +62,14 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
{joinedProjectIds.length > 0 ? (
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<div className="space-y-7 p-7 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-7 md:p-7 p-3 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto",
|
||||
{
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{currentUser && <UserGreetingsView user={currentUser} />}
|
||||
|
||||
<DashboardWidgets />
|
||||
|
||||
@@ -97,10 +97,10 @@ export const ActivityList: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
<div className="relative flex items-center space-x-2">
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1.5">
|
||||
<div className="relative px-1.5 mt-4">
|
||||
<div className="mt-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
{activityItem.field ? (
|
||||
@@ -127,7 +127,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||
<div className="flex gap-1 break-words text-sm text-custom-text-200">
|
||||
<div className="break-words text-sm text-custom-text-200">
|
||||
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
@@ -135,6 +135,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activityItem.workspace_detail?.slug}/profile/${activityItem.actor_detail.id}`}
|
||||
className="inline"
|
||||
>
|
||||
<span className="text-gray font-medium">
|
||||
{currentUser?.id === activityItem.actor_detail.id
|
||||
@@ -143,7 +144,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
|
||||
</span>
|
||||
</Link>
|
||||
)}{" "}
|
||||
<div className="flex gap-1 truncate">
|
||||
<div className="inline gap-1">
|
||||
{message}{" "}
|
||||
<span className="flex-shrink-0 whitespace-nowrap">
|
||||
{calculateTimeAgo(activityItem.created_at)}
|
||||
|
||||
@@ -120,10 +120,10 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
<div className="relative flex items-center space-x-2">
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1.5">
|
||||
<div className="relative px-1.5 mt-4">
|
||||
<div className="mt-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
{activityItem.field ? (
|
||||
@@ -150,7 +150,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||
<div className="flex gap-1 break-words text-sm text-custom-text-200">
|
||||
<div className="break-words text-sm text-custom-text-200">
|
||||
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
@@ -158,6 +158,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
||||
className="inline"
|
||||
>
|
||||
<span className="text-gray font-medium">
|
||||
{currentUser?.id === activityItem.actor_detail.id
|
||||
@@ -166,7 +167,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
</span>
|
||||
</Link>
|
||||
)}{" "}
|
||||
<div className="flex gap-1 truncate">
|
||||
<div className="inline gap-1">
|
||||
{message}{" "}
|
||||
<span className="flex-shrink-0 whitespace-nowrap">
|
||||
{calculateTimeAgo(activityItem.created_at)}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ProfileActivity = observer(() => {
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-1 w-4/5 break-words">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<p className="inline text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{currentUser?.id === activity.actor_detail?.id ? "You" : activity.actor_detail?.display_name}{" "}
|
||||
</span>
|
||||
@@ -73,7 +73,7 @@ export const ProfileActivity = observer(() => {
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-custom-text-200">{calculateTimeAgo(activity.created_at)}</p>
|
||||
<p className="text-xs text-custom-text-200 whitespace-nowrap ">{calculateTimeAgo(activity.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X } from "lucide-react";
|
||||
// helpers
|
||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@/constants/filters";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "@/helpers/string.helper";
|
||||
// constants
|
||||
@@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC<Props> = observer((props) => {
|
||||
const getDateLabel = (value: string): string => {
|
||||
let dateLabel = "";
|
||||
|
||||
const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value);
|
||||
const dateDetails = PROJECT_CREATED_AT_FILTER_OPTIONS.find((d) => d.value === value);
|
||||
|
||||
if (dateDetails) dateLabel = dateDetails.name;
|
||||
else {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { DateFilterModal } from "@/components/core";
|
||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||
// constants
|
||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@/constants/filters";
|
||||
// helpers
|
||||
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||
|
||||
@@ -16,13 +16,12 @@ type Props = {
|
||||
|
||||
export const FilterCreatedDate: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
// state
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
|
||||
// derived values
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
|
||||
const filteredOptions = PROJECT_CREATED_AT_FILTER_OPTIONS.filter((d) =>
|
||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -62,10 +61,15 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
|
||||
isChecked={appliedFilters?.includes(option.value) ? true : false}
|
||||
onClick={() => handleUpdate(option.value)}
|
||||
title={option.name}
|
||||
multiple
|
||||
multiple={false}
|
||||
/>
|
||||
))}
|
||||
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||
<FilterOption
|
||||
isChecked={isCustomDateSelected()}
|
||||
onClick={handleCustomDate}
|
||||
title="Custom"
|
||||
multiple={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
|
||||
@@ -216,6 +216,10 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Project name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
@@ -232,9 +236,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">
|
||||
<>{errors?.name?.message}</>
|
||||
</span>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Description</h4>
|
||||
|
||||
@@ -112,7 +112,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} View</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -137,6 +137,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
@@ -215,8 +216,8 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
? "Updating View..."
|
||||
: "Update View"
|
||||
: isSubmitting
|
||||
? "Creating View..."
|
||||
: "Create View"}
|
||||
? "Creating View..."
|
||||
: "Create View"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -125,35 +125,38 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||
Workspace Name
|
||||
<span className="ml-0.5 text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Workspace name is required",
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`,
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: "Workspace name should not exceed 80 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<Input
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setValue("name", e.target.value);
|
||||
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"));
|
||||
}}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Enter workspace name..."
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Workspace name is required",
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`,
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: "Workspace name should not exceed 80 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<Input
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setValue("name", e.target.value);
|
||||
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"));
|
||||
}}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Enter workspace name..."
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="workspaceUrl">
|
||||
|
||||
@@ -31,3 +31,22 @@ export const DATE_BEFORE_FILTER_OPTIONS = [
|
||||
value: "1_months;before;fromnow",
|
||||
},
|
||||
];
|
||||
|
||||
export const PROJECT_CREATED_AT_FILTER_OPTIONS = [
|
||||
{
|
||||
name: "Today",
|
||||
value: "today;custom;custom",
|
||||
},
|
||||
{
|
||||
name: "Yesterday",
|
||||
value: "yesterday;custom;custom",
|
||||
},
|
||||
{
|
||||
name: "Last 7 days",
|
||||
value: "last_7_days;custom;custom",
|
||||
},
|
||||
{
|
||||
name: "Last 30 days",
|
||||
value: "last_30_days;custom;custom",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -36,6 +36,14 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
|
||||
const [value, operator, from] = filter.split(";");
|
||||
|
||||
const dateValue = getDate(value);
|
||||
|
||||
if (operator === "custom" && from === "custom") {
|
||||
if (value === "today") return differenceInCalendarDays(date, new Date()) === 0;
|
||||
if (value === "yesterday") return differenceInCalendarDays(date, new Date()) === -1;
|
||||
if (value === "last_7_days") return differenceInCalendarDays(date, new Date()) >= -7;
|
||||
if (value === "last_30_days") return differenceInCalendarDays(date, new Date()) >= -30;
|
||||
}
|
||||
|
||||
if (!from && dateValue) {
|
||||
if (operator === "after") return date >= dateValue;
|
||||
if (operator === "before") return date <= dateValue;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-popper": "^2.3.0",
|
||||
"sharp": "^0.32.1",
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
|
||||
@@ -46,8 +46,8 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<section className="mx-auto h-full w-full flex flex-col overflow-hidden px-8 pb-8 lg:w-3/5">
|
||||
<div className="flex items-center border-b border-custom-border-100 gap-4 pb-3.5 mt-5 md:mt-16">
|
||||
<section className="mx-auto mt-5 md:mt-16 h-full w-full flex flex-col overflow-hidden px-5 md:px-8 pb-8 lg:w-3/5">
|
||||
<div className="flex items-center border-b border-custom-border-100 gap-4 pb-3.5">
|
||||
<SidebarHamburgerToggle onClick={() => themeStore.toggleSidebar()} />
|
||||
<h3 className="text-xl font-medium">Activity</h3>
|
||||
</div>
|
||||
|
||||
@@ -630,3 +630,8 @@ div.web-view-spinner div.bar12 {
|
||||
.scrollbar-lg::-webkit-scrollbar-thumb {
|
||||
border: 4px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* highlight class */
|
||||
.highlight {
|
||||
border: 1px solid rgb(var(--color-primary-100));
|
||||
}
|
||||
21
yarn.lock
21
yarn.lock
@@ -2755,7 +2755,7 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
|
||||
"@types/react@*", "@types/react@^18.2.42":
|
||||
version "18.2.42"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
|
||||
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
|
||||
@@ -3699,6 +3699,11 @@ commondir@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
||||
integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
|
||||
|
||||
compute-scroll-into-view@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87"
|
||||
integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
@@ -7633,6 +7638,13 @@ schema-utils@^3.1.1:
|
||||
ajv "^6.12.5"
|
||||
ajv-keywords "^3.5.2"
|
||||
|
||||
scroll-into-view-if-needed@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f"
|
||||
integrity sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==
|
||||
dependencies:
|
||||
compute-scroll-into-view "^3.0.2"
|
||||
|
||||
selecto@~1.26.3:
|
||||
version "1.26.3"
|
||||
resolved "https://registry.yarnpkg.com/selecto/-/selecto-1.26.3.tgz#12f259112b943d395731524e3bb0115da7372212"
|
||||
@@ -7774,6 +7786,13 @@ slash@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
|
||||
|
||||
smooth-scroll-into-view-if-needed@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/smooth-scroll-into-view-if-needed/-/smooth-scroll-into-view-if-needed-2.0.2.tgz#5bd4ebef668474d6618ce8704650082e93068371"
|
||||
integrity sha512-z54WzUSlM+xHHvJu3lMIsh+1d1kA4vaakcAtQvqzeGJ5Ffau7EKjpRrMHh1/OBo5zyU2h30ZYEt77vWmPHqg7Q==
|
||||
dependencies:
|
||||
scroll-into-view-if-needed "^3.1.0"
|
||||
|
||||
snake-case@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"
|
||||
|
||||
Reference in New Issue
Block a user