forked from github/plane
Merge branch 'develop' of github.com:makeplane/plane into feat/bulk_issue_operations
This commit is contained in:
commit
05a6f972b2
@ -7,7 +7,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center"><b>Plane</b></h3>
|
<h3 align="center"><b>Plane</b></h3>
|
||||||
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
|
<p align="center"><b>Flexible, extensible open-source project management</b></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.com/invite/A92xrEGCge">
|
<a href="https://discord.com/invite/A92xrEGCge">
|
||||||
|
@ -408,7 +408,6 @@ def analytic_export_task(email, data, slug):
|
|||||||
distribution,
|
distribution,
|
||||||
x_axis,
|
x_axis,
|
||||||
y_axis,
|
y_axis,
|
||||||
segment,
|
|
||||||
key,
|
key,
|
||||||
assignee_details,
|
assignee_details,
|
||||||
label_details,
|
label_details,
|
||||||
|
@ -12,19 +12,19 @@ from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Conc
|
|||||||
from plane.db.models import Issue
|
from plane.db.models import Issue
|
||||||
|
|
||||||
|
|
||||||
def annotate_with_monthly_dimension(queryset, field_name):
|
def annotate_with_monthly_dimension(queryset, field_name, attribute):
|
||||||
# Get the year and the months
|
# Get the year and the months
|
||||||
year = ExtractYear(field_name)
|
year = ExtractYear(field_name)
|
||||||
month = ExtractMonth(field_name)
|
month = ExtractMonth(field_name)
|
||||||
# Concat the year and month
|
# Concat the year and month
|
||||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||||
# Annotate the dimension
|
# Annotate the dimension
|
||||||
return queryset.annotate(dimension=dimension)
|
return queryset.annotate(**{attribute: dimension})
|
||||||
|
|
||||||
def extract_axis(queryset, x_axis):
|
def extract_axis(queryset, x_axis):
|
||||||
# Format the dimension when the axis is in date
|
# Format the dimension when the axis is in date
|
||||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||||
queryset = annotate_with_monthly_dimension(queryset, x_axis)
|
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
|
||||||
return queryset, "dimension"
|
return queryset, "dimension"
|
||||||
else:
|
else:
|
||||||
return queryset.annotate(dimension=F(x_axis)), "dimension"
|
return queryset.annotate(dimension=F(x_axis)), "dimension"
|
||||||
@ -47,7 +47,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
|||||||
|
|
||||||
#
|
#
|
||||||
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||||
queryset = annotate_with_monthly_dimension(queryset, segment)
|
queryset = annotate_with_monthly_dimension(queryset, segment, "segmented")
|
||||||
segment = "segmented"
|
segment = "segmented"
|
||||||
|
|
||||||
queryset = queryset.values(x_axis)
|
queryset = queryset.values(x_axis)
|
||||||
|
@ -41,6 +41,8 @@
|
|||||||
"@tiptap/extension-task-list": "^2.1.7",
|
"@tiptap/extension-task-list": "^2.1.7",
|
||||||
"@tiptap/extension-text-style": "^2.1.11",
|
"@tiptap/extension-text-style": "^2.1.11",
|
||||||
"@tiptap/extension-underline": "^2.1.7",
|
"@tiptap/extension-underline": "^2.1.7",
|
||||||
|
"@tiptap/prosemirror-tables": "^1.1.4",
|
||||||
|
"jsx-dom-cjs": "^8.0.3",
|
||||||
"@tiptap/pm": "^2.1.7",
|
"@tiptap/pm": "^2.1.7",
|
||||||
"@tiptap/react": "^2.1.7",
|
"@tiptap/react": "^2.1.7",
|
||||||
"@tiptap/starter-kit": "^2.1.10",
|
"@tiptap/starter-kit": "^2.1.10",
|
||||||
|
@ -2,8 +2,11 @@
|
|||||||
// import "./styles/tailwind.css";
|
// import "./styles/tailwind.css";
|
||||||
// import "./styles/editor.css";
|
// import "./styles/editor.css";
|
||||||
|
|
||||||
|
export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection";
|
||||||
|
|
||||||
// utils
|
// utils
|
||||||
export * from "./lib/utils";
|
export * from "./lib/utils";
|
||||||
|
export * from "./ui/extensions/table/table";
|
||||||
export { startImageUpload } from "./ui/plugins/upload-image";
|
export { startImageUpload } from "./ui/plugins/upload-image";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Editor, EditorContent } from "@tiptap/react";
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ImageResizer } from "../extensions/image/image-resize";
|
import { ImageResizer } from "../extensions/image/image-resize";
|
||||||
import { TableMenu } from "../menus/table-menu";
|
|
||||||
|
|
||||||
interface EditorContentProps {
|
interface EditorContentProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
@ -10,10 +9,8 @@ interface EditorContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
|
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
<div className={`contentEditor ${editorContentCustomClassNames}`}>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{editor?.isEditable && <TableMenu editor={editor} />}
|
|
||||||
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
|
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,10 +8,10 @@ import TaskList from "@tiptap/extension-task-list";
|
|||||||
import { Markdown } from "tiptap-markdown";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||||
|
|
||||||
import { CustomTableCell } from "./table/table-cell";
|
import TableHeader from "./table/table-header/table-header";
|
||||||
import { Table } from "./table";
|
import Table from "./table/table";
|
||||||
import { TableHeader } from "./table/table-header";
|
import TableCell from "./table/table-cell/table-cell";
|
||||||
import { TableRow } from "@tiptap/extension-table-row";
|
import TableRow from "./table/table-row/table-row";
|
||||||
|
|
||||||
import ImageExtension from "./image";
|
import ImageExtension from "./image";
|
||||||
|
|
||||||
@ -95,7 +95,7 @@ export const CoreEditorExtensions = (
|
|||||||
}),
|
}),
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
CustomTableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||||
];
|
];
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
import { Table as BaseTable } from "@tiptap/extension-table";
|
|
||||||
|
|
||||||
const Table = BaseTable.configure({
|
|
||||||
resizable: true,
|
|
||||||
cellMinWidth: 100,
|
|
||||||
allowTableNodeSelection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Table };
|
|
@ -1,32 +0,0 @@
|
|||||||
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];
|
|
||||||
},
|
|
||||||
});
|
|
@ -0,0 +1 @@
|
|||||||
|
export { default as default } from "./table-cell"
|
@ -0,0 +1,58 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core"
|
||||||
|
|
||||||
|
export interface TableCellOptions {
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<TableCellOptions>({
|
||||||
|
name: "tableCell",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content: "paragraph+",
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute("colwidth")
|
||||||
|
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
tableRole: "cell",
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "td" }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"td",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
style: `background-color: ${node.attrs.background}`
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
@ -1,7 +0,0 @@
|
|||||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
const TableHeader = BaseTableHeader.extend({
|
|
||||||
content: "paragraph",
|
|
||||||
});
|
|
||||||
|
|
||||||
export { TableHeader };
|
|
@ -0,0 +1 @@
|
|||||||
|
export { default as default } from "./table-header"
|
@ -0,0 +1,57 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core"
|
||||||
|
|
||||||
|
export interface TableHeaderOptions {
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
|
}
|
||||||
|
export default Node.create<TableHeaderOptions>({
|
||||||
|
name: "tableHeader",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content: "paragraph+",
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute("colwidth")
|
||||||
|
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "rgb(var(--color-primary-100))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
tableRole: "header_cell",
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "th" }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"th",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
style: `background-color: ${node.attrs.background}`
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1 @@
|
|||||||
|
export { default as default } from "./table-row"
|
@ -0,0 +1,31 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core"
|
||||||
|
|
||||||
|
export interface TableRowOptions {
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<TableRowOptions>({
|
||||||
|
name: "tableRow",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content: "(tableCell | tableHeader)*",
|
||||||
|
|
||||||
|
tableRole: "row",
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "tr" }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"tr",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
55
packages/editor/core/src/ui/extensions/table/table/icons.ts
Normal file
55
packages/editor/core/src/ui/extensions/table/table/icons.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
const icons = {
|
||||||
|
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||||
|
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
||||||
|
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
|
||||||
|
insertLeftTableIcon: `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`,
|
||||||
|
insertRightTableIcon: `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`,
|
||||||
|
insertTopTableIcon: `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
`,
|
||||||
|
insertBottomTableIcon:`<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
>
|
||||||
|
<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 icons;
|
@ -0,0 +1 @@
|
|||||||
|
export { default as default } from "./table"
|
@ -0,0 +1,117 @@
|
|||||||
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import { findParentNode } from "@tiptap/core";
|
||||||
|
import { DecorationSet, Decoration } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
const key = new PluginKey("tableControls");
|
||||||
|
|
||||||
|
export function tableControls() {
|
||||||
|
return new Plugin({
|
||||||
|
key,
|
||||||
|
state: {
|
||||||
|
init() {
|
||||||
|
return new TableControlsState();
|
||||||
|
},
|
||||||
|
apply(tr, prev) {
|
||||||
|
return prev.apply(tr);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mousemove: (view, event) => {
|
||||||
|
const pluginState = key.getState(view.state);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(event.target as HTMLElement).closest(".tableWrapper") &&
|
||||||
|
pluginState.values.hoveredTable
|
||||||
|
) {
|
||||||
|
return view.dispatch(
|
||||||
|
view.state.tr.setMeta(key, {
|
||||||
|
setHoveredTable: null,
|
||||||
|
setHoveredCell: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const table = findParentNode((node) => node.type.name === "table")(
|
||||||
|
TextSelection.create(view.state.doc, pos.pos),
|
||||||
|
);
|
||||||
|
const cell = findParentNode(
|
||||||
|
(node) =>
|
||||||
|
node.type.name === "tableCell" ||
|
||||||
|
node.type.name === "tableHeader",
|
||||||
|
)(TextSelection.create(view.state.doc, pos.pos));
|
||||||
|
|
||||||
|
if (!table || !cell) return;
|
||||||
|
|
||||||
|
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
|
||||||
|
return view.dispatch(
|
||||||
|
view.state.tr.setMeta(key, {
|
||||||
|
setHoveredTable: table,
|
||||||
|
setHoveredCell: cell,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorations: (state) => {
|
||||||
|
const pluginState = key.getState(state);
|
||||||
|
if (!pluginState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hoveredTable, hoveredCell } = pluginState.values;
|
||||||
|
const docSize = state.doc.content.size;
|
||||||
|
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
|
||||||
|
const decorations = [
|
||||||
|
Decoration.node(
|
||||||
|
hoveredTable.pos,
|
||||||
|
hoveredTable.pos + hoveredTable.node.nodeSize,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
hoveredTable,
|
||||||
|
hoveredCell,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return DecorationSet.create(state.doc, decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TableControlsState {
|
||||||
|
values;
|
||||||
|
|
||||||
|
constructor(props = {}) {
|
||||||
|
this.values = {
|
||||||
|
hoveredTable: null,
|
||||||
|
hoveredCell: null,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(tr: any) {
|
||||||
|
const actions = tr.getMeta(key);
|
||||||
|
|
||||||
|
if (actions?.setHoveredTable !== undefined) {
|
||||||
|
this.values.hoveredTable = actions.setHoveredTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions?.setHoveredCell !== undefined) {
|
||||||
|
this.values.hoveredCell = actions.setHoveredCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,530 @@
|
|||||||
|
import { h } from "jsx-dom-cjs";
|
||||||
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||||
|
import tippy, { Instance, Props } from "tippy.js";
|
||||||
|
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
CellSelection,
|
||||||
|
TableMap,
|
||||||
|
updateColumnsOnResize,
|
||||||
|
} from "@tiptap/prosemirror-tables";
|
||||||
|
|
||||||
|
import icons from "./icons";
|
||||||
|
|
||||||
|
export function updateColumns(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
colgroup: HTMLElement,
|
||||||
|
table: HTMLElement,
|
||||||
|
cellMinWidth: number,
|
||||||
|
overrideCol?: number,
|
||||||
|
overrideValue?: any,
|
||||||
|
) {
|
||||||
|
let totalWidth = 0;
|
||||||
|
let fixedWidth = true;
|
||||||
|
let nextDOM = colgroup.firstChild as HTMLElement;
|
||||||
|
const row = node.firstChild;
|
||||||
|
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||||
|
const { colspan, colwidth } = row.child(i).attrs;
|
||||||
|
|
||||||
|
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||||
|
const hasWidth =
|
||||||
|
overrideCol === col ? overrideValue : colwidth && colwidth[j];
|
||||||
|
const cssWidth = hasWidth ? `${hasWidth}px` : "";
|
||||||
|
|
||||||
|
totalWidth += hasWidth || cellMinWidth;
|
||||||
|
|
||||||
|
if (!hasWidth) {
|
||||||
|
fixedWidth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextDOM) {
|
||||||
|
colgroup.appendChild(document.createElement("col")).style.width =
|
||||||
|
cssWidth;
|
||||||
|
} else {
|
||||||
|
if (nextDOM.style.width !== cssWidth) {
|
||||||
|
nextDOM.style.width = cssWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDOM = nextDOM.nextSibling as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (nextDOM) {
|
||||||
|
const after = nextDOM.nextSibling;
|
||||||
|
|
||||||
|
nextDOM.parentNode?.removeChild(nextDOM);
|
||||||
|
nextDOM = after as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedWidth) {
|
||||||
|
table.style.width = `${totalWidth}px`;
|
||||||
|
table.style.minWidth = "";
|
||||||
|
} else {
|
||||||
|
table.style.width = "";
|
||||||
|
table.style.minWidth = `${totalWidth}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTippyOptions: Partial<Props> = {
|
||||||
|
allowHTML: true,
|
||||||
|
arrow: false,
|
||||||
|
trigger: "click",
|
||||||
|
animation: "scale-subtle",
|
||||||
|
theme: "light-border no-padding",
|
||||||
|
interactive: true,
|
||||||
|
hideOnClick: true,
|
||||||
|
placement: "right",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.updateAttributes("tableCell", {
|
||||||
|
background: backgroundColor,
|
||||||
|
})
|
||||||
|
.updateAttributes("tableHeader", {
|
||||||
|
background: backgroundColor,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsToolboxItems = [
|
||||||
|
{
|
||||||
|
label: "Add Column Before",
|
||||||
|
icon: icons.insertLeftTableIcon,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().addColumnBefore().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add Column After",
|
||||||
|
icon: icons.insertRightTableIcon,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().addColumnAfter().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pick Column Color",
|
||||||
|
icon: icons.colorPicker,
|
||||||
|
action: ({
|
||||||
|
editor,
|
||||||
|
triggerButton,
|
||||||
|
controlsContainer,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
triggerButton: HTMLElement;
|
||||||
|
controlsContainer;
|
||||||
|
}) => {
|
||||||
|
createColorPickerToolbox({
|
||||||
|
triggerButton,
|
||||||
|
tippyOptions: {
|
||||||
|
appendTo: controlsContainer,
|
||||||
|
},
|
||||||
|
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete Column",
|
||||||
|
icon: icons.deleteColumn,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().deleteColumn().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rowsToolboxItems = [
|
||||||
|
{
|
||||||
|
label: "Add Row Above",
|
||||||
|
icon: icons.insertTopTableIcon,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().addRowBefore().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add Row Below",
|
||||||
|
icon: icons.insertBottomTableIcon,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().addRowAfter().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pick Row Color",
|
||||||
|
icon: icons.colorPicker,
|
||||||
|
action: ({
|
||||||
|
editor,
|
||||||
|
triggerButton,
|
||||||
|
controlsContainer,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
triggerButton: HTMLButtonElement;
|
||||||
|
controlsContainer:
|
||||||
|
| Element
|
||||||
|
| "parent"
|
||||||
|
| ((ref: Element) => Element)
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
createColorPickerToolbox({
|
||||||
|
triggerButton,
|
||||||
|
tippyOptions: {
|
||||||
|
appendTo: controlsContainer,
|
||||||
|
},
|
||||||
|
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete Row",
|
||||||
|
icon: icons.deleteRow,
|
||||||
|
action: ({ editor }: { editor: Editor }) =>
|
||||||
|
editor.chain().focus().deleteRow().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function createToolbox({
|
||||||
|
triggerButton,
|
||||||
|
items,
|
||||||
|
tippyOptions,
|
||||||
|
onClickItem,
|
||||||
|
}: {
|
||||||
|
triggerButton: HTMLElement;
|
||||||
|
items: { icon: string; label: string }[];
|
||||||
|
tippyOptions: any;
|
||||||
|
onClickItem: any;
|
||||||
|
}): Instance<Props> {
|
||||||
|
const toolbox = tippy(triggerButton, {
|
||||||
|
content: h(
|
||||||
|
"div",
|
||||||
|
{ className: "tableToolbox" },
|
||||||
|
items.map((item) =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
className: "toolboxItem",
|
||||||
|
onClick() {
|
||||||
|
onClickItem(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("div", {
|
||||||
|
className: "iconContainer",
|
||||||
|
innerHTML: item.icon,
|
||||||
|
}),
|
||||||
|
h("div", { className: "label" }, item.label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...tippyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createColorPickerToolbox({
|
||||||
|
triggerButton,
|
||||||
|
tippyOptions,
|
||||||
|
onSelectColor = () => {},
|
||||||
|
}: {
|
||||||
|
triggerButton: HTMLElement;
|
||||||
|
tippyOptions: Partial<Props>;
|
||||||
|
onSelectColor?: (color: string) => void;
|
||||||
|
}) {
|
||||||
|
const items = {
|
||||||
|
Default: "rgb(var(--color-primary-100))",
|
||||||
|
Orange: "#FFE5D1",
|
||||||
|
Grey: "#F1F1F1",
|
||||||
|
Yellow: "#FEF3C7",
|
||||||
|
Green: "#DCFCE7",
|
||||||
|
Red: "#FFDDDD",
|
||||||
|
Blue: "#D9E4FF",
|
||||||
|
Pink: "#FFE8FA",
|
||||||
|
Purple: "#E8DAFB",
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorPicker = tippy(triggerButton, {
|
||||||
|
...defaultTippyOptions,
|
||||||
|
content: h(
|
||||||
|
"div",
|
||||||
|
{ className: "tableColorPickerToolbox" },
|
||||||
|
Object.entries(items).map(([key, value]) =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
className: "toolboxItem",
|
||||||
|
onClick: () => {
|
||||||
|
onSelectColor(value);
|
||||||
|
colorPicker.hide();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("div", {
|
||||||
|
className: "colorContainer",
|
||||||
|
style: {
|
||||||
|
backgroundColor: value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
className: "label",
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onHidden: (instance) => {
|
||||||
|
instance.destroy();
|
||||||
|
},
|
||||||
|
showOnCreate: true,
|
||||||
|
...tippyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return colorPicker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TableView implements NodeView {
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
cellMinWidth: number;
|
||||||
|
decorations: Decoration[];
|
||||||
|
editor: Editor;
|
||||||
|
getPos: () => number;
|
||||||
|
hoveredCell;
|
||||||
|
map: TableMap;
|
||||||
|
root: HTMLElement;
|
||||||
|
table: HTMLElement;
|
||||||
|
colgroup: HTMLElement;
|
||||||
|
tbody: HTMLElement;
|
||||||
|
rowsControl?: HTMLElement;
|
||||||
|
columnsControl?: HTMLElement;
|
||||||
|
columnsToolbox?: Instance<Props>;
|
||||||
|
rowsToolbox?: Instance<Props>;
|
||||||
|
controls?: HTMLElement;
|
||||||
|
|
||||||
|
get dom() {
|
||||||
|
return this.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contentDOM() {
|
||||||
|
return this.tbody;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
cellMinWidth: number,
|
||||||
|
decorations: Decoration[],
|
||||||
|
editor: Editor,
|
||||||
|
getPos: () => number,
|
||||||
|
) {
|
||||||
|
this.node = node;
|
||||||
|
this.cellMinWidth = cellMinWidth;
|
||||||
|
this.decorations = decorations;
|
||||||
|
this.editor = editor;
|
||||||
|
this.getPos = getPos;
|
||||||
|
this.hoveredCell = null;
|
||||||
|
this.map = TableMap.get(node);
|
||||||
|
|
||||||
|
if (editor.isEditable) {
|
||||||
|
this.rowsControl = h(
|
||||||
|
"div",
|
||||||
|
{ className: "rowsControl" },
|
||||||
|
h("button", {
|
||||||
|
onClick: () => this.selectRow(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.columnsControl = h(
|
||||||
|
"div",
|
||||||
|
{ className: "columnsControl" },
|
||||||
|
h("button", {
|
||||||
|
onClick: () => this.selectColumn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.controls = h(
|
||||||
|
"div",
|
||||||
|
{ className: "tableControls", contentEditable: "false" },
|
||||||
|
this.rowsControl,
|
||||||
|
this.columnsControl,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.columnsToolbox = createToolbox({
|
||||||
|
triggerButton: this.columnsControl.querySelector("button"),
|
||||||
|
items: columnsToolboxItems,
|
||||||
|
tippyOptions: {
|
||||||
|
...defaultTippyOptions,
|
||||||
|
appendTo: this.controls,
|
||||||
|
},
|
||||||
|
onClickItem: (item) => {
|
||||||
|
item.action({
|
||||||
|
editor: this.editor,
|
||||||
|
triggerButton: this.columnsControl?.firstElementChild,
|
||||||
|
controlsContainer: this.controls,
|
||||||
|
});
|
||||||
|
this.columnsToolbox?.hide();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rowsToolbox = createToolbox({
|
||||||
|
triggerButton: this.rowsControl.firstElementChild,
|
||||||
|
items: rowsToolboxItems,
|
||||||
|
tippyOptions: {
|
||||||
|
...defaultTippyOptions,
|
||||||
|
appendTo: this.controls,
|
||||||
|
},
|
||||||
|
onClickItem: (item) => {
|
||||||
|
item.action({
|
||||||
|
editor: this.editor,
|
||||||
|
triggerButton: this.rowsControl?.firstElementChild,
|
||||||
|
controlsContainer: this.controls,
|
||||||
|
});
|
||||||
|
this.rowsToolbox?.hide();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table
|
||||||
|
|
||||||
|
this.colgroup = h(
|
||||||
|
"colgroup",
|
||||||
|
null,
|
||||||
|
Array.from({ length: this.map.width }, () => 1).map(() => h("col")),
|
||||||
|
);
|
||||||
|
this.tbody = h("tbody");
|
||||||
|
this.table = h("table", null, this.colgroup, this.tbody);
|
||||||
|
|
||||||
|
this.root = h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
className: "tableWrapper controls--disabled",
|
||||||
|
},
|
||||||
|
this.controls,
|
||||||
|
this.table,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(node: ProseMirrorNode, decorations) {
|
||||||
|
if (node.type !== this.node.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.node = node;
|
||||||
|
this.decorations = decorations;
|
||||||
|
this.map = TableMap.get(this.node);
|
||||||
|
|
||||||
|
if (this.editor.isEditable) {
|
||||||
|
this.updateControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.colgroup.children.length !== this.map.width) {
|
||||||
|
const cols = Array.from({ length: this.map.width }, () => 1).map(() =>
|
||||||
|
h("col"),
|
||||||
|
);
|
||||||
|
this.colgroup.replaceChildren(...cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColumnsOnResize(
|
||||||
|
this.node,
|
||||||
|
this.colgroup,
|
||||||
|
this.table,
|
||||||
|
this.cellMinWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreMutation() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateControls() {
|
||||||
|
const { hoveredTable: table, hoveredCell: cell } = Object.values(
|
||||||
|
this.decorations,
|
||||||
|
).reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
if (curr.spec.hoveredCell !== undefined) {
|
||||||
|
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curr.spec.hoveredTable !== undefined) {
|
||||||
|
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, HTMLElement>,
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (table === undefined || cell === undefined) {
|
||||||
|
return this.root.classList.add("controls--disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.root.classList.remove("controls--disabled");
|
||||||
|
this.hoveredCell = cell;
|
||||||
|
|
||||||
|
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||||
|
|
||||||
|
const tableRect = this.table.getBoundingClientRect();
|
||||||
|
const cellRect = cellDom.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.columnsControl.style.left = `${
|
||||||
|
cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft
|
||||||
|
}px`;
|
||||||
|
this.columnsControl.style.width = `${cellRect.width}px`;
|
||||||
|
|
||||||
|
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`;
|
||||||
|
this.rowsControl.style.height = `${cellRect.height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectColumn() {
|
||||||
|
if (!this.hoveredCell) return;
|
||||||
|
|
||||||
|
const colIndex = this.map.colCount(
|
||||||
|
this.hoveredCell.pos - (this.getPos() + 1),
|
||||||
|
);
|
||||||
|
const anchorCellPos = this.hoveredCell.pos;
|
||||||
|
const headCellPos =
|
||||||
|
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
|
||||||
|
(this.getPos() + 1);
|
||||||
|
|
||||||
|
const cellSelection = CellSelection.create(
|
||||||
|
this.editor.view.state.doc,
|
||||||
|
anchorCellPos,
|
||||||
|
headCellPos,
|
||||||
|
);
|
||||||
|
this.editor.view.dispatch(
|
||||||
|
// @ts-ignore
|
||||||
|
this.editor.state.tr.setSelection(cellSelection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRow() {
|
||||||
|
if (!this.hoveredCell) return;
|
||||||
|
|
||||||
|
const anchorCellPos = this.hoveredCell.pos;
|
||||||
|
const anchorCellIndex = this.map.map.indexOf(
|
||||||
|
anchorCellPos - (this.getPos() + 1),
|
||||||
|
);
|
||||||
|
const headCellPos =
|
||||||
|
this.map.map[anchorCellIndex + (this.map.width - 1)] +
|
||||||
|
(this.getPos() + 1);
|
||||||
|
|
||||||
|
const cellSelection = CellSelection.create(
|
||||||
|
this.editor.state.doc,
|
||||||
|
anchorCellPos,
|
||||||
|
headCellPos,
|
||||||
|
);
|
||||||
|
this.editor.view.dispatch(
|
||||||
|
// @ts-ignore
|
||||||
|
this.editor.view.state.tr.setSelection(cellSelection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
298
packages/editor/core/src/ui/extensions/table/table/table.ts
Normal file
298
packages/editor/core/src/ui/extensions/table/table/table.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { TextSelection } from "@tiptap/pm/state"
|
||||||
|
|
||||||
|
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
|
||||||
|
import {
|
||||||
|
addColumnAfter,
|
||||||
|
addColumnBefore,
|
||||||
|
addRowAfter,
|
||||||
|
addRowBefore,
|
||||||
|
CellSelection,
|
||||||
|
columnResizing,
|
||||||
|
deleteColumn,
|
||||||
|
deleteRow,
|
||||||
|
deleteTable,
|
||||||
|
fixTables,
|
||||||
|
goToNextCell,
|
||||||
|
mergeCells,
|
||||||
|
setCellAttr,
|
||||||
|
splitCell,
|
||||||
|
tableEditing,
|
||||||
|
toggleHeader,
|
||||||
|
toggleHeaderCell
|
||||||
|
} from "@tiptap/prosemirror-tables"
|
||||||
|
|
||||||
|
import { tableControls } from "./table-controls"
|
||||||
|
import { TableView } from "./table-view"
|
||||||
|
import { createTable } from "./utilities/create-table"
|
||||||
|
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"
|
||||||
|
|
||||||
|
export interface TableOptions {
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
|
resizable: boolean
|
||||||
|
handleWidth: number
|
||||||
|
cellMinWidth: number
|
||||||
|
lastColumnResizable: boolean
|
||||||
|
allowTableNodeSelection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
table: {
|
||||||
|
insertTable: (options?: {
|
||||||
|
rows?: number
|
||||||
|
cols?: number
|
||||||
|
withHeaderRow?: boolean
|
||||||
|
}) => ReturnType
|
||||||
|
addColumnBefore: () => ReturnType
|
||||||
|
addColumnAfter: () => ReturnType
|
||||||
|
deleteColumn: () => ReturnType
|
||||||
|
addRowBefore: () => ReturnType
|
||||||
|
addRowAfter: () => ReturnType
|
||||||
|
deleteRow: () => ReturnType
|
||||||
|
deleteTable: () => ReturnType
|
||||||
|
mergeCells: () => ReturnType
|
||||||
|
splitCell: () => ReturnType
|
||||||
|
toggleHeaderColumn: () => ReturnType
|
||||||
|
toggleHeaderRow: () => ReturnType
|
||||||
|
toggleHeaderCell: () => ReturnType
|
||||||
|
mergeOrSplit: () => ReturnType
|
||||||
|
setCellAttribute: (name: string, value: any) => ReturnType
|
||||||
|
goToNextCell: () => ReturnType
|
||||||
|
goToPreviousCell: () => ReturnType
|
||||||
|
fixTables: () => ReturnType
|
||||||
|
setCellSelection: (position: {
|
||||||
|
anchorCell: number
|
||||||
|
headCell?: number
|
||||||
|
}) => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeConfig<Options, Storage> {
|
||||||
|
tableRole?:
|
||||||
|
| string
|
||||||
|
| ((this: {
|
||||||
|
name: string
|
||||||
|
options: Options
|
||||||
|
storage: Storage
|
||||||
|
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
|
||||||
|
}) => string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create({
|
||||||
|
name: "table",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
resizable: true,
|
||||||
|
handleWidth: 5,
|
||||||
|
cellMinWidth: 100,
|
||||||
|
lastColumnResizable: true,
|
||||||
|
allowTableNodeSelection: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content: "tableRow+",
|
||||||
|
|
||||||
|
tableRole: "table",
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
|
||||||
|
allowGapCursor: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "table" }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"table",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
["tbody", 0]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertTable:
|
||||||
|
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
|
||||||
|
({ tr, dispatch, editor }) => {
|
||||||
|
const node = createTable(
|
||||||
|
editor.schema,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
withHeaderRow
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const offset = tr.selection.anchor + 1
|
||||||
|
|
||||||
|
tr.replaceSelectionWith(node)
|
||||||
|
.scrollIntoView()
|
||||||
|
.setSelection(
|
||||||
|
TextSelection.near(tr.doc.resolve(offset))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
addColumnBefore:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => addColumnBefore(state, dispatch),
|
||||||
|
addColumnAfter:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => addColumnAfter(state, dispatch),
|
||||||
|
deleteColumn:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => deleteColumn(state, dispatch),
|
||||||
|
addRowBefore:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => addRowBefore(state, dispatch),
|
||||||
|
addRowAfter:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => addRowAfter(state, dispatch),
|
||||||
|
deleteRow:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => deleteRow(state, dispatch),
|
||||||
|
deleteTable:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => deleteTable(state, dispatch),
|
||||||
|
mergeCells:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => mergeCells(state, dispatch),
|
||||||
|
splitCell:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => splitCell(state, dispatch),
|
||||||
|
toggleHeaderColumn:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
|
||||||
|
toggleHeaderRow:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
|
||||||
|
toggleHeaderCell:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
|
||||||
|
mergeOrSplit:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => {
|
||||||
|
if (mergeCells(state, dispatch)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return splitCell(state, dispatch)
|
||||||
|
},
|
||||||
|
setCellAttribute:
|
||||||
|
(name, value) =>
|
||||||
|
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
|
||||||
|
goToNextCell:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
|
||||||
|
goToPreviousCell:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
|
||||||
|
fixTables:
|
||||||
|
() =>
|
||||||
|
({ state, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
fixTables(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
setCellSelection:
|
||||||
|
(position) =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
const selection = CellSelection.create(
|
||||||
|
tr.doc,
|
||||||
|
position.anchorCell,
|
||||||
|
position.headCell
|
||||||
|
)
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
tr.setSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Tab: () => {
|
||||||
|
if (this.editor.commands.goToNextCell()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.editor.can().addRowAfter()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.editor.chain().addRowAfter().goToNextCell().run()
|
||||||
|
},
|
||||||
|
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
|
||||||
|
Backspace: deleteTableWhenAllCellsSelected,
|
||||||
|
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||||
|
Delete: deleteTableWhenAllCellsSelected,
|
||||||
|
"Mod-Delete": deleteTableWhenAllCellsSelected
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ editor, getPos, node, decorations }) => {
|
||||||
|
const { cellMinWidth } = this.options
|
||||||
|
|
||||||
|
return new TableView(
|
||||||
|
node,
|
||||||
|
cellMinWidth,
|
||||||
|
decorations,
|
||||||
|
editor,
|
||||||
|
getPos as () => number
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const isResizable = this.options.resizable && this.editor.isEditable
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
tableEditing({
|
||||||
|
allowTableNodeSelection: this.options.allowTableNodeSelection
|
||||||
|
}),
|
||||||
|
tableControls()
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isResizable) {
|
||||||
|
plugins.unshift(
|
||||||
|
columnResizing({
|
||||||
|
handleWidth: this.options.handleWidth,
|
||||||
|
cellMinWidth: this.options.cellMinWidth,
|
||||||
|
// View: TableView,
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
lastColumnResizable: this.options.lastColumnResizable
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
},
|
||||||
|
|
||||||
|
extendNodeSchema(extension) {
|
||||||
|
const context = {
|
||||||
|
name: extension.name,
|
||||||
|
options: extension.options,
|
||||||
|
storage: extension.storage
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableRole: callOrReturn(
|
||||||
|
getExtensionField(extension, "tableRole", context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,12 @@
|
|||||||
|
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
|
||||||
|
|
||||||
|
export function createCell(
|
||||||
|
cellType: NodeType,
|
||||||
|
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||||
|
): ProsemirrorNode | null | undefined {
|
||||||
|
if (cellContent) {
|
||||||
|
return cellType.createChecked(null, cellContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cellType.createAndFill()
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
|
||||||
|
|
||||||
|
import { createCell } from "./create-cell"
|
||||||
|
import { getTableNodeTypes } from "./get-table-node-types"
|
||||||
|
|
||||||
|
export function createTable(
|
||||||
|
schema: Schema,
|
||||||
|
rowsCount: number,
|
||||||
|
colsCount: number,
|
||||||
|
withHeaderRow: boolean,
|
||||||
|
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||||
|
): ProsemirrorNode {
|
||||||
|
const types = getTableNodeTypes(schema)
|
||||||
|
const headerCells: ProsemirrorNode[] = []
|
||||||
|
const cells: ProsemirrorNode[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < colsCount; index += 1) {
|
||||||
|
const cell = createCell(types.cell, cellContent)
|
||||||
|
|
||||||
|
if (cell) {
|
||||||
|
cells.push(cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withHeaderRow) {
|
||||||
|
const headerCell = createCell(types.header_cell, cellContent)
|
||||||
|
|
||||||
|
if (headerCell) {
|
||||||
|
headerCells.push(headerCell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ProsemirrorNode[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < rowsCount; index += 1) {
|
||||||
|
rows.push(
|
||||||
|
types.row.createChecked(
|
||||||
|
null,
|
||||||
|
withHeaderRow && index === 0 ? headerCells : cells
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.table.createChecked(null, rows)
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
|
||||||
|
|
||||||
|
import { isCellSelection } from "./is-cell-selection"
|
||||||
|
|
||||||
|
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
|
||||||
|
editor
|
||||||
|
}) => {
|
||||||
|
const { selection } = editor.state
|
||||||
|
|
||||||
|
if (!isCellSelection(selection)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let cellCount = 0
|
||||||
|
const table = findParentNodeClosestToPos(
|
||||||
|
selection.ranges[0].$from,
|
||||||
|
(node) => node.type.name === "table"
|
||||||
|
)
|
||||||
|
|
||||||
|
table?.node.descendants((node) => {
|
||||||
|
if (node.type.name === "table") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["tableCell", "tableHeader"].includes(node.type.name)) {
|
||||||
|
cellCount += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const allCellsSelected = cellCount === selection.ranges.length
|
||||||
|
|
||||||
|
if (!allCellsSelected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.deleteTable()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
import { NodeType, Schema } from "prosemirror-model"
|
||||||
|
|
||||||
|
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
|
||||||
|
if (schema.cached.tableNodeTypes) {
|
||||||
|
return schema.cached.tableNodeTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: { [key: string]: NodeType } = {}
|
||||||
|
|
||||||
|
Object.keys(schema.nodes).forEach((type) => {
|
||||||
|
const nodeType = schema.nodes[type]
|
||||||
|
|
||||||
|
if (nodeType.spec.tableRole) {
|
||||||
|
roles[nodeType.spec.tableRole] = nodeType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.cached.tableNodeTypes = roles
|
||||||
|
|
||||||
|
return roles
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { CellSelection } from "@tiptap/prosemirror-tables"
|
||||||
|
|
||||||
|
export function isCellSelection(value: unknown): value is CellSelection {
|
||||||
|
return value instanceof CellSelection
|
||||||
|
}
|
@ -27,8 +27,6 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|||||||
const selectItem = (index: number) => {
|
const selectItem = (index: number) => {
|
||||||
const item = props.items[index];
|
const item = props.items[index];
|
||||||
|
|
||||||
console.log(props.command);
|
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -71,7 +69,7 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
enterHandler();
|
enterHandler();
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -79,7 +77,7 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return props.items && props.items.length !== 0 ? (
|
return props.items && props.items.length !== 0 ? (
|
||||||
<div className="absolute max-h-40 bg-custom-background-100 rounded-md shadow-custom-shadow-sm text-custom-text-300 text-sm overflow-y-auto w-48 p-1 space-y-0.5">
|
<div className="mentions absolute max-h-40 bg-custom-background-100 rounded-md shadow-custom-shadow-sm text-custom-text-300 text-sm overflow-y-auto w-48 p-1 space-y-0.5">
|
||||||
{props.items.length ? (
|
{props.items.length ? (
|
||||||
props.items.map((item, index) => (
|
props.items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
|
@ -9,7 +9,6 @@ export interface CustomMentionOptions extends MentionOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
id: {
|
id: {
|
||||||
@ -54,6 +53,3 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
|
|||||||
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,7 +1,37 @@
|
|||||||
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
import {
|
||||||
|
BoldIcon,
|
||||||
|
Heading1,
|
||||||
|
CheckSquare,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
QuoteIcon,
|
||||||
|
ImageIcon,
|
||||||
|
TableIcon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
ItalicIcon,
|
||||||
|
UnderlineIcon,
|
||||||
|
StrikethroughIcon,
|
||||||
|
CodeIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { UploadImage } from "../../../types/upload-image";
|
import { UploadImage } from "../../../types/upload-image";
|
||||||
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
|
import {
|
||||||
|
insertImageCommand,
|
||||||
|
insertTableCommand,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleBold,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleCode,
|
||||||
|
toggleHeadingOne,
|
||||||
|
toggleHeadingThree,
|
||||||
|
toggleHeadingTwo,
|
||||||
|
toggleItalic,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleStrike,
|
||||||
|
toggleTaskList,
|
||||||
|
toggleUnderline,
|
||||||
|
} from "../../../lib/editor-commands";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export interface EditorMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -15,95 +45,101 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
|||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||||
command: () => toggleHeadingOne(editor),
|
command: () => toggleHeadingOne(editor),
|
||||||
icon: Heading1,
|
icon: Heading1,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "H2",
|
name: "H2",
|
||||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||||
command: () => toggleHeadingTwo(editor),
|
command: () => toggleHeadingTwo(editor),
|
||||||
icon: Heading2,
|
icon: Heading2,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "H3",
|
name: "H3",
|
||||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||||
command: () => toggleHeadingThree(editor),
|
command: () => toggleHeadingThree(editor),
|
||||||
icon: Heading3,
|
icon: Heading3,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "bold",
|
name: "bold",
|
||||||
isActive: () => editor?.isActive("bold"),
|
isActive: () => editor?.isActive("bold"),
|
||||||
command: () => toggleBold(editor),
|
command: () => toggleBold(editor),
|
||||||
icon: BoldIcon,
|
icon: BoldIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "italic",
|
name: "italic",
|
||||||
isActive: () => editor?.isActive("italic"),
|
isActive: () => editor?.isActive("italic"),
|
||||||
command: () => toggleItalic(editor),
|
command: () => toggleItalic(editor),
|
||||||
icon: ItalicIcon,
|
icon: ItalicIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "underline",
|
name: "underline",
|
||||||
isActive: () => editor?.isActive("underline"),
|
isActive: () => editor?.isActive("underline"),
|
||||||
command: () => toggleUnderline(editor),
|
command: () => toggleUnderline(editor),
|
||||||
icon: UnderlineIcon,
|
icon: UnderlineIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "strike",
|
name: "strike",
|
||||||
isActive: () => editor?.isActive("strike"),
|
isActive: () => editor?.isActive("strike"),
|
||||||
command: () => toggleStrike(editor),
|
command: () => toggleStrike(editor),
|
||||||
icon: StrikethroughIcon,
|
icon: StrikethroughIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "code",
|
name: "code",
|
||||||
isActive: () => editor?.isActive("code"),
|
isActive: () => editor?.isActive("code"),
|
||||||
command: () => toggleCode(editor),
|
command: () => toggleCode(editor),
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "bullet-list",
|
name: "bullet-list",
|
||||||
isActive: () => editor?.isActive("bulletList"),
|
isActive: () => editor?.isActive("bulletList"),
|
||||||
command: () => toggleBulletList(editor),
|
command: () => toggleBulletList(editor),
|
||||||
icon: ListIcon,
|
icon: ListIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "To-do List",
|
name: "To-do List",
|
||||||
isActive: () => editor.isActive("taskItem"),
|
isActive: () => editor.isActive("taskItem"),
|
||||||
command: () => toggleTaskList(editor),
|
command: () => toggleTaskList(editor),
|
||||||
icon: CheckSquare,
|
icon: CheckSquare,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "ordered-list",
|
name: "ordered-list",
|
||||||
isActive: () => editor?.isActive("orderedList"),
|
isActive: () => editor?.isActive("orderedList"),
|
||||||
command: () => toggleOrderedList(editor),
|
command: () => toggleOrderedList(editor),
|
||||||
icon: ListOrderedIcon
|
icon: ListOrderedIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "quote",
|
name: "quote",
|
||||||
isActive: () => editor?.isActive("quote"),
|
isActive: () => editor?.isActive("quote"),
|
||||||
command: () => toggleBlockquote(editor),
|
command: () => toggleBlockquote(editor),
|
||||||
icon: QuoteIcon
|
icon: QuoteIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||||
name: "quote",
|
name: "table",
|
||||||
isActive: () => editor?.isActive("table"),
|
isActive: () => editor?.isActive("table"),
|
||||||
command: () => insertTableCommand(editor),
|
command: () => insertTableCommand(editor),
|
||||||
icon: TableIcon
|
icon: TableIcon,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
|
export const ImageItem = (
|
||||||
|
editor: Editor,
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void,
|
||||||
|
): EditorMenuItem => ({
|
||||||
name: "image",
|
name: "image",
|
||||||
isActive: () => editor?.isActive("image"),
|
isActive: () => editor?.isActive("image"),
|
||||||
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
})
|
});
|
||||||
|
@ -1,120 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Rows, Columns, ToggleRight } from "lucide-react";
|
|
||||||
import InsertLeftTableIcon from "./InsertLeftTableIcon";
|
|
||||||
import InsertRightTableIcon from "./InsertRightTableIcon";
|
|
||||||
import InsertTopTableIcon from "./InsertTopTableIcon";
|
|
||||||
import InsertBottomTableIcon from "./InsertBottomTableIcon";
|
|
||||||
import { cn, findTableAncestor } from "../../../lib/utils";
|
|
||||||
import { Tooltip } from "./tooltip";
|
|
||||||
|
|
||||||
interface TableMenuItem {
|
|
||||||
command: () => void;
|
|
||||||
icon: any;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("click", handleWindowClick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("click", handleWindowClick);
|
|
||||||
};
|
|
||||||
}, [tableLocation, editor]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`absolute z-20 left-1/2 -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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -8,10 +8,10 @@ import TaskList from "@tiptap/extension-task-list";
|
|||||||
import { Markdown } from "tiptap-markdown";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||||
|
|
||||||
import { CustomTableCell } from "../extensions/table/table-cell";
|
import TableHeader from "../extensions/table/table-header/table-header";
|
||||||
import { Table } from "../extensions/table";
|
import Table from "../extensions/table/table";
|
||||||
import { TableHeader } from "../extensions/table/table-header";
|
import TableCell from "../extensions/table/table-cell/table-cell";
|
||||||
import { TableRow } from "@tiptap/extension-table-row";
|
import TableRow from "../extensions/table/table-row/table-row";
|
||||||
|
|
||||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||||
import { isValidHttpUrl } from "../../lib/utils";
|
import { isValidHttpUrl } from "../../lib/utils";
|
||||||
@ -91,7 +91,7 @@ export const CoreReadOnlyEditorExtensions = (
|
|||||||
}),
|
}),
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
CustomTableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
||||||
];
|
];
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { EnterKeyExtension } from "./enter-key-extension";
|
import { EnterKeyExtension } from "./enter-key-extension";
|
||||||
|
|
||||||
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
||||||
EnterKeyExtension(onEnterKeyPress),
|
// EnterKeyExtension(onEnterKeyPress),
|
||||||
];
|
];
|
||||||
|
@ -72,12 +72,10 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(complexItems);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-stretch gap-1.5 w-full h-9">
|
<div className="flex items-stretch gap-1.5 w-full h-9 overflow-x-scroll">
|
||||||
{props.commentAccessSpecifier && (
|
{props.commentAccessSpecifier && (
|
||||||
<div className="flex-shrink-0 flex items-stretch gap-0.5 border border-custom-border-200 rounded p-1">
|
<div className="flex-shrink-0 flex items-stretch gap-0.5 border-[0.5px] border-custom-border-200 rounded p-1">
|
||||||
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
|
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
|
||||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||||
<button
|
<button
|
||||||
@ -102,102 +100,118 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-stretch justify-between gap-2 w-full border border-custom-border-200 bg-custom-background-90 rounded p-1">
|
<div className="flex items-stretch justify-between gap-2 w-full border-[0.5px] border-custom-border-200 bg-custom-background-90 rounded p-1">
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
<div className="flex items-stretch gap-0.5 pr-2.5 border-r border-custom-border-200">
|
<div className="flex items-stretch gap-0.5 pr-2.5 border-r border-custom-border-200">
|
||||||
{basicMarkItems.map((item, index) => (
|
{basicMarkItems.map((item, index) => (
|
||||||
<button
|
<Tooltip
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
tooltipContent={<span className="capitalize">{item.name}</span>}
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-background-80":
|
|
||||||
item.isActive(),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<item.icon
|
<button
|
||||||
className={cn("h-3.5 w-3.5", {
|
type="button"
|
||||||
"text-custom-text-100": item.isActive(),
|
onClick={item.command}
|
||||||
})}
|
className={cn(
|
||||||
strokeWidth={2.5}
|
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
||||||
/>
|
{
|
||||||
</button>
|
"text-custom-text-100 bg-custom-background-80":
|
||||||
|
item.isActive(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-3.5 w-3.5", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
|
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
|
||||||
{listItems.map((item, index) => (
|
{listItems.map((item, index) => (
|
||||||
<button
|
<Tooltip
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
tooltipContent={<span className="capitalize">{item.name}</span>}
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-background-80":
|
|
||||||
item.isActive(),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<item.icon
|
<button
|
||||||
className={cn("h-3.5 w-3.5", {
|
type="button"
|
||||||
"text-custom-text-100": item.isActive(),
|
onClick={item.command}
|
||||||
})}
|
className={cn(
|
||||||
strokeWidth={2.5}
|
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
||||||
/>
|
{
|
||||||
</button>
|
"text-custom-text-100 bg-custom-background-80":
|
||||||
|
item.isActive(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-3.5 w-3.5", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
|
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
|
||||||
{userActionItems.map((item, index) => (
|
{userActionItems.map((item, index) => (
|
||||||
<button
|
<Tooltip
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
tooltipContent={<span className="capitalize">{item.name}</span>}
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-background-80":
|
|
||||||
item.isActive(),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<item.icon
|
<button
|
||||||
className={cn("h-3.5 w-3.5", {
|
type="button"
|
||||||
"text-custom-text-100": item.isActive(),
|
onClick={item.command}
|
||||||
})}
|
className={cn(
|
||||||
strokeWidth={2.5}
|
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
||||||
/>
|
{
|
||||||
</button>
|
"text-custom-text-100 bg-custom-background-80":
|
||||||
|
item.isActive(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-3.5 w-3.5", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-stretch gap-0.5 pl-2.5">
|
<div className="flex items-stretch gap-0.5 pl-2.5">
|
||||||
{complexItems.map((item, index) => (
|
{complexItems.map((item, index) => (
|
||||||
<button
|
<Tooltip
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
tooltipContent={<span className="capitalize">{item.name}</span>}
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-background-80":
|
|
||||||
item.isActive(),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<item.icon
|
<button
|
||||||
className={cn("h-3.5 w-3.5", {
|
type="button"
|
||||||
"text-custom-text-100": item.isActive(),
|
onClick={item.command}
|
||||||
})}
|
className={cn(
|
||||||
strokeWidth={2.5}
|
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
|
||||||
/>
|
{
|
||||||
</button>
|
"text-custom-text-100 bg-custom-background-80":
|
||||||
|
item.isActive(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-3.5 w-3.5", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.submitButton}
|
<div className="sticky right-1">{props.submitButton}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ interface ICoreReadOnlyEditor {
|
|||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
customClassName?: string;
|
customClassName?: string;
|
||||||
|
mentionHighlights: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||||
@ -26,10 +27,12 @@ const LiteReadOnlyEditor = ({
|
|||||||
customClassName,
|
customClassName,
|
||||||
value,
|
value,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
mentionHighlights
|
||||||
}: EditorCoreProps) => {
|
}: EditorCoreProps) => {
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
value,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
mentionHighlights
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
useRef,
|
||||||
|
useLayoutEffect,
|
||||||
|
} from "react";
|
||||||
import { Editor, Range, Extension } from "@tiptap/core";
|
import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
@ -18,7 +25,18 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { UploadImage } from "../";
|
import { UploadImage } from "../";
|
||||||
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
import {
|
||||||
|
cn,
|
||||||
|
insertTableCommand,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleTaskList,
|
||||||
|
insertImageCommand,
|
||||||
|
toggleHeadingOne,
|
||||||
|
toggleHeadingTwo,
|
||||||
|
toggleHeadingThree,
|
||||||
|
} from "@plane/editor-core";
|
||||||
|
|
||||||
interface CommandItemProps {
|
interface CommandItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -37,7 +55,15 @@ const Command = Extension.create({
|
|||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "/",
|
char: "/",
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
command: ({
|
||||||
|
editor,
|
||||||
|
range,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
props: any;
|
||||||
|
}) => {
|
||||||
props.command({ editor, range });
|
props.command({ editor, range });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -59,127 +85,135 @@ const Command = Extension.create({
|
|||||||
const getSuggestionItems =
|
const getSuggestionItems =
|
||||||
(
|
(
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void,
|
||||||
) =>
|
) =>
|
||||||
({ query }: { query: string }) =>
|
({ query }: { query: string }) =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
title: "Text",
|
title: "Text",
|
||||||
description: "Just start typing with plain text.",
|
description: "Just start typing with plain text.",
|
||||||
searchTerms: ["p", "paragraph"],
|
searchTerms: ["p", "paragraph"],
|
||||||
icon: <Text size={18} />,
|
icon: <Text size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
editor
|
||||||
},
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode("paragraph", "paragraph")
|
||||||
|
.run();
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 1",
|
{
|
||||||
description: "Big section heading.",
|
title: "Heading 1",
|
||||||
searchTerms: ["title", "big", "large"],
|
description: "Big section heading.",
|
||||||
icon: <Heading1 size={18} />,
|
searchTerms: ["title", "big", "large"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading1 size={18} />,
|
||||||
toggleHeadingOne(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingOne(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 2",
|
{
|
||||||
description: "Medium section heading.",
|
title: "Heading 2",
|
||||||
searchTerms: ["subtitle", "medium"],
|
description: "Medium section heading.",
|
||||||
icon: <Heading2 size={18} />,
|
searchTerms: ["subtitle", "medium"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading2 size={18} />,
|
||||||
toggleHeadingTwo(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingTwo(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 3",
|
{
|
||||||
description: "Small section heading.",
|
title: "Heading 3",
|
||||||
searchTerms: ["subtitle", "small"],
|
description: "Small section heading.",
|
||||||
icon: <Heading3 size={18} />,
|
searchTerms: ["subtitle", "small"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading3 size={18} />,
|
||||||
toggleHeadingThree(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingThree(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "To-do List",
|
{
|
||||||
description: "Track tasks with a to-do list.",
|
title: "To-do List",
|
||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
description: "Track tasks with a to-do list.",
|
||||||
icon: <CheckSquare size={18} />,
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <CheckSquare size={18} />,
|
||||||
toggleTaskList(editor, range)
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleTaskList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Bullet List",
|
{
|
||||||
description: "Create a simple bullet list.",
|
title: "Bullet List",
|
||||||
searchTerms: ["unordered", "point"],
|
description: "Create a simple bullet list.",
|
||||||
icon: <List size={18} />,
|
searchTerms: ["unordered", "point"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <List size={18} />,
|
||||||
toggleBulletList(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleBulletList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Divider",
|
{
|
||||||
description: "Visually divide blocks",
|
title: "Divider",
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
description: "Visually divide blocks",
|
||||||
icon: <MinusSquare size={18} />,
|
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <MinusSquare size={18} />,
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Table",
|
{
|
||||||
description: "Create a Table",
|
title: "Table",
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
description: "Create a Table",
|
||||||
icon: <Table size={18} />,
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Table size={18} />,
|
||||||
insertTableCommand(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
insertTableCommand(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Numbered List",
|
{
|
||||||
description: "Create a list with numbering.",
|
title: "Numbered List",
|
||||||
searchTerms: ["ordered"],
|
description: "Create a list with numbering.",
|
||||||
icon: <ListOrdered size={18} />,
|
searchTerms: ["ordered"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <ListOrdered size={18} />,
|
||||||
toggleOrderedList(editor, range)
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleOrderedList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Quote",
|
{
|
||||||
description: "Capture a quote.",
|
title: "Quote",
|
||||||
searchTerms: ["blockquote"],
|
description: "Capture a quote.",
|
||||||
icon: <TextQuote size={18} />,
|
searchTerms: ["blockquote"],
|
||||||
command: ({ editor, range }: CommandProps) =>
|
icon: <TextQuote size={18} />,
|
||||||
toggleBlockquote(editor, range)
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
toggleBlockquote(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code",
|
||||||
|
description: "Capture a code snippet.",
|
||||||
|
searchTerms: ["codeblock"],
|
||||||
|
icon: <Code size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image from your computer.",
|
||||||
|
searchTerms: ["photo", "picture", "media"],
|
||||||
|
icon: <ImageIcon size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Code",
|
].filter((item) => {
|
||||||
description: "Capture a code snippet.",
|
if (typeof query === "string" && query.length > 0) {
|
||||||
searchTerms: ["codeblock"],
|
const search = query.toLowerCase();
|
||||||
icon: <Code size={18} />,
|
return (
|
||||||
command: ({ editor, range }: CommandProps) =>
|
item.title.toLowerCase().includes(search) ||
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
item.description.toLowerCase().includes(search) ||
|
||||||
},
|
(item.searchTerms &&
|
||||||
{
|
item.searchTerms.some((term: string) => term.includes(search)))
|
||||||
title: "Image",
|
);
|
||||||
description: "Upload an image from your computer.",
|
}
|
||||||
searchTerms: ["photo", "picture", "media"],
|
return true;
|
||||||
icon: <ImageIcon size={18} />,
|
});
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((item) => {
|
|
||||||
if (typeof query === "string" && query.length > 0) {
|
|
||||||
const search = query.toLowerCase();
|
|
||||||
return (
|
|
||||||
item.title.toLowerCase().includes(search) ||
|
|
||||||
item.description.toLowerCase().includes(search) ||
|
|
||||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||||
const containerHeight = container.offsetHeight;
|
const containerHeight = container.offsetHeight;
|
||||||
@ -213,7 +247,7 @@ const CommandList = ({
|
|||||||
command(item);
|
command(item);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[command, items]
|
[command, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -266,11 +300,17 @@ const CommandList = ({
|
|||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
{
|
||||||
|
"bg-custom-primary-100/5 text-custom-text-100":
|
||||||
|
index === selectedIndex,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
>
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-custom-border-300 bg-custom-background-100">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{item.title}</p>
|
<p className="font-medium">{item.title}</p>
|
||||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
<p className="text-xs text-custom-text-300">{item.description}</p>
|
||||||
@ -331,7 +371,9 @@ const renderItems = () => {
|
|||||||
|
|
||||||
export const SlashCommand = (
|
export const SlashCommand = (
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void,
|
||||||
) =>
|
) =>
|
||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||||
import { FC, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import { BoldIcon } from "lucide-react";
|
import { BoldIcon } from "lucide-react";
|
||||||
|
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { LinkSelector } from "./link-selector";
|
import { LinkSelector } from "./link-selector";
|
||||||
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
|
import {
|
||||||
|
BoldItem,
|
||||||
|
cn,
|
||||||
|
CodeItem,
|
||||||
|
isCellSelection,
|
||||||
|
ItalicItem,
|
||||||
|
StrikeThroughItem,
|
||||||
|
UnderLineItem,
|
||||||
|
} from "@plane/editor-core";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -26,14 +34,35 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
|||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
...props,
|
...props,
|
||||||
shouldShow: ({ editor }) => {
|
shouldShow: ({ view, state, editor }) => {
|
||||||
if (!editor.isEditable) {
|
const { selection } = state;
|
||||||
|
|
||||||
|
const { empty } = selection;
|
||||||
|
const hasEditorFocus = view.hasFocus();
|
||||||
|
|
||||||
|
// if (typeof window !== "undefined") {
|
||||||
|
// const selection: any = window?.getSelection();
|
||||||
|
// if (selection.rangeCount !== 0) {
|
||||||
|
// const range = selection.getRangeAt(0);
|
||||||
|
// if (findTableAncestor(range.startContainer)) {
|
||||||
|
// console.log("table");
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasEditorFocus ||
|
||||||
|
empty ||
|
||||||
|
!editor.isEditable ||
|
||||||
|
editor.isActive("image") ||
|
||||||
|
isNodeSelection(selection) ||
|
||||||
|
isCellSelection(selection) ||
|
||||||
|
isSelecting
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (editor.isActive("image")) {
|
return true;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return editor.view.state.selection.content().size > 0;
|
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
@ -47,50 +76,83 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
|||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown() {
|
||||||
|
function handleMouseMove() {
|
||||||
|
if (!props.editor.state.selection.empty) {
|
||||||
|
setIsSelecting(true);
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
setIsSelecting(false);
|
||||||
|
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{...bubbleMenuProps}
|
{...bubbleMenuProps}
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||||
>
|
>
|
||||||
{!props.editor.isActive("table") && (
|
{isSelecting ? null : (
|
||||||
<NodeSelector
|
<>
|
||||||
editor={props.editor!}
|
{!props.editor.isActive("table") && (
|
||||||
isOpen={isNodeSelectorOpen}
|
<NodeSelector
|
||||||
setIsOpen={() => {
|
editor={props.editor!}
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
isOpen={isNodeSelectorOpen}
|
||||||
setIsLinkSelectorOpen(false);
|
setIsOpen={() => {
|
||||||
}}
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||||
/>
|
setIsLinkSelectorOpen(false);
|
||||||
)}
|
}}
|
||||||
<LinkSelector
|
|
||||||
editor={props.editor!!}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
)}
|
||||||
))}
|
<LinkSelector
|
||||||
</div>
|
editor={props.editor!!}
|
||||||
|
isOpen={isLinkSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5":
|
||||||
|
item.isActive(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,8 @@ import { ThemeProvider } from "next-themes";
|
|||||||
// styles
|
// styles
|
||||||
import "styles/globals.css";
|
import "styles/globals.css";
|
||||||
import "styles/editor.css";
|
import "styles/editor.css";
|
||||||
|
import "styles/table.css";
|
||||||
|
|
||||||
// contexts
|
// contexts
|
||||||
import { ToastContextProvider } from "contexts/toast.context";
|
import { ToastContextProvider } from "contexts/toast.context";
|
||||||
// mobx store provider
|
// mobx store provider
|
||||||
|
194
space/styles/table.css
Normal file
194
space/styles/table.css
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table td,
|
||||||
|
.tableWrapper table th {
|
||||||
|
min-width: 1em;
|
||||||
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
|
padding: 10px 15px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table td > *,
|
||||||
|
.tableWrapper table th > * {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0.25rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table td.has-focus,
|
||||||
|
.tableWrapper table th.has-focus {
|
||||||
|
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
background-color: rgba(var(--color-primary-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table th * {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table .selectedCell:after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(var(--color-primary-300), 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper table .column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 4px;
|
||||||
|
z-index: 99;
|
||||||
|
background-color: rgba(var(--color-primary-400));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .columnsControl,
|
||||||
|
.tableWrapper .tableControls .rowsControl {
|
||||||
|
transition: opacity ease-in 100ms;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .columnsControl {
|
||||||
|
height: 20px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .columnsControl > button {
|
||||||
|
color: white;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||||
|
width: 30px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .rowsControl {
|
||||||
|
width: 20px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .rowsControl > button {
|
||||||
|
color: white;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||||
|
height: 30px;
|
||||||
|
width: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls button {
|
||||||
|
background-color: rgba(var(--color-primary-100));
|
||||||
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
|
border-radius: 2px;
|
||||||
|
background-size: 1.25rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
transition: transform ease-out 100ms, background-color ease-out 100ms;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: #000 0px 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||||
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
|
background-color: rgba(var(--color-background-100));
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 200px;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem {
|
||||||
|
background-color: rgba(var(--color-background-100));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
padding: 0.1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
|
||||||
|
background-color: rgba(var(--color-background-100), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||||
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableToolbox {
|
||||||
|
background-color: rgba(var(--color-background-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .label,
|
||||||
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(var(--color-text-300));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor .tableWrapper .tableControls .rowsControl,
|
||||||
|
.tableWrapper.controls--disabled .tableControls .rowsControl,
|
||||||
|
.resize-cursor .tableWrapper .tableControls .columnsControl,
|
||||||
|
.tableWrapper.controls--disabled .tableControls .columnsControl {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { BarDatum } from "@nivo/bar";
|
|||||||
// components
|
// components
|
||||||
import { CustomTooltip } from "./custom-tooltip";
|
import { CustomTooltip } from "./custom-tooltip";
|
||||||
// ui
|
// ui
|
||||||
import { BarGraph } from "components/ui";
|
import { BarGraph, Tooltip } from "components/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { findStringWithMostCharacters } from "helpers/array.helper";
|
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||||
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
|
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
|
||||||
@ -72,42 +72,50 @@ export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, param
|
|||||||
renderTick:
|
renderTick:
|
||||||
params.x_axis === "assignees__id"
|
params.x_axis === "assignees__id"
|
||||||
? (datum) => {
|
? (datum) => {
|
||||||
const avatar = analytics.extras.assignee_details?.find(
|
const assignee = analytics.extras.assignee_details?.find((a) => a?.assignees__id === datum?.value);
|
||||||
(a) => a?.assignees__display_name === datum?.value
|
|
||||||
)?.assignees__avatar;
|
|
||||||
|
|
||||||
if (avatar && avatar !== "")
|
if (assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
<Tooltip tooltipContent={assignee?.assignees__display_name}>
|
||||||
<image
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
x={-8}
|
<image
|
||||||
y={10}
|
x={-8}
|
||||||
width={16}
|
y={10}
|
||||||
height={16}
|
width={16}
|
||||||
xlinkHref={avatar}
|
height={16}
|
||||||
style={{ clipPath: "circle(50%)" }}
|
xlinkHref={assignee?.assignees__avatar}
|
||||||
/>
|
style={{ clipPath: "circle(50%)" }}
|
||||||
</g>
|
/>
|
||||||
|
</g>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
<Tooltip tooltipContent={assignee?.assignees__display_name}>
|
||||||
<circle cy={18} r={8} fill="#374151" />
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
<circle cy={18} r={8} fill="#374151" />
|
||||||
{params.x_axis === "assignees__id"
|
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||||
? datum.value && datum.value !== "None"
|
{params.x_axis === "assignees__id"
|
||||||
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
|
? datum.value && datum.value !== "None"
|
||||||
: "?"
|
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
|
||||||
: datum.value && datum.value !== "None"
|
: "?"
|
||||||
? `${datum.value}`.toUpperCase()[0]
|
: datum.value && datum.value !== "None"
|
||||||
: "?"}
|
? `${datum.value}`.toUpperCase()[0]
|
||||||
</text>
|
: "?"}
|
||||||
</g>
|
</text>
|
||||||
|
</g>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: (datum) => (
|
: (datum) => (
|
||||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
<g transform={`translate(${datum.x},${datum.y + 10})`}>
|
||||||
<text x={0} y={21} textAnchor="middle" fontSize={10}>
|
<text
|
||||||
|
x={0}
|
||||||
|
y={datum.y}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={10}
|
||||||
|
className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`}
|
||||||
|
>
|
||||||
{generateDisplayName(datum.value, analytics, params, "x_axis")}
|
{generateDisplayName(datum.value, analytics, params, "x_axis")}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
@ -66,6 +66,7 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
onChange(val);
|
onChange(val);
|
||||||
}}
|
}}
|
||||||
|
params={params}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -3,16 +3,19 @@ import { useRouter } from "next/router";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomSelect } from "@plane/ui";
|
import { CustomSelect } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { TXAxisValues } from "types";
|
import { IAnalyticsParams, TXAxisValues } from "types";
|
||||||
// constants
|
// constants
|
||||||
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: TXAxisValues;
|
value: TXAxisValues;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
|
params: IAnalyticsParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
|
export const SelectXAxis: React.FC<Props> = (props) => {
|
||||||
|
const { value, onChange, params } = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cycleId, moduleId } = router.query;
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
|
|||||||
maxHeight="lg"
|
maxHeight="lg"
|
||||||
>
|
>
|
||||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||||
|
if (params.segment === item.value) return null;
|
||||||
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
|
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
|
||||||
if (moduleId && item.value === "issue_module__module_id") return null;
|
if (moduleId && item.value === "issue_module__module_id") return null;
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// hooks
|
// hooks
|
||||||
@ -8,23 +8,9 @@ import useLocalStorage from "hooks/use-local-storage";
|
|||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui";
|
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui";
|
||||||
// helper
|
// helper
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
|
// constants
|
||||||
const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[] = [
|
import { MODULE_VIEW_LAYOUTS } from "constants/module";
|
||||||
{
|
|
||||||
type: "list",
|
|
||||||
icon: List,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "grid",
|
|
||||||
icon: LayoutGrid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "gantt_chart",
|
|
||||||
icon: GanttChartSquare,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ModulesListHeader: React.FC = observer(() => {
|
export const ModulesListHeader: React.FC = observer(() => {
|
||||||
// router
|
// router
|
||||||
@ -68,23 +54,26 @@ export const ModulesListHeader: React.FC = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{moduleViewOptions.map((option) => (
|
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
|
||||||
<Tooltip
|
{MODULE_VIEW_LAYOUTS.map((layout) => (
|
||||||
key={option.type}
|
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||||
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span>}
|
<button
|
||||||
position="bottom"
|
type="button"
|
||||||
>
|
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
|
||||||
<button
|
modulesView == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||||
type="button"
|
}`}
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
onClick={() => setModulesView(layout.key)}
|
||||||
modulesView === option.type ? "bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200"
|
>
|
||||||
}`}
|
<layout.icon
|
||||||
onClick={() => setModulesView(option.type)}
|
strokeWidth={2}
|
||||||
>
|
className={`h-3.5 w-3.5 ${
|
||||||
<option.icon className="h-3.5 w-3.5" />
|
modulesView == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||||
</button>
|
}`}
|
||||||
</Tooltip>
|
/>
|
||||||
))}
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
prependIcon={<Plus />}
|
prependIcon={<Plus />}
|
||||||
|
@ -3,22 +3,82 @@ import { useRouter } from "next/router";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
|
||||||
|
// helper
|
||||||
|
import { truncateText } from "helpers/string.helper";
|
||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, LayersIcon } from "@plane/ui";
|
import { Breadcrumbs, BreadcrumbItem, LayersIcon } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
|
||||||
|
// types
|
||||||
|
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "types";
|
||||||
// helper
|
// helper
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
|
|
||||||
export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const { project: projectStore } = useMobxStore();
|
const { project: projectStore, archivedIssueFilters: archivedIssueFiltersStore } = useMobxStore();
|
||||||
|
|
||||||
const { currentProjectDetails } = projectStore;
|
const { currentProjectDetails } = projectStore;
|
||||||
|
|
||||||
|
// for archived issues list layout is the only option
|
||||||
|
const activeLayout = "list";
|
||||||
|
|
||||||
|
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
const newValues = archivedIssueFiltersStore.userFilters?.[key] ?? [];
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((val) => {
|
||||||
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (archivedIssueFiltersStore.userFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
else newValues.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
|
||||||
|
filters: {
|
||||||
|
[key]: newValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
|
||||||
|
display_filters: {
|
||||||
|
...archivedIssueFiltersStore.userDisplayFilters,
|
||||||
|
...updatedDisplayFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisplayPropertiesUpdate = (property: Partial<IIssueDisplayProperties>) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
archivedIssueFiltersStore.updateDisplayProperties(workspaceSlug.toString(), projectId.toString(), property);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
|
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
<ArrowLeft fontSize={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
@ -46,6 +106,33 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
|||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* filter options */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||||
|
<FilterSelection
|
||||||
|
filters={archivedIssueFiltersStore.userFilters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.archived_issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
|
||||||
|
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
|
||||||
|
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
<FiltersDropdown title="Display" placement="bottom-end">
|
||||||
|
<DisplayFiltersSelection
|
||||||
|
displayFilters={archivedIssueFiltersStore.userDisplayFilters}
|
||||||
|
displayProperties={archivedIssueFiltersStore.userDisplayProperties}
|
||||||
|
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
|
||||||
|
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -24,13 +24,15 @@ export const WorkspaceDashboardHeader = () => {
|
|||||||
Dashboard
|
Dashboard
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 px-3">
|
<div className="flex items-center gap-3 px-3">
|
||||||
<button
|
<a
|
||||||
onClick={() => setIsProductUpdatesModalOpen(true)}
|
href="https://plane.so/changelog"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
|
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
|
||||||
>
|
>
|
||||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||||
{"What's New?"}
|
{"What's New?"}
|
||||||
</button>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
|
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
|
||||||
href="https://github.com/makeplane/plane"
|
href="https://github.com/makeplane/plane"
|
||||||
|
@ -52,7 +52,7 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -74,33 +74,35 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
|||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit(handleAddComment)}>
|
<form onSubmit={handleSubmit(handleAddComment)}>
|
||||||
<div>
|
<div>
|
||||||
<div className="relative">
|
<Controller
|
||||||
<Controller
|
name="access"
|
||||||
name="access"
|
control={control}
|
||||||
control={control}
|
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||||
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
<Controller
|
||||||
<Controller
|
name="comment_html"
|
||||||
name="comment_html"
|
control={control}
|
||||||
control={control}
|
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||||
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
<LiteTextEditorWithRef
|
||||||
<LiteTextEditorWithRef
|
onEnterKeyPress={handleSubmit(handleAddComment)}
|
||||||
onEnterKeyPress={handleSubmit(handleAddComment)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
deleteFile={fileService.deleteImage}
|
||||||
deleteFile={fileService.deleteImage}
|
ref={editorRef}
|
||||||
ref={editorRef}
|
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
debouncedUpdatesEnabled={false}
|
||||||
debouncedUpdatesEnabled={false}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
commentAccessSpecifier={
|
||||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
showAccessSpecifier
|
||||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
? { accessValue, onAccessChange, showAccessSpecifier, commentAccess }
|
||||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
: undefined
|
||||||
/>
|
}
|
||||||
)}
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
/>
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button variant="neutral-primary" type="submit" disabled={isSubmitting || disabled}>
|
<Button variant="neutral-primary" type="submit" disabled={isSubmitting || disabled}>
|
||||||
{isSubmitting ? "Adding..." : "Comment"}
|
{isSubmitting ? "Adding..." : "Comment"}
|
||||||
|
@ -147,6 +147,7 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
||||||
</div>
|
</div>
|
||||||
|
131
web/components/issues/delete-archived-issue-modal.tsx
Normal file
131
web/components/issues/delete-archived-issue-modal.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
// mobx store
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleClose: () => void;
|
||||||
|
data: IIssue;
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteArchivedIssueModal: React.FC<Props> = observer((props) => {
|
||||||
|
const { data, isOpen, handleClose, onSubmit } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { archivedIssueDetail: archivedIssueDetailStore } = useMobxStore();
|
||||||
|
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIssueDelete = async () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await archivedIssueDetailStore
|
||||||
|
.deleteArchivedIssue(workspaceSlug.toString(), data.project, data.id)
|
||||||
|
.then(() => {
|
||||||
|
if (onSubmit) onSubmit();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const error = err?.detail;
|
||||||
|
const errorString = Array.isArray(error) ? error[0] : error;
|
||||||
|
|
||||||
|
setToastAlert({
|
||||||
|
title: "Error",
|
||||||
|
type: "error",
|
||||||
|
message: errorString || "Something went wrong.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||||
|
<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 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
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-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<div className="flex w-full items-center justify-start gap-6">
|
||||||
|
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center justify-start">
|
||||||
|
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Archived Issue</h3>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
<p className="text-sm text-custom-text-200">
|
||||||
|
Are you sure you want to delete issue{" "}
|
||||||
|
<span className="break-words font-medium text-custom-text-100">
|
||||||
|
{data?.project_detail.identifier}-{data?.sequence_id}
|
||||||
|
</span>
|
||||||
|
{""}? All of the data related to the archived issue will be permanently removed. This action
|
||||||
|
cannot be undone.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="neutral-primary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleIssueDelete} loading={isDeleteLoading}>
|
||||||
|
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
});
|
@ -20,3 +20,6 @@ export * from "./confirm-issue-discard";
|
|||||||
export * from "./draft-issue-form";
|
export * from "./draft-issue-form";
|
||||||
export * from "./draft-issue-modal";
|
export * from "./draft-issue-modal";
|
||||||
export * from "./delete-draft-issue-modal";
|
export * from "./delete-draft-issue-modal";
|
||||||
|
|
||||||
|
// archived issue
|
||||||
|
export * from "./delete-archived-issue-modal";
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
// store
|
// store
|
||||||
@ -66,7 +65,7 @@ const Inputs = (props: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="text-sm font-medium leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
<h4 className="text-xs leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -74,7 +73,7 @@ const Inputs = (props: any) => {
|
|||||||
{...register("name", {
|
{...register("name", {
|
||||||
required: "Issue title is required.",
|
required: "Issue title is required.",
|
||||||
})}
|
})}
|
||||||
className="w-full pr-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
className="w-full pr-2 py-1.5 rounded-md bg-transparent text-xs font-medium leading-5 text-custom-text-200 outline-none"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -181,15 +180,7 @@ export const CalendarInlineCreateIssueForm: React.FC<Props> = observer((props) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Transition
|
{isOpen && (
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-in-out duration-200 transform"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in-out duration-200 transform"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`transition-all z-20 w-full ${
|
className={`transition-all z-20 w-full ${
|
||||||
@ -198,21 +189,21 @@ export const CalendarInlineCreateIssueForm: React.FC<Props> = observer((props) =
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmitHandler)}
|
onSubmit={handleSubmit(onSubmitHandler)}
|
||||||
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
|
className="flex w-full px-2 border-[0.5px] border-custom-border-200 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-2xs transition-opacity"
|
||||||
>
|
>
|
||||||
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
)}
|
||||||
|
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<div className="hidden group-hover:block border-[0.5px] border-custom-border-200 rounded">
|
<div className="hidden group-hover:block border-[0.5px] border-custom-border-200 rounded">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-1 py-1.5 rounded-md"
|
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1.5 rounded-md"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,81 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
// mobx store
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// components
|
||||||
|
import { AppliedFiltersList } from "components/issues";
|
||||||
|
// types
|
||||||
|
import { IIssueFilterOptions } from "types";
|
||||||
|
|
||||||
|
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { archivedIssueFilters: archivedIssueFiltersStore, project: projectStore } = useMobxStore();
|
||||||
|
|
||||||
|
const userFilters = archivedIssueFiltersStore.userFilters;
|
||||||
|
|
||||||
|
// filters whose value not null or empty array
|
||||||
|
const appliedFilters: IIssueFilterOptions = {};
|
||||||
|
Object.entries(userFilters).forEach(([key, value]) => {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
if (Array.isArray(value) && value.length === 0) return;
|
||||||
|
|
||||||
|
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
// remove all values of the key if value is null
|
||||||
|
if (!value) {
|
||||||
|
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
|
||||||
|
filters: {
|
||||||
|
[key]: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the passed value from the key
|
||||||
|
let newValues = archivedIssueFiltersStore.userFilters?.[key] ?? [];
|
||||||
|
newValues = newValues.filter((val) => val !== value);
|
||||||
|
|
||||||
|
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
|
||||||
|
filters: {
|
||||||
|
[key]: newValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAllFilters = () => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
const newFilters: IIssueFilterOptions = {};
|
||||||
|
Object.keys(userFilters).forEach((key) => {
|
||||||
|
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
|
||||||
|
filters: { ...newFilters },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// return if no filters are applied
|
||||||
|
if (Object.keys(appliedFilters).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<AppliedFiltersList
|
||||||
|
appliedFilters={appliedFilters}
|
||||||
|
handleClearAllFilters={handleClearAllFilters}
|
||||||
|
handleRemoveFilter={handleRemoveFilter}
|
||||||
|
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
|
||||||
|
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
|
||||||
|
states={projectStore.states?.[projectId?.toString() ?? ""]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -3,3 +3,4 @@ export * from "./global-view-root";
|
|||||||
export * from "./module-root";
|
export * from "./module-root";
|
||||||
export * from "./project-view-root";
|
export * from "./project-view-root";
|
||||||
export * from "./project-root";
|
export * from "./project-root";
|
||||||
|
export * from "./archived-issue";
|
||||||
|
@ -1,25 +1,21 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
|
|
||||||
// store
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// constants
|
|
||||||
import { createIssuePayload } from "constants/issue";
|
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useKeypress from "hooks/use-keypress";
|
import useKeypress from "hooks/use-keypress";
|
||||||
import useProjectDetails from "hooks/use-project-details";
|
import useProjectDetails from "hooks/use-project-details";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// helpers
|
||||||
|
import { renderDateFormat } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
import { renderDateFormat } from "helpers/date-time.helper";
|
// constants
|
||||||
|
import { createIssuePayload } from "constants/issue";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
prePopulatedData?: Partial<IIssue>;
|
prePopulatedData?: Partial<IIssue>;
|
||||||
@ -149,25 +145,17 @@ export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Transition
|
{isOpen && (
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-in-out duration-200 transform"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in-out duration-200 transform"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<form
|
<form
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="flex py-3 px-4 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm"
|
className="flex py-3 px-2 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-2xs"
|
||||||
onSubmit={handleSubmit(onSubmitHandler)}
|
onSubmit={handleSubmit(onSubmitHandler)}
|
||||||
>
|
>
|
||||||
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
|
<div className="w-3 h-3 rounded-full border border-custom-border-1000 flex-shrink-0" />
|
||||||
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
<h4 className="text-xs text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
||||||
<Inputs register={register} setFocus={setFocus} />
|
<Inputs register={register} setFocus={setFocus} />
|
||||||
</form>
|
</form>
|
||||||
</Transition>
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
|
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
|
||||||
@ -181,7 +169,7 @@ export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
|||||||
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
// store
|
// store
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
@ -39,7 +38,7 @@ const Inputs = (props: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium leading-5 text-custom-text-300">{projectDetails?.identifier ?? "..."}</h4>
|
<h4 className="text-xs font-medium leading-5 text-custom-text-300">{projectDetails?.identifier ?? "..."}</h4>
|
||||||
<input
|
<input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Issue Title"
|
placeholder="Issue Title"
|
||||||
@ -151,26 +150,18 @@ export const BoardInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Transition
|
{isOpen && (
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-in-out duration-200 transform"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in-out duration-200 transform"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<form
|
<form
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onSubmit={handleSubmit(onSubmitHandler)}
|
onSubmit={handleSubmit(onSubmitHandler)}
|
||||||
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
|
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 mx-1.5 rounded bg-custom-background-300 shadow-custom-shadow-sm"
|
||||||
>
|
>
|
||||||
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
||||||
</form>
|
</form>
|
||||||
</Transition>
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<p className="text-xs ml-3 italic text-custom-text-200">
|
<p className="text-xs ml-3 italic mb-2 text-custom-text-200">
|
||||||
Press {"'"}Enter{"'"} to add another issue
|
Press {"'"}Enter{"'"} to add another issue
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -178,10 +169,10 @@ export const BoardInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
|||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-3 rounded-md"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -12,11 +12,12 @@ interface IssueBlockProps {
|
|||||||
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
||||||
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
||||||
display_properties: any;
|
display_properties: any;
|
||||||
|
isReadonly?: boolean;
|
||||||
showEmptyGroup?: boolean;
|
showEmptyGroup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||||
const { columnId, issue, handleIssues, quickActions, display_properties, showEmptyGroup } = props;
|
const { columnId, issue, handleIssues, quickActions, display_properties, showEmptyGroup, isReadonly } = props;
|
||||||
|
|
||||||
const updateIssue = (group_by: string | null, issueToUpdate: IIssue) => {
|
const updateIssue = (group_by: string | null, issueToUpdate: IIssue) => {
|
||||||
handleIssues(group_by, issueToUpdate, "update");
|
handleIssues(group_by, issueToUpdate, "update");
|
||||||
@ -26,7 +27,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<div className="text-sm p-3 relative bg-custom-background-100 flex items-center gap-3">
|
<div className="text-sm p-3 relative bg-custom-background-100 flex items-center gap-3">
|
||||||
{display_properties && display_properties?.key && (
|
{display_properties && display_properties?.key && (
|
||||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
<div className="flex-shrink-0 text-xs text-custom-text-300 font-medium">
|
||||||
{issue?.project_detail?.identifier}-{issue.sequence_id}
|
{issue?.project_detail?.identifier}-{issue.sequence_id}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -37,6 +38,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
|||||||
workspaceSlug={issue?.workspace_detail?.slug}
|
workspaceSlug={issue?.workspace_detail?.slug}
|
||||||
projectId={issue?.project_detail?.id}
|
projectId={issue?.project_detail?.id}
|
||||||
issueId={issue?.id}
|
issueId={issue?.id}
|
||||||
|
isArchived={issue?.archived_at !== null}
|
||||||
handleIssue={(issueToUpdate) => {
|
handleIssue={(issueToUpdate) => {
|
||||||
handleIssues(!columnId && columnId === "null" ? null : columnId, issueToUpdate as IIssue, "update");
|
handleIssues(!columnId && columnId === "null" ? null : columnId, issueToUpdate as IIssue, "update");
|
||||||
}}
|
}}
|
||||||
@ -50,6 +52,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
|||||||
<KanBanProperties
|
<KanBanProperties
|
||||||
columnId={columnId}
|
columnId={columnId}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
|
isReadonly={isReadonly}
|
||||||
handleIssues={updateIssue}
|
handleIssues={updateIssue}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
|
@ -7,6 +7,7 @@ import { IIssue } from "types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
columnId: string;
|
columnId: string;
|
||||||
issues: IIssue[];
|
issues: IIssue[];
|
||||||
|
isReadonly?: boolean;
|
||||||
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
||||||
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
||||||
display_properties: any;
|
display_properties: any;
|
||||||
@ -14,7 +15,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IssueBlocksList: FC<Props> = (props) => {
|
export const IssueBlocksList: FC<Props> = (props) => {
|
||||||
const { columnId, issues, handleIssues, quickActions, display_properties, showEmptyGroup } = props;
|
const { columnId, issues, handleIssues, quickActions, display_properties, showEmptyGroup, isReadonly } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative divide-y-[0.5px] divide-custom-border-200">
|
<div className="w-full h-full relative divide-y-[0.5px] divide-custom-border-200">
|
||||||
@ -26,6 +27,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
|||||||
issue={issue}
|
issue={issue}
|
||||||
handleIssues={handleIssues}
|
handleIssues={handleIssues}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
|
isReadonly={isReadonly}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
|
@ -12,6 +12,7 @@ export interface IGroupByList {
|
|||||||
issues: any;
|
issues: any;
|
||||||
group_by: string | null;
|
group_by: string | null;
|
||||||
list: any;
|
list: any;
|
||||||
|
isReadonly?: boolean;
|
||||||
listKey: string;
|
listKey: string;
|
||||||
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
||||||
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
||||||
@ -26,6 +27,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
|
|||||||
issues,
|
issues,
|
||||||
group_by,
|
group_by,
|
||||||
list,
|
list,
|
||||||
|
isReadonly,
|
||||||
listKey,
|
listKey,
|
||||||
handleIssues,
|
handleIssues,
|
||||||
quickActions,
|
quickActions,
|
||||||
@ -58,6 +60,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
|
|||||||
handleIssues={handleIssues}
|
handleIssues={handleIssues}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -79,6 +82,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
|
|||||||
export interface IList {
|
export interface IList {
|
||||||
issues: any;
|
issues: any;
|
||||||
group_by: string | null;
|
group_by: string | null;
|
||||||
|
isReadonly?: boolean;
|
||||||
handleDragDrop?: (result: any) => void | undefined;
|
handleDragDrop?: (result: any) => void | undefined;
|
||||||
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
|
||||||
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
|
||||||
@ -98,6 +102,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
issues,
|
issues,
|
||||||
group_by,
|
group_by,
|
||||||
|
isReadonly,
|
||||||
handleIssues,
|
handleIssues,
|
||||||
quickActions,
|
quickActions,
|
||||||
display_properties,
|
display_properties,
|
||||||
@ -124,6 +129,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
is_list
|
is_list
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -138,6 +144,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -152,6 +159,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -166,6 +174,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -180,6 +189,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -194,6 +204,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -208,6 +219,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -222,6 +234,7 @@ export const List: React.FC<IList> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
display_properties={display_properties}
|
display_properties={display_properties}
|
||||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||||
|
isReadonly={isReadonly}
|
||||||
showEmptyGroup={showEmptyGroup}
|
showEmptyGroup={showEmptyGroup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
@ -39,7 +38,7 @@ const Inputs = (props: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="text-sm font-medium leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
<h4 className="text-xs font-medium text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -150,39 +149,33 @@ export const ListInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100">
|
<div className="bg-custom-background-100">
|
||||||
<Transition
|
{isOpen && (
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-in-out duration-200 transform"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in-out duration-200 transform"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<form
|
<form
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onSubmit={handleSubmit(onSubmitHandler)}
|
onSubmit={handleSubmit(onSubmitHandler)}
|
||||||
className="flex border-[0.5px] border-t-0 border-custom-border-100 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-sm z-10"
|
className="absolute flex items-center gap-x-3 border-[0.5px] w-full border-t-0 border-custom-border-100 px-3 bg-custom-background-100 shadow-custom-shadow-sm z-10"
|
||||||
>
|
>
|
||||||
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
||||||
</form>
|
</form>
|
||||||
</Transition>
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
|
<p className="text-xs ml-3 my-3 mt-14 italic text-custom-text-200">
|
||||||
Press {"'"}Enter{"'"} to add another issue
|
Press {"'"}Enter{"'"} to add another issue
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<div className="w-full border-t-[0.5px] border-custom-border-200">
|
||||||
type="button"
|
<button
|
||||||
className="flex items-center gap-x-[6px] text-custom-primary-100 px-3 py-1 rounded-md"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
className="flex items-center gap-x-[6px] text-custom-primary-100 p-3"
|
||||||
>
|
onClick={() => setIsOpen(true)}
|
||||||
<PlusIcon className="h-3 w-3" />
|
>
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
</button>
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -63,7 +63,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
value={issue?.state_detail || null}
|
value={issue?.state_detail || null}
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
onChange={handleState}
|
onChange={handleState}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
<IssuePropertyPriority
|
<IssuePropertyPriority
|
||||||
value={issue?.priority || null}
|
value={issue?.priority || null}
|
||||||
onChange={handlePriority}
|
onChange={handlePriority}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -83,7 +83,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
projectId={issue?.project_detail?.id || null}
|
projectId={issue?.project_detail?.id || null}
|
||||||
value={issue?.labels || null}
|
value={issue?.labels || null}
|
||||||
onChange={handleLabel}
|
onChange={handleLabel}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -95,7 +95,8 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
value={issue?.assignees || null}
|
value={issue?.assignees || null}
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
onChange={handleAssignee}
|
onChange={handleAssignee}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -104,7 +105,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
<IssuePropertyDate
|
<IssuePropertyDate
|
||||||
value={issue?.start_date || null}
|
value={issue?.start_date || null}
|
||||||
onChange={(date: string) => handleStartDate(date)}
|
onChange={(date: string) => handleStartDate(date)}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
placeHolder="Start date"
|
placeHolder="Start date"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -114,7 +115,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
<IssuePropertyDate
|
<IssuePropertyDate
|
||||||
value={issue?.target_date || null}
|
value={issue?.target_date || null}
|
||||||
onChange={(date: string) => handleTargetDate(date)}
|
onChange={(date: string) => handleTargetDate(date)}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
placeHolder="Target date"
|
placeHolder="Target date"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -126,7 +127,7 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
|
|||||||
value={issue?.estimate_point || null}
|
value={issue?.estimate_point || null}
|
||||||
hideDropdownArrow
|
hideDropdownArrow
|
||||||
onChange={handleEstimate}
|
onChange={handleEstimate}
|
||||||
disabled={false}
|
disabled={isReadonly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// components
|
||||||
|
import { List } from "../default";
|
||||||
|
import { ArchivedIssueQuickActions } from "components/issues";
|
||||||
|
// helpers
|
||||||
|
import { orderArrayBy } from "helpers/array.helper";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
|
||||||
|
|
||||||
|
export const ArchivedIssueListLayout: FC = observer(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const {
|
||||||
|
project: projectStore,
|
||||||
|
archivedIssues: archivedIssueStore,
|
||||||
|
archivedIssueFilters: archivedIssueFiltersStore,
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const issues = archivedIssueStore.getIssues;
|
||||||
|
const display_properties = archivedIssueFiltersStore?.userDisplayProperties || null;
|
||||||
|
const group_by: string | null = archivedIssueFiltersStore?.userDisplayFilters?.group_by || null;
|
||||||
|
|
||||||
|
const handleIssues = (group_by: string | null, issue: IIssue, action: "delete" | "update") => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
archivedIssueStore.deleteArchivedIssue(group_by, null, issue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
|
||||||
|
|
||||||
|
const states = projectStore?.projectStates || null;
|
||||||
|
const priorities = ISSUE_PRIORITIES || null;
|
||||||
|
const labels = projectStore?.projectLabels || null;
|
||||||
|
const members = projectStore?.projectMembers || null;
|
||||||
|
const stateGroups = ISSUE_STATE_GROUPS || null;
|
||||||
|
const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null;
|
||||||
|
const estimates =
|
||||||
|
projectDetails?.estimate !== null
|
||||||
|
? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full bg-custom-background-90">
|
||||||
|
<List
|
||||||
|
issues={issues}
|
||||||
|
group_by={group_by}
|
||||||
|
isReadonly
|
||||||
|
handleIssues={handleIssues}
|
||||||
|
quickActions={(group_by, issue) => (
|
||||||
|
<ArchivedIssueQuickActions issue={issue} handleDelete={async () => handleIssues(group_by, issue, "delete")} />
|
||||||
|
)}
|
||||||
|
display_properties={display_properties}
|
||||||
|
states={states}
|
||||||
|
stateGroups={stateGroups}
|
||||||
|
priorities={priorities}
|
||||||
|
labels={labels}
|
||||||
|
members={members?.map((m) => m.member) ?? null}
|
||||||
|
projects={projects}
|
||||||
|
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -3,3 +3,4 @@ export * from "./module-root";
|
|||||||
export * from "./profile-issues-root";
|
export * from "./profile-issues-root";
|
||||||
export * from "./project-root";
|
export * from "./project-root";
|
||||||
export * from "./project-view-root";
|
export * from "./project-view-root";
|
||||||
|
export * from "./archived-issue-root";
|
||||||
|
@ -47,11 +47,17 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
|||||||
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<Boolean>(false);
|
||||||
|
|
||||||
const projectMembers = projectId ? projectStore?.members?.[projectId] : undefined;
|
const projectMembers = projectId ? projectStore?.members?.[projectId] : undefined;
|
||||||
|
|
||||||
const fetchProjectMembers = () =>
|
const fetchProjectMembers = () => {
|
||||||
workspaceSlug && projectId && projectStore.fetchProjectMembers(workspaceSlug, projectId);
|
setIsLoading(true);
|
||||||
|
if (workspaceSlug && projectId)
|
||||||
|
workspaceSlug &&
|
||||||
|
projectId &&
|
||||||
|
projectStore.fetchProjectMembers(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const options = (projectMembers ?? [])?.map((member) => ({
|
const options = (projectMembers ?? [])?.map((member) => ({
|
||||||
value: member.member.id,
|
value: member.member.id,
|
||||||
@ -128,7 +134,7 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
|||||||
className={`flex items-center justify-between gap-1 w-full text-xs ${
|
className={`flex items-center justify-between gap-1 w-full text-xs ${
|
||||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
onClick={() => fetchProjectMembers()}
|
onClick={() => !projectMembers && fetchProjectMembers()}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||||
@ -152,33 +158,31 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
||||||
{filteredOptions ? (
|
{isLoading ? (
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
|
||||||
active && !selected ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
{option.content}
|
|
||||||
{selected && <Check className={`h-3.5 w-3.5`} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2 p-1">
|
|
||||||
<p className="text-left text-custom-text-200 ">No matching results</p>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-custom-text-200">Loading...</p>
|
<p className="text-center text-custom-text-200">Loading...</p>
|
||||||
|
) : filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||||
|
active && !selected ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
{option.content}
|
||||||
|
{selected && <Check className={`h-3.5 w-3.5`} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 p-1">
|
||||||
|
<p className="text-left text-custom-text-200 ">No matching results</p>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,11 +51,15 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
|
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<Boolean>(false);
|
||||||
|
|
||||||
const projectLabels = projectId && projectStore?.labels?.[projectId];
|
const projectLabels = projectId && projectStore?.labels?.[projectId];
|
||||||
|
|
||||||
const fetchProjectLabels = () =>
|
const fetchProjectLabels = () => {
|
||||||
workspaceSlug && projectId && projectStore.fetchProjectLabels(workspaceSlug, projectId);
|
setIsLoading(true);
|
||||||
|
if (workspaceSlug && projectId)
|
||||||
|
projectStore.fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const options = (projectLabels ? projectLabels : []).map((label) => ({
|
const options = (projectLabels ? projectLabels : []).map((label) => ({
|
||||||
value: label.id,
|
value: label.id,
|
||||||
@ -131,10 +135,10 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`h-full flex items-center justify-center text-xs rounded px-2.5 py-1 hover:bg-custom-background-80 ${
|
className={`h-full flex items-center justify-center text-xs rounded px-2.5 py-1 hover:bg-custom-background-80 ${
|
||||||
noLabelBorder ? "" : "border-[0.5px] border-custom-border-300"
|
noLabelBorder ? "" : "border-[0.5px] border-custom-border-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Select labels
|
Select labels
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -161,7 +165,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "cursor-pointer hover:bg-custom-background-80"
|
: "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
onClick={() => fetchProjectLabels()}
|
onClick={() => !projectLabels && fetchProjectLabels()}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||||
@ -186,33 +190,31 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
||||||
{filteredOptions ? (
|
{isLoading ? (
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
{option.content}
|
|
||||||
{selected && <Check className={`h-3.5 w-3.5`} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2 p-1">
|
|
||||||
<p className="text-left text-custom-text-200 ">No matching results</p>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-custom-text-200">Loading...</p>
|
<p className="text-center text-custom-text-200">Loading...</p>
|
||||||
|
) : filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
{option.content}
|
||||||
|
{selected && <Check className={`h-3.5 w-3.5`} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 p-1">
|
||||||
|
<p className="text-left text-custom-text-200 ">No matching results</p>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,14 +47,20 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<Boolean>(false);
|
||||||
|
|
||||||
const projectStates: IState[] = [];
|
const projectStates: IState[] = [];
|
||||||
const projectStatesByGroup = projectId && projectStore?.states?.[projectId];
|
const projectStatesByGroup = projectId && projectStore?.states?.[projectId];
|
||||||
if (projectStatesByGroup)
|
if (projectStatesByGroup)
|
||||||
for (const group in projectStatesByGroup) projectStates.push(...projectStatesByGroup[group]);
|
for (const group in projectStatesByGroup) projectStates.push(...projectStatesByGroup[group]);
|
||||||
|
|
||||||
const fetchProjectStates = () =>
|
const fetchProjectStates = () => {
|
||||||
workspaceSlug && projectId && projectStore.fetchProjectStates(workspaceSlug, projectId);
|
setIsLoading(true);
|
||||||
|
if (workspaceSlug && projectId)
|
||||||
|
workspaceSlug &&
|
||||||
|
projectId &&
|
||||||
|
projectStore.fetchProjectStates(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownOptions = projectStates?.map((state) => ({
|
const dropdownOptions = projectStates?.map((state) => ({
|
||||||
value: state.id,
|
value: state.id,
|
||||||
@ -113,7 +119,7 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
|
|||||||
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 ${
|
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 ${
|
||||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
onClick={() => fetchProjectStates()}
|
onClick={() => !projectStatesByGroup && fetchProjectStates()}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||||
@ -137,33 +143,31 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
||||||
{filteredOptions ? (
|
{isLoading ? (
|
||||||
filteredOptions.length > 0 ? (
|
|
||||||
filteredOptions.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
{option.content}
|
|
||||||
{selected && <Check className="h-3.5 w-3.5" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2 p-1">
|
|
||||||
<p className="text-left text-custom-text-200 ">No matching results</p>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-custom-text-200">Loading...</p>
|
<p className="text-center text-custom-text-200">Loading...</p>
|
||||||
|
) : filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
{option.content}
|
||||||
|
{selected && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 p-1">
|
||||||
|
<p className="text-left text-custom-text-200 ">No matching results</p>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { CustomMenu } from "@plane/ui";
|
||||||
|
import { Link, Trash2 } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// components
|
||||||
|
import { DeleteArchivedIssueModal } from "components/issues";
|
||||||
|
// helpers
|
||||||
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
handleDelete: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArchivedIssueQuickActions: React.FC<Props> = (props) => {
|
||||||
|
const { issue, handleDelete } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
// states
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const handleCopyIssueLink = () => {
|
||||||
|
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`).then(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link copied",
|
||||||
|
message: "Issue link copied to clipboard",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DeleteArchivedIssueModal
|
||||||
|
data={issue}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
onSubmit={handleDelete}
|
||||||
|
/>
|
||||||
|
<CustomMenu placement="bottom-start" ellipsis>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyIssueLink();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Delete issue
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
export * from "./cycle-issue";
|
export * from "./cycle-issue";
|
||||||
export * from "./module-issue";
|
export * from "./module-issue";
|
||||||
export * from "./project-issue";
|
export * from "./project-issue";
|
||||||
|
export * from "./archived-issue";
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// mobx store
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// components
|
||||||
|
import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot } from "components/issues";
|
||||||
|
|
||||||
|
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { archivedIssueFilters: archivedIssueFiltersStore, archivedIssues: archivedIssueStore } = useMobxStore();
|
||||||
|
|
||||||
|
useSWR(workspaceSlug && projectId ? `ARCHIVED_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => {
|
||||||
|
if (workspaceSlug && projectId) {
|
||||||
|
await archivedIssueFiltersStore.fetchUserProjectFilters(workspaceSlug.toString(), projectId.toString());
|
||||||
|
await archivedIssueStore.fetchIssues(workspaceSlug.toString(), projectId.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full flex flex-col overflow-hidden">
|
||||||
|
<ArchivedIssueAppliedFiltersRoot />
|
||||||
|
<div className="w-full h-full overflow-auto">
|
||||||
|
<ArchivedIssueListLayout />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -3,3 +3,4 @@ export * from "./global-view-layout-root";
|
|||||||
export * from "./module-layout-root";
|
export * from "./module-layout-root";
|
||||||
export * from "./project-layout-root";
|
export * from "./project-layout-root";
|
||||||
export * from "./project-view-layout-root";
|
export * from "./project-view-layout-root";
|
||||||
|
export * from "./archived-issue-layout-root";
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
// hooks
|
||||||
|
import useSubIssue from "hooks/use-sub-issue";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
expandedIssues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpreadsheetAttachmentColumn: React.FC<Props> = (props) => {
|
||||||
|
const { issue, expandedIssues } = props;
|
||||||
|
|
||||||
|
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||||
|
|
||||||
|
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center text-xs h-full w-full">
|
||||||
|
{issue.attachment_count} {issue.attachment_count === 1 ? "attachment" : "attachments"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded &&
|
||||||
|
!isLoading &&
|
||||||
|
subIssues &&
|
||||||
|
subIssues.length > 0 &&
|
||||||
|
subIssues.map((subIssue: IIssue) => (
|
||||||
|
<SpreadsheetAttachmentColumn key={subIssue.id} issue={subIssue} expandedIssues={expandedIssues} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -142,6 +142,39 @@ export const SpreadsheetColumnsList: React.FC<Props> = observer((props) => {
|
|||||||
property="updated_on"
|
property="updated_on"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{displayProperties.link && (
|
||||||
|
<SpreadsheetColumn
|
||||||
|
displayFilters={displayFilters}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
expandedIssues={expandedIssues}
|
||||||
|
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||||
|
handleUpdateIssue={handleUpdateIssue}
|
||||||
|
issues={issues}
|
||||||
|
property="link"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayProperties.attachment_count && (
|
||||||
|
<SpreadsheetColumn
|
||||||
|
displayFilters={displayFilters}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
expandedIssues={expandedIssues}
|
||||||
|
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||||
|
handleUpdateIssue={handleUpdateIssue}
|
||||||
|
issues={issues}
|
||||||
|
property="attachment_count"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayProperties.sub_issue_count && (
|
||||||
|
<SpreadsheetColumn
|
||||||
|
displayFilters={displayFilters}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
expandedIssues={expandedIssues}
|
||||||
|
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||||
|
handleUpdateIssue={handleUpdateIssue}
|
||||||
|
issues={issues}
|
||||||
|
property="sub_issue_count"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
export * from "./issue";
|
export * from "./issue";
|
||||||
export * from "./assignee-column";
|
export * from "./assignee-column";
|
||||||
|
export * from "./attachment-column";
|
||||||
export * from "./columns-list";
|
export * from "./columns-list";
|
||||||
export * from "./created-on-column";
|
export * from "./created-on-column";
|
||||||
export * from "./due-date-column";
|
export * from "./due-date-column";
|
||||||
export * from "./estimate-column";
|
export * from "./estimate-column";
|
||||||
export * from "./label-column";
|
export * from "./label-column";
|
||||||
|
export * from "./link-column";
|
||||||
export * from "./priority-column";
|
export * from "./priority-column";
|
||||||
export * from "./start-date-column";
|
export * from "./start-date-column";
|
||||||
export * from "./state-column";
|
export * from "./state-column";
|
||||||
|
export * from "./sub-issue-column";
|
||||||
export * from "./updated-on-column";
|
export * from "./updated-on-column";
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
// hooks
|
||||||
|
import useSubIssue from "hooks/use-sub-issue";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
expandedIssues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpreadsheetLinkColumn: React.FC<Props> = (props) => {
|
||||||
|
const { issue, expandedIssues } = props;
|
||||||
|
|
||||||
|
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||||
|
|
||||||
|
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center text-xs h-full w-full">
|
||||||
|
{issue.link_count} {issue.link_count === 1 ? "link" : "links"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded &&
|
||||||
|
!isLoading &&
|
||||||
|
subIssues &&
|
||||||
|
subIssues.length > 0 &&
|
||||||
|
subIssues.map((subIssue: IIssue) => (
|
||||||
|
<SpreadsheetLinkColumn key={subIssue.id} issue={subIssue} expandedIssues={expandedIssues} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
// hooks
|
||||||
|
import useSubIssue from "hooks/use-sub-issue";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
expandedIssues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpreadsheetSubIssueColumn: React.FC<Props> = (props) => {
|
||||||
|
const { issue, expandedIssues } = props;
|
||||||
|
|
||||||
|
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||||
|
|
||||||
|
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center text-xs h-full w-full">
|
||||||
|
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded &&
|
||||||
|
!isLoading &&
|
||||||
|
subIssues &&
|
||||||
|
subIssues.length > 0 &&
|
||||||
|
subIssues.map((subIssue: IIssue) => (
|
||||||
|
<SpreadsheetSubIssueColumn key={subIssue.id} issue={subIssue} expandedIssues={expandedIssues} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
@ -39,7 +38,7 @@ const Inputs = (props: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="text-sm font-medium leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
<h4 className="text-xs w-20 leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -47,7 +46,7 @@ const Inputs = (props: any) => {
|
|||||||
{...register("name", {
|
{...register("name", {
|
||||||
required: "Issue title is required.",
|
required: "Issue title is required.",
|
||||||
})}
|
})}
|
||||||
className="w-full px-2 py-3 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
className="w-full py-3 rounded-md bg-transparent text-sm leading-5 text-custom-text-200 outline-none"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -154,15 +153,7 @@ export const SpreadsheetInlineCreateIssueForm: React.FC<Props> = observer((props
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Transition
|
{isOpen && (
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-in-out duration-200 transform"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in-out duration-200 transform"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<form
|
<form
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -172,7 +163,7 @@ export const SpreadsheetInlineCreateIssueForm: React.FC<Props> = observer((props
|
|||||||
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
|
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
|
||||||
@ -181,14 +172,16 @@ export const SpreadsheetInlineCreateIssueForm: React.FC<Props> = observer((props
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<div className="flex items-center">
|
||||||
type="button"
|
<button
|
||||||
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 pt-3 rounded-md"
|
||||||
>
|
onClick={() => setIsOpen(true)}
|
||||||
<PlusIcon className="h-4 w-4" />
|
>
|
||||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
</button>
|
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -12,13 +12,16 @@ import useLocalStorage from "hooks/use-local-storage";
|
|||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
SpreadsheetAssigneeColumn,
|
SpreadsheetAssigneeColumn,
|
||||||
|
SpreadsheetAttachmentColumn,
|
||||||
SpreadsheetCreatedOnColumn,
|
SpreadsheetCreatedOnColumn,
|
||||||
SpreadsheetDueDateColumn,
|
SpreadsheetDueDateColumn,
|
||||||
SpreadsheetEstimateColumn,
|
SpreadsheetEstimateColumn,
|
||||||
SpreadsheetLabelColumn,
|
SpreadsheetLabelColumn,
|
||||||
|
SpreadsheetLinkColumn,
|
||||||
SpreadsheetPriorityColumn,
|
SpreadsheetPriorityColumn,
|
||||||
SpreadsheetStartDateColumn,
|
SpreadsheetStartDateColumn,
|
||||||
SpreadsheetStateColumn,
|
SpreadsheetStateColumn,
|
||||||
|
SpreadsheetSubIssueColumn,
|
||||||
SpreadsheetUpdatedOnColumn,
|
SpreadsheetUpdatedOnColumn,
|
||||||
} from "components/issues";
|
} from "components/issues";
|
||||||
// ui
|
// ui
|
||||||
@ -178,7 +181,6 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "priority" ? (
|
) : property === "priority" ? (
|
||||||
<SpreadsheetPriorityColumn
|
<SpreadsheetPriorityColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -186,7 +188,6 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "estimate" ? (
|
) : property === "estimate" ? (
|
||||||
<SpreadsheetEstimateColumn
|
<SpreadsheetEstimateColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -194,7 +195,6 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "assignee" ? (
|
) : property === "assignee" ? (
|
||||||
<SpreadsheetAssigneeColumn
|
<SpreadsheetAssigneeColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -203,7 +203,6 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "labels" ? (
|
) : property === "labels" ? (
|
||||||
<SpreadsheetLabelColumn
|
<SpreadsheetLabelColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -212,7 +211,6 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "start_date" ? (
|
) : property === "start_date" ? (
|
||||||
<SpreadsheetStartDateColumn
|
<SpreadsheetStartDateColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -220,24 +218,21 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : property === "due_date" ? (
|
) : property === "due_date" ? (
|
||||||
<SpreadsheetDueDateColumn
|
<SpreadsheetDueDateColumn
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
disabled={disableUserActions}
|
disabled={disableUserActions}
|
||||||
expandedIssues={expandedIssues}
|
expandedIssues={expandedIssues}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||||
/>
|
/>
|
||||||
) : property === "created_on" ? (
|
) : property === "created_on" ? (
|
||||||
<SpreadsheetCreatedOnColumn
|
<SpreadsheetCreatedOnColumn expandedIssues={expandedIssues} issue={issue} />
|
||||||
key={`${property}-${issue.id}`}
|
|
||||||
expandedIssues={expandedIssues}
|
|
||||||
issue={issue}
|
|
||||||
/>
|
|
||||||
) : property === "updated_on" ? (
|
) : property === "updated_on" ? (
|
||||||
<SpreadsheetUpdatedOnColumn
|
<SpreadsheetUpdatedOnColumn expandedIssues={expandedIssues} issue={issue} />
|
||||||
key={`${property}-${issue.id}`}
|
) : property === "link" ? (
|
||||||
expandedIssues={expandedIssues}
|
<SpreadsheetLinkColumn expandedIssues={expandedIssues} issue={issue} />
|
||||||
issue={issue}
|
) : property === "attachment_count" ? (
|
||||||
/>
|
<SpreadsheetAttachmentColumn expandedIssues={expandedIssues} issue={issue} />
|
||||||
|
) : property === "sub_issue_count" ? (
|
||||||
|
<SpreadsheetSubIssueColumn expandedIssues={expandedIssues} issue={issue} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -158,6 +158,7 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
|
|||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { Globe2, Lock } from "lucide-react";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
|
// hooks
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
// components
|
// components
|
||||||
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
import { Globe2, Lock } from "lucide-react";
|
|
||||||
|
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
|
||||||
|
|
||||||
const defaultValues: Partial<IIssueComment> = {
|
const defaultValues: Partial<IIssueComment> = {
|
||||||
access: "INTERNAL",
|
access: "INTERNAL",
|
||||||
@ -75,36 +74,7 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(handleAddComment)}>
|
<form onSubmit={handleSubmit(handleAddComment)}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="relative h-full">
|
<div className="h-full">
|
||||||
{showAccessSpecifier && (
|
|
||||||
<div className="absolute bottom-2 left-3 z-[1]">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="access"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
|
||||||
{commentAccess.map((access) => (
|
|
||||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(access.key)}
|
|
||||||
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
|
|
||||||
value === access.key ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<access.icon
|
|
||||||
className={`w-4 h-4 -mt-1 ${
|
|
||||||
value === access.key ? "!text-custom-text-100" : "!text-custom-text-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Controller
|
<Controller
|
||||||
name="access"
|
name="access"
|
||||||
control={control}
|
control={control}
|
||||||
@ -124,7 +94,11 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
|
|||||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
commentAccessSpecifier={
|
||||||
|
showAccessSpecifier
|
||||||
|
? { accessValue, onAccessChange, showAccessSpecifier, commentAccess }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
submitButton={
|
submitButton={
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
@ -14,6 +14,7 @@ interface IIssueComment {
|
|||||||
issueCommentRemove: (commentId: string) => void;
|
issueCommentRemove: (commentId: string) => void;
|
||||||
issueCommentReactionCreate: (commentId: string, reaction: string) => void;
|
issueCommentReactionCreate: (commentId: string, reaction: string) => void;
|
||||||
issueCommentReactionRemove: (commentId: string, reaction: string) => void;
|
issueCommentReactionRemove: (commentId: string, reaction: string) => void;
|
||||||
|
showCommentAccessSpecifier: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssueComment: FC<IIssueComment> = (props) => {
|
export const IssueComment: FC<IIssueComment> = (props) => {
|
||||||
@ -28,6 +29,7 @@ export const IssueComment: FC<IIssueComment> = (props) => {
|
|||||||
issueCommentRemove,
|
issueCommentRemove,
|
||||||
issueCommentReactionCreate,
|
issueCommentReactionCreate,
|
||||||
issueCommentReactionRemove,
|
issueCommentReactionRemove,
|
||||||
|
showCommentAccessSpecifier,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const handleAddComment = async (formData: any) => {
|
const handleAddComment = async (formData: any) => {
|
||||||
@ -40,10 +42,7 @@ export const IssueComment: FC<IIssueComment> = (props) => {
|
|||||||
<div className="font-medium text-lg">Activity</div>
|
<div className="font-medium text-lg">Activity</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<IssueCommentEditor
|
<IssueCommentEditor onSubmit={handleAddComment} showAccessSpecifier={showCommentAccessSpecifier} />
|
||||||
onSubmit={handleAddComment}
|
|
||||||
// showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IssueActivityCard
|
<IssueActivityCard
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
|
@ -139,22 +139,18 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{errors.name ? errors.name.message : null}</span>
|
<span>{errors.name ? errors.name.message : null}</span>
|
||||||
|
<RichTextEditor
|
||||||
<span className="">
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
<RichTextEditor
|
deleteFile={fileService.deleteImage}
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
value={issue?.description_html}
|
||||||
deleteFile={fileService.deleteImage}
|
debouncedUpdatesEnabled={false}
|
||||||
value={issue?.description_html}
|
onChange={(description: Object, description_html: string) => {
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedIssueDescription(description_html);
|
||||||
onChange={(description: Object, description_html: string) => {
|
}}
|
||||||
debouncedIssueDescription(description_html);
|
customClassName="mt-0"
|
||||||
}}
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
customClassName="mt-0"
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
/>
|
||||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<IssueReaction
|
<IssueReaction
|
||||||
issueReactions={issueReactions}
|
issueReactions={issueReactions}
|
||||||
user={user}
|
user={user}
|
||||||
|
@ -32,13 +32,14 @@ import { IssueService } from "services/issue";
|
|||||||
interface IPeekOverviewProperties {
|
interface IPeekOverviewProperties {
|
||||||
issue: IIssue;
|
issue: IIssue;
|
||||||
issueUpdate: (issue: Partial<IIssue>) => void;
|
issueUpdate: (issue: Partial<IIssue>) => void;
|
||||||
user: any;
|
disableUserActions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueService = new IssueService();
|
const issueService = new IssueService();
|
||||||
|
|
||||||
export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => {
|
export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => {
|
||||||
const { issue, issueUpdate, user } = props;
|
const { issue, issueUpdate, disableUserActions } = props;
|
||||||
|
// states
|
||||||
const [linkModal, setLinkModal] = useState(false);
|
const [linkModal, setLinkModal] = useState(false);
|
||||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
||||||
|
|
||||||
@ -172,8 +173,6 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
const isNotAllowed = user?.memberRole?.isGuest || user?.memberRole?.isViewer;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LinkModal
|
<LinkModal
|
||||||
@ -196,7 +195,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>State</p>
|
<p>State</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarStateSelect value={issue?.state || ""} onChange={handleState} disabled={isNotAllowed} />
|
<SidebarStateSelect value={issue?.state || ""} onChange={handleState} disabled={disableUserActions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -207,7 +206,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Assignees</p>
|
<p>Assignees</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarAssigneeSelect value={issue.assignees || []} onChange={handleAssignee} disabled={isNotAllowed} />
|
<SidebarAssigneeSelect
|
||||||
|
value={issue.assignees || []}
|
||||||
|
onChange={handleAssignee}
|
||||||
|
disabled={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -218,7 +221,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Priority</p>
|
<p>Priority</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarPrioritySelect value={issue.priority || ""} onChange={handlePriority} disabled={isNotAllowed} />
|
<SidebarPrioritySelect
|
||||||
|
value={issue.priority || ""}
|
||||||
|
onChange={handlePriority}
|
||||||
|
disabled={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -229,7 +236,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Estimate</p>
|
<p>Estimate</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarEstimateSelect value={issue.estimate_point} onChange={handleEstimate} disabled={isNotAllowed} />
|
<SidebarEstimateSelect
|
||||||
|
value={issue.estimate_point}
|
||||||
|
onChange={handleEstimate}
|
||||||
|
disabled={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -246,7 +257,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
onChange={handleStartDate}
|
onChange={handleStartDate}
|
||||||
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
|
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
|
||||||
maxDate={maxDate ?? undefined}
|
maxDate={maxDate ?? undefined}
|
||||||
disabled={isNotAllowed}
|
disabled={disableUserActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -264,7 +275,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
onChange={handleTargetDate}
|
onChange={handleTargetDate}
|
||||||
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
|
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
|
||||||
minDate={minDate ?? undefined}
|
minDate={minDate ?? undefined}
|
||||||
disabled={isNotAllowed}
|
disabled={disableUserActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -276,7 +287,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Parent</p>
|
<p>Parent</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarParentSelect onChange={handleParent} issueDetails={issue} disabled={isNotAllowed} />
|
<SidebarParentSelect onChange={handleParent} issueDetails={issue} disabled={disableUserActions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -290,7 +301,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Cycle</p>
|
<p>Cycle</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarCycleSelect issueDetail={issue} handleCycleChange={addIssueToCycle} disabled={isNotAllowed} />
|
<SidebarCycleSelect
|
||||||
|
issueDetail={issue}
|
||||||
|
handleCycleChange={addIssueToCycle}
|
||||||
|
disabled={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -300,7 +315,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Module</p>
|
<p>Module</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SidebarModuleSelect issueDetail={issue} handleModuleChange={addIssueToModule} disabled={isNotAllowed} />
|
<SidebarModuleSelect
|
||||||
|
issueDetail={issue}
|
||||||
|
handleModuleChange={addIssueToModule}
|
||||||
|
disabled={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 w-full">
|
<div className="flex items-start gap-2 w-full">
|
||||||
@ -313,8 +332,8 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
issueDetails={issue}
|
issueDetails={issue}
|
||||||
labelList={issue.labels}
|
labelList={issue.labels}
|
||||||
submitChanges={handleLabels}
|
submitChanges={handleLabels}
|
||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={disableUserActions}
|
||||||
uneditable={isNotAllowed}
|
uneditable={disableUserActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -330,11 +349,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
<p>Links</p>
|
<p>Links</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{!isNotAllowed && (
|
{!disableUserActions && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-90"
|
disableUserActions ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-90"
|
||||||
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs hover:text-custom-text-200 text-custom-text-300`}
|
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs hover:text-custom-text-200 text-custom-text-300`}
|
||||||
onClick={() => setLinkModal(true)}
|
onClick={() => setLinkModal(true)}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { FC, ReactNode } from "react";
|
import { FC, ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
import { IssueView } from "./view";
|
import { IssueView } from "./view";
|
||||||
@ -7,22 +9,77 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
|||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// helpers
|
||||||
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
|
||||||
interface IIssuePeekOverview {
|
interface IIssuePeekOverview {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
handleIssue: (issue: Partial<IIssue>) => void;
|
handleIssue: (issue: Partial<IIssue>) => void;
|
||||||
|
isArchived?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, handleIssue, children } = props;
|
const { workspaceSlug, projectId, issueId, handleIssue, children, isArchived = false } = props;
|
||||||
|
|
||||||
const { issueDetail: issueDetailStore }: RootStore = useMobxStore();
|
const router = useRouter();
|
||||||
|
const { peekIssueId } = router.query as { peekIssueId: string };
|
||||||
|
|
||||||
|
const {
|
||||||
|
user: userStore,
|
||||||
|
issue: issueStore,
|
||||||
|
issueDetail: issueDetailStore,
|
||||||
|
archivedIssueDetail: archivedIssueDetailStore,
|
||||||
|
archivedIssues: archivedIssuesStore,
|
||||||
|
project: projectStore,
|
||||||
|
}: RootStore = useMobxStore();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
useSWR(
|
||||||
|
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
|
||||||
|
? `ISSUE_PEEK_OVERVIEW_${workspaceSlug}_${projectId}_${peekIssueId}`
|
||||||
|
: null,
|
||||||
|
async () => {
|
||||||
|
if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId) {
|
||||||
|
if (isArchived) await archivedIssueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, issueId);
|
||||||
|
else await issueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, issueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
copyUrlToClipboard(
|
||||||
|
`${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${peekIssueId}`
|
||||||
|
).then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link Copied!",
|
||||||
|
message: "Issue link copied to clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectToIssueDetail = () => {
|
||||||
|
router.push({
|
||||||
|
pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const issue = isArchived ? archivedIssueDetailStore.getIssue : issueDetailStore.getIssue;
|
||||||
|
const isLoading = isArchived ? archivedIssueDetailStore.loader : issueDetailStore.loader;
|
||||||
|
|
||||||
const issueUpdate = (_data: Partial<IIssue>) => {
|
const issueUpdate = (_data: Partial<IIssue>) => {
|
||||||
handleIssue(_data);
|
if (handleIssue) {
|
||||||
|
handleIssue(_data);
|
||||||
|
issueDetailStore.updateIssue(workspaceSlug, projectId, issueId, _data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueReactionCreate = (reaction: string) =>
|
const issueReactionCreate = (reaction: string) =>
|
||||||
@ -50,13 +107,31 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
|
|
||||||
const issueSubscriptionRemove = () => issueDetailStore.removeIssueSubscription(workspaceSlug, projectId, issueId);
|
const issueSubscriptionRemove = () => issueDetailStore.removeIssueSubscription(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
const handleDeleteIssue = () => issueDetailStore.deleteIssue(workspaceSlug, projectId, issueId);
|
const handleDeleteIssue = async () => {
|
||||||
|
if (isArchived) await archivedIssuesStore.deleteArchivedIssue(workspaceSlug, projectId, issue!);
|
||||||
|
else await issueStore.deleteIssue(workspaceSlug, projectId, issue!);
|
||||||
|
const { query } = router;
|
||||||
|
if (query.peekIssueId) {
|
||||||
|
delete query.peekIssueId;
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRole = userStore.currentProjectRole ?? 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueView
|
<IssueView
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
issue={issue}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isArchived={isArchived}
|
||||||
|
handleCopyText={handleCopyText}
|
||||||
|
redirectToIssueDetail={redirectToIssueDetail}
|
||||||
issueUpdate={issueUpdate}
|
issueUpdate={issueUpdate}
|
||||||
issueReactionCreate={issueReactionCreate}
|
issueReactionCreate={issueReactionCreate}
|
||||||
issueReactionRemove={issueReactionRemove}
|
issueReactionRemove={issueReactionRemove}
|
||||||
@ -68,6 +143,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
issueSubscriptionCreate={issueSubscriptionCreate}
|
issueSubscriptionCreate={issueSubscriptionCreate}
|
||||||
issueSubscriptionRemove={issueSubscriptionRemove}
|
issueSubscriptionRemove={issueSubscriptionRemove}
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
disableUserActions={[5, 10].includes(userRole)}
|
||||||
|
showCommentAccessSpecifier={projectStore.currentProjectDetails?.is_deployed}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</IssueView>
|
</IssueView>
|
||||||
|
@ -1,27 +1,30 @@
|
|||||||
import { FC, ReactNode, useState } from "react";
|
import { FC, ReactNode, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { MoveRight, MoveDiagonal, Bell, Link2, Trash2 } from "lucide-react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { MoveRight, MoveDiagonal, Bell, Link2, Trash2 } from "lucide-react";
|
||||||
// components
|
// components
|
||||||
import { PeekOverviewIssueDetails } from "./issue-detail";
|
import { PeekOverviewIssueDetails } from "./issue-detail";
|
||||||
import { PeekOverviewProperties } from "./properties";
|
import { PeekOverviewProperties } from "./properties";
|
||||||
import { IssueComment } from "./activity";
|
import { IssueComment } from "./activity";
|
||||||
import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui";
|
import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui";
|
||||||
import { DeleteIssueModal } from "../delete-issue-modal";
|
import { DeleteIssueModal } from "../delete-issue-modal";
|
||||||
|
import { DeleteArchivedIssueModal } from "../delete-archived-issue-modal";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
import { RootStore } from "store/root";
|
import { RootStore } from "store/root";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// helpers
|
|
||||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
|
||||||
|
|
||||||
interface IIssueView {
|
interface IIssueView {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
issue: IIssue | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
handleCopyText: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
redirectToIssueDetail: () => void;
|
||||||
issueUpdate: (issue: Partial<IIssue>) => void;
|
issueUpdate: (issue: Partial<IIssue>) => void;
|
||||||
issueReactionCreate: (reaction: string) => void;
|
issueReactionCreate: (reaction: string) => void;
|
||||||
issueReactionRemove: (reaction: string) => void;
|
issueReactionRemove: (reaction: string) => void;
|
||||||
@ -34,6 +37,8 @@ interface IIssueView {
|
|||||||
issueSubscriptionRemove: () => void;
|
issueSubscriptionRemove: () => void;
|
||||||
handleDeleteIssue: () => Promise<void>;
|
handleDeleteIssue: () => Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
disableUserActions?: boolean;
|
||||||
|
showCommentAccessSpecifier?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TPeekModes = "side-peek" | "modal" | "full-screen";
|
type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||||
@ -61,6 +66,11 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
projectId,
|
projectId,
|
||||||
issueId,
|
issueId,
|
||||||
|
issue,
|
||||||
|
isLoading,
|
||||||
|
isArchived,
|
||||||
|
handleCopyText,
|
||||||
|
redirectToIssueDetail,
|
||||||
issueUpdate,
|
issueUpdate,
|
||||||
issueReactionCreate,
|
issueReactionCreate,
|
||||||
issueReactionRemove,
|
issueReactionRemove,
|
||||||
@ -73,6 +83,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
issueSubscriptionRemove,
|
issueSubscriptionRemove,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
children,
|
children,
|
||||||
|
disableUserActions = false,
|
||||||
|
showCommentAccessSpecifier = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -83,20 +95,6 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues/${peekIssueId}`).then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Issue link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRoutePeekId = () => {
|
const updateRoutePeekId = () => {
|
||||||
if (issueId != peekIssueId) {
|
if (issueId != peekIssueId) {
|
||||||
const { query } = router;
|
const { query } = router;
|
||||||
@ -117,23 +115,6 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectToIssueDetail = () => {
|
|
||||||
router.push({
|
|
||||||
pathname: `/${workspaceSlug}/projects/${projectId}/issues/${issueId}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useSWR(
|
|
||||||
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
|
|
||||||
? `ISSUE_PEEK_OVERVIEW_${workspaceSlug}_${projectId}_${peekIssueId}`
|
|
||||||
: null,
|
|
||||||
async () => {
|
|
||||||
if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId) {
|
|
||||||
await issueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, issueId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useSWR(
|
useSWR(
|
||||||
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
|
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
|
||||||
? `ISSUE_PEEK_OVERVIEW_SUBSCRIPTION_${workspaceSlug}_${projectId}_${peekIssueId}`
|
? `ISSUE_PEEK_OVERVIEW_SUBSCRIPTION_${workspaceSlug}_${projectId}_${peekIssueId}`
|
||||||
@ -145,10 +126,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const issue = issueDetailStore.getIssue;
|
const issueReactions = issueDetailStore.getIssueReactions || [];
|
||||||
const issueReactions = issueDetailStore.getIssueReactions;
|
const issueComments = issueDetailStore.getIssueComments || [];
|
||||||
const issueComments = issueDetailStore.getIssueComments;
|
const issueSubscription = issueDetailStore.getIssueSubscription || [];
|
||||||
const issueSubscription = issueDetailStore.getIssueSubscription;
|
|
||||||
|
|
||||||
const user = userStore?.currentUser;
|
const user = userStore?.currentUser;
|
||||||
|
|
||||||
@ -156,7 +136,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{issue && (
|
{issue && !isArchived && (
|
||||||
<DeleteIssueModal
|
<DeleteIssueModal
|
||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
handleClose={() => setDeleteIssueModal(false)}
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
@ -164,6 +144,14 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
onSubmit={handleDeleteIssue}
|
onSubmit={handleDeleteIssue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{issue && isArchived && (
|
||||||
|
<DeleteArchivedIssueModal
|
||||||
|
data={issue}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
|
onSubmit={handleDeleteIssue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="w-full !text-base">
|
<div className="w-full !text-base">
|
||||||
{children && (
|
{children && (
|
||||||
<div onClick={updateRoutePeekId} className="w-full cursor-pointer">
|
<div onClick={updateRoutePeekId} className="w-full cursor-pointer">
|
||||||
@ -224,30 +212,37 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
{!isArchived && (
|
||||||
size="sm"
|
<Button
|
||||||
prependIcon={<Bell className="h-3 w-3" />}
|
size="sm"
|
||||||
variant="outline-primary"
|
prependIcon={<Bell className="h-3 w-3" />}
|
||||||
onClick={() =>
|
variant="outline-primary"
|
||||||
issueSubscription && issueSubscription.subscribed
|
onClick={() =>
|
||||||
? issueSubscriptionRemove
|
issueSubscription && issueSubscription.subscribed
|
||||||
: issueSubscriptionCreate
|
? issueSubscriptionRemove
|
||||||
}
|
: issueSubscriptionCreate
|
||||||
>
|
}
|
||||||
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
|
>
|
||||||
</Button>
|
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<button onClick={handleCopyText}>
|
<button onClick={handleCopyText}>
|
||||||
<Link2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200 -rotate-45" />
|
<Link2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200 -rotate-45" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeleteIssueModal(true)}>
|
{!disableUserActions && (
|
||||||
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
|
<button onClick={() => setDeleteIssueModal(true)}>
|
||||||
</button>
|
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* content */}
|
{/* content */}
|
||||||
<div className="w-full h-full overflow-hidden overflow-y-auto">
|
<div className="relative w-full h-full overflow-hidden overflow-y-auto">
|
||||||
{issueDetailStore?.loader && !issue ? (
|
{isArchived && (
|
||||||
|
<div className="absolute top-0 left-0 h-full w-full z-[999] flex items-center justify-center bg-custom-background-100 opacity-60" />
|
||||||
|
)}
|
||||||
|
{isLoading && !issue ? (
|
||||||
<div className="text-center py-10">Loading...</div>
|
<div className="text-center py-10">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
issue && (
|
issue && (
|
||||||
@ -264,7 +259,11 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
issueReactionRemove={issueReactionRemove}
|
issueReactionRemove={issueReactionRemove}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PeekOverviewProperties issue={issue} issueUpdate={issueUpdate} user={user} />
|
<PeekOverviewProperties
|
||||||
|
issue={issue}
|
||||||
|
issueUpdate={issueUpdate}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
/>
|
||||||
|
|
||||||
<IssueComment
|
<IssueComment
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
@ -277,6 +276,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
issueCommentRemove={issueCommentRemove}
|
issueCommentRemove={issueCommentRemove}
|
||||||
issueCommentReactionCreate={issueCommentReactionCreate}
|
issueCommentReactionCreate={issueCommentReactionCreate}
|
||||||
issueCommentReactionRemove={issueCommentReactionRemove}
|
issueCommentReactionRemove={issueCommentReactionRemove}
|
||||||
|
showCommentAccessSpecifier={showCommentAccessSpecifier}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -305,10 +305,15 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
issueCommentRemove={issueCommentRemove}
|
issueCommentRemove={issueCommentRemove}
|
||||||
issueCommentReactionCreate={issueCommentReactionCreate}
|
issueCommentReactionCreate={issueCommentReactionCreate}
|
||||||
issueCommentReactionRemove={issueCommentReactionRemove}
|
issueCommentReactionRemove={issueCommentReactionRemove}
|
||||||
|
showCommentAccessSpecifier={showCommentAccessSpecifier}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-4 py-5">
|
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-4 py-5">
|
||||||
<PeekOverviewProperties issue={issue} issueUpdate={issueUpdate} user={user} />
|
<PeekOverviewProperties
|
||||||
|
issue={issue}
|
||||||
|
issueUpdate={issueUpdate}
|
||||||
|
disableUserActions={disableUserActions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||||
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
|
// constants
|
||||||
|
import { snoozeOptions } from "constants/notification";
|
||||||
// helper
|
// helper
|
||||||
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
|
||||||
import {
|
import {
|
||||||
@ -19,14 +17,12 @@ import {
|
|||||||
renderShortDate,
|
renderShortDate,
|
||||||
renderShortDateWithYearFormat,
|
renderShortDateWithYearFormat,
|
||||||
} from "helpers/date-time.helper";
|
} from "helpers/date-time.helper";
|
||||||
|
|
||||||
// type
|
// type
|
||||||
import type { IUserNotification } from "types";
|
import type { IUserNotification } from "types";
|
||||||
// constants
|
|
||||||
import { snoozeOptions } from "constants/notification";
|
|
||||||
|
|
||||||
type NotificationCardProps = {
|
type NotificationCardProps = {
|
||||||
notification: IUserNotification;
|
notification: IUserNotification;
|
||||||
|
isSnoozedTabOpen: boolean;
|
||||||
markNotificationReadStatus: (notificationId: string) => Promise<void>;
|
markNotificationReadStatus: (notificationId: string) => Promise<void>;
|
||||||
markNotificationReadStatusToggle: (notificationId: string) => Promise<void>;
|
markNotificationReadStatusToggle: (notificationId: string) => Promise<void>;
|
||||||
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
|
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
|
||||||
@ -37,6 +33,7 @@ type NotificationCardProps = {
|
|||||||
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
notification,
|
notification,
|
||||||
|
isSnoozedTabOpen,
|
||||||
markNotificationReadStatus,
|
markNotificationReadStatus,
|
||||||
markNotificationReadStatusToggle,
|
markNotificationReadStatusToggle,
|
||||||
markNotificationArchivedStatus,
|
markNotificationArchivedStatus,
|
||||||
@ -49,6 +46,8 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
|||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -92,52 +91,54 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5 w-full overflow-hidden">
|
<div className="space-y-2.5 w-full overflow-hidden">
|
||||||
{ !notification.message ? <div className="text-sm w-full break-words">
|
{!notification.message ? (
|
||||||
<span className="font-semibold">
|
<div className="text-sm w-full break-words">
|
||||||
{notification.triggered_by_details.is_bot
|
<span className="font-semibold">
|
||||||
? notification.triggered_by_details.first_name
|
{notification.triggered_by_details.is_bot
|
||||||
: notification.triggered_by_details.display_name}{" "}
|
? notification.triggered_by_details.first_name
|
||||||
</span>
|
: notification.triggered_by_details.display_name}{" "}
|
||||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
|
</span>
|
||||||
{notification.data.issue_activity.field === "comment"
|
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
|
||||||
? "commented"
|
{notification.data.issue_activity.field === "comment"
|
||||||
: notification.data.issue_activity.field === "None"
|
? "commented"
|
||||||
? null
|
: notification.data.issue_activity.field === "None"
|
||||||
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
|
? null
|
||||||
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
|
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
|
||||||
? "to"
|
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
|
||||||
: ""}
|
? "to"
|
||||||
<span className="font-semibold">
|
: ""}
|
||||||
{" "}
|
<span className="font-semibold">
|
||||||
{notification.data.issue_activity.field !== "None" ? (
|
{" "}
|
||||||
notification.data.issue_activity.field !== "comment" ? (
|
{notification.data.issue_activity.field !== "None" ? (
|
||||||
notification.data.issue_activity.field === "target_date" ? (
|
notification.data.issue_activity.field !== "comment" ? (
|
||||||
renderShortDateWithYearFormat(notification.data.issue_activity.new_value)
|
notification.data.issue_activity.field === "target_date" ? (
|
||||||
) : notification.data.issue_activity.field === "attachment" ? (
|
renderShortDateWithYearFormat(notification.data.issue_activity.new_value)
|
||||||
"the issue"
|
) : notification.data.issue_activity.field === "attachment" ? (
|
||||||
) : notification.data.issue_activity.field === "description" ? (
|
"the issue"
|
||||||
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
|
) : notification.data.issue_activity.field === "description" ? (
|
||||||
|
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
|
||||||
|
) : (
|
||||||
|
notification.data.issue_activity.new_value
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
notification.data.issue_activity.new_value
|
<span>
|
||||||
|
{`"`}
|
||||||
|
{notification.data.issue_activity.new_value.length > 55
|
||||||
|
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
|
||||||
|
: notification.data.issue_activity.issue_comment}
|
||||||
|
{`"`}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span>
|
"the issue and assigned it to you."
|
||||||
{`"`}
|
)}
|
||||||
{notification.data.issue_activity.new_value.length > 55
|
</span>
|
||||||
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
|
</div>
|
||||||
: notification.data.issue_activity.issue_comment}
|
) : (
|
||||||
{`"`}
|
<div className="text-sm w-full break-words">
|
||||||
</span>
|
<span className="semi-bold">{notification.message}</span>
|
||||||
)
|
</div>
|
||||||
) : (
|
)}
|
||||||
"the issue and assigned it to you."
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div> : <div className="text-sm w-full break-words">
|
|
||||||
<span className="semi-bold">
|
|
||||||
{ notification.message }
|
|
||||||
</span>
|
|
||||||
</div> }
|
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 text-xs">
|
<div className="flex justify-between gap-2 text-xs">
|
||||||
<p className="text-custom-text-300">
|
<p className="text-custom-text-300">
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// components
|
|
||||||
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
|
||||||
|
|
||||||
//icon
|
|
||||||
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
|
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
|
||||||
|
// ui
|
||||||
|
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { getNumberCount } from "helpers/string.helper";
|
import { getNumberCount } from "helpers/string.helper";
|
||||||
|
|
||||||
// type
|
// type
|
||||||
import type { NotificationType, NotificationCount } from "types";
|
import type { NotificationType, NotificationCount } from "types";
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import React, { Fragment } from "react";
|
import React, { Fragment } from "react";
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import useUserNotification from "hooks/use-user-notifications";
|
import useUserNotification from "hooks/use-user-notifications";
|
||||||
// components
|
// components
|
||||||
import { EmptyState } from "components/common";
|
import { EmptyState } from "components/common";
|
||||||
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
|
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
|
||||||
import { Loader, Tooltip } from "@plane/ui";
|
import { Loader, Tooltip } from "@plane/ui";
|
||||||
// icons
|
|
||||||
import { Bell } from "lucide-react";
|
|
||||||
// images
|
// images
|
||||||
import emptyNotification from "public/empty-state/notification.svg";
|
import emptyNotification from "public/empty-state/notification.svg";
|
||||||
// helpers
|
// helpers
|
||||||
@ -15,7 +15,7 @@ import { getNumberCount } from "helpers/string.helper";
|
|||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
export const NotificationPopover = () => {
|
export const NotificationPopover = observer(() => {
|
||||||
const store: any = useMobxStore();
|
const store: any = useMobxStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -121,6 +121,7 @@ export const NotificationPopover = () => {
|
|||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
|
isSnoozedTabOpen={snoozed}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
markNotificationArchivedStatus={markNotificationArchivedStatus}
|
markNotificationArchivedStatus={markNotificationArchivedStatus}
|
||||||
markNotificationReadStatus={markNotificationAsRead}
|
markNotificationReadStatus={markNotificationAsRead}
|
||||||
@ -193,4 +194,4 @@ export const NotificationPopover = () => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -2,14 +2,14 @@ import { Fragment, FC } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { Transition, Dialog } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
// date helper
|
// date helper
|
||||||
import { getAllTimeIn30MinutesInterval } from "helpers/date-time.helper";
|
import { getAllTimeIn30MinutesInterval } from "helpers/date-time.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// ui
|
||||||
import { Button, CustomSelect } from "@plane/ui";
|
import { Button, CustomSelect } from "@plane/ui";
|
||||||
import { CustomDatePicker } from "components/ui";
|
import { CustomDatePicker } from "components/ui";
|
||||||
import { X } from "lucide-react";
|
|
||||||
// types
|
// types
|
||||||
import type { IUserNotification } from "types";
|
import type { IUserNotification } from "types";
|
||||||
|
|
||||||
@ -172,6 +172,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
|
|||||||
onChange(val);
|
onChange(val);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 w-full rounded-md border border-custom-border-300 bg-custom-background-100 text-custom-text-100 focus:outline-none !text-sm"
|
className="px-3 py-2 w-full rounded-md border border-custom-border-300 bg-custom-background-100 text-custom-text-100 focus:outline-none !text-sm"
|
||||||
|
wrapperClassName="w-full"
|
||||||
noBorder
|
noBorder
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
/>
|
/>
|
||||||
|
@ -181,7 +181,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
|
|||||||
<div className="flex items-center cursor-pointer gap-2 text-custom-text-200">
|
<div className="flex items-center cursor-pointer gap-2 text-custom-text-200">
|
||||||
<AvatarGroup showTooltip={false}>
|
<AvatarGroup showTooltip={false}>
|
||||||
{projectMembersIds.map((memberId) => {
|
{projectMembersIds.map((memberId) => {
|
||||||
const member = project.members?.find((m) => m.id === memberId);
|
const member = project.members?.find((m) => m.member_id === memberId);
|
||||||
|
|
||||||
if (!member) return null;
|
if (!member) return null;
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useState, Fragment } from "react";
|
import { useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { Transition, Dialog } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
@ -17,10 +18,12 @@ type TJoinProjectModalProps = {
|
|||||||
|
|
||||||
export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
|
export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
|
||||||
const { handleClose, isOpen, project, workspaceSlug } = props;
|
const { handleClose, isOpen, project, workspaceSlug } = props;
|
||||||
// store
|
|
||||||
const { project: projectStore } = useMobxStore();
|
|
||||||
// states
|
// states
|
||||||
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
|
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
|
||||||
|
// store
|
||||||
|
const { project: projectStore } = useMobxStore();
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleJoin = () => {
|
const handleJoin = () => {
|
||||||
setIsJoiningLoading(true);
|
setIsJoiningLoading(true);
|
||||||
@ -29,6 +32,8 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
|
|||||||
.joinProject(workspaceSlug, [project.id])
|
.joinProject(workspaceSlug, [project.id])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsJoiningLoading(false);
|
setIsJoiningLoading(false);
|
||||||
|
|
||||||
|
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
|
||||||
handleClose();
|
handleClose();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
@ -82,7 +82,7 @@ export const PrioritySelect: React.FC<Props> = ({
|
|||||||
: value === "low"
|
: value === "low"
|
||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: "text-custom-text-200"
|
: "text-custom-text-200"
|
||||||
} ${value === "urgent" && highlightUrgentPriority ? "text-white" : "text-red-500"}`}
|
} ${value === "urgent" ? (highlightUrgentPriority ? "text-white" : "text-red-500") : ""}`}
|
||||||
/>
|
/>
|
||||||
{showTitle && <span className="capitalize text-xs">{value}</span>}
|
{showTitle && <span className="capitalize text-xs">{value}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,18 +23,21 @@ export const CYCLE_TAB_LIST = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CYCLE_VIEWS = [
|
export const CYCLE_VIEW_LAYOUTS = [
|
||||||
{
|
{
|
||||||
key: "list",
|
key: "list",
|
||||||
icon: List,
|
icon: List,
|
||||||
|
title: "List layout",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "board",
|
key: "board",
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
|
title: "Grid layout",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "gantt",
|
key: "gantt",
|
||||||
icon: GanttChartSquare,
|
icon: GanttChartSquare,
|
||||||
|
title: "Gantt layout",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import { TModuleStatus } from "types";
|
import { TModuleStatus } from "types";
|
||||||
|
|
||||||
@ -51,3 +52,21 @@ export const MODULE_STATUS: {
|
|||||||
bgColor: "bg-red-50",
|
bgColor: "bg-red-50",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const MODULE_VIEW_LAYOUTS: { key: "list" | "grid" | "gantt_chart"; icon: any; title: string }[] = [
|
||||||
|
{
|
||||||
|
key: "list",
|
||||||
|
icon: List,
|
||||||
|
title: "List layout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grid",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
title: "Grid layout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gantt_chart",
|
||||||
|
icon: GanttChartSquare,
|
||||||
|
title: "Gantt layout",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
37
web/constants/page.ts
Normal file
37
web/constants/page.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
|
|
||||||
|
export const PAGE_VIEW_LAYOUTS = [
|
||||||
|
{
|
||||||
|
key: "list",
|
||||||
|
icon: List,
|
||||||
|
title: "List layout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "detailed",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
title: "Detailed layout",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PAGE_TABS_LIST: { key: string; title: string }[] = [
|
||||||
|
{
|
||||||
|
key: "recent",
|
||||||
|
title: "Recent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "all",
|
||||||
|
title: "All",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "favorites",
|
||||||
|
title: "Favorites",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created-by-me",
|
||||||
|
title: "Created by me",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created-by-others",
|
||||||
|
title: "Created by others",
|
||||||
|
},
|
||||||
|
];
|
@ -72,4 +72,25 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
|
|||||||
descendingOrderKey: "updated_at",
|
descendingOrderKey: "updated_at",
|
||||||
descendingOrderTitle: "Old",
|
descendingOrderTitle: "Old",
|
||||||
},
|
},
|
||||||
|
link: {
|
||||||
|
title: "Link",
|
||||||
|
ascendingOrderKey: "-link_count",
|
||||||
|
ascendingOrderTitle: "Most",
|
||||||
|
descendingOrderKey: "link_count",
|
||||||
|
descendingOrderTitle: "Least",
|
||||||
|
},
|
||||||
|
attachment_count: {
|
||||||
|
title: "Attachment",
|
||||||
|
ascendingOrderKey: "-attachment_count",
|
||||||
|
ascendingOrderTitle: "Most",
|
||||||
|
descendingOrderKey: "attachment_count",
|
||||||
|
descendingOrderTitle: "Least",
|
||||||
|
},
|
||||||
|
sub_issue_count: {
|
||||||
|
title: "Sub-issue",
|
||||||
|
ascendingOrderKey: "-sub_issues_count",
|
||||||
|
ascendingOrderTitle: "Most",
|
||||||
|
descendingOrderKey: "sub_issues_count",
|
||||||
|
descendingOrderTitle: "Least",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,13 @@ import {
|
|||||||
VIEW_ISSUES,
|
VIEW_ISSUES,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
export const addSpaceIfCamelCase = (str: string) => {
|
||||||
|
if (str === undefined || str === null) return "";
|
||||||
|
|
||||||
|
if (typeof str !== "string") str = `${str}`;
|
||||||
|
|
||||||
|
return str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||||
|
};
|
||||||
|
|
||||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||||
|
|
||||||
|
@ -148,6 +148,8 @@ const useUserNotification = () => {
|
|||||||
handleReadMutation(isRead ? "unread" : "read");
|
handleReadMutation(isRead ? "unread" : "read");
|
||||||
mutateNotification(notificationId, { read_at: isRead ? null : new Date() });
|
mutateNotification(notificationId, { read_at: isRead ? null : new Date() });
|
||||||
|
|
||||||
|
if (readNotification) removeNotification(notificationId);
|
||||||
|
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
await userNotificationServices
|
await userNotificationServices
|
||||||
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
|
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
|
||||||
|
@ -17,15 +17,21 @@ export const UserAuthWrapper: FC<IUserAuthWrapper> = (props) => {
|
|||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// fetching user information
|
// fetching user information
|
||||||
const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => userStore.fetchCurrentUser());
|
const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => userStore.fetchCurrentUser(), {
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
});
|
||||||
// fetching user settings
|
// fetching user settings
|
||||||
useSWR("CURRENT_USER_SETTINGS", () => userStore.fetchCurrentUserSettings());
|
useSWR("CURRENT_USER_SETTINGS", () => userStore.fetchCurrentUserSettings(), {
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
});
|
||||||
// fetching all workspaces
|
// fetching all workspaces
|
||||||
useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces());
|
useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces(), {
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!currentUser && !error) {
|
if (!currentUser && !error) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen grid place-items-center p-4">
|
<div className="h-screen grid place-items-center p-4 bg-custom-background-100">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,23 +3,15 @@ require("dotenv").config({ path: ".env" });
|
|||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const extraImageDomains = (process.env.NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS ?? "")
|
|
||||||
.split(",")
|
|
||||||
.filter((domain) => domain.length > 0);
|
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
images: {
|
images: {
|
||||||
domains: [
|
remotePatterns: [
|
||||||
"vinci-web.s3.amazonaws.com",
|
{
|
||||||
"planefs-staging.s3.ap-south-1.amazonaws.com",
|
protocol: "https",
|
||||||
"planefs.s3.amazonaws.com",
|
hostname: "**",
|
||||||
"planefs-staging.s3.amazonaws.com",
|
},
|
||||||
"images.unsplash.com",
|
|
||||||
"avatars.githubusercontent.com",
|
|
||||||
"localhost",
|
|
||||||
...extraImageDomains,
|
|
||||||
],
|
],
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
@ -4,6 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
import { AppLayout } from "layouts/app-layout";
|
import { AppLayout } from "layouts/app-layout";
|
||||||
// contexts
|
// contexts
|
||||||
import { IssueViewContextProvider } from "contexts/issue-view.context";
|
import { IssueViewContextProvider } from "contexts/issue-view.context";
|
||||||
|
import { ArchivedIssueLayoutRoot } from "components/issues";
|
||||||
// ui
|
// ui
|
||||||
import { ArchiveIcon } from "@plane/ui";
|
import { ArchiveIcon } from "@plane/ui";
|
||||||
import { ProjectArchivedIssuesHeader } from "components/headers";
|
import { ProjectArchivedIssuesHeader } from "components/headers";
|
||||||
@ -29,7 +30,7 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = () => {
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* <IssuesView /> */}
|
<ArchivedIssueLayoutRoot />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -20,11 +20,9 @@ import emptyCycle from "public/empty-state/cycle.svg";
|
|||||||
import { TCycleView, TCycleLayout } from "types";
|
import { TCycleView, TCycleLayout } from "types";
|
||||||
import { NextPageWithLayout } from "types/app";
|
import { NextPageWithLayout } from "types/app";
|
||||||
// constants
|
// constants
|
||||||
import { CYCLE_TAB_LIST, CYCLE_VIEWS } from "constants/cycle";
|
import { CYCLE_TAB_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
|
||||||
// lib cookie
|
// lib cookie
|
||||||
import { setLocalStorage, getLocalStorage } from "lib/local-storage";
|
import { setLocalStorage, getLocalStorage } from "lib/local-storage";
|
||||||
// helpers
|
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
|
||||||
|
|
||||||
const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||||
const [createModal, setCreateModal] = useState(false);
|
const [createModal, setCreateModal] = useState(false);
|
||||||
@ -118,7 +116,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
|||||||
handleCurrentView(CYCLE_TAB_LIST[i].key as TCycleView);
|
handleCurrentView(CYCLE_TAB_LIST[i].key as TCycleView);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-between border-b border-custom-border-300 px-4 sm:px-5 pb-4 sm:pb-0">
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-end sm:items-center border-b border-custom-border-200 px-4 sm:px-5 pb-4 sm:pb-0">
|
||||||
<Tab.List as="div" className="flex items-center overflow-x-scroll">
|
<Tab.List as="div" className="flex items-center overflow-x-scroll">
|
||||||
{CYCLE_TAB_LIST.map((tab) => (
|
{CYCLE_TAB_LIST.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
@ -133,28 +131,28 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
|||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
{CYCLE_VIEWS && CYCLE_VIEWS.length > 0 && cycleStore?.cycleView != "active" && (
|
{cycleStore?.cycleView != "active" && (
|
||||||
<div className="justify-end sm:justify-start flex items-center gap-x-1">
|
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
|
||||||
{CYCLE_VIEWS.map((view) => {
|
{CYCLE_VIEW_LAYOUTS.map((layout) => {
|
||||||
if (view.key === "gantt" && cycleStore?.cycleView === "draft") return null;
|
if (layout.key === "gantt" && cycleStore?.cycleView === "draft") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||||
key={view.key}
|
|
||||||
tooltipContent={
|
|
||||||
<span className="capitalize">{replaceUnderscoreIfSnakeCase(view.key)} Layout</span>
|
|
||||||
}
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
|
||||||
cycleStore?.cycleLayout === view.key
|
cycleStore?.cycleLayout == layout.key
|
||||||
? "bg-custom-sidebar-background-80"
|
? "bg-custom-background-100 shadow-custom-shadow-2xs"
|
||||||
: "text-custom-sidebar-text-200"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleCurrentLayout(view.key as TCycleLayout)}
|
onClick={() => handleCurrentLayout(layout.key as TCycleLayout)}
|
||||||
>
|
>
|
||||||
<view.icon className="h-3.5 w-3.5" />
|
<layout.icon
|
||||||
|
strokeWidth={2}
|
||||||
|
className={`h-3.5 w-3.5 ${
|
||||||
|
cycleStore?.cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
@ -5,16 +5,18 @@ import { Tab } from "@headlessui/react";
|
|||||||
// hooks
|
// hooks
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
// icons
|
|
||||||
import { LayoutGrid, List } from "lucide-react";
|
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "layouts/app-layout";
|
import { AppLayout } from "layouts/app-layout";
|
||||||
// components
|
// components
|
||||||
import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "components/pages";
|
import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "components/pages";
|
||||||
import { PagesHeader } from "components/headers";
|
import { PagesHeader } from "components/headers";
|
||||||
|
// ui
|
||||||
|
import { Tooltip } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { TPageViewProps } from "types";
|
import { TPageViewProps } from "types";
|
||||||
import { NextPageWithLayout } from "types/app";
|
import { NextPageWithLayout } from "types/app";
|
||||||
|
// constants
|
||||||
|
import { PAGE_TABS_LIST, PAGE_VIEW_LAYOUTS } from "constants/page";
|
||||||
|
|
||||||
const AllPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.AllPagesList), {
|
const AllPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.AllPagesList), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@ -32,8 +34,6 @@ const OtherPagesList = dynamic<TPagesListProps>(() => import("components/pages")
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabsList = ["Recent", "All", "Favorites", "Created by me", "Created by others"];
|
|
||||||
|
|
||||||
const ProjectPagesPage: NextPageWithLayout = () => {
|
const ProjectPagesPage: NextPageWithLayout = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
@ -77,25 +77,25 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
|
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
|
||||||
<div className="flex gap-4 justify-between">
|
<div className="flex gap-4 justify-between">
|
||||||
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
|
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
|
||||||
<div className="flex gap-x-1">
|
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
|
||||||
<button
|
{PAGE_VIEW_LAYOUTS.map((layout) => (
|
||||||
type="button"
|
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-80 ${
|
<button
|
||||||
viewType === "list" ? "bg-custom-background-80" : ""
|
type="button"
|
||||||
}`}
|
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
|
||||||
onClick={() => setViewType("list")}
|
viewType == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||||
>
|
}`}
|
||||||
<List className="h-4 w-4" />
|
onClick={() => setViewType(layout.key as TPageViewProps)}
|
||||||
</button>
|
>
|
||||||
<button
|
<layout.icon
|
||||||
type="button"
|
strokeWidth={2}
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-80 ${
|
className={`h-3.5 w-3.5 ${
|
||||||
viewType === "detailed" ? "bg-custom-background-80" : ""
|
viewType == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setViewType("detailed")}
|
/>
|
||||||
>
|
</button>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
</Tooltip>
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
@ -121,9 +121,9 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
>
|
>
|
||||||
<Tab.List as="div" className="mb-6 flex items-center justify-between">
|
<Tab.List as="div" className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex gap-4 items-center flex-wrap">
|
<div className="flex gap-4 items-center flex-wrap">
|
||||||
{tabsList.map((tab, index) => (
|
{PAGE_TABS_LIST.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={`${tab}-${index}`}
|
key={tab.key}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`rounded-full border px-5 py-1.5 text-sm outline-none ${
|
`rounded-full border px-5 py-1.5 text-sm outline-none ${
|
||||||
selected
|
selected
|
||||||
@ -132,7 +132,7 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab.title}
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ import NProgress from "nprogress";
|
|||||||
// styles
|
// styles
|
||||||
import "styles/globals.css";
|
import "styles/globals.css";
|
||||||
import "styles/editor.css";
|
import "styles/editor.css";
|
||||||
|
import "styles/table.css";
|
||||||
import "styles/command-pallette.css";
|
import "styles/command-pallette.css";
|
||||||
import "styles/nprogress.css";
|
import "styles/nprogress.css";
|
||||||
import "styles/react-datepicker.css";
|
import "styles/react-datepicker.css";
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user