Merge branch 'develop' of github.com:makeplane/plane into feat/bulk_issue_operations

This commit is contained in:
NarayanBavisetti 2023-11-05 23:21:07 +05:30
commit 05a6f972b2
109 changed files with 4112 additions and 1299 deletions

View File

@ -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">

View File

@ -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,

View File

@ -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)

View File

@ -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",

View File

@ -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

View File

@ -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>

View File

@ -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),
]; ];

View File

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

View File

@ -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];
},
});

View File

@ -0,0 +1 @@
export { default as default } from "./table-cell"

View File

@ -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
]
}
})

View File

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

View File

@ -0,0 +1 @@
export { default as default } from "./table-header"

View File

@ -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
]
}
})

View File

@ -0,0 +1 @@
export { default as default } from "./table-row"

View File

@ -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
]
}
})

View 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;

View File

@ -0,0 +1 @@
export { default as default } from "./table"

View File

@ -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;
}
}

View File

@ -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),
);
}
}

View 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)
)
}
}
})

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}

View File

@ -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

View File

@ -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)]
}, },
}) })

View File

@ -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,
}) });

View File

@ -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>
);
};

View File

@ -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),
]; ];

View File

@ -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),
]; ];

View File

@ -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,12 +100,15 @@ 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}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button" type="button"
onClick={item.command} onClick={item.command}
className={cn( className={cn(
@ -125,12 +126,16 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
strokeWidth={2.5} strokeWidth={2.5}
/> />
</button> </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}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button" type="button"
onClick={item.command} onClick={item.command}
className={cn( className={cn(
@ -148,12 +153,16 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
strokeWidth={2.5} strokeWidth={2.5}
/> />
</button> </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}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button" type="button"
onClick={item.command} onClick={item.command}
className={cn( className={cn(
@ -171,12 +180,16 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
strokeWidth={2.5} strokeWidth={2.5}
/> />
</button> </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}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button" type="button"
onClick={item.command} onClick={item.command}
className={cn( className={cn(
@ -194,10 +207,11 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
strokeWidth={2.5} strokeWidth={2.5}
/> />
</button> </button>
</Tooltip>
))} ))}
</div> </div>
</div> </div>
{props.submitButton} <div className="sticky right-1">{props.submitButton}</div>
</div> </div>
</div> </div>
); );

View File

@ -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 });

View File

@ -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,7 +85,9 @@ 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 }) =>
[ [
@ -69,7 +97,12 @@ const getSuggestionItems =
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();
}, },
}, },
{ {
@ -105,7 +138,7 @@ const getSuggestionItems =
searchTerms: ["todo", "task", "list", "check", "checkbox"], searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />, icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range) toggleTaskList(editor, range);
}, },
}, },
{ {
@ -141,7 +174,7 @@ const getSuggestionItems =
searchTerms: ["ordered"], searchTerms: ["ordered"],
icon: <ListOrdered size={18} />, icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range) toggleOrderedList(editor, range);
}, },
}, },
{ {
@ -150,7 +183,7 @@ const getSuggestionItems =
searchTerms: ["blockquote"], searchTerms: ["blockquote"],
icon: <TextQuote size={18} />, icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range) toggleBlockquote(editor, range),
}, },
{ {
title: "Code", title: "Code",
@ -175,7 +208,8 @@ const getSuggestionItems =
return ( return (
item.title.toLowerCase().includes(search) || item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) || item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) (item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
); );
} }
return true; return true;
@ -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: {

View File

@ -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,11 +76,41 @@ 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"
> >
{isSelecting ? null : (
<>
{!props.editor.isActive("table") && ( {!props.editor.isActive("table") && (
<NodeSelector <NodeSelector
editor={props.editor!} editor={props.editor!}
@ -79,8 +138,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
className={cn( className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors", "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(), "text-custom-text-100 bg-custom-primary-100/5":
} item.isActive(),
},
)} )}
> >
<item.icon <item.icon
@ -91,6 +151,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
</button> </button>
))} ))}
</div> </div>
</>
)}
</BubbleMenu> </BubbleMenu>
); );
}; };

View File

@ -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
View 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;
}

View File

@ -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,25 +72,26 @@ 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 (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<image <image
x={-8} x={-8}
y={10} y={10}
width={16} width={16}
height={16} height={16}
xlinkHref={avatar} xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }} style={{ clipPath: "circle(50%)" }}
/> />
</g> </g>
</Tooltip>
); );
else else
return ( return (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" /> <circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff"> <text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
@ -103,11 +104,18 @@ export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, param
: "?"} : "?"}
</text> </text>
</g> </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>

View File

@ -66,6 +66,7 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
onChange(val); onChange(val);
}} }}
params={params}
/> />
)} )}
/> />

View File

@ -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;

View File

@ -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>}
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 ${
modulesView === option.type ? "bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200" modulesView == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`} }`}
onClick={() => setModulesView(option.type)} onClick={() => setModulesView(layout.key)}
> >
<option.icon className="h-3.5 w-3.5" /> <layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
modulesView == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button> </button>
</Tooltip> </Tooltip>
))} ))}
</div>
<Button <Button
variant="primary" variant="primary"
prependIcon={<Plus />} prependIcon={<Plus />}

View File

@ -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>
); );
}); });

View File

@ -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"

View File

@ -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,7 +74,6 @@ 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}
@ -92,7 +91,11 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
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={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }} commentAccessSpecifier={
showAccessSpecifier
? { accessValue, onAccessChange, showAccessSpecifier, commentAccess }
: undefined
}
mentionSuggestions={editorSuggestions.mentionSuggestions} mentionSuggestions={editorSuggestions.mentionSuggestions}
mentionHighlights={editorSuggestions.mentionHighlights} mentionHighlights={editorSuggestions.mentionHighlights}
/> />
@ -100,7 +103,6 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
/> />
)} )}
/> />
</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"}

View File

@ -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>

View 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>
);
});

View File

@ -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";

View File

@ -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>

View File

@ -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>
);
});

View File

@ -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";

View File

@ -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>
)} )}

View File

@ -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>
)} )}

View File

@ -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}

View File

@ -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}
/> />

View File

@ -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}
/> />
)} )}

View File

@ -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 && (
<div className="w-full border-t-[0.5px] border-custom-border-200">
<button <button
type="button" type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-3 py-1 rounded-md" className="flex items-center gap-x-[6px] text-custom-primary-100 p-3"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<PlusIcon className="h-3 w-3" /> <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> </div>
); );

View File

@ -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}
/> />
)} )}

View File

@ -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>
);
});

View File

@ -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";

View File

@ -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,8 +158,9 @@ 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 ? ( <p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
<Combobox.Option <Combobox.Option
key={option.value} key={option.value}
@ -176,9 +183,6 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
<span className="flex items-center gap-2 p-1"> <span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p> <p className="text-left text-custom-text-200 ">No matching results</p>
</span> </span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)} )}
</div> </div>
</div> </div>

View File

@ -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,
@ -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,8 +190,9 @@ 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 ? ( <p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
<Combobox.Option <Combobox.Option
key={option.value} key={option.value}
@ -210,9 +215,6 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
<span className="flex items-center gap-2 p-1"> <span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p> <p className="text-left text-custom-text-200 ">No matching results</p>
</span> </span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)} )}
</div> </div>
</div> </div>

View File

@ -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,8 +143,9 @@ 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 ? ( <p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
<Combobox.Option <Combobox.Option
key={option.value} key={option.value}
@ -161,9 +168,6 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
<span className="flex items-center gap-2 p-1"> <span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p> <p className="text-left text-custom-text-200 ">No matching results</p>
</span> </span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)} )}
</div> </div>
</div> </div>

View File

@ -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>
</>
);
};

View File

@ -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";

View File

@ -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>
);
});

View File

@ -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";

View File

@ -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} />
))}
</>
);
};

View File

@ -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"
/>
)}
</> </>
); );
}); });

View File

@ -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";

View File

@ -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} />
))}
</>
);
};

View File

@ -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} />
))}
</>
);
};

View File

@ -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 && (
<div className="flex items-center">
<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 pt-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>
</div>
)} )}
</div> </div>
); );

View File

@ -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>
))} ))}

View File

@ -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">

View File

@ -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"

View File

@ -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}

View File

@ -139,8 +139,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
)} )}
</div> </div>
<span>{errors.name ? errors.name.message : null}</span> <span>{errors.name ? errors.name.message : null}</span>
<span className="">
<RichTextEditor <RichTextEditor
uploadFile={fileService.getUploadFileFunction(workspaceSlug)} uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage} deleteFile={fileService.deleteImage}
@ -153,8 +151,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
mentionSuggestions={editorSuggestions.mentionSuggestions} mentionSuggestions={editorSuggestions.mentionSuggestions}
mentionHighlights={editorSuggestions.mentionHighlights} mentionHighlights={editorSuggestions.mentionHighlights}
/> />
</span>
<IssueReaction <IssueReaction
issueReactions={issueReactions} issueReactions={issueReactions}
user={user} user={user}

View File

@ -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}

View File

@ -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>) => {
if (handleIssue) {
handleIssue(_data); 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>

View File

@ -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,6 +212,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{!isArchived && (
<Button <Button
size="sm" size="sm"
prependIcon={<Bell className="h-3 w-3" />} prependIcon={<Bell className="h-3 w-3" />}
@ -236,18 +225,24 @@ export const IssueView: FC<IIssueView> = observer((props) => {
> >
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"} {issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
</Button> </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>
{!disableUserActions && (
<button onClick={() => setDeleteIssueModal(true)}> <button onClick={() => setDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" /> <Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button> </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>
)} )}

View File

@ -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,7 +91,8 @@ 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 ? (
<div className="text-sm w-full break-words">
<span className="font-semibold"> <span className="font-semibold">
{notification.triggered_by_details.is_bot {notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name ? notification.triggered_by_details.first_name
@ -133,11 +133,12 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
"the issue and assigned it to you." "the issue and assigned it to you."
)} )}
</span> </span>
</div> : <div className="text-sm w-full break-words"> </div>
<span className="semi-bold"> ) : (
{ notification.message } <div className="text-sm w-full break-words">
</span> <span className="semi-bold">{notification.message}</span>
</div> } </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">

View File

@ -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";

View File

@ -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>
</> </>
); );
}; });

View File

@ -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()}
/> />

View File

@ -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;

View File

@ -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(() => {

View File

@ -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>

View File

@ -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",
}, },
]; ];

View File

@ -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
View 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",
},
];

View File

@ -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",
},
}; };

View File

@ -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, " ");

View File

@ -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)

View File

@ -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>

View File

@ -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,
}, },

View File

@ -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>
); );
}; };

View File

@ -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>
); );

View File

@ -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">
{PAGE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<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-background-80 ${ className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
viewType === "list" ? "bg-custom-background-80" : "" viewType == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`} }`}
onClick={() => setViewType("list")} onClick={() => setViewType(layout.key as TPageViewProps)}
> >
<List className="h-4 w-4" /> <layout.icon
</button> strokeWidth={2}
<button className={`h-3.5 w-3.5 ${
type="button" viewType == layout.key ? "text-custom-text-100" : "text-custom-text-200"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-80 ${
viewType === "detailed" ? "bg-custom-background-80" : ""
}`} }`}
onClick={() => setViewType("detailed")} />
>
<LayoutGrid className="h-4 w-4" />
</button> </button>
</Tooltip>
))}
</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>

View File

@ -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