feat: init
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TFilters } from "@/types/issue";
|
||||
// components
|
||||
import { AppliedPriorityFilters } from "./priority";
|
||||
import { AppliedStateFilters } from "./state";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: TFilters;
|
||||
handleRemoveAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TFilters, value: string | null) => void;
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof TFilters;
|
||||
const filterValue = value as TFilters[keyof TFilters];
|
||||
|
||||
if (!filterValue) return;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filterKey}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
|
||||
>
|
||||
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filterKey === "priority" && (
|
||||
<AppliedPriorityFilters
|
||||
handleRemove={(val) => handleRemoveFilter("priority", val)}
|
||||
values={(filterValue ?? []) as TFilters["priority"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterKey === "state" && (
|
||||
<AppliedStateFilters
|
||||
handleRemove={(val) => handleRemoveFilter("state", val)}
|
||||
values={filterValue ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAllFilters}
|
||||
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
|
||||
>
|
||||
{t("common.clear_all")}
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import type { IIssueLabel } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
labels: IIssueLabel[] | undefined;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export const AppliedLabelsFilters: React.FC<Props> = (props) => {
|
||||
const { handleRemove, labels, values } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((labelId) => {
|
||||
const labelDetails = labels?.find((l) => l.id === labelId);
|
||||
|
||||
if (!labelDetails) return null;
|
||||
|
||||
return (
|
||||
<div key={labelId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: labelDetails.color,
|
||||
}}
|
||||
/>
|
||||
<span className="normal-case">{labelDetails.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(labelId)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import type { TIssuePriorities } from "@plane/propel/icons";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
values: TIssuePriorities[];
|
||||
};
|
||||
|
||||
export const AppliedPriorityFilters: React.FC<Props> = (props) => {
|
||||
const { handleRemove, values } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{values &&
|
||||
values.length > 0 &&
|
||||
values.map((priority) => (
|
||||
<div key={priority} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<PriorityIcon priority={priority} className={`h-3 w-3`} />
|
||||
{priority}
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(priority)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// hooks
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// store
|
||||
import type { TIssueLayout, TIssueQueryFilters } from "@/types/issue";
|
||||
// components
|
||||
import { AppliedFiltersList } from "./filters-list";
|
||||
|
||||
type TIssueAppliedFilters = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
|
||||
// derived values
|
||||
const issueFilters = getIssueFilters(anchor);
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
const userFilters = issueFilters?.filters || {};
|
||||
|
||||
const appliedFilters: any = {};
|
||||
Object.entries(userFilters).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
appliedFilters[key] = value;
|
||||
});
|
||||
|
||||
const updateRouteParams = useCallback(
|
||||
(key: keyof TIssueQueryFilters, value: string[]) => {
|
||||
const state = key === "state" ? value : (issueFilters?.filters?.state ?? []);
|
||||
const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []);
|
||||
const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []);
|
||||
|
||||
const params: {
|
||||
board: TIssueLayout | string;
|
||||
priority?: string;
|
||||
states?: string;
|
||||
labels?: string;
|
||||
} = {
|
||||
board: activeLayout || "list",
|
||||
};
|
||||
|
||||
if (priority.length > 0) params.priority = priority.join(",");
|
||||
if (state.length > 0) params.states = state.join(",");
|
||||
if (labels.length > 0) params.labels = labels.join(",");
|
||||
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
router.push(`/issues/${anchor}?${qs}`);
|
||||
},
|
||||
[activeLayout, anchor, issueFilters, router]
|
||||
);
|
||||
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TIssueQueryFilters, value: string | null) => {
|
||||
let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
|
||||
|
||||
if (value === null) newValues = [];
|
||||
else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
||||
updateIssueFilters(anchor, "filters", key, newValues);
|
||||
updateRouteParams(key, newValues);
|
||||
},
|
||||
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
|
||||
);
|
||||
|
||||
const handleRemoveAllFilters = () => {
|
||||
initIssueFilters(
|
||||
anchor,
|
||||
{
|
||||
display_filters: { layout: activeLayout || "list" },
|
||||
filters: {
|
||||
state: [],
|
||||
priority: [],
|
||||
labels: [],
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`);
|
||||
};
|
||||
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-custom-border-200 bg-custom-background-100 p-4">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters || {}}
|
||||
handleRemoveFilter={handleFilters as any}
|
||||
handleRemoveAllFilters={handleRemoveAllFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export const AppliedStateFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, values } = props;
|
||||
|
||||
const { sortedStates: states } = useStates();
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((stateId) => {
|
||||
const stateDetails = states?.find((s) => s.id === stateId);
|
||||
|
||||
if (!stateDetails) return null;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} size={EIconSize.SM} />
|
||||
{stateDetails.name}
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(stateId)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment, useState } from "react";
|
||||
import type { Placement } from "@popperjs/core";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
placement?: Placement;
|
||||
};
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
const { children, title = "Dropdown", placement } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover as="div">
|
||||
{({ open }) => {
|
||||
if (open) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button ref={setReferenceElement} variant="neutral-primary" size="sm">
|
||||
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={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"
|
||||
>
|
||||
<Popover.Panel>
|
||||
<div
|
||||
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// lucide icons
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface IFilterHeader {
|
||||
title: string;
|
||||
isPreviewEnabled: boolean;
|
||||
handleIsPreviewEnabled: () => void;
|
||||
}
|
||||
|
||||
export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => (
|
||||
<div className="sticky top-0 flex items-center justify-between gap-2 bg-custom-background-100">
|
||||
<div className="flex-grow truncate text-xs font-medium text-custom-text-300">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-5 w-5 flex-shrink-0 place-items-center rounded hover:bg-custom-background-80"
|
||||
onClick={handleIsPreviewEnabled}
|
||||
>
|
||||
{isPreviewEnabled ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// lucide icons
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
isChecked: boolean;
|
||||
title: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
export const FilterOption: React.FC<Props> = (props) => {
|
||||
const { icon, isChecked, multiple = true, onClick, title } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded p-1.5 hover:bg-custom-background-80"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={`grid h-3 w-3 flex-shrink-0 place-items-center border bg-custom-background-90 ${
|
||||
isChecked ? "border-custom-primary-100 bg-custom-primary-100 text-white" : "border-custom-border-300"
|
||||
} ${multiple ? "rounded-sm" : "rounded-full"}`}
|
||||
>
|
||||
{isChecked && <Check size={10} strokeWidth={3} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{icon && <div className="grid w-5 flex-shrink-0 place-items-center">{icon}</div>}
|
||||
<div className="flex-grow truncate text-xs text-custom-text-200">{title}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
1
apps/space/core/components/issues/filters/index.ts
Normal file
1
apps/space/core/components/issues/filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
85
apps/space/core/components/issues/filters/labels.tsx
Normal file
85
apps/space/core/components/issues/filters/labels.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import type { IIssueLabel } from "@/types/issue";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
const LabelIcons = ({ color }: { color: string }) => (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
);
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
labels: IIssueLabel[] | undefined;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterLabels: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((label) => (
|
||||
<FilterOption
|
||||
key={label?.id}
|
||||
isChecked={appliedFilters?.includes(label?.id) ? true : false}
|
||||
onClick={() => handleUpdate(label?.id)}
|
||||
icon={<LabelIcons color={label.color} />}
|
||||
title={label.name}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
apps/space/core/components/issues/filters/priority.tsx
Normal file
57
apps/space/core/components/issues/filters/priority.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterPriority: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = ISSUE_PRIORITY_FILTERS.filter((p) => p.key.includes(searchQuery.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((priority) => (
|
||||
<FilterOption
|
||||
key={priority.key}
|
||||
isChecked={appliedFilters?.includes(priority.key) ? true : false}
|
||||
onClick={() => handleUpdate(priority.key)}
|
||||
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
|
||||
title={t(priority.titleTranslationKey)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">{t("common.search.no_matches_found")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
72
apps/space/core/components/issues/filters/root.tsx
Normal file
72
apps/space/core/components/issues/filters/root.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants";
|
||||
// components
|
||||
import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown";
|
||||
import { FilterSelection } from "@/components/issues/filters/selection";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// types
|
||||
import type { TIssueQueryFilters } from "@/types/issue";
|
||||
|
||||
type IssueFiltersDropdownProps = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const { getIssueFilters, updateIssueFilters } = useIssueFilter();
|
||||
// derived values
|
||||
const issueFilters = getIssueFilters(anchor);
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
const updateRouteParams = useCallback(
|
||||
(key: keyof TIssueQueryFilters, value: string[]) => {
|
||||
const state = key === "state" ? value : (issueFilters?.filters?.state ?? []);
|
||||
const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []);
|
||||
const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []);
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels });
|
||||
router.push(`/issues/${anchor}?${queryParam}`);
|
||||
},
|
||||
[anchor, activeLayout, issueFilters, router]
|
||||
);
|
||||
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TIssueQueryFilters, value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
|
||||
|
||||
if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
|
||||
updateIssueFilters(anchor, "filters", key, newValues);
|
||||
updateRouteParams(key, newValues);
|
||||
},
|
||||
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="z-10 flex h-full w-full flex-col">
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFilters={handleFilters as any}
|
||||
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
82
apps/space/core/components/issues/filters/selection.tsx
Normal file
82
apps/space/core/components/issues/filters/selection.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
// types
|
||||
import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
|
||||
// local imports
|
||||
import { FilterPriority } from "./priority";
|
||||
import { FilterState } from "./state";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions;
|
||||
handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
layoutDisplayFiltersOptions: TIssueFilterKeys[];
|
||||
};
|
||||
|
||||
export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
const { filters, handleFilters, layoutDisplayFiltersOptions } = props;
|
||||
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions.includes(filter);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||
placeholder="Search"
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5">
|
||||
{/* priority */}
|
||||
{isFilterEnabled("priority") && (
|
||||
<div className="py-2">
|
||||
<FilterPriority
|
||||
appliedFilters={filters.priority ?? null}
|
||||
handleUpdate={(val) => handleFilters("priority", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state */}
|
||||
{isFilterEnabled("state") && (
|
||||
<div className="py-2">
|
||||
<FilterState
|
||||
appliedFilters={filters.state ?? null}
|
||||
handleUpdate={(val) => handleFilters("state", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{/* {isFilterEnabled("labels") && (
|
||||
<div className="py-2">
|
||||
<FilterLabels
|
||||
appliedFilters={filters.labels ?? null}
|
||||
handleUpdate={(val) => handleFilters("labels", val)}
|
||||
labels={labels}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
85
apps/space/core/components/issues/filters/state.tsx
Normal file
85
apps/space/core/components/issues/filters/state.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const FilterState: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
const { sortedStates: states } = useStates();
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleViewToggle = () => {
|
||||
if (!filteredOptions) return;
|
||||
|
||||
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||
else setItemsToRender(filteredOptions.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((state) => (
|
||||
<FilterOption
|
||||
key={state.id}
|
||||
isChecked={appliedFilters?.includes(state.id) ? true : false}
|
||||
onClick={() => handleUpdate(state.id)}
|
||||
icon={<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.MD} />}
|
||||
title={state.name}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||
onClick={handleViewToggle}
|
||||
>
|
||||
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
17
apps/space/core/components/issues/issue-layouts/error.tsx
Normal file
17
apps/space/core/components/issues/issue-layouts/error.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Image from "next/image";
|
||||
// assets
|
||||
import SomethingWentWrongImage from "public/something-went-wrong.svg";
|
||||
|
||||
export const SomethingWentWrongError = () => (
|
||||
<div className="grid min-h-screen w-full place-items-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
|
||||
<div className="grid h-32 w-32 place-items-center">
|
||||
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mt-12 text-3xl font-semibold">Oops! Something went wrong.</h1>
|
||||
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
1
apps/space/core/components/issues/issue-layouts/index.ts
Normal file
1
apps/space/core/components/issues/issue-layouts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TLoader } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
|
||||
interface Props {
|
||||
children: string | React.ReactNode | React.ReactNode[];
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const IssueLayoutHOC = observer((props: Props) => {
|
||||
const { getIssueLoader, getGroupIssueCount } = props;
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
if (getIssueLoader() === "init-loader" || issueCount === undefined) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (getGroupIssueCount(undefined, undefined, false) === 0) {
|
||||
return <div className="flex w-full h-full items-center justify-center">No work items Found</div>;
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store/use-issue";
|
||||
|
||||
import { KanBan } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
export const IssueKanbanLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue();
|
||||
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchMoreIssues = useCallback(
|
||||
(groupId?: string, subgroupId?: string) => {
|
||||
if (getIssueLoader(groupId, subgroupId) !== "pagination") {
|
||||
fetchNextPublicIssues(anchor, groupId, subgroupId);
|
||||
}
|
||||
},
|
||||
[anchor, getIssueLoader, fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
const debouncedFetchMoreIssues = debounce(
|
||||
(groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId),
|
||||
300,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div
|
||||
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 overflow-x-auto overflow-y-hidden`}
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative h-full w-max min-w-full bg-custom-background-90">
|
||||
<div className="h-full w-max">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={null}
|
||||
groupBy="state"
|
||||
showEmptyGroup
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={debouncedFetchMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
|
||||
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
};
|
||||
export const BlockReactions = observer((props: Props) => {
|
||||
const { issueId } = props;
|
||||
const { anchor } = useParams();
|
||||
const { canVote, canReact } = usePublish(anchor.toString());
|
||||
|
||||
// if the user cannot vote or react then return empty
|
||||
if (!canVote && !canReact) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap border-t-[1px] outline-transparent w-full border-t-custom-border-200 bg-custom-background-90 rounded-b"
|
||||
)}
|
||||
>
|
||||
<div className="py-2 px-3 flex flex-wrap items-center gap-2">
|
||||
{canVote && (
|
||||
<div
|
||||
className={cn(`flex items-center gap-2 pr-1`, {
|
||||
"after:h-6 after:ml-1 after:w-[1px] after:bg-custom-border-200": canReact,
|
||||
})}
|
||||
>
|
||||
<IssueVotes anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{canReact && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
110
apps/space/core/components/issues/issue-layouts/kanban/block.tsx
Normal file
110
apps/space/core/components/issues/issue-layouts/kanban/block.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
// plane ui
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
//
|
||||
import type { IIssue } from "@/types/issue";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { getIssueBlockId } from "../utils";
|
||||
import { BlockReactions } from "./block-reactions";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
groupId: string;
|
||||
subGroupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
interface IssueDetailsBlockProps {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||
const { issue, displayProperties } = props;
|
||||
const { anchor } = useParams();
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
{project_details?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<div className="w-full line-clamp-1 text-sm text-custom-text-100 mb-1.5">
|
||||
<Tooltip tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<IssueProperties
|
||||
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
const { issueId, groupId, subGroupId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
// hooks
|
||||
const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails();
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("group/kanban-block relative p-1.5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
className="w-full"
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
|
||||
</Link>
|
||||
<BlockReactions issueId={issueId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanIssueBlock.displayName = "KanbanIssueBlock";
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { KanbanIssueBlock } from "./block";
|
||||
|
||||
interface IssueBlocksListProps {
|
||||
subGroupId: string;
|
||||
groupId: string;
|
||||
issueIds: string[];
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
|
||||
const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<>
|
||||
{issueIds.map((issueId) => {
|
||||
if (!issueId) return null;
|
||||
|
||||
let draggableId = issueId;
|
||||
if (groupId) draggableId = `${draggableId}__${groupId}`;
|
||||
if (subGroupId) draggableId = `${draggableId}__${subGroupId}`;
|
||||
|
||||
return (
|
||||
<KanbanIssueBlock
|
||||
key={draggableId}
|
||||
issueId={issueId}
|
||||
groupId={groupId}
|
||||
subGroupId={subGroupId}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { isNil } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
// components
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { KanbanGroup } from "./kanban-group";
|
||||
|
||||
export interface IKanBan {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId?: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
}
|
||||
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
subGroupId = "null",
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
showEmptyGroup = true,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
|
||||
if (!showEmptyGroup) {
|
||||
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
|
||||
}
|
||||
return groupVisibility;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative w-full flex gap-2 px-2 ${subGroupBy ? "h-full" : "h-full"}`}>
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((subList: IGroupByColumn) => {
|
||||
const groupByVisibilityToggle = visibilityGroupBy(subList);
|
||||
|
||||
if (groupByVisibilityToggle.showGroup === false) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={subList.id}
|
||||
className={`group relative flex flex-shrink-0 flex-col ${
|
||||
groupByVisibilityToggle.showIssues ? `w-[350px]` : ``
|
||||
} `}
|
||||
>
|
||||
{isNil(subGroupBy) && (
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupBy={groupBy}
|
||||
icon={subList.icon as any}
|
||||
title={subList.name}
|
||||
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByVisibilityToggle.showIssues && (
|
||||
<KanbanGroup
|
||||
groupId={subList.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
subGroupId={subGroupId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle } from "lucide-react";
|
||||
// types
|
||||
import type { TIssueGroupByOptions } from "@plane/types";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
const { icon, title, count } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative flex flex-shrink-0 gap-2 p-1.5 w-full flex-row items-center`}>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className={`relative flex items-center gap-1 w-full flex-row overflow-hidden`}>
|
||||
<div className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={`flex-shrink-0 text-sm font-medium text-custom-text-300 pl-2`}>{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
// mobx
|
||||
|
||||
interface IHeaderSubGroupByCard {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
|
||||
const { icon, title, count, isExpanded, toggleExpanded } = props;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5 cursor-pointer`}
|
||||
onClick={() => toggleExpanded()}
|
||||
>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
|
||||
{isExpanded ? <ChevronUp width={14} strokeWidth={2} /> : <ChevronDown width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1 text-sm">
|
||||
<div className="line-clamp-1 text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import type {
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
// local imports
|
||||
import { KanbanIssueBlocksList } from "./blocks-list";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// Loader components
|
||||
const KanbanIssueBlockLoader = forwardRef<HTMLSpanElement>((props, ref) => (
|
||||
<span ref={ref} className="block h-28 m-1.5 animate-pulse bg-custom-background-80 rounded" />
|
||||
));
|
||||
KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader";
|
||||
|
||||
export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
const {
|
||||
groupId,
|
||||
subGroupId,
|
||||
subGroupBy,
|
||||
displayProperties,
|
||||
groupedIssueIds,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
// hooks
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLSpanElement | null>(null);
|
||||
const columnRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef;
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId);
|
||||
}, [loadMoreIssues, groupId, subGroupId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(groupId, subGroupId);
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const isSubGroup = !!subGroupId && subGroupId !== "null";
|
||||
|
||||
const issueIds = isSubGroup
|
||||
? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? [])
|
||||
: ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<KanbanIssueBlockLoader />
|
||||
) : (
|
||||
<div
|
||||
className="w-full p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
{" "}
|
||||
Load More ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${groupId}__${subGroupId}`}
|
||||
className={cn("relative h-full transition-all min-h-[120px]", { "vertical-scrollbar scrollbar-md": !subGroupBy })}
|
||||
ref={columnRef}
|
||||
>
|
||||
<KanbanIssueBlocksList
|
||||
subGroupId={subGroupId}
|
||||
groupId={groupId}
|
||||
issueIds={issueIds || []}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
|
||||
{shouldLoadMore && (isSubGroup ? <>{loadMore}</> : <KanbanIssueBlockLoader ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { KanBan } from "./default";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
|
||||
|
||||
export interface IKanBanSwimLanes {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
showEmptyGroup: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
orderBy,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupByList || !subGroupByList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
|
||||
<SubGroupSwimlaneHeader
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
groupList={groupByList}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{subGroupBy && (
|
||||
<SubGroupSwimlane
|
||||
groupList={subGroupByList}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
orderBy={orderBy}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroupSwimlaneHeader {
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
groupList: IGroupByColumn[];
|
||||
showEmptyGroup: boolean;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
}
|
||||
|
||||
const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => {
|
||||
let subGroupHeaderVisibility = true;
|
||||
|
||||
if (showEmptyGroup) subGroupHeaderVisibility = true;
|
||||
else {
|
||||
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
|
||||
else subGroupHeaderVisibility = false;
|
||||
}
|
||||
|
||||
return subGroupHeaderVisibility;
|
||||
};
|
||||
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
|
||||
({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => (
|
||||
<div className="relative flex h-max min-h-full w-full items-center gap-2">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => {
|
||||
const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup);
|
||||
|
||||
if (subGroupByVisibilityToggle === false) return <></>;
|
||||
return (
|
||||
<div key={`${subGroupBy}_${group.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
|
||||
<HeaderGroupByCard groupBy={groupBy} icon={group.icon} title={group.name} count={groupCount} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
groupList,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-max min-h-full w-full">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => (
|
||||
<SubGroup
|
||||
key={group.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroup {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
group: IGroupByColumn;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroup: React.FC<ISubGroup> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
group,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const visibilitySubGroupBy = (
|
||||
_list: IGroupByColumn,
|
||||
subGroupCount: number
|
||||
): { showGroup: boolean; showIssues: boolean } => {
|
||||
const subGroupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (showEmptyGroup) subGroupVisibility.showGroup = true;
|
||||
else {
|
||||
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
|
||||
else subGroupVisibility.showGroup = false;
|
||||
}
|
||||
return subGroupVisibility;
|
||||
};
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0;
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount);
|
||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-shrink-0 flex-col">
|
||||
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||
<div className="sticky left-0 flex-shrink-0">
|
||||
<HeaderSubGroupByCard
|
||||
icon={group.icon as any}
|
||||
title={group.name || ""}
|
||||
count={issueCount}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subGroupByVisibilityToggle.showIssues && isExpanded && (
|
||||
<div className="relative">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
subGroupId={group.id}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
|
||||
// constants
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store/use-issue";
|
||||
import { List } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesListLayoutRoot = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const {
|
||||
groupedIssueIds: storeGroupedIssueIds,
|
||||
fetchNextPublicIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = useIssue();
|
||||
|
||||
const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined;
|
||||
// auth
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadMoreIssues = useCallback(
|
||||
(groupId?: string) => {
|
||||
fetchNextPublicIssues(anchor, groupId);
|
||||
},
|
||||
[anchor, fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div className={`relative size-full bg-custom-background-90`}>
|
||||
<List
|
||||
displayProperties={displayProperties}
|
||||
groupBy={"state"}
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup
|
||||
/>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
// plane ui
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
//
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
groupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
const { anchor } = useParams();
|
||||
const { issueId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
// ref
|
||||
const issueRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails();
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectIdentifier = project_details?.identifier;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={issueRef}
|
||||
className={cn(
|
||||
"group/list-block min-h-11 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 hover:bg-custom-background-90 p-3 pl-1.5 text-sm transition-colors border-b border-b-custom-border-200",
|
||||
{
|
||||
"border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
"last:border-b-transparent": !getIsIssuePeeked(issue.id),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full truncate">
|
||||
<div className="flex flex-grow items-center gap-0.5 truncate">
|
||||
<div className="flex items-center gap-1">
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300 px-4">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
id={`issue-${issue.id}`}
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipContent={issue.name} position="top-start">
|
||||
<p className="truncate">{issue.name}</p>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<IssueProperties
|
||||
className="relative flex flex-wrap md:flex-grow md:flex-shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FC, MutableRefObject } from "react";
|
||||
// types
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
import { IssueBlock } from "./block";
|
||||
|
||||
interface Props {
|
||||
issueIds: string[] | undefined;
|
||||
groupId: string;
|
||||
displayProperties?: IIssueDisplayProperties;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const { issueIds = [], groupId, displayProperties } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{issueIds &&
|
||||
issueIds?.length > 0 &&
|
||||
issueIds.map((issueId: string) => (
|
||||
<IssueBlock key={issueId} issueId={issueId} displayProperties={displayProperties} groupId={groupId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type {
|
||||
GroupByColumnTypes,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TIssueGroupByOptions,
|
||||
IGroupByColumn,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { ListGroup } from "./list-group";
|
||||
|
||||
export interface IList {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const List: React.FC<IList> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative size-full flex flex-col">
|
||||
{groupList && (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="size-full vertical-scrollbar scrollbar-lg relative overflow-auto vertical-scrollbar-margin-top-md"
|
||||
>
|
||||
{groupList.map((group: IGroupByColumn) => (
|
||||
<ListGroup
|
||||
key={group.id}
|
||||
groupIssueIds={groupedIssueIds?.[group.id]}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CircleDashed } from "lucide-react";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupID: string;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
toggleListGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
||||
const { groupID, icon, title, count, toggleListGroup } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2 py-1.5"
|
||||
onClick={() => toggleListGroup(groupID)}
|
||||
>
|
||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
|
||||
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer">
|
||||
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { Fragment, forwardRef, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import type {
|
||||
IGroupByColumn,
|
||||
TIssueGroupByOptions,
|
||||
IIssueDisplayProperties,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { IssueBlocksList } from "./blocks-list";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
|
||||
interface Props {
|
||||
groupIssueIds: string[] | undefined;
|
||||
group: IGroupByColumn;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
// List loader component
|
||||
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div ref={ref} className="flex items-center justify-between h-11 p-3 border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-10 bg-custom-background-80 rounded animate-pulse" />
|
||||
<span className={`h-5 w-52 bg-custom-background-80 rounded animate-pulse`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<Fragment key={index}>
|
||||
<span key={index} className="h-5 w-5 bg-custom-background-80 rounded animate-pulse" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
ListLoaderItemRow.displayName = "ListLoaderItemRow";
|
||||
|
||||
export const ListGroup = observer((props: Props) => {
|
||||
const {
|
||||
groupIssueIds = [],
|
||||
group,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
containerRef,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const groupRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
|
||||
const isPaginating = !!getIssueLoader(group.id);
|
||||
|
||||
useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds
|
||||
? groupIssueIds.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<ListLoaderItemRow />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
"h-11 relative flex items-center gap-3 bg-custom-background-100 border border-transparent border-t-custom-border-200 pl-6 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
}
|
||||
onClick={() => loadMoreIssues(group.id)}
|
||||
>
|
||||
{t("common.load_more")} ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const validateEmptyIssueGroups = (issueCount: number = 0) => {
|
||||
if (!showEmptyGroup && issueCount <= 0) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const toggleListGroup = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy;
|
||||
|
||||
return validateEmptyIssueGroups(groupIssueCount) ? (
|
||||
<div ref={groupRef} className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`)}>
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pl-2 pr-3 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupID={group.id}
|
||||
icon={group.icon}
|
||||
title={group.name || ""}
|
||||
count={groupIssueCount}
|
||||
toggleListGroup={toggleListGroup}
|
||||
/>
|
||||
</div>
|
||||
{shouldExpand && (
|
||||
<div className="relative">
|
||||
{groupIssueIds && (
|
||||
<IssueBlocksList
|
||||
issueIds={groupIssueIds}
|
||||
groupId={group.id}
|
||||
displayProperties={displayProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldLoadMore && (groupBy ? <>{loadMore}</> : <ListLoaderItemRow ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Link, Paperclip } from "lucide-react";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
//// hooks
|
||||
import type { IIssue } from "@/types/issue";
|
||||
import { IssueBlockCycle } from "./cycle";
|
||||
import { IssueBlockDate } from "./due-date";
|
||||
import { IssueBlockLabels } from "./labels";
|
||||
import { IssueBlockMembers } from "./member";
|
||||
import { IssueBlockModules } from "./modules";
|
||||
import { IssueBlockPriority } from "./priority";
|
||||
import { IssueBlockState } from "./state";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const { issue, displayProperties, className } = props;
|
||||
|
||||
if (!displayProperties || !issue.project_id) return null;
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* basic properties */}
|
||||
{/* state */}
|
||||
{issue.state_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5">
|
||||
<IssueBlockState stateId={issue.state_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* priority */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5">
|
||||
<IssueBlockPriority priority={issue.priority} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* label */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
|
||||
<div className="h-5">
|
||||
<IssueBlockLabels labelIds={issue.label_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
{issue?.start_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate
|
||||
due_date={issue?.start_date}
|
||||
stateId={issue?.state_id ?? undefined}
|
||||
shouldHighLight={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* target/due date */}
|
||||
{issue?.target_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5">
|
||||
<IssueBlockMembers memberIds={issue.assignee_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* modules */}
|
||||
{issue.module_ids && issue.module_ids.length > 0 && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5">
|
||||
<IssueBlockModules moduleIds={issue.module_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* cycles */}
|
||||
{issue.cycle_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5">
|
||||
<IssueBlockCycle cycleId={issue.cycle_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* estimates */}
|
||||
{/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)} */}
|
||||
|
||||
{/* extra render properties */}
|
||||
{/* sub-issues */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="sub_issue_count"
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Sub-work items" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
|
||||
{
|
||||
"hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ViewsIcon className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.sub_issues_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* attachments */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="attachment_count"
|
||||
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.attachment_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* link */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="link"
|
||||
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.link_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { CycleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
|
||||
type Props = {
|
||||
cycleId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => {
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const cycle = getCycleById(cycleId);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? "No Cycle"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs duration-300 focus:outline-none",
|
||||
{ "border-[0.5px] border-custom-border-300": shouldShowBorder }
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-xs gap-1.5">
|
||||
<CycleIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2 } from "lucide-react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
due_date: string | undefined;
|
||||
stateId: string | undefined;
|
||||
shouldHighLight?: boolean;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockDate = observer((props: Props) => {
|
||||
const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props;
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
const formattedDate = renderFormattedDate(due_date);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Due Date" tooltipContent={formattedDate}>
|
||||
<div
|
||||
className={cn("flex h-full items-center gap-1 rounded px-2.5 py-1 text-xs text-custom-text-100", {
|
||||
"text-red-500": shouldHighLight && due_date && shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<CalendarCheck2 className="size-3 flex-shrink-0" />
|
||||
{formattedDate ? formattedDate : "No Date"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tags } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
type Props = {
|
||||
labelIds: string[];
|
||||
shouldShowLabel?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => {
|
||||
const { getLabelsByIds } = useLabel();
|
||||
|
||||
const labels = getLabelsByIds(labelIds);
|
||||
|
||||
const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels";
|
||||
|
||||
if (labels.length <= 0)
|
||||
return (
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
|
||||
<div
|
||||
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs border-[0.5px] border-custom-border-300`}
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{shouldShowLabel && <span>No Labels</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-full flex-wrap items-center gap-2 overflow-hidden">
|
||||
{labels.length <= 2 ? (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<Tooltip key={label.id} position="top" tooltipHeading="Labels" tooltipContent={label?.name ?? ""}>
|
||||
<div
|
||||
key={label?.id}
|
||||
className={`flex overflow-hidden h-full max-w-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs`}
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block w-auto max-w-[100px] truncate">{label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs cursor-not-allowed"
|
||||
`}
|
||||
>
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelsString}>
|
||||
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${labels.length} Labels`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Users } from "lucide-react";
|
||||
// plane ui
|
||||
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
//
|
||||
import type { TPublicMember } from "@/types/member";
|
||||
|
||||
type Props = {
|
||||
memberIds: string[];
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
type AvatarProps = {
|
||||
showTooltip: boolean;
|
||||
members: TPublicMember[];
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps) => {
|
||||
const { showTooltip, members, icon: Icon } = props;
|
||||
|
||||
if (Array.isArray(members)) {
|
||||
if (members.length > 1) {
|
||||
return (
|
||||
<AvatarGroup size="md" showTooltip={!showTooltip}>
|
||||
{members.map((member) => {
|
||||
if (!member) return;
|
||||
return <Avatar key={member.id} src={member.member__avatar} name={member.member__display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
);
|
||||
} else if (members.length === 1) {
|
||||
return (
|
||||
<Avatar
|
||||
src={members[0].member__avatar}
|
||||
name={members[0].member__display_name}
|
||||
size="md"
|
||||
showTooltip={!showTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
|
||||
});
|
||||
|
||||
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
|
||||
const { getMembersByIds } = useMember();
|
||||
|
||||
const members = getMembersByIds(memberIds);
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
className={cn("flex flex-shrink-0 cursor-default items-center rounded-md text-xs", {
|
||||
"border-[0.5px] border-custom-border-300 px-2.5 py-1": shouldShowBorder && !members?.length,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<ButtonAvatars members={members} showTooltip={false} />
|
||||
{!shouldShowBorder && members.length <= 1 && (
|
||||
<span>{members?.[0]?.member__display_name ?? "No Assignees"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { ModuleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
|
||||
type Props = {
|
||||
moduleIds: string[] | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => {
|
||||
const { getModulesByIds } = useModule();
|
||||
|
||||
const modules = getModulesByIds(moduleIds ?? []);
|
||||
|
||||
const modulesString = modules.map((module) => module.name).join(", ");
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-wrap items-center gap-1">
|
||||
<Tooltip tooltipHeading="Modules" tooltipContent={modulesString}>
|
||||
{modules.length <= 1 ? (
|
||||
<div
|
||||
key={modules?.[0]?.id}
|
||||
className={cn("flex h-full flex-shrink-0 cursor-default items-center rounded-md px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<ModuleIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="text-xs">{modules?.[0]?.name ?? "No Modules"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{modules.length} Modules</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { SignalHigh } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssuePriorities } from "@plane/types";
|
||||
// constants
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
|
||||
export const IssueBlockPriority = ({
|
||||
priority,
|
||||
shouldShowName = false,
|
||||
}: {
|
||||
priority: TIssuePriorities | null;
|
||||
shouldShowName?: boolean;
|
||||
}) => {
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null;
|
||||
|
||||
const priorityClasses = {
|
||||
urgent: "bg-red-600/10 text-red-600 border-red-600 px-1",
|
||||
high: "bg-orange-500/20 text-orange-950 border-orange-500",
|
||||
medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500",
|
||||
low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100",
|
||||
none: "hover:bg-custom-background-80 border-custom-border-300",
|
||||
};
|
||||
|
||||
if (priority_detail === null) return <></>;
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={t(priority_detail?.titleTranslationKey || "")}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
|
||||
priorityClasses[priority ?? "none"],
|
||||
{
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": !shouldShowName,
|
||||
// highlight the whole button if text is hidden and priority is urgent
|
||||
"bg-red-600/10 border-red-600": priority === "urgent" && shouldShowName,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{priority ? (
|
||||
<PriorityIcon
|
||||
priority={priority}
|
||||
size={12}
|
||||
className={cn("flex-shrink-0", {
|
||||
// increase the icon size if text is hidden
|
||||
"h-3.5 w-3.5": !shouldShowName,
|
||||
// centre align the icons if text is hidden
|
||||
"translate-x-[0.0625rem]": !shouldShowName && priority === "high",
|
||||
"translate-x-0.5": !shouldShowName && priority === "medium",
|
||||
"translate-x-1": !shouldShowName && priority === "low",
|
||||
// highlight the icon if priority is urgent
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<SignalHigh className="size-3" />
|
||||
)}
|
||||
{shouldShowName && <span className="pl-2 text-sm">{t(priority_detail?.titleTranslationKey || "")}</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
stateId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => {
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div
|
||||
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
<div className="text-xs">{state?.name ?? "State"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
78
apps/space/core/components/issues/issue-layouts/root.tsx
Normal file
78
apps/space/core/components/issues/issue-layouts/root.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root";
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store/use-issue";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// store
|
||||
import type { PublishStore } from "@/store/publish/publish.store";
|
||||
// local imports
|
||||
import { SomethingWentWrongError } from "./error";
|
||||
import { IssueKanbanLayoutRoot } from "./kanban/base-kanban-root";
|
||||
import { IssuesListLayoutRoot } from "./list/base-list-root";
|
||||
|
||||
type Props = {
|
||||
peekId: string | undefined;
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
|
||||
const { peekId, publishSettings } = props;
|
||||
// store hooks
|
||||
const { getIssueFilters } = useIssueFilter();
|
||||
const { fetchPublicIssues } = useIssue();
|
||||
const issueDetailStore = useIssueDetails();
|
||||
// derived values
|
||||
const { anchor } = publishSettings;
|
||||
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLIC_ISSUES_${anchor}` : null,
|
||||
anchor
|
||||
? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 })
|
||||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId) {
|
||||
issueDetailStore.setPeekId(peekId.toString());
|
||||
}
|
||||
}, [peekId, issueDetailStore]);
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
|
||||
{activeLayout && (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{/* applied filters */}
|
||||
<IssueAppliedFilters anchor={anchor} />
|
||||
|
||||
{activeLayout === "list" && (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<IssuesListLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
{activeLayout === "kanban" && (
|
||||
<div className="relative mx-auto h-full w-full p-5">
|
||||
<IssueKanbanLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
240
apps/space/core/components/issues/issue-layouts/utils.tsx
Normal file
240
apps/space/core/components/issues/issue-layouts/utils.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { isNil } from "lodash-es";
|
||||
// types
|
||||
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import type {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TCycleGroups,
|
||||
IIssueDisplayProperties,
|
||||
TGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
// stores
|
||||
import type { ICycleStore } from "@/store/cycle.store";
|
||||
import type { IIssueLabelStore } from "@/store/label.store";
|
||||
import type { IIssueMemberStore } from "@/store/members.store";
|
||||
import type { IIssueModuleStore } from "@/store/module.store";
|
||||
import type { IStateStore } from "@/store/state.store";
|
||||
|
||||
export const HIGHLIGHT_CLASS = "highlight";
|
||||
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
|
||||
|
||||
export const getGroupByColumns = (
|
||||
groupBy: GroupByColumnTypes | null,
|
||||
cycle: ICycleStore,
|
||||
module: IIssueModuleStore,
|
||||
label: IIssueLabelStore,
|
||||
projectState: IStateStore,
|
||||
member: IIssueMemberStore,
|
||||
includeNone?: boolean
|
||||
): IGroupByColumn[] | undefined => {
|
||||
switch (groupBy) {
|
||||
case "cycle":
|
||||
return getCycleColumns(cycle);
|
||||
case "module":
|
||||
return getModuleColumns(module);
|
||||
case "state":
|
||||
return getStateColumns(projectState);
|
||||
case "priority":
|
||||
return getPriorityColumns();
|
||||
case "labels":
|
||||
return getLabelsColumns(label) as any;
|
||||
case "assignees":
|
||||
return getAssigneeColumns(member) as any;
|
||||
case "created_by":
|
||||
return getCreatedByColumns(member) as any;
|
||||
default:
|
||||
if (includeNone) return [{ id: `All Issues`, name: `All work items`, payload: {}, icon: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => {
|
||||
const { cycles } = cycleStore;
|
||||
|
||||
if (!cycles) return;
|
||||
|
||||
const cycleGroups: IGroupByColumn[] = [];
|
||||
|
||||
cycles.map((cycle) => {
|
||||
if (cycle) {
|
||||
const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
cycleGroups.push({
|
||||
id: cycle.id,
|
||||
name: cycle.name,
|
||||
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: cycle.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
cycleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <CycleIcon className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: null },
|
||||
});
|
||||
|
||||
return cycleGroups;
|
||||
};
|
||||
|
||||
const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => {
|
||||
const { modules } = moduleStore;
|
||||
|
||||
if (!modules) return;
|
||||
|
||||
const moduleGroups: IGroupByColumn[] = [];
|
||||
|
||||
modules.map((moduleInfo) => {
|
||||
if (moduleInfo)
|
||||
moduleGroups.push({
|
||||
id: moduleInfo.id,
|
||||
name: moduleInfo.name,
|
||||
icon: <ModuleIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [moduleInfo.id] },
|
||||
});
|
||||
}) as any;
|
||||
moduleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <ModuleIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [] },
|
||||
});
|
||||
|
||||
return moduleGroups as any;
|
||||
};
|
||||
|
||||
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
|
||||
const { sortedStates } = projectState;
|
||||
if (!sortedStates) return;
|
||||
|
||||
return sortedStates.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
icon: (
|
||||
<div className="h-3.5 w-3.5 rounded-full">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.MD} />
|
||||
</div>
|
||||
),
|
||||
payload: { state_id: state.id },
|
||||
})) as any;
|
||||
};
|
||||
|
||||
const getPriorityColumns = () => {
|
||||
const priorities = ISSUE_PRIORITIES;
|
||||
|
||||
return priorities.map((priority) => ({
|
||||
id: priority.key,
|
||||
name: priority.title,
|
||||
icon: <PriorityIcon priority={priority?.key} />,
|
||||
payload: { priority: priority.key },
|
||||
}));
|
||||
};
|
||||
|
||||
const getLabelsColumns = (label: IIssueLabelStore) => {
|
||||
const { labels: storeLabels } = label;
|
||||
|
||||
if (!storeLabels) return;
|
||||
|
||||
const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }];
|
||||
|
||||
return labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
icon: (
|
||||
<div className="h-[12px] w-[12px] rounded-full" style={{ backgroundColor: label.color ? label.color : "#666" }} />
|
||||
),
|
||||
payload: label?.id === "None" ? {} : { label_ids: [label.id] },
|
||||
}));
|
||||
};
|
||||
|
||||
const getAssigneeColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
const assigneeColumns: any = members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: { assignee_ids: [member.id] },
|
||||
}));
|
||||
|
||||
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||
|
||||
return assigneeColumns;
|
||||
};
|
||||
|
||||
const getCreatedByColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
return members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: {},
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDisplayPropertiesCount = (
|
||||
displayProperties: IIssueDisplayProperties,
|
||||
ignoreFields?: (keyof IIssueDisplayProperties)[]
|
||||
) => {
|
||||
const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[];
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const propertyKey of propertyKeys) {
|
||||
if (ignoreFields && ignoreFields.includes(propertyKey)) continue;
|
||||
if (displayProperties[propertyKey]) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
export const getIssueBlockId = (
|
||||
issueId: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId?: string | undefined
|
||||
) => `issue_${issueId}_${groupId}_${subGroupId}`;
|
||||
|
||||
/**
|
||||
* returns empty Array if groupId is None
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
export const getGroupId = (groupId: string) => {
|
||||
if (groupId === "None") return [];
|
||||
return [groupId];
|
||||
};
|
||||
|
||||
/**
|
||||
* method that removes Null or undefined Keys from object
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
export const removeNillKeys = <T,>(obj: T) =>
|
||||
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
|
||||
|
||||
/**
|
||||
* This Method returns if the the grouped values are subGrouped
|
||||
* @param groupedIssueIds
|
||||
* @returns
|
||||
*/
|
||||
export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => {
|
||||
if (!groupedIssueIds || Array.isArray(groupedIssueIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
|
||||
interface IWithDisplayPropertiesHOC {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
|
||||
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WithDisplayPropertiesHOC = observer(
|
||||
({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
|
||||
let shouldDisplayPropertyFromFilters = false;
|
||||
if (Array.isArray(displayPropertyKey))
|
||||
shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]);
|
||||
else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey];
|
||||
|
||||
const renderProperty =
|
||||
shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true);
|
||||
|
||||
if (!renderProperty) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
);
|
||||
127
apps/space/core/components/issues/navbar/controls.tsx
Normal file
127
apps/space/core/components/issues/navbar/controls.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// components
|
||||
import { IssueFiltersDropdown } from "@/components/issues/filters";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
// store
|
||||
import type { PublishStore } from "@/store/publish/publish.store";
|
||||
// types
|
||||
import type { TIssueLayout } from "@/types/issue";
|
||||
// local imports
|
||||
import { IssuesLayoutSelection } from "./layout-selection";
|
||||
import { NavbarTheme } from "./theme";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
export type NavbarControlsProps = {
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
|
||||
// props
|
||||
const { publishSettings } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
// hooks
|
||||
const { getIssueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter();
|
||||
const { setPeekId } = useIssueDetails();
|
||||
// derived values
|
||||
const { anchor, view_props, workspace_detail } = publishSettings;
|
||||
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && workspace_detail) {
|
||||
const viewsAcceptable: string[] = [];
|
||||
let currentBoard: TIssueLayout | null = null;
|
||||
|
||||
if (view_props?.list) viewsAcceptable.push("list");
|
||||
if (view_props?.kanban) viewsAcceptable.push("kanban");
|
||||
if (view_props?.calendar) viewsAcceptable.push("calendar");
|
||||
if (view_props?.gantt) viewsAcceptable.push("gantt");
|
||||
if (view_props?.spreadsheet) viewsAcceptable.push("spreadsheet");
|
||||
|
||||
if (board) {
|
||||
if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout;
|
||||
else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout;
|
||||
}
|
||||
} else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout;
|
||||
}
|
||||
|
||||
if (currentBoard) {
|
||||
if (activeLayout === undefined || activeLayout !== currentBoard) {
|
||||
const { query, queryParam } = queryParamGenerator({ board: currentBoard, peekId, priority, state, labels });
|
||||
const params: any = {
|
||||
display_filters: { layout: (query?.board as string[])[0] },
|
||||
filters: {
|
||||
priority: query?.priority ?? undefined,
|
||||
state: query?.state ?? undefined,
|
||||
labels: query?.labels ?? undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (!isIssueFiltersUpdated(anchor, params)) {
|
||||
initIssueFilters(anchor, params);
|
||||
router.push(`/issues/${anchor}?${queryParam}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
anchor,
|
||||
board,
|
||||
labels,
|
||||
state,
|
||||
priority,
|
||||
peekId,
|
||||
activeLayout,
|
||||
router,
|
||||
initIssueFilters,
|
||||
setPeekId,
|
||||
isIssueFiltersUpdated,
|
||||
view_props,
|
||||
workspace_detail,
|
||||
]);
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* issue views */}
|
||||
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
|
||||
<IssuesLayoutSelection anchor={anchor} />
|
||||
</div>
|
||||
|
||||
{/* issue filters */}
|
||||
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
|
||||
<IssueFiltersDropdown anchor={anchor} />
|
||||
</div>
|
||||
|
||||
{/* theming */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<NavbarTheme />
|
||||
</div>
|
||||
|
||||
{!isInIframe && <UserAvatar />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/space/core/components/issues/navbar/index.ts
Normal file
1
apps/space/core/components/issues/navbar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
14
apps/space/core/components/issues/navbar/layout-icon.tsx
Normal file
14
apps/space/core/components/issues/navbar/layout-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { List, Kanban } from "lucide-react";
|
||||
import type { TIssueLayout } from "@plane/constants";
|
||||
|
||||
export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => {
|
||||
switch (layout) {
|
||||
case "list":
|
||||
return <List {...props} />;
|
||||
case "kanban":
|
||||
return <Kanban {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// ui
|
||||
import { SITES_ISSUE_LAYOUTS } from "@plane/constants";
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// mobx
|
||||
import type { TIssueLayout } from "@/types/issue";
|
||||
import { IssueLayoutIcon } from "./layout-icon";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesLayoutSelection: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const labels = searchParams.get("labels");
|
||||
const state = searchParams.get("state");
|
||||
const priority = searchParams.get("priority");
|
||||
const peekId = searchParams.get("peekId");
|
||||
// hooks
|
||||
const { layoutOptions, getIssueFilters, updateIssueFilters } = useIssueFilter();
|
||||
// derived values
|
||||
const issueFilters = getIssueFilters(anchor);
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
const handleCurrentBoardView = (boardView: TIssueLayout) => {
|
||||
updateIssueFilters(anchor, "display_filters", "layout", boardView);
|
||||
const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels });
|
||||
router.push(`/issues/${anchor}?${queryParam}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
|
||||
{SITES_ISSUE_LAYOUTS.map((layout) => {
|
||||
if (!layoutOptions[layout.key]) return;
|
||||
|
||||
return (
|
||||
<Tooltip key={layout.key} tooltipContent={t(layout.titleTranslationKey)}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
|
||||
activeLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => handleCurrentBoardView(layout.key)}
|
||||
>
|
||||
<IssueLayoutIcon
|
||||
layout={layout.key}
|
||||
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
45
apps/space/core/components/issues/navbar/root.tsx
Normal file
45
apps/space/core/components/issues/navbar/root.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
// components
|
||||
import { ProjectLogo } from "@/components/common/project-logo";
|
||||
// store
|
||||
import type { PublishStore } from "@/store/publish/publish.store";
|
||||
// local imports
|
||||
import { NavbarControls } from "./controls";
|
||||
|
||||
type Props = {
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
export const IssuesNavbarRoot: FC<Props> = observer((props) => {
|
||||
const { publishSettings } = props;
|
||||
// hooks
|
||||
const { project_details } = publishSettings;
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-between w-full gap-4 px-5">
|
||||
{/* project detail */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{project_details ? (
|
||||
<span className="h-7 w-7 flex-shrink-0 grid place-items-center">
|
||||
<ProjectLogo logo={project_details.logo_props} className="text-lg" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium">
|
||||
{project_details?.name || `...`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<NavbarControls publishSettings={publishSettings} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
33
apps/space/core/components/issues/navbar/theme.tsx
Normal file
33
apps/space/core/components/issues/navbar/theme.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
// next theme
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// mobx react lite
|
||||
|
||||
export const NavbarTheme = observer(() => {
|
||||
const [appTheme, setAppTheme] = useState("light");
|
||||
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const handleTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!theme) return;
|
||||
setAppTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTheme}
|
||||
className="relative grid h-7 w-7 place-items-center rounded bg-custom-background-100 text-custom-text-100 hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="material-symbols-rounded text-sm">{appTheme === "light" ? "dark_mode" : "light_mode"}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
128
apps/space/core/components/issues/navbar/user-avatar.tsx
Normal file
128
apps/space/core/components/issues/navbar/user-avatar.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const UserAvatar: FC = observer(() => {
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
// hooks
|
||||
const { data: currentUser, signOut } = useUser();
|
||||
// states
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (csrfToken === undefined)
|
||||
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||
}, [csrfToken]);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-end",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 40],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
|
||||
return (
|
||||
<div className="relative mr-2">
|
||||
{currentUser?.id ? (
|
||||
<div>
|
||||
<Popover as="div">
|
||||
<Popover.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
className="flex items-center gap-2 rounded border border-custom-border-200 p-2"
|
||||
>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
shape="square"
|
||||
size="sm"
|
||||
showTooltip={false}
|
||||
/>
|
||||
<h6 className="text-xs font-medium">
|
||||
{currentUser?.display_name ||
|
||||
`${currentUser?.first_name} ${currentUser?.first_name}` ||
|
||||
currentUser?.email ||
|
||||
"User"}
|
||||
</h6>
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={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"
|
||||
>
|
||||
<Popover.Panel>
|
||||
<div
|
||||
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg p-1"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{csrfToken && (
|
||||
<form method="POST" action={`${API_BASE_URL}/auth/spaces/sign-out/`} onSubmit={signOut}>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="next_path" value={`${pathName}?${queryParam}`} />
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 rounded p-2 whitespace-nowrap hover:bg-custom-background-80 text-sm min-w-36 cursor-pointer"
|
||||
>
|
||||
<LogOut size={12} className="flex-shrink-0 text-red-500" />
|
||||
<div>Sign out</div>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/?next_path=${pathName}?${queryParam}`}>
|
||||
<Button variant="outline-primary">Sign in</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { SitesFileService } from "@plane/services";
|
||||
import type { TIssuePublicComment } from "@plane/types";
|
||||
// editor components
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
// services
|
||||
const fileService = new SitesFileService();
|
||||
|
||||
const defaultValues: Partial<TIssuePublicComment> = {
|
||||
comment_html: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// states
|
||||
const [uploadedAssetIds, setUploadAssetIds] = useState<string[]>([]);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails();
|
||||
const { data: currentUser } = useUser();
|
||||
const { workspace: workspaceID } = usePublish(anchor);
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<TIssuePublicComment>({ defaultValues });
|
||||
|
||||
const onSubmit = async (formData: TIssuePublicComment) => {
|
||||
if (!anchor || !issueId || isSubmitting || !formData.comment_html) return;
|
||||
|
||||
await addIssueComment(anchor, issueId, formData)
|
||||
.then(async (res) => {
|
||||
reset(defaultValues);
|
||||
editorRef.current?.clearEditor();
|
||||
if (uploadedAssetIds.length > 0) {
|
||||
await fileService.updateBulkAssetsUploadStatus(anchor, res.id, {
|
||||
asset_ids: uploadedAssetIds,
|
||||
});
|
||||
setUploadAssetIds([]);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Comment could not be posted. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: on click if he user is not logged in redirect to login page
|
||||
return (
|
||||
<div>
|
||||
<div className="issue-comments-section">
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<LiteTextEditor
|
||||
editable
|
||||
onEnterKeyPress={(e) => {
|
||||
if (currentUser) handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
anchor={anchor}
|
||||
workspaceId={workspaceID?.toString() ?? ""}
|
||||
ref={editorRef}
|
||||
id="peek-overview-add-comment"
|
||||
initialValue={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("comment_html")
|
||||
: value
|
||||
}
|
||||
onChange={(comment_json, comment_html) => onChange(comment_html)}
|
||||
isSubmitting={isSubmitting}
|
||||
placeholder="Add comment..."
|
||||
uploadFile={async (blockId, file) => {
|
||||
const { asset_id } = await uploadCommentAsset(file, anchor);
|
||||
setUploadAssetIds((prev) => [...prev, asset_id]);
|
||||
return asset_id;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Check, MessageSquare, MoreVertical, X } from "lucide-react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { TIssuePublicComment } from "@plane/types";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
|
||||
import { CommentReactions } from "@/components/issues/peek-overview/comment/comment-reactions";
|
||||
// helpers
|
||||
import { timeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
comment: TIssuePublicComment;
|
||||
};
|
||||
|
||||
export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
const { anchor, comment } = props;
|
||||
// store hooks
|
||||
const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails();
|
||||
const { data: currentUser } = useUser();
|
||||
const { workspace: workspaceID } = usePublish(anchor);
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
// states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const showEditorRef = useRef<EditorRefApi>(null);
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
} = useForm<any>({
|
||||
defaultValues: { comment_html: comment.comment_html },
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!anchor || !peekId) return;
|
||||
deleteIssueComment(anchor, peekId, comment.id);
|
||||
};
|
||||
|
||||
const handleCommentUpdate = async (formData: TIssuePublicComment) => {
|
||||
if (!anchor || !peekId) return;
|
||||
updateIssueComment(anchor, peekId, comment.id, formData);
|
||||
setIsEditing(false);
|
||||
editorRef.current?.setEditorValue(formData.comment_html);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileURL(comment.actor_detail.avatar_url)}
|
||||
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">
|
||||
<MessageSquare className="h-3 w-3 text-custom-text-200" aria-hidden="true" strokeWidth={2} />
|
||||
</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
|
||||
onSubmit={handleSubmit(handleCommentUpdate)}
|
||||
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||
>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment_html"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<LiteTextEditor
|
||||
editable
|
||||
anchor={anchor}
|
||||
workspaceId={workspaceID?.toString() ?? ""}
|
||||
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
|
||||
ref={editorRef}
|
||||
id={comment.id}
|
||||
initialValue={value}
|
||||
value={null}
|
||||
onChange={(comment_json, comment_html) => onChange(comment_html)}
|
||||
isSubmitting={isSubmitting}
|
||||
showSubmitButton={false}
|
||||
uploadFile={async (blockId, file) => {
|
||||
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
|
||||
return asset_id;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" strokeWidth={2} />
|
||||
</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)}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<LiteTextEditor
|
||||
editable={false}
|
||||
anchor={anchor}
|
||||
workspaceId={workspaceID?.toString() ?? ""}
|
||||
ref={showEditorRef}
|
||||
id={comment.id}
|
||||
initialValue={comment.comment_html}
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
/>
|
||||
<CommentReactions anchor={anchor} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isInIframe && currentUser?.id === comment?.actor_detail?.id && (
|
||||
<Menu as="div" className="relative w-min text-left">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
onClick={() => {}}
|
||||
className="relative grid cursor-pointer place-items-center rounded p-1 text-custom-text-200 outline-none hover:bg-custom-background-80 hover:text-custom-text-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4 text-custom-text-200 duration-300" strokeWidth={2} />
|
||||
</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 right-0 z-10 mt-1 max-h-36 min-w-[8rem] origin-top-right overflow-auto overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-lg focus:outline-none">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// ui
|
||||
import { ReactionSelector } from "@/components/ui";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
commentId: string;
|
||||
};
|
||||
|
||||
export const CommentReactions: React.FC<Props> = observer((props) => {
|
||||
const { anchor, commentId } = props;
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
|
||||
// hooks
|
||||
const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails();
|
||||
const { data: user } = useUser();
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : [];
|
||||
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
|
||||
|
||||
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
|
||||
|
||||
const handleAddReaction = (reactionHex: string) => {
|
||||
if (!anchor || !peekId) return;
|
||||
addCommentReaction(anchor, peekId, commentId, reactionHex);
|
||||
};
|
||||
|
||||
const handleRemoveReaction = (reactionHex: string) => {
|
||||
if (!anchor || !peekId) return;
|
||||
removeCommentReaction(anchor, peekId, commentId, reactionHex);
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
|
||||
if (userReaction) handleRemoveReaction(reactionHex);
|
||||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{!isInIframe && (
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
if (user) handleReactionClick(value);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
position="top"
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
key={reaction}
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
.map((r) => r?.actor_detail?.display_name)
|
||||
.splice(0, REACTIONS_LIMIT)
|
||||
.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isInIframe) return;
|
||||
if (user) handleReactionClick(reaction);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={cn(
|
||||
`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`,
|
||||
{
|
||||
"cursor-default": isInIframe,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import type { IIssue } from "@/types/issue";
|
||||
// local imports
|
||||
import { PeekOverviewHeader } from "./header";
|
||||
import { PeekOverviewIssueActivity } from "./issue-activity";
|
||||
import { PeekOverviewIssueDetails } from "./issue-details";
|
||||
import { PeekOverviewIssueProperties } from "./issue-properties";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
export const FullScreenPeekView: React.FC<Props> = observer((props) => {
|
||||
const { anchor, handleClose, issueDetails } = props;
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
|
||||
<div className="col-span-7 flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="w-full p-5">
|
||||
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{issueDetails ? (
|
||||
<div className="h-full w-full overflow-y-auto px-6">
|
||||
{/* issue title and description */}
|
||||
<div className="w-full">
|
||||
<PeekOverviewIssueDetails anchor={anchor} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="my-5 h-[1] w-full border-t border-custom-border-200" />
|
||||
{/* issue activity/comments */}
|
||||
<div className="w-full pb-5">
|
||||
<PeekOverviewIssueActivity anchor={anchor} issueDetails={issueDetails} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="px-6">
|
||||
<Loader.Item height="30px" />
|
||||
<div className="mt-3 space-y-2">
|
||||
<Loader.Item height="20px" width="70%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 h-full w-full overflow-y-auto">
|
||||
{/* issue properties */}
|
||||
<div className="w-full px-6 py-5">
|
||||
{issueDetails ? (
|
||||
<PeekOverviewIssueProperties issueDetails={issueDetails} />
|
||||
) : (
|
||||
<Loader className="mt-11 space-y-4">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
129
apps/space/core/components/issues/peek-overview/header.tsx
Normal file
129
apps/space/core/components/issues/peek-overview/header.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link2, MoveRight } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import useClipboardWritePermission from "@/hooks/use-clipboard-write-permission";
|
||||
// types
|
||||
import type { IIssue, IPeekMode } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
const PEEK_MODES: {
|
||||
key: IPeekMode;
|
||||
icon: any;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "side", icon: SidePanelIcon, label: "Side Peek" },
|
||||
{
|
||||
key: "modal",
|
||||
icon: CenterPanelIcon,
|
||||
label: "Modal",
|
||||
},
|
||||
{
|
||||
key: "full",
|
||||
icon: FullScreenPanelIcon,
|
||||
label: "Full Screen",
|
||||
},
|
||||
];
|
||||
|
||||
export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
|
||||
const { peekMode, setPeekMode } = useIssueDetails();
|
||||
const isClipboardWriteAllowed = useClipboardWritePermission();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const urlToCopy = window.location.href;
|
||||
|
||||
copyTextToClipboard(urlToCopy).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Work item link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{peekMode === "side" && (
|
||||
<button type="button" onClick={handleClose} className="text-custom-text-300 hover:text-custom-text-200">
|
||||
<MoveRight className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<Listbox
|
||||
as="div"
|
||||
value={peekMode}
|
||||
onChange={(val) => setPeekMode(val)}
|
||||
className="relative flex-shrink-0 text-left"
|
||||
>
|
||||
<Listbox.Button
|
||||
className={`grid place-items-center text-custom-text-300 hover:text-custom-text-200 ${peekMode === "full" ? "rotate-45" : ""}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</Listbox.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"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-10 mt-1 min-w-[12rem] origin-top-left overflow-y-auto whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-lg focus:outline-none">
|
||||
<div className="space-y-1 p-2">
|
||||
{PEEK_MODES.map((mode) => (
|
||||
<Listbox.Option
|
||||
key={mode.key}
|
||||
value={mode.key}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
|
||||
{mode.label}
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</div>
|
||||
{isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
className="focus:outline-none text-custom-text-300 hover:text-custom-text-200"
|
||||
tabIndex={1}
|
||||
>
|
||||
<Link2 className="h-4 w-4 -rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/space/core/components/issues/peek-overview/index.ts
Normal file
1
apps/space/core/components/issues/peek-overview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./layout";
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
// components
|
||||
import { AddComment } from "@/components/issues/peek-overview/comment/add-comment";
|
||||
import { CommentCard } from "@/components/issues/peek-overview/comment/comment-detail-card";
|
||||
import { Icon } from "@/components/ui";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
// types
|
||||
import type { IIssue } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { details, peekId } = useIssueDetails();
|
||||
const { data: currentUser } = useUser();
|
||||
const { canComment } = usePublish(anchor);
|
||||
// derived values
|
||||
const comments = details[peekId || ""]?.comments || [];
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<h4 className="font-medium">Comments</h4>
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentCard key={comment.id} anchor={anchor} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
{!isInIframe &&
|
||||
(currentUser ? (
|
||||
<>
|
||||
{canComment && (
|
||||
<div className="mt-4">
|
||||
<AddComment anchor={anchor} disabled={!currentUser} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded border border-custom-border-300 bg-custom-background-80 px-2 py-2.5">
|
||||
<p className="flex gap-2 overflow-hidden break-words text-sm text-custom-text-200">
|
||||
<Icon iconName="lock" className="!text-sm" />
|
||||
Sign in to add your comment
|
||||
</p>
|
||||
<Link href={`/?next_path=${pathname}`}>
|
||||
<Button variant="primary">Sign in</Button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { RichTextEditor } from "@/components/editor/rich-text-editor";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
// types
|
||||
import type { IIssue } from "@/types/issue";
|
||||
// local imports
|
||||
import { IssueReactions } from "./issue-reaction";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
|
||||
const { anchor, issueDetails } = props;
|
||||
// store hooks
|
||||
const { project_details, workspace: workspaceID } = usePublish(anchor);
|
||||
// derived values
|
||||
const description = issueDetails.description_html;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h6 className="text-base font-medium text-custom-text-400">
|
||||
{project_details?.identifier}-{issueDetails?.sequence_id}
|
||||
</h6>
|
||||
<h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
|
||||
{description && description !== "" && description !== "<p></p>" && (
|
||||
<RichTextEditor
|
||||
editable={false}
|
||||
anchor={anchor}
|
||||
id={issueDetails.id}
|
||||
initialValue={description}
|
||||
workspaceId={workspaceID?.toString() ?? ""}
|
||||
/>
|
||||
)}
|
||||
<IssueReactions anchor={anchor} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CalendarCheck2, Signal } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
// types
|
||||
import type { IIssue, IPeekMode } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
mode?: IPeekMode;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDetails, mode }) => {
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const { getStateById } = useStates();
|
||||
const state = getStateById(issueDetails?.state_id ?? undefined);
|
||||
|
||||
const { anchor } = useParams();
|
||||
|
||||
const { project_details } = usePublish(anchor?.toString());
|
||||
|
||||
const priority = issueDetails.priority ? getIssuePriorityFilters(issueDetails.priority) : null;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const urlToCopy = window.location.href;
|
||||
|
||||
copyTextToClipboard(urlToCopy).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Link copied!",
|
||||
message: "Work item link copied to clipboard",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={mode === "full" ? "divide-y divide-custom-border-200" : ""}>
|
||||
{mode === "full" && (
|
||||
<div className="flex justify-between gap-2 pb-3">
|
||||
<h6 className="flex items-center gap-2 font-medium">
|
||||
{project_details?.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||
<Icon iconName="link" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`space-y-2 ${mode === "full" ? "pt-3" : ""}`}>
|
||||
<div className="flex items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<DoubleCircleIcon className="size-4 flex-shrink-0" />
|
||||
<span>State</span>
|
||||
</div>
|
||||
<div className="w-3/4 flex items-center gap-1.5 py-0.5 text-sm">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
{addSpaceIfCamelCase(state?.name ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<Signal className="size-4 flex-shrink-0" />
|
||||
<span>Priority</span>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-0.5 text-left text-sm capitalize ${
|
||||
priority?.key === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: priority?.key === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: priority?.key === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: priority?.key === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-custom-border-200 bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
{priority && (
|
||||
<span className="-my-1 grid place-items-center">
|
||||
<Icon iconName={priority?.icon} />
|
||||
</span>
|
||||
)}
|
||||
<span>{t(priority?.titleTranslationKey || "common.none")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 h-8">
|
||||
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
|
||||
<CalendarCheck2 className="size-4 flex-shrink-0" />
|
||||
<span>Due date</span>
|
||||
</div>
|
||||
<div>
|
||||
{issueDetails.target_date ? (
|
||||
<div
|
||||
className={cn("flex items-center gap-1.5 rounded py-0.5 text-xs text-custom-text-100", {
|
||||
"text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
|
||||
})}
|
||||
>
|
||||
<CalendarCheck2 className="size-3" />
|
||||
{renderFormattedDate(issueDetails.target_date)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-custom-text-200">Empty</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
|
||||
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssueReactions: React.FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { canVote, canReact } = usePublish(anchor);
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
{canVote && (
|
||||
<div className="flex items-center gap-2">
|
||||
<IssueVotes anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
{!isInIframe && canReact && (
|
||||
<div className="flex items-center gap-2">
|
||||
<IssueEmojiReactions anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
136
apps/space/core/components/issues/peek-overview/layout.tsx
Normal file
136
apps/space/core/components/issues/peek-overview/layout.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
// local imports
|
||||
import { FullScreenPeekView } from "./full-screen-peek-view";
|
||||
import { SidePeekView } from "./side-peek-view";
|
||||
|
||||
type TIssuePeekOverview = {
|
||||
anchor: string;
|
||||
peekId: string;
|
||||
handlePeekClose?: () => void;
|
||||
};
|
||||
|
||||
export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
||||
const { anchor, peekId, handlePeekClose } = props;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// states
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
|
||||
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
|
||||
// store
|
||||
const issueDetailStore = useIssueDetails();
|
||||
|
||||
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId) {
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}, [anchor, issueDetailStore, peekId]);
|
||||
|
||||
const handleClose = () => {
|
||||
// if close logic is passed down, call that instead of the below logic
|
||||
if (handlePeekClose) {
|
||||
handlePeekClose();
|
||||
return;
|
||||
}
|
||||
|
||||
issueDetailStore.setPeekId(null);
|
||||
let queryParams: any = {
|
||||
board,
|
||||
};
|
||||
if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority };
|
||||
if (state && state.length > 0) queryParams = { ...queryParams, state: state };
|
||||
if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels };
|
||||
queryParams = new URLSearchParams(queryParams).toString();
|
||||
router.push(`/issues/${anchor}?${queryParams}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId) {
|
||||
if (issueDetailStore.peekMode === "side") {
|
||||
setIsSidePeekOpen(true);
|
||||
setIsModalPeekOpen(false);
|
||||
} else {
|
||||
setIsModalPeekOpen(true);
|
||||
setIsSidePeekOpen(false);
|
||||
}
|
||||
} else {
|
||||
setIsSidePeekOpen(false);
|
||||
setIsModalPeekOpen(false);
|
||||
}
|
||||
}, [peekId, issueDetailStore.peekMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root appear show={isSidePeekOpen} as={Fragment}>
|
||||
<Dialog as="div" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-transform duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="fixed right-0 top-0 z-20 h-full w-1/2 bg-custom-background-100 shadow-custom-shadow-sm">
|
||||
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
<Transition.Root appear show={isModalPeekOpen} as={Fragment}>
|
||||
<Dialog as="div" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-20 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Panel>
|
||||
<div
|
||||
className={`fixed left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-custom-background-100 shadow-custom-shadow-xl transition-all duration-300 ${
|
||||
issueDetailStore.peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
}`}
|
||||
>
|
||||
{issueDetailStore.peekMode === "modal" && (
|
||||
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
{issueDetailStore.peekMode === "full" && (
|
||||
<FullScreenPeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
// store hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
// types
|
||||
import type { IIssue } from "@/types/issue";
|
||||
// local imports
|
||||
import { PeekOverviewHeader } from "./header";
|
||||
import { PeekOverviewIssueActivity } from "./issue-activity";
|
||||
import { PeekOverviewIssueDetails } from "./issue-details";
|
||||
import { PeekOverviewIssueProperties } from "./issue-properties";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
handleClose: () => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
};
|
||||
|
||||
export const SidePeekView: React.FC<Props> = observer((props) => {
|
||||
const { anchor, handleClose, issueDetails } = props;
|
||||
// store hooks
|
||||
const { canComment } = usePublish(anchor);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="w-full p-5">
|
||||
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{issueDetails ? (
|
||||
<div className="h-full w-full overflow-y-auto px-6">
|
||||
{/* issue title and description */}
|
||||
<div className="w-full">
|
||||
<PeekOverviewIssueDetails anchor={anchor} issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* issue properties */}
|
||||
<div className="mt-6 w-full">
|
||||
<PeekOverviewIssueProperties issueDetails={issueDetails} />
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="my-5 h-[1] w-full border-t border-custom-border-200" />
|
||||
{/* issue activity/comments */}
|
||||
{canComment && (
|
||||
<div className="w-full pb-5">
|
||||
<PeekOverviewIssueActivity anchor={anchor} issueDetails={issueDetails} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="px-6">
|
||||
<Loader.Item height="30px" />
|
||||
<div className="mt-3 space-y-2">
|
||||
<Loader.Item height="20px" width="70%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
<Loader.Item height="20px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
// lib
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { ReactionSelector } from "@/components/ui";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
|
||||
type IssueEmojiReactionsProps = {
|
||||
anchor: string;
|
||||
issueIdFromProps?: string;
|
||||
size?: "md" | "sm";
|
||||
};
|
||||
|
||||
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
|
||||
const { anchor, issueIdFromProps, size = "md" } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// store hooks
|
||||
const issueDetailsStore = useIssueDetails();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
|
||||
const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
||||
const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id);
|
||||
|
||||
const handleAddReaction = (reactionHex: string) => {
|
||||
if (!issueId) return;
|
||||
issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex);
|
||||
};
|
||||
|
||||
const handleRemoveReaction = (reactionHex: string) => {
|
||||
if (!issueId) return;
|
||||
issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex);
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex);
|
||||
if (userReaction) handleRemoveReaction(reactionHex);
|
||||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
if (user) handleReactionClick(value);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size={size}
|
||||
/>
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
key={reaction}
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
?.map((r) => r?.actor_details?.display_name)
|
||||
?.splice(0, REACTIONS_LIMIT)
|
||||
?.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (user) handleReactionClick(reaction);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-md text-sm text-custom-text-100 ${
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
} ${reactionDimensions}`}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
import useIsInIframe from "@/hooks/use-is-in-iframe";
|
||||
|
||||
type TIssueVotes = {
|
||||
anchor: string;
|
||||
issueIdFromProps?: string;
|
||||
size?: "md" | "sm";
|
||||
};
|
||||
|
||||
export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
||||
const { anchor, issueIdFromProps, size = "md" } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// store hooks
|
||||
const issueDetailsStore = useIssueDetails();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const isInIframe = useIsInIframe();
|
||||
|
||||
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
|
||||
|
||||
const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? [];
|
||||
|
||||
const allUpVotes = votes.filter((vote) => vote.vote === 1);
|
||||
const allDownVotes = votes.filter((vote) => vote.vote === -1);
|
||||
|
||||
const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id);
|
||||
const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id);
|
||||
|
||||
const handleVote = async (e: any, voteValue: 1 | -1) => {
|
||||
if (!issueId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue);
|
||||
|
||||
if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId);
|
||||
else {
|
||||
await issueDetailsStore.addIssueVote(anchor, issueId, {
|
||||
vote: voteValue,
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const VOTES_LIMIT = 1000;
|
||||
|
||||
// derived values
|
||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||
const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* upvote button 👇 */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
{allUpVotes.length > 0 ? (
|
||||
<>
|
||||
{allUpVotes
|
||||
.map((r) => r.actor_details?.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"}
|
||||
</>
|
||||
) : (
|
||||
"No upvotes yet"
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
if (isInIframe) return;
|
||||
if (user) handleVote(e, 1);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
|
||||
votingDimensions,
|
||||
{
|
||||
"border-custom-primary-200 text-custom-primary-200": isUpVotedByUser,
|
||||
"border-custom-border-300": !isUpVotedByUser,
|
||||
"cursor-default": isInIframe,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-rounded !m-0 !p-0 text-base">arrow_upward_alt</span>
|
||||
<span className="text-sm font-normal transition-opacity ease-in-out">{allUpVotes.length}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* downvote button 👇 */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div>
|
||||
{allDownVotes.length > 0 ? (
|
||||
<>
|
||||
{allDownVotes
|
||||
.map((r) => r.actor_details.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"}
|
||||
</>
|
||||
) : (
|
||||
"No downvotes yet"
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
if (isInIframe) return;
|
||||
if (user) handleVote(e, -1);
|
||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
|
||||
votingDimensions,
|
||||
{
|
||||
"border-red-600 text-red-600": isDownVotedByUser,
|
||||
"border-custom-border-300": !isDownVotedByUser,
|
||||
"cursor-default": isInIframe,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-rounded !m-0 !p-0 text-base">arrow_downward_alt</span>
|
||||
<span className="text-sm font-normal transition-opacity ease-in-out">{allDownVotes.length}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user