Merge branch 'develop' of github.com:makeplane/plane into chore/url_patterns

This commit is contained in:
pablohashescobar 2023-10-12 16:18:24 +05:30
commit d8b8bf4b81
80 changed files with 2844 additions and 1078 deletions

View File

@ -2,6 +2,8 @@ name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
branches:
- master
types:
- closed

View File

@ -57,7 +57,7 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,

View File

@ -24,7 +24,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@ -84,7 +83,6 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.export_task import issue_export_task
class IssueViewSet(BaseViewSet):

View File

@ -243,51 +243,6 @@ class IssueViewViewSet(BaseViewSet):
)
class ViewIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, view_id):
try:
view = IssueView.objects.get(pk=view_id)
queries = view.query
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(
**queries, project_id=project_id, workspace__slug=slug
)
.filter(**filters)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueView.DoesNotExist:
return Response(
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite

View File

@ -1,5 +1,62 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
import re
from datetime import timedelta
from django.utils import timezone
# The date from pattern
pattern = re.compile(r"\d+_(weeks|months)$")
# Get the 2_weeks, 3_months
def string_date_filter(filter, duration, subsequent, term, date_filter, offset):
now = timezone.now().date()
if term == "months":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30)
else:
filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30)
else:
filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30)
if term == "weeks":
if subsequent == "after":
if offset == "fromnow":
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
else:
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
filter[f"{date_filter}__lte"] = now + timedelta(days=duration)
else:
filter[f"{date_filter}__lte"] = now - timedelta(days=duration)
def date_filter(filter, date_term, queries):
"""
Handle all date filters
"""
for query in queries:
date_query = query.split(";")
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter="created_at__date",
offset=date_query[2],
)
else:
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
else:
filter[f"{date_term}__lte"] = date_query[0]
def filter_state(params, filter, method):
@ -97,20 +154,10 @@ def filter_created_at(params, filter, method):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
for query in created_ats:
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=created_ats)
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = created_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=params.get("created_at", []))
return filter
@ -118,20 +165,10 @@ def filter_updated_at(params, filter, method):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
for query in updated_ats:
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=updated_ats)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = updated_at_query[0]
date_filter(filter=filter, date_term="created_at__date", queries=params.get("updated_at", []))
return filter
@ -139,20 +176,10 @@ def filter_start_date(params, filter, method):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
for query in start_dates:
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = start_date_query[0]
date_filter(filter=filter, date_term="start_date", queries=start_dates)
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = start_date_query[0]
date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", []))
return filter
@ -160,21 +187,11 @@ def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", []))
return filter
@ -182,20 +199,10 @@ def filter_completed_at(params, filter, method):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
for query in completed_ats:
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
date_filter(filter=filter, date_term="completed_at__date", queries=completed_ats)
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = completed_at_query[0]
date_filter(filter=filter, date_term="completed_at__date", queries=params.get("completed_at", []))
return filter

View File

@ -74,7 +74,6 @@ export const AutoArchiveAutomation: React.FC<Props> = ({
handleChange({ archive_in: val });
}}
input
verticalPosition="bottom"
width="w-full"
disabled={disabled}
>

View File

@ -100,7 +100,6 @@ export const ThemeSwitch: React.FC<Props> = observer(
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>

View File

@ -255,15 +255,11 @@ export const SingleBoard: React.FC<Props> = (props) => {
!isDraftIssuesPage && (
<CustomMenu
customButton={
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap"
>
<div className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap">
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
</div>
}
position="left"
noBorder
>
<CustomMenu.MenuItem

View File

@ -369,12 +369,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
{type && !isNotAllowed && (
<CustomMenu
customButton={
<button
<div
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded p-1 text-left text-xs duration-300 hover:bg-custom-background-80"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</div>
}
>
<CustomMenu.MenuItem

View File

@ -272,7 +272,6 @@ export const SingleList: React.FC<Props> = (props) => {
<PlusIcon className="h-4 w-4" />
</div>
}
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>

View File

@ -255,7 +255,6 @@ export const SpreadsheetView: React.FC<Props> = ({
<CustomMenu
customButtonClassName="!w-full"
className="!w-full"
position="left"
customButton={
<div
className={`relative group flex items-center justify-between gap-1.5 cursor-pointer text-sm text-custom-text-200 hover:text-custom-text-100 w-full py-3 px-2 ${
@ -641,16 +640,11 @@ export const SpreadsheetView: React.FC<Props> = ({
<CustomMenu
className="sticky left-0 z-10"
customButton={
<button
className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full"
type="button"
>
<div className="flex gap-1.5 items-center text-custom-primary-100 pl-4 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full">
<PlusIcon className="h-4 w-4" />
New Issue
</button>
</div>
}
position="left"
verticalPosition="top"
optionsClassName="left-5 !w-36"
noBorder
>

View File

@ -92,7 +92,6 @@ export const SelectRepository: React.FC<Props> = ({
)}
</>
}
position="right"
optionsClassName="w-full"
/>
);

View File

@ -143,7 +143,6 @@ export const JiraGetImportDetail: React.FC = () => {
)}
</span>
}
verticalPosition="top"
>
{projects && projects.length > 0 ? (
projects.map((project) => (

View File

@ -13,10 +13,11 @@ import { renderEmoji } from "helpers/emoji.helper";
type Props = {
projectId?: string | string[];
commentId: string;
readonly?: boolean;
};
export const CommentReaction: React.FC<Props> = (props) => {
const { projectId, commentId } = props;
const { projectId, commentId, readonly = false } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
@ -47,16 +48,18 @@ export const CommentReaction: React.FC<Props> = (props) => {
return (
<div className="flex gap-1.5 items-center mt-2">
<ReactionSelector
size="md"
position="top"
value={
commentReactions
?.filter((reaction) => reaction.actor === user?.id)
.map((r) => r.reaction) || []
}
onSelect={handleReactionClick}
/>
{!readonly && (
<ReactionSelector
size="md"
position="top"
value={
commentReactions
?.filter((reaction) => reaction.actor === user?.id)
.map((r) => r.reaction) || []
}
onSelect={handleReactionClick}
/>
)}
{Object.keys(groupedReactions || {}).map(
(reaction) =>
@ -64,6 +67,7 @@ export const CommentReaction: React.FC<Props> = (props) => {
groupedReactions[reaction].length > 0 && (
<button
type="button"
disabled={readonly}
onClick={() => {
handleReactionClick(reaction);
}}

View File

@ -158,7 +158,7 @@ export const IssueMainContent: React.FC<Props> = ({
</a>
</Link>
<CustomMenu position="left" ellipsis optionsClassName="px-1.5">
<CustomMenu ellipsis optionsClassName="px-1.5">
{siblingIssuesList ? (
siblingIssuesList.length > 0 ? (
<>

View File

@ -93,7 +93,6 @@ export const PeekOverviewHeader: React.FC<Props> = ({
<Icon iconName={peekModes.find((m) => m.key === mode)?.icon ?? ""} />
</button>
}
position="left"
>
{peekModes.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>

View File

@ -33,7 +33,6 @@ export const IssueEstimateSelect: React.FC<Props> = ({ value, onChange }) => {
</div>
}
onChange={onChange}
position="right"
width="w-full min-w-[8rem]"
noChevron
>

View File

@ -47,7 +47,7 @@ export const SidebarCycleSelect: React.FC<Props> = ({
issuesService
.removeIssueFromCycle(workspaceSlug as string, projectId as string, cycleId, bridgeId)
.then((res) => {
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(CYCLE_ISSUES(cycleId));
@ -91,7 +91,6 @@ export const SidebarCycleSelect: React.FC<Props> = ({
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>

View File

@ -20,17 +20,14 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabl
<CustomSelect
value={value}
customButton={
<button
type="button"
className="flex items-center gap-1.5 !text-sm bg-custom-background-80 rounded px-2.5 py-0.5"
>
<div className="flex items-center gap-1.5 !text-sm bg-custom-background-80 rounded px-2.5 py-0.5">
<PlayIcon
className={`h-4 w-4 -rotate-90 ${
value !== null ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
{estimatePoints?.find((e) => e.key === value)?.value ?? "No estimate"}
</button>
</div>
}
onChange={onChange}
disabled={disabled}

View File

@ -87,7 +87,6 @@ export const SidebarModuleSelect: React.FC<Props> = ({
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>

View File

@ -18,8 +18,7 @@ type Props = {
export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabled = false }) => (
<CustomSelect
customButton={
<button
type="button"
<div
className={`flex items-center gap-1.5 text-left text-xs capitalize rounded px-2.5 py-0.5 ${
value === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
@ -36,7 +35,7 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabl
<PriorityIcon priority={value} className="!text-sm" />
</span>
<span>{value ?? "None"}</span>
</button>
</div>
}
value={value}
onChange={onChange}

View File

@ -39,7 +39,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
return (
<CustomSelect
customButton={
<button type="button" className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
<div className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
{selectedState ? (
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
<StateGroupIcon stateGroup={selectedState.group} color={selectedState.color} />
@ -53,12 +53,11 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
) : (
"None"
)}
</button>
</div>
}
value={value}
onChange={onChange}
optionsClassName="w-min"
position="left"
disabled={disabled}
>
{states ? (

View File

@ -247,7 +247,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
</>
}
buttonClassName="whitespace-nowrap"
position="left"
noBorder
noChevron
>
@ -283,7 +282,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) =
</>
}
buttonClassName="whitespace-nowrap"
position="left"
noBorder
noChevron
>

View File

@ -113,10 +113,8 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
multiple
noChevron
position={position}
disabled={isNotAllowed}
onOpen={() => setFetchAssignees(true)}
selfPositioned={selfPositioned}
width="w-full min-w-[12rem]"
/>
);

View File

@ -74,8 +74,6 @@ export const ViewEstimateSelect: React.FC<Props> = ({
maxHeight="md"
noChevron
disabled={isNotAllowed}
position={position}
selfPositioned={selfPositioned}
width="w-full min-w-[8rem]"
>
<CustomSelect.Option value={null}>

View File

@ -146,9 +146,7 @@ export const ViewLabelSelect: React.FC<Props> = ({
{...(customButton ? { customButton: labelsLabel } : { label: labelsLabel })}
multiple
noChevron
position={position}
disabled={isNotAllowed}
selfPositioned={selfPositioned}
footerOption={footerOption}
width="w-full min-w-[12rem]"
/>

View File

@ -59,8 +59,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
}}
maxHeight="md"
customButton={
<button
type="button"
<div
className={`grid place-items-center rounded ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} ${noBorder ? "" : "h-6 w-6 border shadow-sm"} ${
@ -94,12 +93,10 @@ export const ViewPrioritySelect: React.FC<Props> = ({
{noBorder ? capitalizeFirstLetter(issue.priority ?? "None") : ""}
</span>
</Tooltip>
</button>
</div>
}
noChevron
disabled={isNotAllowed}
position={position}
selfPositioned={selfPositioned}
>
{PRIORITIES?.map((priority) => (
<CustomSelect.Option key={priority} value={priority} className="capitalize">

View File

@ -65,7 +65,6 @@ export const SidebarLeadSelect: React.FC<Props> = ({ value, onChange }) => {
}
options={options}
maxHeight="md"
position="right"
onChange={onChange}
/>
</div>

View File

@ -64,7 +64,6 @@ export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
options={options}
onChange={onChange}
maxHeight="md"
position="right"
multiple
/>
</div>

View File

@ -214,12 +214,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
e.stopPropagation();
}}
customButton={
<button
type="button"
className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded"
>
<div className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded">
<Icon iconName="schedule" className="h-5 w-5 text-custom-text-300" />
</button>
</div>
}
optionsClassName="!z-20"
>

View File

@ -167,7 +167,6 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
}
input
width="w-full"
verticalPosition="top"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
@ -197,7 +196,6 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>

View File

@ -384,12 +384,12 @@ export const SinglePageBlock: React.FC<Props> = ({
</button>
<CustomMenu
customButton={
<button
<div
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded px-2.5 py-1 text-left text-xs duration-300 hover:bg-custom-background-90"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<BoltIcon className="h-4.5 w-3.5" />
</button>
</div>
}
>
{block.issue ? (

View File

@ -418,7 +418,6 @@ export const CreateProjectModal: React.FC<Props> = ({
)}
</div>
}
verticalPosition="top"
noChevron
/>
);

View File

@ -1,13 +1,13 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// react-popper
import { usePopper } from "react-popper";
// services
import issuesService from "services/issues.service";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
// headless ui
import { Combobox } from "@headlessui/react";
// component
@ -19,6 +19,7 @@ import { PlusIcon } from "lucide-react";
// types
import { Tooltip } from "components/ui";
import { ICurrentUserResponse, IIssueLabels } from "types";
import { Placement } from "@popperjs/core";
// constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
@ -31,6 +32,7 @@ type Props = {
buttonClassName?: string;
optionsClassName?: string;
maxRender?: number;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
user: ICurrentUserResponse | undefined;
@ -45,21 +47,25 @@ export const LabelSelect: React.FC<Props> = ({
buttonClassName = "",
optionsClassName = "",
maxRender = 2,
placement,
hideDropdownArrow = false,
disabled = false,
user,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [fetchStates, setFetchStates] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [labelModal, setLabelModal] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const { data: issueLabels } = useSWR<IIssueLabels[]>(
projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId) : null,
@ -131,8 +137,6 @@ export const LabelSelect: React.FC<Props> = ({
</div>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
const footerOption = (
<button
type="button"
@ -165,33 +169,35 @@ export const LabelSelect: React.FC<Props> = ({
multiple
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
setFetchStates(true);
} else if (isOpen) setIsOpen(false);
if (open) setFetchStates(true);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
@ -234,8 +240,8 @@ export const LabelSelect: React.FC<Props> = ({
)}
</div>
{footerOption}
</Combobox.Options>
</div>
</div>
</Combobox.Options>
</>
);
}}

View File

@ -77,7 +77,6 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange, isDisabled = fa
]
}
maxHeight="md"
position="right"
width="w-full"
onChange={onChange}
disabled={isDisabled}

View File

@ -1,9 +1,10 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
// react-popper
import { usePopper } from "react-popper";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
import useProjectMembers from "hooks/use-project-members";
import useWorkspaceMembers from "hooks/use-workspace-members";
// headless ui
@ -15,6 +16,7 @@ import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { IUser } from "types";
import { Placement } from "@popperjs/core";
type Props = {
value: string | string[];
@ -25,6 +27,7 @@ type Props = {
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
@ -38,18 +41,22 @@ export const MembersSelect: React.FC<Props> = ({
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [fetchStates, setFetchStates] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const { members } = useProjectMembers(
workspaceSlug?.toString(),
@ -105,8 +112,6 @@ export const MembersSelect: React.FC<Props> = ({
</Tooltip>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
return (
<Combobox
as="div"
@ -117,31 +122,32 @@ export const MembersSelect: React.FC<Props> = ({
multiple
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
setFetchStates(true);
} else if (isOpen) setIsOpen(false);
if (open) setFetchStates(true);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
@ -183,8 +189,8 @@ export const MembersSelect: React.FC<Props> = ({
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</Combobox.Options>
</div>
</div>
</Combobox.Options>
</>
);
}}

View File

@ -1,7 +1,7 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
// react-popper
import { usePopper } from "react-popper";
// headless ui
import { Combobox } from "@headlessui/react";
// icons
@ -12,6 +12,7 @@ import { PriorityIcon } from "components/icons";
import { Tooltip } from "components/ui";
// types
import { TIssuePriorities } from "types";
import { Placement } from "@popperjs/core";
// constants
import { PRIORITIES } from "constants/project";
@ -21,6 +22,7 @@ type Props = {
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
@ -31,14 +33,18 @@ export const PrioritySelect: React.FC<Props> = ({
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const options = PRIORITIES?.map((priority) => ({
value: priority,
@ -87,8 +93,6 @@ export const PrioritySelect: React.FC<Props> = ({
</Tooltip>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
return (
<Combobox
as="div"
@ -97,77 +101,71 @@ export const PrioritySelect: React.FC<Props> = ({
onChange={onChange}
disabled={disabled}
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
} else if (isOpen) setIsOpen(false);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active && !selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</Combobox.Options>
</div>
</>
);
}}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active && !selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <CheckIcon className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};

View File

@ -230,7 +230,6 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
onChange(val);
}}
options={options}
position="left"
width="w-full min-w-[12rem]"
/>
)}
@ -252,12 +251,12 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
<CustomSelect
{...field}
customButton={
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 shadow-sm duration-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 focus:outline-none px-3 py-2.5 text-sm text-left">
<div className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 shadow-sm duration-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 focus:outline-none px-3 py-2.5 text-sm text-left">
<span className="capitalize">
{field.value ? ROLE[field.value] : "Select role"}
</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
</div>
}
input
width="w-full"

View File

@ -1,11 +1,11 @@
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
// react-popper
import { usePopper } from "react-popper";
// services
import stateService from "services/state.service";
// headless ui
@ -16,6 +16,7 @@ import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons";
// types
import { Tooltip } from "components/ui";
import { Placement } from "@popperjs/core";
// constants
import { IState } from "types";
import { STATES_LIST } from "constants/fetch-keys";
@ -29,6 +30,7 @@ type Props = {
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
@ -40,17 +42,21 @@ export const StateSelect: React.FC<Props> = ({
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
}) => {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const dropdownBtn = useRef<any>(null);
const dropdownOptions = useRef<any>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [fetchStates, setFetchStates] = useState<boolean>(false);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const router = useRouter();
const { workspaceSlug } = router.query;
@ -90,8 +96,6 @@ export const StateSelect: React.FC<Props> = ({
</Tooltip>
);
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
return (
<Combobox
as="div"
@ -103,31 +107,32 @@ export const StateSelect: React.FC<Props> = ({
disabled={disabled}
>
{({ open }: { open: boolean }) => {
if (open) {
if (!isOpen) setIsOpen(true);
setFetchStates(true);
} else if (isOpen) setIsOpen(false);
if (open) setFetchStates(true);
return (
<>
<Combobox.Button
ref={dropdownBtn}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
<Combobox.Options
ref={dropdownOptions}
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
@ -169,8 +174,8 @@ export const StateSelect: React.FC<Props> = ({
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</Combobox.Options>
</div>
</div>
</Combobox.Options>
</>
);
}}

View File

@ -1,9 +1,11 @@
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
// react-poppper
import { usePopper } from "react-popper";
// headless ui
import { Menu, Transition } from "@headlessui/react";
import { Menu } from "@headlessui/react";
// ui
import { DropdownProps } from "components/ui";
// icons
@ -20,6 +22,7 @@ export type CustomMenuProps = DropdownProps & {
const CustomMenu = ({
buttonClassName = "",
customButtonClassName = "",
placement,
children,
className = "",
customButton,
@ -30,100 +33,106 @@ const CustomMenu = ({
noBorder = false,
noChevron = false,
optionsClassName = "",
position = "right",
selfPositioned = false,
verticalEllipsis = false,
verticalPosition = "bottom",
width = "auto",
menuButtonOnClick,
}: CustomMenuProps) => (
<Menu as="div" className={`${selfPositioned ? "" : "relative"} w-min text-left ${className}`}>
{({ open }) => (
<>
{customButton ? (
<Menu.Button
as="button"
type="button"
onClick={menuButtonOnClick}
className={customButtonClassName}
disabled={disabled}
>
{customButton}
</Menu.Button>
) : (
<>
{ellipsis || verticalEllipsis ? (
<Menu.Button
}: CustomMenuProps) => {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
return (
<Menu as="div" className={`relative w-min text-left ${className}`}>
{({ open }) => (
<>
{customButton ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={menuButtonOnClick}
disabled={disabled}
className={`relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
className={customButtonClassName}
>
<MoreHorizOutlined
fontSize="small"
className={verticalEllipsis ? "rotate-90" : ""}
/>
</Menu.Button>
) : (
<Menu.Button
type="button"
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap duration-300 ${
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
} ${
noBorder ? "" : "border border-custom-border-300 shadow-sm focus:outline-none"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && (
<ExpandMoreOutlined
sx={{
fontSize: 14,
}}
aria-hidden="true"
/>
)}
</Menu.Button>
)}
</>
)}
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={`absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90 ${
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
>
<div className="py-1">{children}</div>
{customButton}
</button>
</Menu.Button>
) : (
<>
{ellipsis || verticalEllipsis ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={menuButtonOnClick}
disabled={disabled}
className={`relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none ${
disabled
? "cursor-not-allowed"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
<MoreHorizOutlined
fontSize="small"
className={verticalEllipsis ? "rotate-90" : ""}
/>
</button>
</Menu.Button>
) : (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap duration-300 ${
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
} ${
noBorder ? "" : "border border-custom-border-300 shadow-sm focus:outline-none"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && (
<ExpandMoreOutlined
sx={{
fontSize: 14,
}}
aria-hidden="true"
/>
)}
</button>
</Menu.Button>
)}
</>
)}
<Menu.Items>
<div
className={`z-10 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 p-1 text-xs shadow-custom-shadow-rg focus:outline-none bg-custom-background-90 my-1 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="py-1">{children}</div>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
);
</>
)}
</Menu>
);
};
type MenuItemProps = {
children: React.ReactNode;

View File

@ -1,7 +1,9 @@
import React, { useState } from "react";
// react-poppper
import { usePopper } from "react-popper";
// headless ui
import { Combobox, Transition } from "@headlessui/react";
import { Combobox } from "@headlessui/react";
// icons
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
@ -27,9 +29,11 @@ export type CustomSearchSelectProps = DropdownProps & {
);
export const CustomSearchSelect = ({
customButtonClassName = "",
buttonClassName = "",
className = "",
customButton,
placement,
disabled = false,
footerOption,
input = false,
@ -41,14 +45,18 @@ export const CustomSearchSelect = ({
options,
onOpen,
optionsClassName = "",
position = "left",
selfPositioned = false,
value,
verticalPosition = "bottom",
width = "auto",
}: CustomSearchSelectProps) => {
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
const filteredOptions =
query === ""
? options
@ -63,51 +71,54 @@ export const CustomSearchSelect = ({
if (multiple) props.multiple = true;
return (
<Combobox
as="div"
className={`${selfPositioned ? "" : "relative"} flex-shrink-0 text-left ${className}`}
{...props}
>
<Combobox as="div" className={`relative flex-shrink-0 text-left ${className}`} {...props}>
{({ open }: { open: boolean }) => {
if (open && onOpen) onOpen();
return (
<>
{customButton ? (
<Combobox.Button as="div">{customButton}</Combobox.Button>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
>
{customButton}
</button>
</Combobox.Button>
) : (
<Combobox.Button
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Combobox.Button>
)}
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options
className={`absolute z-10 min-w-[10rem] border border-custom-border-300 p-2 rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none ${
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
<Combobox.Options as={React.Fragment}>
<div
className={`z-10 min-w-[10rem] border border-custom-border-300 p-2 rounded-md bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none my-1 ${
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
} ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
<MagnifyingGlassIcon className="h-3 w-3 text-custom-text-200" />
@ -176,8 +187,8 @@ export const CustomSearchSelect = ({
)}
</div>
{footerOption}
</Combobox.Options>
</Transition>
</div>
</Combobox.Options>
</>
);
}}

View File

@ -1,7 +1,9 @@
import React from "react";
import React, { useState } from "react";
// react-popper
import { usePopper } from "react-popper";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
import { Listbox } from "@headlessui/react";
// icons
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { CheckIcon } from "@heroicons/react/24/outline";
@ -15,7 +17,9 @@ export type CustomSelectProps = DropdownProps & {
};
const CustomSelect = ({
customButtonClassName = "",
buttonClassName = "",
placement,
children,
className = "",
customButton,
@ -26,68 +30,83 @@ const CustomSelect = ({
noChevron = false,
onChange,
optionsClassName = "",
position = "left",
selfPositioned = false,
value,
verticalPosition = "bottom",
width = "auto",
}: CustomSelectProps) => (
<Listbox
as="div"
value={value}
onChange={onChange}
className={`${selfPositioned ? "" : "relative"} flex-shrink-0 text-left ${className}`}
disabled={disabled}
>
<>
{customButton ? (
<Listbox.Button as={React.Fragment}>{customButton}</Listbox.Button>
) : (
<Listbox.Button
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
</Listbox.Button>
)}
</>
}: CustomSelectProps) => {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
});
return (
<Listbox
as="div"
value={value}
onChange={onChange}
className={`relative flex-shrink-0 text-left ${className}`}
disabled={disabled}
>
<Listbox.Options
className={`absolute z-10 border border-custom-border-300 mt-1 origin-top-right overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none ${
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
>
<div className="space-y-1 p-2">{children}</div>
<>
{customButton ? (
<Listbox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
>
{customButton}
</button>
</Listbox.Button>
) : (
<Listbox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!noChevron && !disabled && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
</Listbox.Button>
)}
</>
<Listbox.Options>
<div
className={`z-10 border border-custom-border-300 overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-custom-shadow-rg focus:outline-none my-1 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
? "max-h-48"
: maxHeight === "rg"
? "max-h-36"
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="space-y-1 p-2">{children}</div>
</div>
</Listbox.Options>
</Transition>
</Listbox>
);
</Listbox>
);
};
type OptionProps = {
children: React.ReactNode;

View File

@ -1,4 +1,7 @@
import { Placement } from "@popperjs/core";
export type DropdownProps = {
customButtonClassName?: string;
buttonClassName?: string;
customButtonClassName?: string;
className?: string;
@ -10,8 +13,6 @@ export type DropdownProps = {
noChevron?: boolean;
onOpen?: () => void;
optionsClassName?: string;
position?: "right" | "left";
selfPositioned?: boolean;
verticalPosition?: "top" | "bottom";
width?: "auto" | string;
placement?: Placement;
};

View File

@ -41,9 +41,7 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => (
const UserLink = ({ activity }: { activity: IIssueActivity }) => (
<button
type="button"
onClick={() => {
console.log("user", activity.actor);
}}
onClick={() => console.log("user", activity.new_identifier ?? activity.old_identifier)}
className="font-medium text-custom-text-100 inline-flex items-center hover:underline"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
@ -109,9 +107,25 @@ const activityDetails: {
{activity.old_value === ""
? "marked this issue is blocking issue "
: "removed the blocking issue "}
<span className="font-medium text-custom-text-100">
<button
type="button"
onClick={() =>
console.log(
"issue",
JSON.stringify({
project_id: activity.project,
issue_id: activity.new_identifier ?? activity.old_identifier,
issue_identifier:
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value,
})
)
}
className="font-medium text-custom-text-100"
>
{activity.old_value === "" ? activity.new_value : activity.old_value}
</span>
</button>
.
</>
),
@ -124,9 +138,25 @@ const activityDetails: {
{activity.old_value === ""
? "marked this issue is being blocked by issue "
: "removed this issue being blocked by issue "}
<span className="font-medium text-custom-text-100">
<button
type="button"
onClick={() =>
console.log(
"issue",
JSON.stringify({
project_id: activity.project,
issue_id: activity.new_identifier ?? activity.old_identifier,
issue_identifier:
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value,
})
)
}
className="font-medium text-custom-text-100"
>
{activity.old_value === "" ? activity.new_value : activity.old_value}
</span>
</button>
.
</>
),
@ -139,9 +169,25 @@ const activityDetails: {
{activity.old_value === ""
? "marked this issue as duplicate of "
: "removed this issue as a duplicate of "}
<span className="font-medium text-custom-text-100">
<button
type="button"
onClick={() =>
console.log(
"issue",
JSON.stringify({
project_id: activity.project,
issue_id: activity.new_identifier ?? activity.old_identifier,
issue_identifier:
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value,
})
)
}
className="font-medium text-custom-text-100"
>
{activity.verb === "created" ? activity.new_value : activity.old_value}
</span>
</button>
.
</>
),
@ -154,9 +200,25 @@ const activityDetails: {
{activity.old_value === ""
? "marked that this issue relates to "
: "removed the relation from "}
<span className="font-medium text-custom-text-100">
<button
type="button"
onClick={() => {
console.log(
"issue",
JSON.stringify({
project_id: activity.project,
issue_id: activity.new_identifier ?? activity.old_identifier,
issue_identifier:
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value,
})
);
}}
className="font-medium text-custom-text-100"
>
{activity.old_value === "" ? activity.new_value : activity.old_value}
</span>
</button>
.
</>
),
@ -175,14 +237,17 @@ const activityDetails: {
console.log(
"cycle",
JSON.stringify({
cycle_id: activity.new_identifier,
cycle_id: activity.new_identifier ?? activity.old_identifier,
project_id: activity.project,
cycle_name: activity.verb === "created" ? activity.new_value : activity.old_value,
})
)
}
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.new_value}
{activity.verb === "created" || activity.verb === "updated"
? activity.new_value
: activity.old_value}
<Icon iconName="launch" className="!text-xs" />
</button>
</>
@ -295,16 +360,23 @@ const activityDetails: {
{activity.verb === "created" && "added this "}
{activity.verb === "updated" && "updated this "}
{activity.verb === "deleted" && "removed this "}
module{" "}
<button
onClick={() =>
console.log(
"module",
activity.verb === "created" ? activity.new_value : activity.old_value
JSON.stringify({
module_id: activity.new_identifier ?? activity.old_identifier,
project_id: activity.project,
module_name: activity.verb === "created" ? activity.new_value : activity.old_value,
})
)
}
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
module
{activity.verb === "created" || activity.verb === "updated"
? activity.new_value
: activity.old_value}
<Icon iconName="launch" className="!text-xs" />
</button>
.
@ -333,9 +405,25 @@ const activityDetails: {
message: (activity, showIssue) => (
<>
{activity.new_value ? "set the parent to " : "removed the parent "}
<span className="font-medium text-custom-text-100">
<button
type="button"
onClick={() => {
console.log(
"issue",
JSON.stringify({
project_id: activity.project,
issue_id: activity.new_identifier ?? activity.old_identifier,
issue_identifier:
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value,
})
);
}}
className="font-medium text-custom-text-100"
>
{activity.new_value ? activity.new_value : activity.old_value}
</span>
</button>
{showIssue && (
<>
{" "}

View File

@ -0,0 +1,215 @@
import React, { useEffect, useState } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
// icons
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
// hooks
import useUser from "hooks/use-user";
// ui
import { CustomMenu, Icon } from "components/ui";
import { CommentReaction } from "components/issues";
import { TipTapEditor } from "components/tiptap";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import type { IIssueComment } from "types";
type Props = {
comment: IIssueComment;
handleCommentDeletion: (comment: string) => void;
onSubmit: (commentId: string, data: Partial<IIssueComment>) => void;
showAccessSpecifier?: boolean;
workspaceSlug: string;
disabled?: boolean;
};
export const CommentCard: React.FC<Props> = (props) => {
const {
comment,
handleCommentDeletion,
onSubmit,
showAccessSpecifier = false,
workspaceSlug,
disabled,
} = props;
const { user } = useUser();
const editorRef = React.useRef<any>(null);
const showEditorRef = React.useRef<any>(null);
const [isEditing, setIsEditing] = useState(false);
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<IIssueComment>({
defaultValues: comment,
});
const onEnter = (formData: Partial<IIssueComment>) => {
if (isSubmitting) return;
setIsEditing(false);
onSubmit(comment.id, formData);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
};
useEffect(() => {
isEditing && setFocus("comment");
}, [isEditing, setFocus]);
return (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<img
src={comment.actor_detail.avatar}
alt={
comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name
}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{comment.actor_detail.is_bot
? comment.actor_detail.first_name.charAt(0)
: comment.actor_detail.display_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-custom-text-200"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
commented {timeAgo(comment.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<form
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
onSubmit={handleSubmit(onEnter)}
>
<div>
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={watch("comment_html")}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
setValue("comment_json", comment_json);
setValue("comment_html", comment_html);
}}
/>
</div>
<div className="flex gap-1 self-end">
<button
type="submit"
disabled={isSubmitting || disabled}
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
>
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
</button>
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => setIsEditing(false)}
>
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
<div className={`relative ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
<Icon
iconName={comment.access === "INTERNAL" ? "lock" : "public"}
className="!text-xs"
/>
</div>
)}
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
value={comment.comment_html}
editable={false}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
<CommentReaction
readonly={disabled}
projectId={comment.project}
commentId={comment.id}
/>
</div>
</div>
</div>
{user?.id === comment.actor && !disabled && (
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => setIsEditing(true)}
className="flex items-center gap-1"
>
<Icon iconName="edit" />
Edit comment
</CustomMenu.MenuItem>
{showAccessSpecifier && (
<>
{comment.access === "INTERNAL" ? (
<CustomMenu.MenuItem
renderAs="button"
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
className="flex items-center gap-1"
>
<Icon iconName="public" />
Switch to public comment
</CustomMenu.MenuItem>
) : (
<CustomMenu.MenuItem
renderAs="button"
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
className="flex items-center gap-1"
>
<Icon iconName="lock" />
Switch to private comment
</CustomMenu.MenuItem>
)}
</>
)}
<CustomMenu.MenuItem
onClick={() => {
handleCommentDeletion(comment.id);
}}
className="flex items-center gap-1"
>
<Icon iconName="delete" />
Delete comment
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
);
};

View File

@ -0,0 +1,30 @@
import { WebViewModal } from "components/web-view";
type DeleteConfirmationProps = {
isOpen: boolean;
title: string;
content: string | React.ReactNode;
onCancel: () => void;
onConfirm: () => void;
};
export const DeleteConfirmation: React.FC<DeleteConfirmationProps> = (props) => {
const { isOpen, onCancel, onConfirm, title, content } = props;
return (
<WebViewModal isOpen={isOpen} onClose={onCancel} modalTitle={title}>
<div className="text-custom-text-200">
<p>{content}</p>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={onConfirm}
className="w-full py-2 flex items-center justify-center rounded-[4px] bg-red-500/10 text-red-500 border border-red-500 text-base font-medium"
>
Delete
</button>
</div>
</WebViewModal>
);
};

View File

@ -0,0 +1,188 @@
// react
import React, { useState, useEffect } from "react";
// icons
import { ChevronDown } from "lucide-react";
// react date-picker
import DatePicker, { ReactDatePickerProps } from "react-datepicker";
// components
import { WebViewModal } from "./web-view-modal";
import { SecondaryButton, PrimaryButton } from "components/ui";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
interface Props extends ReactDatePickerProps {
value: string | undefined;
onChange: (value: any) => void;
disabled?: boolean;
renderAs?: "input" | "button";
error?: any;
noBorder?: boolean;
}
export const DateSelector: React.FC<Props> = (props) => {
const {
value,
onChange,
disabled = false,
renderAs = "button",
noBorder = true,
error,
className,
} = props;
const [isOpen, setIsOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
useEffect(() => {
if (value) setSelectedDate(new Date(value));
}, [value]);
useEffect(() => {
if (!isOpen) return;
if (value) setSelectedDate(new Date(value));
else setSelectedDate(new Date());
}, [isOpen, value]);
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select due-date"
onClose={() => {
setIsOpen(false);
}}
>
<div className="w-full flex justify-center items-center">
<DatePicker
inline
selected={selectedDate ? new Date(selectedDate) : null}
className={`${
renderAs === "input"
? "block px-2 py-2 text-sm focus:outline-none"
: renderAs === "button"
? `px-2 py-1 text-xs shadow-sm ${
disabled ? "" : "hover:bg-custom-background-80"
} duration-300`
: ""
} ${error ? "border-red-500 bg-red-100" : ""} ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
} ${
noBorder ? "" : "border border-custom-border-200"
} w-full rounded-md caret-transparent outline-none ${className}`}
dateFormat="MMM dd, yyyy"
{...props}
onChange={(val) => {
if (!val) setSelectedDate(null);
else setSelectedDate(val);
}}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex justify-between px-5 text-lg font-medium">
<h4>
{date.toLocaleString("default", { month: "long" })} {date.getFullYear()}
</h4>
<div>
<button
type="button"
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
className="text-custom-text-100"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.2285 14.5416L7.10352 10.4166C7.03407 10.3472 6.98546 10.2778 6.95768 10.2083C6.9299 10.1389 6.91602 10.0625 6.91602 9.97915C6.91602 9.89581 6.9299 9.81942 6.95768 9.74998C6.98546 9.68053 7.03407 9.61109 7.10352 9.54165L11.2493 5.39581C11.3743 5.27081 11.5237 5.20831 11.6973 5.20831C11.8709 5.20831 12.0202 5.27081 12.1452 5.39581C12.2702 5.52081 12.3292 5.67359 12.3223 5.85415C12.3153 6.0347 12.2493 6.18748 12.1243 6.31248L8.45768 9.97915L12.1452 13.6666C12.2702 13.7916 12.3327 13.9375 12.3327 14.1041C12.3327 14.2708 12.2702 14.4166 12.1452 14.5416C12.0202 14.6666 11.8674 14.7291 11.6868 14.7291C11.5063 14.7291 11.3535 14.6666 11.2285 14.5416Z"
fill="#171717"
/>
</svg>
</button>
<button
type="button"
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className="text-custom-text-100"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.37496 14.5417C7.26385 14.4028 7.20482 14.25 7.19788 14.0834C7.19093 13.9167 7.24996 13.7709 7.37496 13.6459L11.0416 9.97919L7.35413 6.29169C7.24302 6.18058 7.19093 6.03128 7.19788 5.84378C7.20482 5.65628 7.26385 5.50697 7.37496 5.39586C7.51385 5.25697 7.66316 5.191 7.82288 5.19794C7.9826 5.20489 8.12496 5.27086 8.24996 5.39586L12.3958 9.54169C12.4652 9.61114 12.5139 9.68058 12.5416 9.75003C12.5694 9.81947 12.5833 9.89586 12.5833 9.97919C12.5833 10.0625 12.5694 10.1389 12.5416 10.2084C12.5139 10.2778 12.4652 10.3473 12.3958 10.4167L8.27079 14.5417C8.14579 14.6667 7.99996 14.7257 7.83329 14.7188C7.66663 14.7118 7.51385 14.6528 7.37496 14.5417Z"
fill="#171717"
/>
</svg>
</button>
</div>
</div>
)}
/>
</div>
<WebViewModal.Footer className="flex items-center gap-2">
<SecondaryButton
type="button"
onClick={() => {
setIsOpen(false);
onChange(null);
setSelectedDate(null);
}}
className="w-full"
>
Clear
</SecondaryButton>
<PrimaryButton
onClick={() => {
if (!selectedDate) onChange(null);
else onChange(renderDateFormat(selectedDate));
setIsOpen(false);
}}
type="button"
className="w-full"
>
Apply
</PrimaryButton>
</WebViewModal.Footer>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-200"
}
>
{value ? (
<div className="-my-0.5 flex items-center gap-2">
{new Date(value).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</div>
) : (
"Due date"
)}
<ChevronDown className="w-4 h-4" />
</button>
</>
);
};

View File

@ -13,6 +13,15 @@ export * from "./select-assignee";
export * from "./select-estimate";
export * from "./add-comment";
export * from "./select-parent";
export * from "./select-blocker";
export * from "./select-blocked";
export * from "./select-blocker-to";
export * from "./select-blocked-by";
export * from "./activity-message";
export * from "./issues-select-bottom-sheet";
export * from "./select-relates-to";
export * from "./select-duplicate";
export * from "./spinner";
export * from "./select-module";
export * from "./select-cycle";
export * from "./confirm-delete";
export * from "./commend-card";
export * from "./date-selector";

View File

@ -17,8 +17,7 @@ import issuesService from "services/issues.service";
import useUser from "hooks/use-user";
// components
import { CommentCard } from "components/issues/comment";
import { Label, AddComment, ActivityMessage, ActivityIcon } from "components/web-view";
import { Label, AddComment, ActivityMessage, ActivityIcon, CommentCard } from "components/web-view";
// helpers
import { timeAgo } from "helpers/date-time.helper";
@ -54,23 +53,32 @@ export const IssueActivity: React.FC<Props> = (props) => {
: null
);
const handleCommentUpdate = async (comment: any) => {
if (!workspaceSlug || !projectId || !issueId) return;
const handleCommentUpdate = async (comment: any, formData: any) => {
if (!workspaceSlug || !projectId || !issueId || !allowed) return;
await issuesService
.patchIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
comment.id,
comment,
formData,
user
)
.then(() => mutateIssueActivity());
.then(() => mutateIssueActivity())
.catch(() =>
console.log(
"toast",
JSON.stringify({
type: "error",
message: "Comment could not be updated. Please try again.",
})
)
);
};
const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
if (!workspaceSlug || !projectId || !issueId || !allowed) return;
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
@ -82,11 +90,20 @@ export const IssueActivity: React.FC<Props> = (props) => {
commentId,
user
)
.then(() => mutateIssueActivity());
.then(() => mutateIssueActivity())
.catch(() =>
console.log(
"toast",
JSON.stringify({
type: "error",
message: "Comment could not be deleted. Please try again.",
})
)
);
};
const handleAddComment = async (formData: IIssueComment) => {
if (!workspaceSlug || !issueDetails) return;
if (!workspaceSlug || !issueDetails || !allowed) return;
await issuesService
.createIssueComment(
@ -104,7 +121,6 @@ export const IssueActivity: React.FC<Props> = (props) => {
"toast",
JSON.stringify({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
)
@ -114,7 +130,7 @@ export const IssueActivity: React.FC<Props> = (props) => {
return (
<div>
<Label>Activity</Label>
<div className="mt-1 space-y-[6px] p-2 border rounded-[4px]">
<div className="mt-1 space-y-[6px] p-2 border border-custom-border-200 rounded-[4px]">
<ul role="list" className="-mb-4">
{issueActivities?.map((activityItem, index) => {
// determines what type of action is performed
@ -208,23 +224,31 @@ export const IssueActivity: React.FC<Props> = (props) => {
comment={activityItem as any}
onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete}
disabled={
!allowed ||
!issueDetails ||
issueDetails.state === "closed" ||
issueDetails.state === "archived"
}
/>
</div>
);
})}
<li>
<div className="my-4">
<AddComment
onSubmit={handleAddComment}
disabled={
!allowed ||
!issueDetails ||
issueDetails.state === "closed" ||
issueDetails.state === "archived"
}
/>
</div>
</li>
{allowed && (
<li>
<div className="my-4">
<AddComment
onSubmit={handleAddComment}
disabled={
!allowed ||
!issueDetails ||
issueDetails.state === "closed" ||
issueDetails.state === "archived"
}
/>
</div>
</li>
)}
</ul>
</div>
</div>

View File

@ -21,8 +21,10 @@ import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys
import { FileText, ChevronRight, X, Image as ImageIcon } from "lucide-react";
// components
import { Label, WebViewModal } from "components/web-view";
import { DeleteAttachmentModal } from "components/issues";
import { Label, WebViewModal, DeleteConfirmation } from "components/web-view";
// helpers
import { getFileName } from "helpers/attachment.helper";
// types
import type { IIssueAttachment } from "types";
@ -47,7 +49,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
if (!acceptedFiles[0] || !workspaceSlug || !allowed) return;
const formData = new FormData();
formData.append("asset", acceptedFiles[0]);
@ -97,9 +99,37 @@ export const IssueAttachments: React.FC<Props> = (props) => {
);
});
},
[issueId, projectId, workspaceSlug]
[issueId, projectId, workspaceSlug, allowed]
);
const handleDeletion = async (assetId: string) => {
if (!workspaceSlug || !projectId) return;
mutate<IIssueAttachment[]>(
ISSUE_ATTACHMENTS(issueId as string),
(prevData) => (prevData ?? [])?.filter((p) => p.id !== assetId),
false
);
await issuesService
.deleteIssueAttachment(
workspaceSlug as string,
projectId as string,
issueId as string,
assetId as string
)
.then(() => mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)))
.catch(() => {
console.log(
"toast",
JSON.stringify({
type: "error",
message: "Something went wrong please try again.",
})
);
});
};
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: 5 * 1024 * 1024,
@ -120,10 +150,24 @@ export const IssueAttachments: React.FC<Props> = (props) => {
return (
<div>
<DeleteAttachmentModal
<DeleteConfirmation
title="Delete Attachment"
content={
<p className="text-sm text-custom-text-200">
Are you sure you want to delete attachment-{" "}
<span className="font-bold">
{getFileName(deleteAttachment?.attributes?.name ?? "")}
</span>
? This attachment will be permanently removed. This action cannot be undone.
</p>
}
isOpen={allowed && attachmentDeleteModal}
setIsOpen={setAttachmentDeleteModal}
data={deleteAttachment}
onCancel={() => setAttachmentDeleteModal(false)}
onConfirm={() => {
if (!deleteAttachment) return;
handleDeletion(deleteAttachment.id);
setAttachmentDeleteModal(false);
}}
/>
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file">
@ -179,6 +223,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
))}
<button
type="button"
disabled={!allowed}
onClick={() => setIsOpen(true)}
className="bg-custom-primary-100/10 border border-dotted rounded-[4px] border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
>

View File

@ -16,7 +16,7 @@ import issuesService from "services/issues.service";
import { Link as LinkIcon, Plus, Pencil, X } from "lucide-react";
// components
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
import { Label, WebViewModal, CreateUpdateLinkForm, DeleteConfirmation } from "components/web-view";
// ui
import { SecondaryButton } from "components/ui";
@ -39,12 +39,13 @@ export const IssueLinks: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedLink, setSelectedLink] = useState<string | null>(null);
const [deleteSelected, setDeleteSelected] = useState<string | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
if (!workspaceSlug || !projectId || !issueDetails || !allowed) return;
const updatedLinks = issueDetails.issue_link.filter((l) => l.id !== linkId);
@ -56,7 +57,7 @@ export const IssueLinks: React.FC<Props> = (props) => {
await issuesService
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetails.id, linkId)
.then((res) => {
.then(() => {
mutate(ISSUE_DETAILS(issueDetails.id));
})
.catch((err) => {
@ -85,6 +86,18 @@ export const IssueLinks: React.FC<Props> = (props) => {
/>
</WebViewModal>
<DeleteConfirmation
title="Delete Link"
content="Are you sure you want to delete this link?"
isOpen={!!deleteSelected}
onCancel={() => setDeleteSelected(null)}
onConfirm={() => {
if (!deleteSelected || !allowed) return;
handleDeleteLink(deleteSelected);
setDeleteSelected(null);
}}
/>
<Label>Links</Label>
<div className="mt-1 space-y-[6px]">
{links?.map((link) => (
@ -92,14 +105,18 @@ export const IssueLinks: React.FC<Props> = (props) => {
key={link.id}
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
>
<Link href={link.url}>
<a target="_blank" className="text-custom-text-200 truncate">
<span>
<LinkIcon className="w-4 h-4 inline-block mr-1" />
</span>
<span>{link.title}</span>
</a>
</Link>
<button
type="button"
onClick={() => {
console.log("link", link.url);
}}
className="text-custom-text-200 truncate"
>
<span>
<LinkIcon className="w-4 h-4 inline-block mr-1" />
</span>
<span>{link.title || link.metadata?.title || link.url}</span>
</button>
{allowed && (
<div className="flex gap-2 items-center">
<button
@ -114,7 +131,7 @@ export const IssueLinks: React.FC<Props> = (props) => {
<button
type="button"
onClick={() => {
handleDeleteLink(link.id);
setDeleteSelected(link.id);
}}
>
<X className="w-[18px] h-[18px] text-custom-text-400" />

View File

@ -8,26 +8,35 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// react hook forms
import { Control, Controller, useWatch } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
// icons
import { BlockedIcon, BlockerIcon } from "components/icons";
import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react";
// hooks
import { useProjectMyMembership } from "contexts/project-member.context";
import useEstimateOption from "hooks/use-estimate-option";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// icons
import {
ChevronDown,
PlayIcon,
User,
X,
CalendarDays,
LayoutGrid,
Users,
CopyPlus,
} from "lucide-react";
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon, RelatedIcon, ContrastIcon } from "components/icons";
// ui
import { SecondaryButton, CustomDatePicker } from "components/ui";
import { SecondaryButton } from "components/ui";
// components
import {
@ -38,63 +47,72 @@ import {
EstimateSelect,
ParentSelect,
BlockerSelect,
BlockedSelect,
BlockedBySelect,
RelatesSelect,
DuplicateSelect,
ModuleSelect,
CycleSelect,
DateSelector,
} from "components/web-view";
// types
import type { IIssue } from "types";
type Props = {
control: Control<IIssue, any>;
submitChanges: (data: Partial<IIssue>) => Promise<void>;
};
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
const { control, submitChanges } = props;
const { submitChanges } = props;
const blockerIssue =
useWatch({
control,
name: "issue_relations",
})?.filter((i) => i.relation_type === "blocked_by") || [];
const { watch, control } = useFormContext<IIssue>();
const blockedIssue = useWatch({
control,
name: "related_issues",
})?.filter((i) => i.relation_type === "blocked_by");
const blockerIssues =
watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || [];
const startDate = useWatch({
control,
name: "start_date",
});
const blockedByIssues = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
const relatedToIssueRelation = [
...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []),
...(watch("issue_relations") ?? [])
?.filter((i) => i.relation_type === "relates_to")
.map((i) => ({
...i,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
const duplicateIssuesRelation = [
...(watch("related_issues")?.filter((i) => i.relation_type === "duplicate") ?? []),
...(watch("issue_relations") ?? [])
?.filter((i) => i.relation_type === "duplicate")
.map((i) => ({
...i,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
const startDate = watch("start_date");
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const isArchive = Boolean(router.query.archive);
const { memberRole } = useProjectMyMembership();
const { user } = useUser();
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
const { isEstimateActive } = useEstimateOption();
const handleMutation = (data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
};
return (
<div>
<div className="space-y-[6px]">
<Label>Details</Label>
<div className="mb-[6px]">
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<LayoutGrid className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
@ -107,6 +125,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
render={({ field: { value } }) => (
<StateSelect
value={value}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val: string) => submitChanges({ state: val })}
/>
)}
@ -114,9 +133,9 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
</div>
<div className="mb-[6px]">
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 text-custom-text-400">
<svg
width="18"
height="18"
@ -126,7 +145,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
>
<path
d="M13.5862 14.5239C13.3459 14.5239 13.1416 14.4398 12.9733 14.2715C12.805 14.1032 12.7209 13.8989 12.7209 13.6585V3.76429C12.7209 3.52391 12.805 3.31958 12.9733 3.15132C13.1416 2.98306 13.3459 2.89893 13.5862 2.89893C13.8266 2.89893 14.031 2.98306 14.1992 3.15132C14.3675 3.31958 14.4516 3.52391 14.4516 3.76429V13.6585C14.4516 13.8989 14.3675 14.1032 14.1992 14.2715C14.031 14.4398 13.8266 14.5239 13.5862 14.5239ZM5.1629 14.5239C5.04676 14.5239 4.93557 14.5018 4.82932 14.4576C4.72308 14.4133 4.63006 14.3513 4.55025 14.2715C4.47045 14.1917 4.40843 14.0986 4.36419 13.9922C4.31996 13.8858 4.29785 13.7746 4.29785 13.6585V11.2643C4.29785 11.0239 4.38198 10.8196 4.55025 10.6513C4.71851 10.4831 4.92283 10.3989 5.16322 10.3989C5.40359 10.3989 5.60791 10.4831 5.77618 10.6513C5.94445 10.8196 6.02859 11.0239 6.02859 11.2643V13.6585C6.02859 13.7746 6.00647 13.8858 5.96223 13.9922C5.91801 14.0986 5.85599 14.1917 5.77618 14.2715C5.69638 14.3513 5.60325 14.4133 5.49678 14.4576C5.39033 14.5018 5.27904 14.5239 5.1629 14.5239ZM9.37473 14.5239C9.13436 14.5239 8.93003 14.4398 8.76176 14.2715C8.59349 14.1032 8.50936 13.8989 8.50936 13.6585V7.5143C8.50936 7.27391 8.59349 7.06958 8.76176 6.90132C8.93003 6.73306 9.13436 6.64893 9.37473 6.64893C9.61511 6.64893 9.81943 6.73306 9.98771 6.90132C10.156 7.06958 10.2401 7.27391 10.2401 7.5143V13.6585C10.2401 13.8989 10.156 14.1032 9.98771 14.2715C9.81943 14.4398 9.61511 14.5239 9.37473 14.5239Z"
fill="#A3A3A3"
fill="currentColor"
/>
</svg>
@ -139,6 +158,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
render={({ field: { value } }) => (
<PrioritySelect
value={value}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val) => submitChanges({ priority: val })}
/>
)}
@ -146,7 +166,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
</div>
<div className="mb-[6px]">
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Users className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
@ -159,7 +179,14 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
render={({ field: { value } }) => (
<AssigneeSelect
value={value}
onChange={(val: string) => submitChanges({ assignees_list: [val] })}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val: string) => {
const assignees = value?.includes(val)
? value?.filter((i) => i !== val)
: [...(value ?? []), val];
submitChanges({ assignees_list: assignees });
}}
/>
)}
/>
@ -169,7 +196,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
{isViewAllOpen && (
<>
{isEstimateActive && (
<div className="mb-[6px]">
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90 text-custom-text-400" />
@ -182,6 +209,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
render={({ field: { value } }) => (
<EstimateSelect
value={value}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val) => submitChanges({ estimate_point: val })}
/>
)}
@ -190,7 +218,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
)}
<div className="mb-[6px]">
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<User className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
@ -203,6 +231,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
render={({ field: { value } }) => (
<ParentSelect
value={value}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val) => submitChanges({ parent: val })}
/>
)}
@ -210,7 +239,9 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
</div>
<div className="mb-[6px]">
{/* blocker to / blocking */}
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
@ -219,89 +250,62 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
<div>
<BlockerSelect
value={null}
onChange={(val) => {
if (!user || !workspaceSlug || !projectId || !issueId) return;
issuesService
.createIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
user,
{
related_list: [
...val.map((issue: any) => ({
issue: issue.blocker_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
})),
],
}
)
.then((response) => {
handleMutation({
issue_relations: [
...blockerIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue: i.related_issue,
})),
],
});
});
}}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
{blockerIssue &&
blockerIssue.map((issue) => (
{blockerIssues &&
blockerIssues.map((relation) => (
<div
key={issue.issue_detail?.id}
key={relation.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
<span
onClick={() =>
console.log(
"issue",
JSON.stringify({
issue_id: relation.issue_detail?.id,
project_id: relation.issue_detail?.project_detail.id,
})
)
}
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${issue.issue_detail?.project_detail.identifier}-${issue.issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlockers = blockerIssue.filter(
(i) => i.issue_detail?.id !== issue.issue_detail?.id
);
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</span>
{!isArchive && (
<button
type="button"
className="duration-300"
onClick={() => {
if (memberRole.isGuest || memberRole.isViewer || !user) return;
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
issue.id,
user
);
handleMutation({
issue_relations: updatedBlockers,
});
}}
>
<X className="h-2 w-2" />
</button>
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
relation.issue,
relation.id,
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
}}
>
<X className="h-2 w-2" />
</button>
)}
</div>
))}
</div>
</div>
<div className="mb-[6px]">
{/* blocked by */}
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
@ -309,89 +313,190 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<span className="text-sm text-custom-text-400">Blocked by</span>
</div>
<div>
<BlockedSelect
value={null}
onChange={(val) => {
if (!user || !workspaceSlug || !projectId || !issueId) return;
issuesService
.createIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
user,
{
related_list: [
...val.map((issue: any) => ({
issue: issue.blocked_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocked_issue_detail,
})),
],
}
)
.then((response) => {
handleMutation({
related_issues: [
...blockedIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue: i.related_issue,
})),
],
});
});
}}
<BlockedBySelect
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
{blockedIssue &&
blockedIssue.map((issue) => (
{blockedByIssues &&
blockedByIssues.map((relation) => (
<div
key={issue.issue_detail?.id}
key={relation.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<a
href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
<span
onClick={() =>
console.log(
"issue",
JSON.stringify({
issue_id: relation.issue_detail?.id,
project_id: relation.issue_detail?.project_detail.id,
})
)
}
className="flex items-center gap-1"
>
<BlockedIcon height={10} width={10} />
{`${issue?.issue_detail?.project_detail?.identifier}-${issue?.issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlocked = blockedIssue.filter((i) => i?.id !== issue?.id);
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
</span>
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
<button
type="button"
className="duration-300"
onClick={() => {
if (memberRole.isGuest || memberRole.isViewer || !user) return;
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
issue.id,
user
);
handleMutation({
related_issues: updatedBlocked,
});
}}
>
<X className="h-2 w-2" />
</button>
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
}}
>
<X className="h-2 w-2" />
</button>
)}
</div>
))}
</div>
</div>
<div className="mb-[6px]">
{/* duplicate */}
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<CopyPlus height={16} width={16} className="text-custom-text-400" />
<span className="text-sm text-custom-text-400">Duplicate</span>
</div>
<div>
<DuplicateSelect
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
{duplicateIssuesRelation &&
duplicateIssuesRelation.map((relation) => (
<div
key={relation.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<span
onClick={() =>
console.log(
"issue",
JSON.stringify({
issue_id: relation.issue_detail?.id,
project_id: relation.issue_detail?.project_detail.id,
})
)
}
className="flex items-center gap-1"
>
<CopyPlus height={10} width={10} />
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
</span>
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
<button
type="button"
className="duration-300"
onClick={() => {
if (memberRole.isGuest || memberRole.isViewer || !user) return;
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
}}
>
<X className="h-2 w-2" />
</button>
)}
</div>
))}
</div>
</div>
{/* relates to */}
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<RelatedIcon height={16} width={16} color="rgb(var(--color-text-400))" />
<span className="text-sm text-custom-text-400">Relates To</span>
</div>
<div>
<RelatesSelect
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
{relatedToIssueRelation &&
relatedToIssueRelation.map((relation) => (
<div
key={relation.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<span
onClick={() =>
console.log(
"issue",
JSON.stringify({
issue_id: relation.issue_detail?.id,
project_id: relation.issue_detail?.project_detail.id,
})
)
}
className="flex items-center gap-1"
>
<RelatedIcon height={10} width={10} />
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
</span>
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
<button
type="button"
className="duration-300"
onClick={() => {
if (memberRole.isGuest || memberRole.isViewer || !user) return;
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
}}
>
<X className="h-2 w-2" />
</button>
)}
</div>
))}
</div>
</div>
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
@ -403,10 +508,11 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
control={control}
name="target_date"
render={({ field: { value } }) => (
<CustomDatePicker
placeholder="Due date"
value={value}
<DateSelector
placeholderText="Due date"
value={value ?? undefined}
wrapperClassName="w-full"
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
onChange={(val) =>
submitChanges({
target_date: val,
@ -421,9 +527,46 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
</div>
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
<span className="text-sm text-custom-text-400">Module</span>
</div>
<div>
<ModuleSelect
value={watch("issue_module")}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
</div>
</div>
<div>
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<ContrastIcon
color="rgba(var(--color-text-400))"
className="h-4 w-4 flex-shrink-0"
/>
<span className="text-sm text-custom-text-400">Cycle</span>
</div>
<div>
<CycleSelect
value={watch("issue_cycle")}
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
/>
</div>
</div>
</div>
</div>
</>
)}
<div className="mb-[6px]">
<div>
<SecondaryButton
type="button"
onClick={() => setIsViewAllOpen((prev) => !prev)}

View File

@ -138,6 +138,7 @@ export const IssueWebViewForm: React.FC<Props> = (props) => {
}
noBorder={!isAllowed}
onChange={(description: Object, description_html: string) => {
if (!isAllowed) return;
setShowAlert(true);
setIsSubmitting("submitting");
onChange(description_html);

View File

@ -0,0 +1,188 @@
// react
import React, { useState, useEffect } from "react";
// next
import { useRouter } from "next/router";
// hooks
import useUser from "hooks/use-user";
import useDebounce from "hooks/use-debounce";
// services
import projectService from "services/project.service";
// components
import { WebViewModal } from "components/web-view";
import { LayerDiagonalIcon } from "components/icons";
import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// types
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types";
type IssuesSelectBottomSheetProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
searchParams: Partial<TProjectIssuesSearchParams>;
singleSelect?: boolean;
};
export const IssuesSelectBottomSheet: React.FC<IssuesSelectBottomSheetProps> = (props) => {
const { isOpen, onClose, onSubmit, searchParams, singleSelect = false } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const { user } = useUser();
const handleClose = () => {
onClose();
setSearchTerm("");
setSelectedIssues([]);
setIsWorkspaceLevel(false);
};
const handleSelect = async (data: ISearchIssueResponse[]) => {
if (!user || !workspaceSlug || !projectId || !issueId) return;
setIsSubmitting(true);
await onSubmit(data).finally(() => {
setIsSubmitting(false);
});
handleClose();
console.log(
"toast",
JSON.stringify({
type: "success",
message: `Issue${data.length > 1 ? "s" : ""} added successfully.`,
})
);
};
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId || !issueId) return;
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
...searchParams,
issue_id: issueId.toString(),
workspace_search: isWorkspaceLevel,
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [
debouncedSearchTerm,
isOpen,
isWorkspaceLevel,
issueId,
projectId,
workspaceSlug,
searchParams,
]);
return (
<WebViewModal isOpen={isOpen} onClose={handleClose} modalTitle="Select issue">
{!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">C</pre>.
</h3>
</div>
)}
<div
className={`flex-shrink-0 flex items-center gap-1 text-xs pb-3 cursor-pointer ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
Workspace Level
</button>
</div>
{isSearching && (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
{!isSearching && (
<WebViewModal.Options
options={issues.map((issue) => ({
value: issue.id,
label: (
<div className="flex items-center gap-2">
<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>
),
checked: selectedIssues.some((i) => i.id === issue.id),
onClick() {
if (singleSelect) {
handleSelect([issue]);
handleClose();
return;
}
if (selectedIssues.some((i) => i.id === issue.id)) {
setSelectedIssues(selectedIssues.filter((i) => i.id !== issue.id));
} else {
setSelectedIssues([...selectedIssues, issue]);
}
},
}))}
/>
)}
{selectedIssues.length > 0 && (
<WebViewModal.Footer className="flex items-center justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={() => {
handleSelect(selectedIssues);
}}
loading={isSubmitting}
>
{isSubmitting ? "Adding..." : "Add selected issues"}
</PrimaryButton>
</WebViewModal.Footer>
)}
</WebViewModal>
);
};

View File

@ -47,7 +47,7 @@ export const AssigneeSelect: React.FC<Props> = (props) => {
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select state"
modalTitle="Select assignees"
onClose={() => {
setIsOpen(false);
}}
@ -74,20 +74,20 @@ export const AssigneeSelect: React.FC<Props> = (props) => {
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2">
<Avatar user={selectedAssignees?.[0].member} />
<span className="text-custom-text-100 text-xs">
<span className="text-custom-text-200 text-xs">
{selectedAssignees?.length} Assignees
</span>
</div>
) : (
"No assignees"
<span className="text-custom-text-200">No assignees</span>
)}
<ChevronDown className="w-5 h-5" />
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);

View File

@ -0,0 +1,127 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { useFormContext } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { IssuesSelectBottomSheet } from "components/web-view";
// types
import type { IIssue, BlockeIssueDetail, ISearchIssueResponse } from "types";
type Props = {
disabled?: boolean;
};
export const BlockedBySelect: React.FC<Props> = (props) => {
const { disabled = false } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { watch } = useFormContext<IIssue>();
const { user } = useUser();
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !issueId || !user || disabled) return;
if (data.length === 0)
return console.log(
"toast",
JSON.stringify({
type: "error",
message: "Please select at least one issue.",
})
);
const selectedIssues: { blocked_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocked_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
const relatedIssues = watch("related_issues");
await issuesService
.createIssueRelation(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
user,
{
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
relation_type: "blocked_by" as const,
issue_detail: issue.blocked_issue_detail,
related_issue: issue.blocked_issue_detail.id,
})),
],
}
)
.then((response) => {
mutate(ISSUE_DETAILS(issueId as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
related_issues: [...relatedIssues, ...response],
};
});
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
setIsBlockedModalOpen(false);
};
return (
<>
<IssuesSelectBottomSheet
isOpen={isBlockedModalOpen}
onSubmit={onSubmit}
onClose={() => setIsBlockedModalOpen(false)}
searchParams={{ issue_relation: true }}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBlockedModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -1,87 +0,0 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// icons
import { ChevronDown } from "lucide-react";
// components
import { ExistingIssuesListModal } from "components/core";
// types
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const BlockedSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const router = useRouter();
const { issueId } = router.query;
const { setToastAlert } = useToast();
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
onChange([...(value || []), ...selectedIssues]);
setIsBlockedModalOpen(false);
};
return (
<>
<ExistingIssuesListModal
isOpen={isBlockedModalOpen}
handleClose={() => setIsBlockedModalOpen(false)}
searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBlockedModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -0,0 +1,133 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { useFormContext } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { IssuesSelectBottomSheet } from "components/web-view";
// types
import { BlockeIssueDetail, ISearchIssueResponse, IIssue } from "types";
type Props = {
disabled?: boolean;
};
export const BlockerSelect: React.FC<Props> = (props) => {
const { disabled = false } = props;
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { watch } = useFormContext<IIssue>();
const { user } = useUser();
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (disabled) return;
if (data.length === 0)
return console.log(
"toast",
JSON.stringify({
type: "error",
message: "Please select at least one issue.",
})
);
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
if (!workspaceSlug || !projectId || !issueId || !user) return;
const blockerIssue =
watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || [];
issuesService
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
related_list: [
...selectedIssues.map((issue) => ({
issue: issue.blocker_issue_detail.id,
relation_type: "blocked_by" as const,
related_issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
})),
],
relation: "blocking",
})
.then((response) => {
mutate(ISSUE_DETAILS(issueId as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
issue_relations: [
...blockerIssue,
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.issue_detail,
issue: i.related_issue,
})),
],
};
});
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
setIsBlockerModalOpen(false);
};
return (
<>
<IssuesSelectBottomSheet
isOpen={isBlockerModalOpen}
onClose={() => setIsBlockerModalOpen(false)}
onSubmit={onSubmit}
searchParams={{ issue_relation: true }}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBlockerModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -1,87 +0,0 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// icons
import { ChevronDown } from "lucide-react";
// components
import { ExistingIssuesListModal } from "components/core";
// types
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const BlockerSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const router = useRouter();
const { issueId } = router.query;
const { setToastAlert } = useToast();
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
onChange([...(value || []), ...selectedIssues]);
setIsBlockerModalOpen(false);
};
return (
<>
<ExistingIssuesListModal
isOpen={isBlockerModalOpen}
handleClose={() => setIsBlockerModalOpen(false)}
searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBlockerModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -0,0 +1,152 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// services
import issuesService from "services/issues.service";
import cyclesService from "services/cycles.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import {
ISSUE_DETAILS,
INCOMPLETE_CYCLES_LIST,
CYCLE_ISSUES,
PROJECT_ISSUES_ACTIVITY,
} from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { WebViewModal } from "components/web-view";
// types
import { ICycle, IIssueCycle } from "types";
type Props = {
disabled?: boolean;
value?: IIssueCycle | null;
};
export const CycleSelect: React.FC<Props> = (props) => {
const { disabled = false, value } = props;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: incompleteCycles } = useSWR(
workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () =>
cyclesService.getCyclesWithParams(
workspaceSlug as string,
projectId as string,
"incomplete"
)
: null
);
const { user } = useUser();
const handleCycleChange = (cycleDetails: ICycle) => {
if (!workspaceSlug || !projectId || !issueId || disabled) return;
issuesService
.addIssueToCycle(
workspaceSlug as string,
projectId as string,
cycleDetails.id,
{
issues: [issueId.toString()],
},
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
};
const removeIssueFromCycle = (bridgeId?: string, cycleId?: string) => {
if (!workspaceSlug || !projectId || !bridgeId || !cycleId || disabled) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prev) => {
if (!prev) return prev;
return {
...prev,
issue_cycle: null,
};
},
false
);
issuesService
.removeIssueFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId, bridgeId)
.then(() => {
mutate(CYCLE_ISSUES(cycleId));
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.log(e);
});
};
return (
<>
<WebViewModal
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
modalTitle="Select Module"
>
<WebViewModal.Options
options={[
...(incompleteCycles ?? []).map((cycle) => ({
checked: cycle.id === value?.cycle,
label: cycle.name,
value: cycle.id,
onClick: () => {
handleCycleChange(cycle);
setIsBottomSheetOpen(false);
},
})),
{
checked: !value,
label: "None",
onClick: () => {
setIsBottomSheetOpen(false);
removeIssueFromCycle(value?.id, value?.cycle);
},
value: "none",
},
]}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsBottomSheetOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">{value?.cycle_detail.name ?? "Select cycle"}</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -0,0 +1,116 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { IssuesSelectBottomSheet } from "components/web-view";
// types
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
type Props = {
disabled?: boolean;
};
export const DuplicateSelect: React.FC<Props> = (props) => {
const { disabled = false } = props;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !issueId || !user || disabled) return;
if (data.length === 0)
return console.log(
"toast",
JSON.stringify({
type: "error",
message: "Please select at least one issue.",
})
);
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
if (!user) return;
issuesService
.createIssueRelation(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
user,
{
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "duplicate" as const,
})),
],
}
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
setIsBottomSheetOpen(false);
};
return (
<>
<IssuesSelectBottomSheet
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
onSubmit={onSubmit}
searchParams={{ issue_relation: true }}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBottomSheetOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -0,0 +1,147 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// services
import modulesService from "services/modules.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import {
ISSUE_DETAILS,
MODULE_LIST,
MODULE_ISSUES,
PROJECT_ISSUES_ACTIVITY,
} from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { WebViewModal } from "components/web-view";
// types
import { IModule, IIssueModule } from "types";
type Props = {
disabled?: boolean;
value?: IIssueModule | null;
};
export const ModuleSelect: React.FC<Props> = (props) => {
const { disabled = false, value } = props;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: modules } = useSWR(
workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => modulesService.getModules(workspaceSlug as string, projectId as string)
: null
);
const { user } = useUser();
const handleModuleChange = (moduleDetail: IModule) => {
if (!workspaceSlug || !projectId || !issueId || disabled) return;
modulesService
.addIssuesToModule(
workspaceSlug as string,
projectId as string,
moduleDetail.id,
{
issues: [issueId.toString()],
},
user
)
.then(() => {
mutate(ISSUE_DETAILS(issueId.toString()));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
};
const removeIssueFromModule = (bridgeId?: string, moduleId?: string) => {
if (!workspaceSlug || !projectId || !moduleId || !bridgeId || disabled) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prev) => {
if (!prev) return prev;
return {
...prev,
issue_module: null,
};
},
false
);
modulesService
.removeIssueFromModule(workspaceSlug as string, projectId as string, moduleId, bridgeId)
.then(() => {
mutate(MODULE_ISSUES(moduleId));
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.log(e);
});
};
return (
<>
<WebViewModal
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
modalTitle="Select Module"
>
<WebViewModal.Options
options={[
...(modules ?? []).map((mod) => ({
checked: mod.id === value?.module,
label: mod.name,
value: mod.id,
onClick: () => {
handleModuleChange(mod);
setIsBottomSheetOpen(false);
},
})),
{
checked: !value,
label: "None",
onClick: () => {
setIsBottomSheetOpen(false);
removeIssueFromModule(value?.id, value?.module);
},
value: "none",
},
]}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsBottomSheetOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">
{value?.module_detail?.name ?? "Select module"}
</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -14,7 +14,10 @@ import issuesService from "services/issues.service";
import { ISSUE_DETAILS } from "constants/fetch-keys";
// components
import { ParentIssuesListModal } from "components/issues";
import { IssuesSelectBottomSheet } from "components/web-view";
// icons
import { ChevronDown, X } from "lucide-react";
// types
import { ISearchIssueResponse } from "types";
@ -26,7 +29,7 @@ type Props = {
};
export const ParentSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const { onChange, disabled = false } = props;
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
@ -42,35 +45,67 @@ export const ParentSelect: React.FC<Props> = (props) => {
: null
);
const parentIssueResult = selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: issueDetails?.parent
? `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
: null; // defaults to null
return (
<>
<ParentIssuesListModal
<IssuesSelectBottomSheet
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(issue) => {
onClose={() => setIsParentModalOpen(false)}
singleSelect
onSubmit={async (issues) => {
if (disabled) return;
const issue = issues[0];
onChange(issue.id);
setSelectedParentIssue(issue);
}}
issueId={issueId as string}
projectId={projectId as string}
searchParams={{
parent: true,
issue_id: issueId as string,
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsParentModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
{parentIssueResult ? (
<div className="flex justify-between items-center gap-0.5">
<button
type="button"
onClick={() => {
setIsParentModalOpen(true);
}}
>
<span>{parentIssueResult}</span>
</button>
<button
type="button"
disabled={disabled}
className="pr-2.5"
onClick={() => {
onChange(null);
setSelectedParentIssue(null);
}}
>
<X className="w-4 h-4 text-custom-text-200" />
</button>
</div>
) : (
<button
type="button"
disabled={disabled}
onClick={() => {
setIsParentModalOpen(true);
}}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
<span className="text-custom-text-200">Select issue</span>
)}
</button>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
)}
</>
);
};

View File

@ -74,11 +74,13 @@ export const PrioritySelect: React.FC<Props> = (props) => {
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
{value ? capitalizeFirstLetter(value) : "None"}
<ChevronDown className="w-5 h-5" />
<span className="text-custom-text-200">
{value ? capitalizeFirstLetter(value) : "None"}
</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);

View File

@ -0,0 +1,114 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// icons
import { ChevronDown } from "lucide-react";
// components
import { IssuesSelectBottomSheet } from "components/web-view";
// types
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
type Props = {
disabled?: boolean;
};
export const RelatesSelect: React.FC<Props> = (props) => {
const { disabled = false } = props;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !issueId || !user || disabled) return;
if (data.length === 0)
return console.log(
"toast",
JSON.stringify({
type: "error",
message: "Please select at least one issue.",
})
);
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
},
}));
issuesService
.createIssueRelation(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
user,
{
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "relates_to" as const,
})),
],
}
)
.then(() => {
mutate(ISSUE_DETAILS(issueId as string));
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
});
setIsBottomSheetOpen(false);
};
return (
<>
<IssuesSelectBottomSheet
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
onSubmit={onSubmit}
searchParams={{ issue_relation: true }}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsBottomSheetOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
<span className="text-custom-text-200">Select issue</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);
};

View File

@ -78,11 +78,11 @@ export const StateSelect: React.FC<Props> = (props) => {
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"
}
>
{selectedState?.name || "Select a state"}
<ChevronDown className="w-5 h-5" />
<span className="text-custom-text-200">{selectedState?.name || "Select a state"}</span>
<ChevronDown className="w-4 h-4 text-custom-text-200" />
</button>
</>
);

View File

@ -0,0 +1,5 @@
export const Spinner: React.FC = () => (
<div className="animate-spin duration-[2500ms]">
<img src="/web-view-spinner.png" alt="spinner" />
</div>
);

View File

@ -1,5 +1,5 @@
// react
import React from "react";
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
@ -8,7 +8,7 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// icons
import { X } from "lucide-react";
import { X, PlusIcon } from "lucide-react";
// services
import issuesService from "services/issues.service";
@ -21,10 +21,12 @@ import useUser from "hooks/use-user";
// ui
import { Spinner } from "components/ui";
import { IIssue } from "types";
// components
import { Label } from "components/web-view";
import { Label, IssuesSelectBottomSheet, DeleteConfirmation } from "components/web-view";
// types
import { IIssue, ISearchIssueResponse } from "types";
type Props = {
issueDetails?: IIssue;
@ -34,7 +36,12 @@ export const SubIssueList: React.FC<Props> = (props) => {
const { issueDetails } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId, issueId } = router.query;
const isArchive = Boolean(router.query.archive);
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const [issueSelectedForDelete, setIssueSelectedForDelete] = useState<IIssue | null>(null);
const { user } = useUser();
@ -46,8 +53,8 @@ export const SubIssueList: React.FC<Props> = (props) => {
: null
);
const handleSubIssueRemove = (issue: any) => {
if (!workspaceSlug || !issueDetails || !user) return;
const handleSubIssueRemove = (issue: IIssue | null) => {
if (!workspaceSlug || !issueDetails || !user || !issue) return;
mutate(
SUB_ISSUES(issueDetails.id),
@ -72,8 +79,40 @@ export const SubIssueList: React.FC<Props> = (props) => {
.finally(() => mutate(SUB_ISSUES(issueDetails.id)));
};
const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !issueId || isArchive) return;
const payload = {
sub_issue_ids: data.map((i) => i.id),
};
await issuesService
.addSubIssues(workspaceSlug.toString(), projectId.toString(), issueId.toString(), payload)
.finally(() => {
mutate(SUB_ISSUES(issueId.toString()));
});
};
return (
<div>
<IssuesSelectBottomSheet
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
onSubmit={addAsSubIssueFromExistingIssues}
searchParams={{ sub_issue: true, issue_id: issueId as string }}
/>
<DeleteConfirmation
title="Remove sub issue"
content="Are you sure you want to remove this sub issue?"
isOpen={!!issueSelectedForDelete}
onCancel={() => setIssueSelectedForDelete(null)}
onConfirm={() => {
if (isArchive) return;
setIssueSelectedForDelete(null);
handleSubIssueRemove(issueSelectedForDelete);
}}
/>
<Label>Sub Issues</Label>
<div className="p-3 border border-custom-border-200 rounded-[4px]">
{!subIssuesResponse && (
@ -97,12 +136,28 @@ export const SubIssueList: React.FC<Props> = (props) => {
</p>
<p className="text-sm font-normal">{subIssue.name}</p>
</div>
<button type="button" onClick={() => handleSubIssueRemove(subIssue)}>
<button
type="button"
disabled={isArchive}
onClick={() => {
if (isArchive) return;
setIssueSelectedForDelete(subIssue);
}}
>
<X className="w-[18px] h-[18px] text-custom-text-400" />
</button>
</div>
))}
</div>
<button
type="button"
disabled={isArchive}
onClick={() => setIsBottomSheetOpen(true)}
className="flex items-center gap-x-1 mt-3"
>
<PlusIcon className="w-[18px] h-[18px] text-custom-text-400" />
<p className="text-sm text-custom-text-400">Add sub issue</p>
</button>
</div>
);
};

View File

@ -63,7 +63,7 @@ export const WebViewModal = (props: Props) => {
<XMarkIcon className="w-6 h-6 text-custom-text-200" />
</button>
</div>
<div className="mt-6 max-h-60 overflow-auto">{children}</div>
<div className="flex flex-col mt-6 h-full max-h-[70vh]">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
@ -75,7 +75,7 @@ export const WebViewModal = (props: Props) => {
type OptionsProps = {
options: Array<{
label: string;
label: string | React.ReactNode;
value: string | null;
checked: boolean;
icon?: any;
@ -84,14 +84,14 @@ type OptionsProps = {
};
const Options: React.FC<OptionsProps> = ({ options }) => (
<div className="divide-y">
<div className="divide-y divide-custom-border-300 flex-1 overflow-auto">
{options.map((option) => (
<div key={option.value} className="flex items-center justify-between gap-2 py-[14px]">
<div className="flex items-center gap-x-2">
<div onClick={option.onClick} className="flex items-center gap-x-2 w-full">
<input
type="checkbox"
checked={option.checked}
onChange={option.onClick}
readOnly
className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4"
/>
@ -104,5 +104,16 @@ const Options: React.FC<OptionsProps> = ({ options }) => (
</div>
);
type FooterProps = {
children: React.ReactNode;
className?: string;
};
const Footer: React.FC<FooterProps> = ({ children, className }) => (
<div className={`mt-2 flex-shrink-0 ${className ? className : ""}`}>{children}</div>
);
WebViewModal.Options = Options;
WebViewModal.Footer = Footer;
WebViewModal.Options.displayName = "WebViewModal.Options";
WebViewModal.Footer.displayName = "WebViewModal.Footer";

View File

@ -11,7 +11,7 @@ import { CURRENT_USER } from "constants/fetch-keys";
import { AlertCircle } from "lucide-react";
// ui
import { Spinner } from "components/ui";
import { Spinner } from "components/web-view";
type Props = {
children: React.ReactNode;
@ -40,7 +40,6 @@ const WebViewLayout: React.FC<Props> = ({ children, fullScreen = true }) => {
return (
<div className="h-screen grid place-items-center p-4">
<div className="flex flex-col items-center gap-3 text-center">
<h3 className="text-xl">Loading your profile...</h3>
<Spinner />
</div>
</div>

View File

@ -25,6 +25,7 @@
"@nivo/line": "0.80.0",
"@nivo/pie": "0.80.0",
"@nivo/scatterplot": "0.80.0",
"@popperjs/core": "^2.11.8",
"@sentry/nextjs": "^7.36.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
@ -72,6 +73,7 @@
"react-hook-form": "^7.38.0",
"react-markdown": "^8.0.7",
"react-moveable": "^0.54.1",
"react-popper": "^2.3.0",
"sharp": "^0.32.1",
"sonner": "^0.6.2",
"swr": "^2.1.3",

View File

@ -298,8 +298,6 @@ const Profile: NextPage = () => {
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""}
width="w-full"
input
verticalPosition="top"
position="right"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
@ -362,7 +360,6 @@ const Profile: NextPage = () => {
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>

View File

@ -427,13 +427,10 @@ const SinglePage: NextPage = () => {
)}
<CustomSearchSelect
customButton={
<button
type="button"
className="flex items-center gap-1 rounded-sm bg-custom-background-80 p-1.5 text-xs"
>
<div className="flex items-center gap-1 rounded-sm bg-custom-background-80 p-1.5 text-xs">
<PlusIcon className="h-3.5 w-3.5" />
{pageDetails.labels.length <= 0 && <span>Add Label</span>}
</button>
</div>
}
value={pageDetails.labels}
footerOption={

View File

@ -412,7 +412,7 @@ const MembersSettings: NextPage = () => {
)}
<CustomSelect
customButton={
<button className="flex item-center gap-1">
<div className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
@ -423,7 +423,7 @@ const MembersSettings: NextPage = () => {
{member.memberId !== user?.id && (
<Icon iconName="expand_more" className="text-lg font-medium" />
)}
</button>
</div>
}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
@ -455,7 +455,6 @@ const MembersSettings: NextPage = () => {
});
});
}}
position="right"
disabled={
member.memberId === user?.id ||
!member.member ||

View File

@ -269,7 +269,7 @@ const MembersSettings: NextPage = () => {
)}
<CustomSelect
customButton={
<button className="flex item-center gap-1">
<div className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
@ -280,7 +280,7 @@ const MembersSettings: NextPage = () => {
{member.memberId !== user?.id && (
<Icon iconName="expand_more" className="text-lg font-medium" />
)}
</button>
</div>
}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
@ -307,7 +307,6 @@ const MembersSettings: NextPage = () => {
});
});
}}
position="right"
disabled={
member.memberId === currentUser?.member.id ||
!member.status ||

View File

@ -15,7 +15,8 @@ import WebViewLayout from "layouts/web-view-layout";
// components
import { TipTapEditor } from "components/tiptap";
import { PrimaryButton, Spinner } from "components/ui";
import { PrimaryButton } from "components/ui";
import { Spinner } from "components/web-view";
const Editor: NextPage = () => {
const [isLoading, setIsLoading] = useState(false);
@ -41,13 +42,13 @@ const Editor: NextPage = () => {
}, [isEditable, setValue, router]);
return (
<WebViewLayout fullScreen={isLoading}>
<WebViewLayout fullScreen>
{isLoading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : (
<>
<div className="w-full h-full flex flex-col justify-between">
<Controller
name="data_html"
control={control}
@ -64,8 +65,7 @@ const Editor: NextPage = () => {
editable={isEditable}
noBorder={true}
workspaceSlug={workspaceSlug?.toString() ?? ""}
debouncedUpdatesEnabled={true}
customClassName="min-h-[150px] shadow-sm"
customClassName="h-full shadow-sm overflow-auto"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
@ -77,7 +77,7 @@ const Editor: NextPage = () => {
/>
{isEditable && (
<PrimaryButton
className="mt-4 w-[calc(100%-30px)] h-[45px] mx-[15px] text-[17px]"
className="mt-4 w-[calc(100%-30px)] h-[45px] mx-[15px] text-[17px] my-[15px]"
onClick={() => {
console.log(
"submitted",
@ -90,7 +90,7 @@ const Editor: NextPage = () => {
Submit
</PrimaryButton>
)}
</>
</div>
)}
</WebViewLayout>
);

View File

@ -8,7 +8,7 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react hook forms
import { useForm } from "react-hook-form";
import { useFormContext, useForm, FormProvider } from "react-hook-form";
// services
import issuesService from "services/issues.service";
@ -23,9 +23,6 @@ import useProjectMembers from "hooks/use-project-members";
// layouts
import WebViewLayout from "layouts/web-view-layout";
// ui
import { Spinner } from "components/ui";
// components
import {
IssueWebViewForm,
@ -34,46 +31,58 @@ import {
IssuePropertiesDetail,
IssueLinks,
IssueActivity,
Spinner,
} from "components/web-view";
// types
import type { IIssue } from "types";
const MobileWebViewIssueDetail = () => {
const MobileWebViewIssueDetail_ = () => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const isArchive = Boolean(router.query.archive);
const memberRole = useProjectMembers(
workspaceSlug as string,
projectId as string,
!!workspaceSlug && !!projectId
);
const isAllowed = Boolean(memberRole.isMember || memberRole.isOwner);
const isAllowed = Boolean((memberRole.isMember || memberRole.isOwner) && !isArchive);
const { user } = useUser();
const { register, control, reset, handleSubmit, watch } = useForm<IIssue>({
defaultValues: {
name: "",
description: "",
description_html: "",
state: "",
},
});
const formContext = useFormContext<IIssue>();
const { register, handleSubmit, control, watch, reset } = formContext;
const {
data: issueDetails,
mutate: mutateIssueDetails,
data: issue,
mutate: mutateIssue,
error,
} = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
workspaceSlug && projectId && issueId && !isArchive ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId && !isArchive
? () =>
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
const { data: archiveIssueDetails, mutate: mutateaArchiveIssue } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && issueId && isArchive ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId && isArchive
? () =>
issuesService.retrieveArchivedIssue(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
const issueDetails = isArchive ? archiveIssueDetails : issue;
const mutateIssueDetails = isArchive ? mutateaArchiveIssue : mutateIssue;
useEffect(() => {
if (!issueDetails) return;
reset({
@ -132,7 +141,6 @@ const MobileWebViewIssueDetail = () => {
<div className="px-4 py-2 h-full">
<div className="h-full flex justify-center items-center">
<Spinner />
Loading...
</div>
</div>
</WebViewLayout>
@ -147,6 +155,10 @@ const MobileWebViewIssueDetail = () => {
return (
<WebViewLayout>
{isArchive && (
<div className="w-full h-screen top-0 left-0 fixed z-50 bg-white/20 pointer-events-none" />
)}
<div className="px-6 py-2 h-full overflow-auto space-y-3">
<IssueWebViewForm
isAllowed={isAllowed}
@ -160,7 +172,7 @@ const MobileWebViewIssueDetail = () => {
<SubIssueList issueDetails={issueDetails!} />
<IssuePropertiesDetail control={control} submitChanges={submitChanges} />
<IssuePropertiesDetail submitChanges={submitChanges} />
<IssueAttachments allowed={isAllowed} />
@ -172,4 +184,14 @@ const MobileWebViewIssueDetail = () => {
);
};
const MobileWebViewIssueDetail = () => {
const methods = useForm();
return (
<FormProvider {...methods}>
<MobileWebViewIssueDetail_ />
</FormProvider>
);
};
export default MobileWebViewIssueDetail;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -359,3 +359,114 @@ body {
.disable-scroll {
overflow: hidden !important;
}
div.web-view-spinner {
position: relative;
width: 54px;
height: 54px;
display: inline-block;
margin-left: 50%;
margin-right: 50%;
padding: 10px;
border-radius: 10px;
}
div.web-view-spinner div {
width: 6%;
height: 16%;
background: rgb(var(--color-text-400));
position: absolute;
left: 49%;
top: 43%;
opacity: 0;
border-radius: 50px;
-webkit-border-radius: 50px;
box-shadow: 0 0 3px rgba(0,0,0,0.2);
-webkit-box-shadow: 0 0 3px rgba(0,0,0,0.2);
animation: fade 1s linear infinite;
-webkit-animation: fade 1s linear infinite;
}
@keyframes fade {
from {opacity: 1;}
to {opacity: 0.25;}
}
@-webkit-keyframes fade {
from {opacity: 1;}
to {opacity: 0.25;}
}
div.web-view-spinner div.bar1 {
transform: rotate(0deg) translate(0, -130%);
-webkit-transform:rotate(0deg) translate(0, -130%);
animation-delay: 0s;
-webkit-animation-delay: 0s;
}
div.web-view-spinner div.bar2 {
transform:rotate(30deg) translate(0, -130%);
-webkit-transform:rotate(30deg) translate(0, -130%);
animation-delay: -0.9167s;
-webkit-animation-delay: -0.9167s;
}
div.web-view-spinner div.bar3 {
transform:rotate(60deg) translate(0, -130%);
-webkit-transform:rotate(60deg) translate(0, -130%);
animation-delay: -0.833s;
-webkit-animation-delay: -0.833s;
}
div.web-view-spinner div.bar4 {
transform:rotate(90deg) translate(0, -130%);
-webkit-transform:rotate(90deg) translate(0, -130%);
animation-delay: -0.7497s;
-webkit-animation-delay: -0.7497s;
}
div.web-view-spinner div.bar5 {
transform:rotate(120deg) translate(0, -130%);
-webkit-transform:rotate(120deg) translate(0, -130%);
animation-delay: -0.667s;
-webkit-animation-delay: -0.667s;
}
div.web-view-spinner div.bar6 {
transform:rotate(150deg) translate(0, -130%);
-webkit-transform:rotate(150deg) translate(0, -130%);
animation-delay: -0.5837s;
-webkit-animation-delay: -0.5837s;
}
div.web-view-spinner div.bar7 {
transform:rotate(180deg) translate(0, -130%);
-webkit-transform:rotate(180deg) translate(0, -130%);
animation-delay: -0.5s;
-webkit-animation-delay: -0.5s;
}
div.web-view-spinner div.bar8 {
transform:rotate(210deg) translate(0, -130%);
-webkit-transform:rotate(210deg) translate(0, -130%);
animation-delay: -0.4167s;
-webkit-animation-delay: -0.4167s;
}
div.web-view-spinner div.bar9 {
transform:rotate(240deg) translate(0, -130%);
-webkit-transform:rotate(240deg) translate(0, -130%);
animation-delay: -0.333s;
-webkit-animation-delay: -0.333s;
}
div.web-view-spinner div.bar10 {
transform:rotate(270deg) translate(0, -130%);
-webkit-transform:rotate(270deg) translate(0, -130%);
animation-delay: -0.2497s;
-webkit-animation-delay: -0.2497s;
}
div.web-view-spinner div.bar11 {
transform:rotate(300deg) translate(0, -130%);
-webkit-transform:rotate(300deg) translate(0, -130%);
animation-delay: -0.167s;
-webkit-animation-delay: -0.167s;
}
div.web-view-spinner div.bar12 {
transform:rotate(330deg) translate(0, -130%);
-webkit-transform:rotate(330deg) translate(0, -130%);
animation-delay: -0.0833s;
-webkit-animation-delay: -0.0833s;
}

View File

@ -6628,20 +6628,13 @@ prosemirror-menu@^1.2.1:
prosemirror-history "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.18.1, prosemirror-model@^1.8.1:
prosemirror-model@1.18.1, prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.18.1, prosemirror-model@^1.19.0, prosemirror-model@^1.8.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.18.1.tgz#1d5d6b6de7b983ee67a479dc607165fdef3935bd"
integrity sha512-IxSVBKAEMjD7s3n8cgtwMlxAXZrC7Mlag7zYsAKDndAqnDScvSmp/UdnRTV/B33lTCVU3CCm7dyAn/rVVD0mcw==
dependencies:
orderedmap "^2.0.0"
prosemirror-model@^1.19.0:
version "1.19.3"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.19.3.tgz#f0d55285487fefd962d0ac695f716f4ec6705006"
integrity sha512-tgSnwN7BS7/UM0sSARcW+IQryx2vODKX4MI7xpqY2X+iaepJdKBPc7I4aACIsDV/LTaTjt12Z56MhDr9LsyuZQ==
dependencies:
orderedmap "^2.0.0"
prosemirror-schema-basic@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.2.tgz#6695f5175e4628aab179bf62e5568628b9cfe6c7"