Merge branch 'develop' of gurusainath:makeplane/plane into fix/kanban-sorting

This commit is contained in:
gurusainath 2023-09-08 12:42:29 +05:30
commit aef71fbc45
58 changed files with 1416 additions and 508 deletions

View File

@ -1575,7 +1575,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
)
)
.distinct()
)
).order_by("created_at")
else:
return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist:
@ -2195,6 +2195,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)

View File

@ -482,7 +482,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@ -924,8 +924,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
project_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Project.DoesNotExist:
return Response(
{"error": "The requested resource does not exists"},

View File

@ -532,7 +532,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
# Delete joined workspace invites
workspace_invitations.delete()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
@ -846,7 +846,7 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
workspace_member.view_props = request.data.get("view_props", {})
workspace_member.save()
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "User not a member of workspace"},

View File

@ -1,36 +1,36 @@
# base requirements
Django==4.2.3
Django==4.2.5
django-braces==1.15.0
django-taggit==4.0.0
psycopg==3.1.9
psycopg==3.1.10
django-oauth-toolkit==2.3.0
mistune==3.0.1
djangorestframework==3.14.0
redis==4.6.0
django-nested-admin==4.0.2
django-cors-headers==4.1.0
django-cors-headers==4.2.0
whitenoise==6.5.0
django-allauth==0.54.0
django-allauth==0.55.2
faker==18.11.2
django-filter==23.2
jsonmodels==2.6.0
djangorestframework-simplejwt==5.2.2
sentry-sdk==1.27.0
djangorestframework-simplejwt==5.3.0
sentry-sdk==1.30.0
django-s3-storage==0.14.0
django-crum==0.7.9
django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.21.0
google-api-python-client==2.92.0
google-auth==2.22.0
google-api-python-client==2.97.0
django-redis==5.3.0
uvicorn==0.22.0
uvicorn==0.23.2
channels==4.0.0
openai==0.27.8
openai==0.28.0
slack-sdk==3.21.3
celery==5.3.1
celery==5.3.4
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
psycopg-binary==3.1.10
psycopg-c==3.1.10
scout-apm==2.26.1
openpyxl==3.1.2

View File

@ -1,11 +1,11 @@
-r base.txt
dj-database-url==2.0.0
gunicorn==20.1.0
dj-database-url==2.1.0
gunicorn==21.2.0
whitenoise==6.5.0
django-storages==1.13.2
boto3==1.27.0
django-anymail==10.0
django-storages==1.14
boto3==1.28.40
django-anymail==10.1
django-debug-toolbar==4.1.0
gevent==23.7.0
psycogreen==1.0.2

View File

@ -131,7 +131,7 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none px-3 py-2 text-sm`}
>
<span className="text-custom-text-400">{value || "Select your role..."}</span>
<span className={value ? "" : "text-custom-text-400"}>{value || "Select your role..."}</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Listbox.Button>

View File

@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
// images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg";
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
export const SignInView = observer(() => {
const { user: userStore } = useMobxStore();

View File

@ -1,17 +1,9 @@
"use client";
// helpers
import { renderFullDate } from "constants/helpers";
import { renderFullDate } from "helpers/date-time.helper";
export const findHowManyDaysLeft = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
return Math.ceil(timeDiff / (1000 * 3600 * 24));
};
const dueDateIcon = (
export const dueDateIconDetails = (
date: string,
stateGroup: string
): {
@ -26,17 +18,24 @@ const dueDateIcon = (
className = "";
} else {
const today = new Date();
const dueDate = new Date(date);
today.setHours(0, 0, 0, 0);
const targetDate = new Date(date);
targetDate.setHours(0, 0, 0, 0);
if (dueDate < today) {
const timeDifference = targetDate.getTime() - today.getTime();
if (timeDifference < 0) {
iconName = "event_busy";
className = "text-red-500";
} else if (dueDate > today) {
iconName = "calendar_today";
className = "";
} else {
} else if (timeDifference === 0) {
iconName = "today";
className = "text-red-500";
} else if (timeDifference === 24 * 60 * 60 * 1000) {
iconName = "event";
className = "text-yellow-500";
} else {
iconName = "calendar_today";
className = "";
}
}
@ -47,7 +46,7 @@ const dueDateIcon = (
};
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
const iconDetails = dueDateIcon(due_date, group);
const iconDetails = dueDateIconDetails(due_date, group);
return (
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs">

View File

@ -2,9 +2,10 @@
import useToast from "hooks/use-toast";
// icons
import { Icon } from "components/ui";
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
// helpers
import { renderDateFormat } from "constants/helpers";
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
import { renderFullDate } from "helpers/date-time.helper";
import { dueDateIconDetails } from "../board-views/block-due-date";
// types
import { IIssue } from "types/issue";
import { IPeekMode } from "store/issue_details";
@ -16,35 +17,16 @@ type Props = {
mode?: IPeekMode;
};
const validDate = (date: any, state: string): string => {
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
else {
const today = new Date();
const dueDate = new Date(date);
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
else return `bg-green-500/10 text-green-500 border-green-500/50`;
}
};
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mode }) => {
const { setToastAlert } = useToast();
const startDate = issueDetails.start_date;
const targetDate = issueDetails.target_date;
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
const state = issueDetails.state_detail;
const stateGroup = issueGroupFilter(state.group);
const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null;
const dueDateIcon = dueDateIconDetails(issueDetails.target_date, state.group);
const handleCopyLink = () => {
const urlToCopy = window.location.href;
@ -125,11 +107,11 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
</div>
<div>
{issueDetails.target_date ? (
<div
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
${validDate(issueDetails.target_date, state)}`}
>
{renderDateFormat(issueDetails.target_date)}
<div className="h-6 rounded flex items-center gap-1 px-2.5 py-1 border border-custom-border-100 text-custom-text-100 text-xs bg-custom-background-80">
<span className={`material-symbols-rounded text-sm -my-0.5 ${dueDateIcon.className}`}>
{dueDateIcon.iconName}
</span>
{renderFullDate(issueDetails.target_date)}
</div>
) : (
<span className="text-custom-text-200">Empty</span>

View File

@ -77,14 +77,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
{!props.editor.isActive("table") && (
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}

View File

@ -28,7 +28,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "H1",
@ -69,7 +72,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
{
name: "Quote",
icon: TextQuote,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: () => editor.isActive("blockquote"),
},
{

View File

@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript";
@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
lowlight.registerLanguage("ts", ts);
@ -27,113 +32,122 @@ export const TiptapExtensions = (
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
},
code: {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow,
];

View File

@ -0,0 +1,32 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => {
isHeader: element.tagName === "TD";
},
renderHTML: (attributes) => {
tag: attributes.isHeader ? "th" : "td";
},
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
["span", { class: "absolute top-0 right-0" }],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View File

@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph",
});
export { TableHeader };

View File

@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true,
});
export { Table };

View File

@ -6,6 +6,7 @@ import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
export interface ITipTapRichTextEditor {
value: string;
@ -37,6 +38,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
@ -81,8 +83,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
@ -98,6 +100,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
<TableMenu editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>

View File

@ -1,43 +1,51 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
const TrackImageDeletionPlugin = () =>
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const TrackImageDeletionPlugin = (): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== "image") return;
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== "image") {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach((node) => {
removedImages.forEach(async (node) => {
const src = node.attrs.src;
onNodeDeleted(src);
await onNodeDeleted(src);
});
});
@ -47,10 +55,14 @@ const TrackImageDeletionPlugin = () =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
async function onNodeDeleted(src: string): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@ -1,4 +1,3 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
@ -46,7 +45,11 @@ export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id);
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
);
return found.length ? found[0].from : null;
}
@ -59,8 +62,6 @@ export async function startImageUpload(
) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};
@ -93,7 +94,9 @@ export async function startImageUpload(
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr.replaceWith(pos, pos, node).setMeta(uploadKey, { remove: { id } });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
}
@ -107,7 +110,9 @@ const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string>
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService.uploadFile(workspaceSlug, formData).then((response) => response.asset);
const imageUrl = await fileService
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
const image = new Image();
image.src = imageUrl;

View File

@ -1,5 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
export function TiptapEditorProps(
workspaceSlug: string,
@ -21,6 +22,15 @@ export function TiptapEditorProps(
},
},
handlePaste: (view, event) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
event.preventDefault();
const file = event.clipboardData.files[0];
@ -31,6 +41,15 @@ export function TiptapEditorProps(
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
event.preventDefault();
const file = event.dataTransfer.files[0];

View File

@ -15,6 +15,7 @@ import {
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
@ -46,6 +47,9 @@ const Command = Extension.create({
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
@ -53,7 +57,10 @@ const Command = Extension.create({
});
const getSuggestionItems =
(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
(
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
({ query }: { query: string }) =>
[
{
@ -119,6 +126,20 @@ const getSuggestionItems =
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
@ -134,14 +155,21 @@ const getSuggestionItems =
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
@ -190,7 +218,15 @@ export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
}
};
const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(

View File

@ -0,0 +1,16 @@
const InsertBottomTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertBottomTableIcon;

View File

@ -0,0 +1,15 @@
const InsertLeftTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertLeftTableIcon;

View File

@ -0,0 +1,16 @@
const InsertRightTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertRightTableIcon;

View File

@ -0,0 +1,15 @@
const InsertTopTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertTopTableIcon;

View File

@ -0,0 +1,143 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import { cn } from "../utils";
import { Tooltip } from "components/ui";
import InsertLeftTableIcon from "./InsertLeftTableIcon";
import InsertRightTableIcon from "./InsertRightTableIcon";
import InsertTopTableIcon from "./InsertTopTableIcon";
import InsertBottomTableIcon from "./InsertBottomTableIcon";
interface TableMenuItem {
command: () => void;
icon: any;
key: string;
name: string;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
}
return node as HTMLTableElement;
};
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const isOpen = editor?.isActive("table");
const items: TableMenuItem[] = [
{
command: () => editor.chain().focus().addColumnBefore().run(),
icon: InsertLeftTableIcon,
key: "insert-column-left",
name: "Insert 1 column left",
},
{
command: () => editor.chain().focus().addColumnAfter().run(),
icon: InsertRightTableIcon,
key: "insert-column-right",
name: "Insert 1 column right",
},
{
command: () => editor.chain().focus().addRowBefore().run(),
icon: InsertTopTableIcon,
key: "insert-row-above",
name: "Insert 1 row above",
},
{
command: () => editor.chain().focus().addRowAfter().run(),
icon: InsertBottomTableIcon,
key: "insert-row-below",
name: "Insert 1 row below",
},
{
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
key: "delete-column",
name: "Delete column",
},
{
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
key: "delete-row",
name: "Delete row",
},
{
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
key: "toggle-header-row",
name: "Toggle header row",
},
];
useEffect(() => {
if (!window) return;
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
let parent = tableNode?.parentElement;
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
while (parent) {
if (!parent.classList.contains("disable-scroll"))
parent.classList.add("disable-scroll");
parent = parent.parentElement;
}
} else {
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
scrollDisabledContainers.forEach((container) => {
container.classList.remove("disable-scroll");
});
}
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, [tableLocation, editor]);
return (
<section
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden"
}`}
style={{
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
left: `${tableLocation.left}px`,
}}
>
{items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}>
<button
onClick={item.command}
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
title={item.name}
>
<item.icon
className={cn("h-4 w-4 text-lg", {
"text-red-600": item.key.includes("delete"),
})}
/>
</button>
</Tooltip>
))}
</section>
);
};

View File

@ -1,36 +0,0 @@
export const renderDateFormat = (date: string | Date | null) => {
if (!date) return "N/A";
var d = new Date(date),
month = "" + (d.getMonth() + 1),
day = "" + d.getDate(),
year = d.getFullYear();
if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day;
return [year, month, day].join("-");
};
/**
* @description Returns date and month, if date is of the current year
* @description Returns date, month adn year, if date is of a different year than current
* @param {string} date
* @example renderFullDate("2023-01-01") // 1 Jan
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
*/
export const renderFullDate = (date: string): string => {
if (!date) return "";
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const currentDate: Date = new Date();
const [year, month, day]: number[] = date.split("-").map(Number);
const formattedMonth: string = months[month - 1];
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
else return `${formattedDay} ${formattedMonth}, ${year}`;
};

View File

@ -12,3 +12,26 @@ export const timeAgo = (time: any) => {
time = +new Date();
}
};
/**
* @description Returns date and month, if date is of the current year
* @description Returns date, month adn year, if date is of a different year than current
* @param {string} date
* @example renderFullDate("2023-01-01") // 1 Jan
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
*/
export const renderFullDate = (date: string): string => {
if (!date) return "";
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const currentDate: Date = new Date();
const [year, month, day]: number[] = date.split("-").map(Number);
const formattedMonth: string = months[month - 1];
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
else return `${formattedDay} ${formattedMonth}, ${year}`;
};

View File

@ -1,7 +1,7 @@
import React, { useEffect } from "react";
import Image from "next/image";
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg";
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,15 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="276.000000pt" height="276.000000pt" viewBox="0 0 276.000000 276.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,276.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M930 2300 l0 -450 460 0 460 0 0 -460 0 -460 450 0 450 0 0 910 0
910 -910 0 -910 0 0 -450z"/>
<path d="M10 1380 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
<path d="M930 460 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 690 B

View File

@ -38,7 +38,7 @@ export const InboxIssueActivity: React.FC<Props> = ({ issueDetails }) => {
: null
);
const handleCommentUpdate = async (comment: IIssueComment) => {
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
if (!workspaceSlug || !projectId || !inboxIssueId) return;
await issuesService
@ -46,8 +46,8 @@ export const InboxIssueActivity: React.FC<Props> = ({ issueDetails }) => {
workspaceSlug as string,
projectId as string,
inboxIssueId as string,
comment.id,
comment,
commentId,
data,
user
)
.then(() => mutateIssueActivity());

View File

@ -15,14 +15,16 @@ import { IIssueActivity, IIssueComment } from "types";
type Props = {
activity: IIssueActivity[] | undefined;
handleCommentUpdate: (comment: IIssueComment) => Promise<void>;
handleCommentUpdate: (commentId: string, data: Partial<IIssueComment>) => Promise<void>;
handleCommentDelete: (commentId: string) => Promise<void>;
showAccessSpecifier?: boolean;
};
export const IssueActivitySection: React.FC<Props> = ({
activity,
handleCommentUpdate,
handleCommentDelete,
showAccessSpecifier = false,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
@ -131,10 +133,11 @@ export const IssueActivitySection: React.FC<Props> = ({
return (
<div key={activityItem.id} className="mt-4">
<CommentCard
workspaceSlug={workspaceSlug as string}
comment={activityItem as IIssueComment}
onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete}
onSubmit={handleCommentUpdate}
showAccessSpecifier={showAccessSpecifier}
workspaceSlug={workspaceSlug as string}
/>
</div>
);

View File

@ -7,7 +7,7 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/rea
// hooks
import useUser from "hooks/use-user";
// ui
import { CustomMenu } from "components/ui";
import { CustomMenu, Icon } from "components/ui";
import { CommentReaction } from "components/issues";
import { TipTapEditor } from "components/tiptap";
// helpers
@ -16,17 +16,19 @@ import { timeAgo } from "helpers/date-time.helper";
import type { IIssueComment } from "types";
type Props = {
workspaceSlug: string;
comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void;
handleCommentDeletion: (comment: string) => void;
onSubmit: (commentId: string, data: Partial<IIssueComment>) => void;
showAccessSpecifier?: boolean;
workspaceSlug: string;
};
export const CommentCard: React.FC<Props> = ({
comment,
workspaceSlug,
onSubmit,
handleCommentDeletion,
onSubmit,
showAccessSpecifier = false,
workspaceSlug,
}) => {
const { user } = useUser();
@ -45,11 +47,11 @@ export const CommentCard: React.FC<Props> = ({
defaultValues: comment,
});
const onEnter = (formData: IIssueComment) => {
const onEnter = (formData: Partial<IIssueComment>) => {
if (isSubmitting) return;
setIsEditing(false);
onSubmit(formData);
onSubmit(comment.id, formData);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
@ -99,7 +101,7 @@ export const CommentCard: React.FC<Props> = ({
: comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(comment.created_at)}
commented {timeAgo(comment.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
@ -137,7 +139,15 @@ export const CommentCard: React.FC<Props> = ({
</button>
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<div className={`relative ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
<Icon
iconName={comment.access === "INTERNAL" ? "lock" : "public"}
className="!text-xs"
/>
</div>
)}
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
@ -151,13 +161,44 @@ export const CommentCard: React.FC<Props> = ({
</div>
{user?.id === comment.actor && (
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => setIsEditing(true)}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => setIsEditing(true)}
className="flex items-center gap-1"
>
<Icon iconName="edit" />
Edit comment
</CustomMenu.MenuItem>
{showAccessSpecifier && (
<>
{comment.access === "INTERNAL" ? (
<CustomMenu.MenuItem
renderAs="button"
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
className="flex items-center gap-1"
>
<Icon iconName="public" />
Switch to public comment
</CustomMenu.MenuItem>
) : (
<CustomMenu.MenuItem
renderAs="button"
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
className="flex items-center gap-1"
>
<Icon iconName="lock" />
Switch to private comment
</CustomMenu.MenuItem>
)}
</>
)}
<CustomMenu.MenuItem
onClick={() => {
handleCommentDeletion(comment.id);
}}
className="flex items-center gap-1"
>
Delete
<Icon iconName="delete" />
Delete comment
</CustomMenu.MenuItem>
</CustomMenu>
)}

View File

@ -77,7 +77,7 @@ export const IssueMainContent: React.FC<Props> = ({
: null
);
const handleCommentUpdate = async (comment: IIssueComment) => {
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
if (!workspaceSlug || !projectId || !issueId) return;
await issuesService
@ -85,8 +85,8 @@ export const IssueMainContent: React.FC<Props> = ({
workspaceSlug as string,
projectId as string,
issueId as string,
comment.id,
comment,
commentId,
data,
user
)
.then(() => mutateIssueActivity());
@ -222,6 +222,7 @@ export const IssueMainContent: React.FC<Props> = ({
activity={issueActivity}
handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete}
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
/>
<AddComment
onSubmit={handleAddComment}

View File

@ -5,6 +5,7 @@ import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { AddComment, IssueActivitySection } from "components/issues";
// types
@ -22,6 +23,7 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
const { setToastAlert } = useToast();
const { user } = useUser();
const { projectDetails } = useProjectDetails();
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
workspaceSlug && issue ? PROJECT_ISSUES_ACTIVITY(issue.id) : null,
@ -30,18 +32,11 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
: null
);
const handleCommentUpdate = async (comment: IIssueComment) => {
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
if (!workspaceSlug || !issue) return;
await issuesService
.patchIssueComment(
workspaceSlug as string,
issue.project,
issue.id,
comment.id,
comment,
user
)
.patchIssueComment(workspaceSlug as string, issue.project, issue.id, commentId, data, user)
.then(() => mutateIssueActivity());
};
@ -80,9 +75,13 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
activity={issueActivity}
handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete}
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
/>
<div className="mt-4">
<AddComment onSubmit={handleAddComment} />
<AddComment
onSubmit={handleAddComment}
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
/>
</div>
</div>
</div>

View File

@ -32,122 +32,122 @@ export const TiptapExtensions = (
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
},
code: {
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow,
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow,
];

View File

@ -1,9 +1,10 @@
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
// components
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { useImperativeHandle, useRef, forwardRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
@ -55,6 +56,12 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(value);
}
}, [value]);
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
@ -76,8 +83,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;

View File

@ -1,43 +1,51 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
const TrackImageDeletionPlugin = () =>
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const TrackImageDeletionPlugin = (): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== "image") return;
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== "image") {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach((node) => {
removedImages.forEach(async (node) => {
const src = node.attrs.src;
onNodeDeleted(src);
await onNodeDeleted(src);
});
});
@ -47,10 +55,14 @@ const TrackImageDeletionPlugin = () =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
async function onNodeDeleted(src: string): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@ -1,4 +1,3 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";

View File

@ -120,7 +120,11 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
</div>
<div className="inline">
<PrimaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
<PrimaryButton
type="submit"
disabled={isSubmitting || disabled}
className="mt-2 w-10 h-10 flex items-center justify-center"
>
<Send className="w-4 h-4" />
</PrimaryButton>
</div>

View File

@ -12,3 +12,6 @@ export * from "./issue-activity";
export * from "./select-assignee";
export * from "./select-estimate";
export * from "./add-comment";
export * from "./select-parent";
export * from "./select-blocker";
export * from "./select-blocked";

View File

@ -44,7 +44,6 @@ export const IssueActivity: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { data: issueActivities, mutate: mutateIssueActivity } = useSWR(
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
@ -104,11 +103,14 @@ export const IssueActivity: React.FC<Props> = (props) => {
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
console.log(
"toast",
JSON.stringify({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
)
);
};

View File

@ -17,11 +17,8 @@ import { useDropzone } from "react-dropzone";
// fetch key
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// hooks
import useToast from "hooks/use-toast";
// icons
import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { FileText, ChevronRight, X, Image as ImageIcon } from "lucide-react";
// components
import { Label, WebViewModal } from "components/web-view";
@ -34,6 +31,8 @@ type Props = {
allowed: boolean;
};
const isImage = (fileName: string) => /\.(gif|jpe?g|tiff?|png|webp|bmp)$/i.test(fileName);
export const IssueAttachments: React.FC<Props> = (props) => {
const { allowed } = props;
@ -46,8 +45,6 @@ export const IssueAttachments: React.FC<Props> = (props) => {
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
const { setToastAlert } = useToast();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
@ -77,23 +74,30 @@ export const IssueAttachments: React.FC<Props> = (props) => {
false
);
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
setToastAlert({
type: "success",
title: "Success!",
message: "File added successfully.",
});
console.log(
"toast",
JSON.stringify({
type: "success",
title: "Success!",
message: "File added successfully.",
})
);
setIsOpen(false);
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
setToastAlert({
type: "error",
title: "error!",
message: "Something went wrong. please check file type & size (max 5 MB)",
});
console.log(
"toast",
JSON.stringify({
type: "error",
title: "error!",
message: "Something went wrong. please check file type & size (max 5 MB)",
})
);
});
},
[issueId, projectId, setToastAlert, workspaceSlug]
[issueId, projectId, workspaceSlug]
);
const { getRootProps, getInputProps } = useDropzone({
@ -136,7 +140,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
) : (
<>
<h3 className="text-lg">Upload</h3>
<ChevronRightIcon className="w-5 h-5" />
<ChevronRight className="w-5 h-5" />
</>
)}
</div>
@ -151,8 +155,13 @@ export const IssueAttachments: React.FC<Props> = (props) => {
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
>
<Link href={attachment.asset}>
<a target="_blank" className="text-custom-text-200 truncate">
{attachment.attributes.name}
<a target="_blank" className="text-custom-text-200 truncate flex items-center">
{isImage(attachment.attributes.name) ? (
<ImageIcon className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
) : (
<FileText className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
)}
<span className="truncate">{attachment.attributes.name}</span>
</a>
</Link>
{allowed && (
@ -163,7 +172,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
setAttachmentDeleteModal(true);
}}
>
<XMarkIcon className="w-5 h-5 text-custom-text-100" />
<X className="w-[18px] h-[18px] text-custom-text-400" />
</button>
)}
</div>
@ -171,7 +180,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
<button
type="button"
onClick={() => setIsOpen(true)}
className="bg-custom-primary-100/10 border border-dotted border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
className="bg-custom-primary-100/10 border border-dotted rounded-[4px] border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
>
Click to upload file here
</button>

View File

@ -12,7 +12,8 @@ import { mutate } from "swr";
import issuesService from "services/issues.service";
// icons
import { LinkIcon, PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// import { LinkIcon, PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Link as LinkIcon, Plus, Pencil, X } from "lucide-react";
// components
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
@ -108,7 +109,7 @@ export const IssueLinks: React.FC<Props> = (props) => {
setSelectedLink(link.id);
}}
>
<PencilIcon className="w-5 h-5 text-custom-text-100" />
<Pencil className="w-[18px] h-[18px] text-custom-text-400" />
</button>
<button
type="button"
@ -116,7 +117,7 @@ export const IssueLinks: React.FC<Props> = (props) => {
handleDeleteLink(link.id);
}}
>
<TrashIcon className="w-5 h-5 text-red-500 hover:bg-red-500/20" />
<X className="w-[18px] h-[18px] text-custom-text-400" />
</button>
</div>
)}
@ -128,7 +129,7 @@ export const IssueLinks: React.FC<Props> = (props) => {
onClick={() => setIsOpen(true)}
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
>
<PlusIcon className="w-4 h-4 inline-block mr-1" />
<Plus className="w-[18px] h-[18px] inline-block mr-1" />
<span>Add</span>
</SecondaryButton>
</div>

View File

@ -1,17 +1,21 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// react hook forms
import { Control, Controller } from "react-hook-form";
import { Control, Controller, useWatch } from "react-hook-form";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// ui
import { Icon, SecondaryButton } from "components/ui";
import { SecondaryButton, CustomDatePicker } from "components/ui";
// components
import {
@ -20,6 +24,8 @@ import {
PrioritySelect,
AssigneeSelect,
EstimateSelect,
ParentSelect,
BlockerSelect,
} from "components/web-view";
// types
@ -33,6 +39,24 @@ type Props = {
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
const { control, submitChanges } = props;
const blockerIssue = useWatch({
control,
name: "blocker_issues",
});
const blockedIssue = useWatch({
control,
name: "blocked_issues",
});
const startDate = useWatch({
control,
name: "start_date",
});
const router = useRouter();
const { workspaceSlug } = router.query;
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
const { isEstimateActive } = useEstimateOption();
@ -43,8 +67,8 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="grid_view" />
<span className="text-sm text-custom-text-200">State</span>
<LayoutGrid className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
<span className="text-sm text-custom-text-400">State</span>
</div>
<div>
<Controller
@ -63,8 +87,20 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="signal_cellular_alt" />
<span className="text-sm text-custom-text-200">Priority</span>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.5862 14.5239C13.3459 14.5239 13.1416 14.4398 12.9733 14.2715C12.805 14.1032 12.7209 13.8989 12.7209 13.6585V3.76429C12.7209 3.52391 12.805 3.31958 12.9733 3.15132C13.1416 2.98306 13.3459 2.89893 13.5862 2.89893C13.8266 2.89893 14.031 2.98306 14.1992 3.15132C14.3675 3.31958 14.4516 3.52391 14.4516 3.76429V13.6585C14.4516 13.8989 14.3675 14.1032 14.1992 14.2715C14.031 14.4398 13.8266 14.5239 13.5862 14.5239ZM5.1629 14.5239C5.04676 14.5239 4.93557 14.5018 4.82932 14.4576C4.72308 14.4133 4.63006 14.3513 4.55025 14.2715C4.47045 14.1917 4.40843 14.0986 4.36419 13.9922C4.31996 13.8858 4.29785 13.7746 4.29785 13.6585V11.2643C4.29785 11.0239 4.38198 10.8196 4.55025 10.6513C4.71851 10.4831 4.92283 10.3989 5.16322 10.3989C5.40359 10.3989 5.60791 10.4831 5.77618 10.6513C5.94445 10.8196 6.02859 11.0239 6.02859 11.2643V13.6585C6.02859 13.7746 6.00647 13.8858 5.96223 13.9922C5.91801 14.0986 5.85599 14.1917 5.77618 14.2715C5.69638 14.3513 5.60325 14.4133 5.49678 14.4576C5.39033 14.5018 5.27904 14.5239 5.1629 14.5239ZM9.37473 14.5239C9.13436 14.5239 8.93003 14.4398 8.76176 14.2715C8.59349 14.1032 8.50936 13.8989 8.50936 13.6585V7.5143C8.50936 7.27391 8.59349 7.06958 8.76176 6.90132C8.93003 6.73306 9.13436 6.64893 9.37473 6.64893C9.61511 6.64893 9.81943 6.73306 9.98771 6.90132C10.156 7.06958 10.2401 7.27391 10.2401 7.5143V13.6585C10.2401 13.8989 10.156 14.1032 9.98771 14.2715C9.81943 14.4398 9.61511 14.5239 9.37473 14.5239Z"
fill="#A3A3A3"
/>
</svg>
<span className="text-sm text-custom-text-400">Priority</span>
</div>
<div>
<Controller
@ -83,8 +119,8 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="person" />
<span className="text-sm text-custom-text-200">Assignee</span>
<Users className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
<span className="text-sm text-custom-text-400">Assignee</span>
</div>
<div>
<Controller
@ -106,8 +142,8 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<span className="text-sm text-custom-text-200">Estimate</span>
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90 text-custom-text-400" />
<span className="text-sm text-custom-text-400">Estimate</span>
</div>
<div>
<Controller
@ -124,6 +160,179 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div>
</div>
)}
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<User className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
<span className="text-sm text-custom-text-400">Parent</span>
</div>
<div>
<Controller
control={control}
name="parent"
render={({ field: { value } }) => (
<ParentSelect
value={value}
onChange={(val) => submitChanges({ parent: val })}
/>
)}
/>
</div>
</div>
</div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<BlockerIcon height={16} width={16} />
<span className="text-sm text-custom-text-400">Blocking</span>
</div>
<div>
<Controller
control={control}
name="blocker_issues"
render={({ field: { value } }) => (
<BlockerSelect
value={value}
onChange={(val) =>
submitChanges({
blocker_issues: val,
blockers_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
})
}
/>
)}
/>
</div>
</div>
{blockerIssue &&
blockerIssue.map((issue) => (
<div
key={issue.blocker_issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlockers = blockerIssue.filter(
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
);
submitChanges({
blocker_issues: updatedBlockers,
blockers_list: updatedBlockers.map(
(i) => i.blocker_issue_detail?.id ?? ""
),
});
}}
>
<X className="h-2 w-2" />
</button>
</div>
))}
</div>
</div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<BlockedIcon height={16} width={16} />
<span className="text-sm text-custom-text-400">Blocked by</span>
</div>
<div>
<Controller
control={control}
name="blocked_issues"
render={({ field: { value } }) => (
<BlockerSelect
value={value}
onChange={(val) =>
submitChanges({
blocked_issues: val,
blocks_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
})
}
/>
)}
/>
</div>
</div>
{blockedIssue &&
blockedIssue.map((issue) => (
<div
key={issue.blocked_issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<a
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockedIcon height={10} width={10} />
{`${issue?.blocked_issue_detail?.project_detail?.identifier}-${issue?.blocked_issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlocked = blockedIssue.filter(
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
);
submitChanges({
blocked_issues: updatedBlocked,
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
});
}}
>
<X className="h-2 w-2" />
</button>
</div>
))}
</div>
</div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<CalendarDays className="w-4 h-4 text-custom-text-400" />
<span className="text-sm text-custom-text-400">Due date</span>
</div>
<div>
<Controller
control={control}
name="target_date"
render={({ field: { value } }) => (
<CustomDatePicker
placeholder="Due date"
value={value}
wrapperClassName="w-full"
onChange={(val) =>
submitChanges({
target_date: val,
})
}
className="border-transparent !shadow-none !w-[6.75rem]"
minDate={startDate ? new Date(startDate) : undefined}
/>
)}
/>
</div>
</div>
</div>
</div>
</>
)}
<div className="mb-[6px]">
@ -135,7 +344,7 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
<span className="text-base text-custom-primary-100">
{isViewAllOpen ? "View less" : "View all"}
</span>
<ChevronDownIcon
<ChevronDown
size={16}
className={`ml-1 text-custom-primary-100 ${isViewAllOpen ? "-rotate-180" : ""}`}
/>

View File

@ -8,7 +8,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { ChevronDown } from "lucide-react";
// services
import projectService from "services/project.service";
@ -87,8 +87,7 @@ export const AssigneeSelect: React.FC<Props> = (props) => {
) : (
"No assignees"
)}
{/* {selectedAssignee?.member.display_name || "Select assignee"} */}
<ChevronDownIcon className="w-5 h-5" />
<ChevronDown className="w-5 h-5" />
</button>
</>
);

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import React, { useState } from "react";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
import { ChevronDown, PlayIcon } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
@ -76,7 +76,7 @@ export const EstimateSelect: React.FC<Props> = (props) => {
) : (
"No estimate"
)}
<ChevronDownIcon className="w-5 h-5" />
<ChevronDown className="w-5 h-5" />
</button>
</>
);

View File

@ -0,0 +1,76 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
// fetch key
import { ISSUE_DETAILS } from "constants/fetch-keys";
// components
import { ParentIssuesListModal } from "components/issues";
// types
import { ISearchIssueResponse } from "types";
type Props = {
value: string | null;
onChange: (value: any) => void;
disabled?: boolean;
};
export const ParentSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
return (
<>
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
issueId={issueId as string}
projectId={projectId as string}
/>
<button
type="button"
disabled={disabled}
onClick={() => setIsParentModalOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</button>
</>
);
};

View File

@ -2,7 +2,7 @@
import React, { useState } from "react";
// icons
import { ChevronDownIcon } from "lucide-react";
import { ChevronDown } from "lucide-react";
// constants
import { PRIORITIES } from "constants/project";
@ -76,7 +76,7 @@ export const PrioritySelect: React.FC<Props> = (props) => {
}
>
{value ? capitalizeFirstLetter(value) : "None"}
<ChevronDownIcon className="w-5 h-5" />
<ChevronDown className="w-5 h-5" />
</button>
</>
);

View File

@ -8,7 +8,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { ChevronDown } from "lucide-react";
// services
import stateService from "services/state.service";
@ -82,7 +82,7 @@ export const StateSelect: React.FC<Props> = (props) => {
}
>
{selectedState?.name || "Select a state"}
<ChevronDownIcon className="w-5 h-5" />
<ChevronDown className="w-5 h-5" />
</button>
</>
);

View File

@ -8,7 +8,7 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { X } from "lucide-react";
// services
import issuesService from "services/issues.service";
@ -98,7 +98,7 @@ export const SubIssueList: React.FC<Props> = (props) => {
<p className="text-sm font-normal">{subIssue.name}</p>
</div>
<button type="button" onClick={() => handleSubIssueRemove(subIssue)}>
<XMarkIcon className="w-5 h-5 text-custom-text-200" />
<X className="w-[18px] h-[18px] text-custom-text-400" />
</button>
</div>
))}

View File

@ -47,7 +47,7 @@ export const WebViewModal = (props: Props) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-none rounded-tr-[4px] rounded-tl-[4px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:mt-8 w-full">
<Dialog.Panel className="relative transform overflow-hidden rounded-none rounded-tr-[20px] rounded-tl-[20px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:mt-8 w-full">
<div className="flex justify-between items-center w-full">
<Dialog.Title
as="h3"
@ -84,9 +84,9 @@ type OptionsProps = {
};
const Options: React.FC<OptionsProps> = ({ options }) => (
<div className="space-y-6">
<div className="divide-y">
{options.map((option) => (
<div key={option.value} className="flex items-center justify-between gap-2 py-2">
<div key={option.value} className="flex items-center justify-between gap-2 py-[14px]">
<div className="flex items-center gap-x-2">
<input
type="checkbox"

View File

@ -0,0 +1,60 @@
// swr
import useSWR from "swr";
// services
import userService from "services/user.service";
// fetch keys
import { CURRENT_USER } from "constants/fetch-keys";
// icons
import { AlertCircle } from "lucide-react";
// ui
import { Spinner } from "components/ui";
type Props = {
children: React.ReactNode;
};
const getIfInWebview = (userAgent: NavigatorID["userAgent"]) => {
if (/iphone|ipod|ipad/.test(userAgent) || userAgent.includes("wv")) return true;
else return false;
};
const useMobileDetect = () => {
const userAgent = typeof navigator === "undefined" ? "SSR" : navigator.userAgent;
return getIfInWebview(userAgent);
};
const WebViewLayout: React.FC<Props> = ({ children }) => {
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
const isWebview = useMobileDetect();
if (!currentUser && !error) {
return (
<div className="h-screen grid place-items-center p-4">
<div className="flex flex-col items-center gap-3 text-center">
<h3 className="text-xl">Loading your profile...</h3>
<Spinner />
</div>
</div>
);
}
return (
<div className="h-screen w-full bg-custom-background-100">
{error || !isWebview ? (
<div className="flex flex-col items-center justify-center gap-y-3 h-full text-center text-custom-text-200">
<AlertCircle size={64} />
<h2 className="text-2xl font-semibold">You are not authorized to view this page.</h2>
</div>
) : (
children
)}
</div>
);
};
export default WebViewLayout;

View File

@ -21,7 +21,7 @@ import useUser from "hooks/use-user";
import useProjectMembers from "hooks/use-project-members";
// layouts
import DefaultLayout from "layouts/default-layout";
import WebViewLayout from "layouts/web-view-layout";
// ui
import { Spinner } from "components/ui";
@ -128,25 +128,25 @@ const MobileWebViewIssueDetail = () => {
if (!error && !issueDetails)
return (
<DefaultLayout>
<WebViewLayout>
<div className="px-4 py-2 h-full">
<div className="h-full flex justify-center items-center">
<Spinner />
Loading...
</div>
</div>
</DefaultLayout>
</WebViewLayout>
);
if (error)
return (
<DefaultLayout>
<WebViewLayout>
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
</DefaultLayout>
</WebViewLayout>
);
return (
<DefaultLayout>
<WebViewLayout>
<div className="px-6 py-2 h-full overflow-auto space-y-3">
<IssueWebViewForm
isAllowed={isAllowed}
@ -168,7 +168,7 @@ const MobileWebViewIssueDetail = () => {
<IssueActivity allowed={isAllowed} issueDetails={issueDetails!} />
</div>
</DefaultLayout>
</WebViewLayout>
);
};

View File

@ -8,11 +8,19 @@ const nonValidatedRoutes = [
"/reset-password",
"/workspace-member-invitation",
"/sign-up",
"/m/",
];
const validateRouteCheck = (route: string): boolean => {
let validationToggle = false;
const routeCheck = nonValidatedRoutes.find((_route: string) => _route === route);
let routeCheck = false;
nonValidatedRoutes.forEach((_route: string) => {
if (route.includes(_route)) {
routeCheck = true;
}
});
if (routeCheck) validationToggle = true;
return validationToggle;
};

View File

@ -204,7 +204,7 @@ export class ProjectIssuesServices extends APIService {
projectId: string,
issueId: string,
commentId: string,
data: IIssueComment,
data: Partial<IIssueComment>,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.patch(