Compare commits

..

29 Commits

Author SHA1 Message Date
Aaryan Khandelwal 22836ea03e fix: editor placeholder color (#6430) 2025-01-20 15:52:23 +05:30
Anmol Singh Bhatia 13cc8b0e96 chore: workspace view loading state improvement (#6423) 2025-01-17 19:50:56 +05:30
Vamsi Krishna 9addcde553 fix: padding issue cycle active cycles menu (#6424) 2025-01-17 19:46:19 +05:30
Bavisetti Narayan 26a9b7fced fix: dashboard completed issues count (#6422) 2025-01-17 18:03:28 +05:30
Vamsi Krishna 62dd80874f fix: issue store condition (#6420) 2025-01-17 16:53:33 +05:30
Anmol Singh Bhatia 9ae1ce0a9a chore: helper function added and code refactor (#6419) 2025-01-17 15:41:34 +05:30
Bavisetti Narayan 00cc338c07 [WEB-3039] fix: assignee count in dashboard (#6418)
* fix: assignee count in dashboard

* fix: removed the extra filter
2025-01-17 15:24:03 +05:30
Anmol Singh Bhatia 4432be15e4 [WEB-3166] chore: global empty state components (#6414)
* chore: detailed and simple empty state component added

* chore: section empty state component added

* chore: asset path helper hook added
2025-01-17 13:52:08 +05:30
Anmol Singh Bhatia 20893c6017 fix: draft issue fetch (#6416) 2025-01-17 13:51:17 +05:30
Nikhil 95f43a7bb6 fix: admin login when the user is not present (#6399) 2025-01-16 19:59:04 +05:30
Akshita Goyal fd7eedc343 [WEB-3096] feat: stickies page (#6380)
* feat: added independent stickies page

* chore: randomized sticky color

* chore: search in stickies

* feat: dnd

* fix: quick links

* fix: stickies abrupt rendering

* fix: handled edge cases for dnd

* fix: empty states

* fix: build and lint

* fix: handled new sticky when last sticky is emoty

* fix: new sticky condition

* refactor: stickies empty states, store

* chore: update stickies empty states

* fix: random sticky color

* fix: header

* refactor: better error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2025-01-16 19:57:51 +05:30
Aaryan Khandelwal d2c9b437f4 [WEB-3164] chore: update tailwindcss version (#6413)
* [WEB-3164] chore: update tailwind version

* chore: add tailwindcss to editor package

* chore: remove tailwindcss package from separate apps
2025-01-16 19:45:05 +05:30
Prateek Shourya 2f57d0e138 fix: add client validation and find similar language from browser config if exact match is not available (#6412) 2025-01-16 17:34:41 +05:30
Prateek Shourya 59ddc02a31 [WEB-3153] improvement: add support for nested translations and ICU formatting (#6411)
* improvement: add support for nested translations and ICU formatting

* chore: comment update
2025-01-16 17:29:57 +05:30
Anmol Singh Bhatia 3ac20741d9 fix: issue visibility (#6410) 2025-01-16 15:08:33 +05:30
Aaryan Khandelwal 8ea0772a1b fix: page open in new tab action (#6408) 2025-01-16 13:41:02 +05:30
sriram veeraghanta bddad8932b feat: Chinese language support (#6407)
* feat: chinese language support

* fix: following iso standards
2025-01-16 13:29:57 +05:30
Vamsi Krishna 8acea7f599 chore: cycle store restructuring (#6405) 2025-01-15 21:05:05 +05:30
M. Palanikannan a908bf9edd [PE-232] chore: management of disabled extensions (#6317)
* chore: added mobile editor required changes

* fix: turbo.json

---------

Co-authored-by: Lakhan <Lakhanbaheti9@gmail.com>
2025-01-15 20:23:09 +05:30
Vamsi Krishna 79fff4744a chore: modified functionality and placement for live button (#6400) 2025-01-15 16:21:00 +05:30
Vamsi Krishna 369d927321 [WEB-3102]fix: transfer issues count (#6384)
* fix: updated cancelled issues count into pending issues

* chore: code refactor

* chore: added pending issues count

* chore: added pending issues count

* chore: added pending_issues to api response

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-01-15 16:19:22 +05:30
M. Palanikannan 996d11de12 [PE-210] feat: editor performance (#6269)
* bump: upgrade editor

* fix: remove editor ref in use

* fix: added editor state to reduce rerenders

* fix: add editor rerendering optimization

* fix: wrong condition in scroll summary

* fix: removing ref usage internally in read only editor as well

* fix: remove unused methods from read only editor

* fix: add editable prop again

* regression: added the types for onHeadingChange

* fix: types

* fix: improve the check condition
2025-01-15 16:18:49 +05:30
Vamsi Krishna 0345336d90 chore: removed guests from assignees filters (#6402) 2025-01-15 16:05:56 +05:30
Vamsi Krishna 75d14e7c3a fix: removed redundant custom menu (#6388) 2025-01-15 16:00:26 +05:30
Vamsi Krishna 76fdb81249 chore: added current cycle to cycle dropdown (#6376) 2025-01-15 15:59:57 +05:30
Prateek Shourya 88669af141 fix: hide transfer issues option from cycles list when used outside project scope (#6401) 2025-01-15 15:56:35 +05:30
Anmol Singh Bhatia 71dcbd938e [WEB-3078] chore: empty state config (#6397)
* chore: empty state config updated

* chore: code refactor

* chore: date range picker icon updated

* fix: tree map content css

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-01-15 15:23:30 +05:30
sriram veeraghanta 4060412b18 fix: upgrade django version to fix vulneribility 2025-01-15 14:42:36 +05:30
Zohir Tamda 9d715683f7 fix: correcting french translations. (#6383) 2025-01-15 13:56:46 +05:30
145 changed files with 4741 additions and 3665 deletions
-1
View File
@@ -35,7 +35,6 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
+14
View File
@@ -132,6 +132,18 @@ class CycleViewSet(BaseViewSet):
),
)
)
.annotate(
pending_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
status=Case(
When(
@@ -214,6 +226,7 @@ class CycleViewSet(BaseViewSet):
"is_favorite",
"total_issues",
"completed_issues",
"pending_issues",
"assignee_ids",
"status",
"version",
@@ -245,6 +258,7 @@ class CycleViewSet(BaseViewSet):
# meta fields
"is_favorite",
"total_issues",
"pending_issues",
"completed_issues",
"assignee_ids",
"status",
+9 -3
View File
@@ -53,10 +53,10 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
@@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
)
.filter(
@@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug):
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset()
stickies = self.get_queryset().order_by("-sort_order")
if query:
stickies = stickies.filter(name__icontains=query)
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
+20 -5
View File
@@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
state_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
priority_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assigned_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
+9
View File
@@ -5,6 +5,9 @@ from django.db import models
# Module imports
from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel):
name = models.TextField(null=True, blank=True)
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self._state.adding:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
+9 -9
View File
@@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View):
# Fetch the user
user = User.objects.filter(email=email).first()
# is_active
if not user.is_active:
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
@@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View):
)
return HttpResponseRedirect(url)
# Error out if the user is not present
if not user:
# is_active
if not user.is_active:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.17
Django==4.2.18
# rest framework
djangorestframework==3.15.2
# postgres
+6 -6
View File
@@ -16,10 +16,10 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
@@ -40,9 +40,9 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
+1
View File
@@ -13,3 +13,4 @@ export * from "./state";
export * from "./swr";
export * from "./user";
export * from "./workspace";
export * from "./stickies";
+1
View File
@@ -0,0 +1 @@
export const STICKIES_PER_PAGE = 30;
+6 -8
View File
@@ -12,14 +12,12 @@
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
"import": "./dist/index.mjs"
},
"./lib": {
"require": "./dist/lib.js",
"types": "./dist/lib.d.mts",
"import": "./dist/lib.mjs",
"module": "./dist/lib.mjs"
"import": "./dist/lib.mjs"
}
},
"scripts": {
@@ -36,7 +34,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@hocuspocus/provider": "^2.15.0",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
@@ -67,12 +65,12 @@
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -1,5 +1,6 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
@@ -13,15 +14,24 @@ type Props = {
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
return extensions;
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
};
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({}),
},
];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
return documentExtensions;
};
@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
disabledExtensions?: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
@@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: ({ savedSelection }) =>
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
icon: ImageIcon,
});
@@ -39,11 +39,11 @@ import {
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TSlashCommandAdditionalOption } from "./root";
import { TExtensionProps } from "./root";
export type TSlashCommandSection = {
key: TSlashCommandSectionKeys;
@@ -51,13 +51,8 @@ export type TSlashCommandSection = {
items: ISlashCommandItem[];
};
type TArgs = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
};
export const getSlashCommandFilteredSections =
(args: TArgs) =>
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
@@ -103,9 +103,9 @@ const renderItems = () => {
};
};
type TExtensionProps = {
export type TExtensionProps = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
disabledExtensions?: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -1,27 +1,21 @@
import { MutableRefObject } from "react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
export const insertContentAtSavedSelection = (
editorRef: MutableRefObject<Editor | null>,
content: string,
savedSelection: Selection
) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
if (!savedSelection) {
if (!editor.state.selection) {
console.error("Saved selection is invalid.");
return;
}
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
try {
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
editor.chain().focus().insertContentAt(safePosition, content).run();
} catch (error) {
console.error("An error occurred while inserting content at saved selection:", error);
}
+49 -76
View File
@@ -1,12 +1,11 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// components
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
provider,
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor(
{
editable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
editorProps: {
...CoreEditorProps({
@@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction: () => {
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
@@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
[editable]
);
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
}, [savedSelection]);
// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (value == null) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
@@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
blur: () => editorRef.current?.commands.blur(),
blur: () => editor.commands.blur(),
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
const resolvedPos = pos ?? savedSelection?.from;
if (!editorRef.current || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
const resolvedPos = pos ?? editor.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
getCurrentCursorPosition: () => savedSelection?.from,
getCurrentCursorPosition: () => editor.state.selection.from,
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
if (editor.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (item) {
if (item.key === "image") {
(item as EditorMenuItem<"image">).command({
savedSelection: savedSelectionRef.current,
});
} else {
item.command(props);
}
item.command(props);
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
@@ -187,20 +171,20 @@ export const useEditor = (props: CustomEditorProps) => {
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
editor?.on("update", () => {
callback(editor?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
editor?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
getHeadings: () => editor?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
editor?.on("transaction", () => {
callback();
});
@@ -208,17 +192,17 @@ export const useEditor = (props: CustomEditorProps) => {
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("transaction");
editor?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
const markdownOutput = editor?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor.getJSON() ?? null;
return {
binary: documentBinary,
@@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
if (!editor) return;
scrollSummary(editor, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editorRef.current.state.doc.content.size;
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editorRef.current
editor
.chain()
.insertContentAt(safePosition, [{ type: "paragraph" }])
.focus()
@@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
getSelectedText: () => {
if (!editorRef.current) return null;
if (!editor) return null;
const { state } = editorRef.current;
const { state } = editor;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
@@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editorRef.current.state.selection;
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
@@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
}),
[editorRef, savedSelection]
[editor]
);
if (!editor) {
return null;
}
// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;
return editor;
};
@@ -1,7 +1,7 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
@@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type {
EditorReadOnlyRefApi,
TExtensions,
TDocumentEventsServer,
TFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -46,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
provider,
} = props;
const editor = useCustomEditor({
const editor = useTiptapEditor({
editable: false,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps({
@@ -77,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
const markdownOutput = editor?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor?.getJSON() ?? null;
return {
binary: documentBinary,
@@ -102,35 +96,22 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
if (!editor) return;
scrollSummary(editor, marking);
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
getDocumentInfo: () => {
if (!editor) return;
return {
characters: editor.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor.state),
words: editor.storage?.characterCount?.words?.() ?? 0,
};
},
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
if (!editor) {
return null;
}
editorRef.current = editor;
return editor;
};
+4 -4
View File
@@ -86,10 +86,6 @@ export type EditorReadOnlyRefApi = {
paragraphs: number;
words: number;
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -105,6 +101,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
setProviderDocument: (value: Uint8Array) => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
}
// editor props
+4 -4
View File
@@ -25,7 +25,7 @@
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
@@ -34,7 +34,7 @@
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
background-color: transparent;
margin: 0;
cursor: pointer;
width: 0.8rem;
@@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div {
ul[data-type="taskList"] li[data-checked="true"] {
& > div > p.editor-paragraph-block {
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
}
[data-text-color] {
+2 -38
View File
@@ -1,42 +1,6 @@
:root {
/* text colors */
--editor-colors-gray-text: #5c5e63;
--editor-colors-peach-text: #ff5b59;
--editor-colors-pink-text: #f65385;
--editor-colors-orange-text: #fd9038;
--editor-colors-green-text: #0fc27b;
--editor-colors-light-blue-text: #17bee9;
--editor-colors-dark-blue-text: #266df0;
--editor-colors-purple-text: #9162f9;
/* end text colors */
}
/* text background colors */
[data-theme="light"],
[data-theme="light-contrast"] {
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
}
[data-theme="dark"],
[data-theme="dark-contrast"] {
--editor-colors-gray-background: #404144;
--editor-colors-peach-background: #593032;
--editor-colors-pink-background: #562e3d;
--editor-colors-orange-background: #583e2a;
--editor-colors-green-background: #1d4a3b;
--editor-colors-light-blue-background: #1f495c;
--editor-colors-dark-blue-background: #223558;
--editor-colors-purple-background: #3d325a;
}
/* end text background colors */
.editor-container {
--color-placeholder: rgba(var(--color-text-100), 0.5);
/* font sizes and line heights */
&.large-font {
--font-size-h1: 1.75rem;
-5
View File
@@ -49,11 +49,6 @@ module.exports = {
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "@plane/**",
group: "external",
+2 -1
View File
@@ -10,7 +10,8 @@
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"@plane/utils": "*"
"@plane/utils": "*",
"intl-messageformat": "^10.7.11"
},
"devDependencies": {
"@plane/eslint-config": "*",
-29
View File
@@ -1,29 +0,0 @@
import { observer } from "mobx-react";
import React, { createContext, useEffect } from "react";
import { Language, languages } from "../config";
import { TranslationStore } from "./store";
// Create the store instance
const translationStore = new TranslationStore();
// Create Context
export const TranslationContext = createContext<TranslationStore>(translationStore);
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
// Handle storage events for cross-tab synchronization
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === "userLanguage" && event.newValue) {
const newLang = event.newValue as Language;
if (languages.includes(newLang)) {
translationStore.setLanguage(newLang);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
});
-42
View File
@@ -1,42 +0,0 @@
import { makeObservable, observable } from "mobx";
import { Language, fallbackLng, languages, translations } from "../config";
export class TranslationStore {
currentLocale: Language = fallbackLng;
constructor() {
makeObservable(this, {
currentLocale: observable.ref,
});
this.initializeLanguage();
}
get availableLanguages() {
return languages;
}
t(key: string) {
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
}
setLanguage(lng: Language) {
try {
localStorage.setItem("userLanguage", lng);
this.currentLocale = lng;
} catch (error) {
console.error(error);
}
}
initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem("userLanguage") as Language;
if (savedLocale && languages.includes(savedLocale)) {
this.setLanguage(savedLocale);
} else {
const browserLang = navigator.language.split("-")[0] as Language;
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
this.setLanguage(newLocale);
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import en from "../locales/en/translations.json";
import fr from "../locales/fr/translations.json";
import es from "../locales/es/translations.json";
import ja from "../locales/ja/translations.json";
export type Language = (typeof languages)[number];
export type Translations = {
[key: string]: {
[key: string]: string;
};
};
export const fallbackLng = "en";
export const languages = ["en", "fr", "es", "ja"] as const;
export const translations: Translations = {
en,
fr,
es,
ja,
};
export const SUPPORTED_LANGUAGES = [
{
label: "English",
value: "en",
},
{
label: "French",
value: "fr",
},
{
label: "Spanish",
value: "es",
},
{
label: "Japanese",
value: "ja",
},
];
+1
View File
@@ -0,0 +1 @@
export * from "./language";
+13
View File
@@ -0,0 +1,13 @@
import { TLanguage, ILanguageOption } from "../types";
export const FALLBACK_LANGUAGE: TLanguage = "en";
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Français", value: "fr" },
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "中文", value: "zh-CN" },
];
export const STORAGE_KEY = "userLanguage";
+19
View File
@@ -0,0 +1,19 @@
import { observer } from "mobx-react";
import React, { createContext } from "react";
// store
import { TranslationStore } from "../store";
export const TranslationContext = createContext<TranslationStore | null>(null);
interface TranslationProviderProps {
children: React.ReactNode;
}
/**
* Provides the translation store to the application
*/
export const TranslationProvider: React.FC<TranslationProviderProps> = observer(({ children }) => {
const [store] = React.useState(() => new TranslationStore());
return <TranslationContext.Provider value={store}>{children}</TranslationContext.Provider>;
});
+25 -7
View File
@@ -1,17 +1,35 @@
import { useContext } from "react";
import { TranslationContext } from "../components";
import { Language } from "../config";
import { useContext } from 'react';
// context
import { TranslationContext } from '../context';
// types
import { ILanguageOption, TLanguage } from '../types';
export function useTranslation() {
export type TTranslationStore = {
t: (key: string, params?: Record<string, any>) => string;
currentLocale: TLanguage;
changeLanguage: (lng: TLanguage) => void;
languages: ILanguageOption[];
};
/**
* Provides the translation store to the application
* @returns {TTranslationStore}
* @returns {(key: string, params?: Record<string, any>) => string} t: method to translate the key with params
* @returns {TLanguage} currentLocale - current locale language
* @returns {(lng: TLanguage) => void} changeLanguage - method to change the language
* @returns {ILanguageOption[]} languages - available languages
* @throws {Error} if the TranslationProvider is not used
*/
export function useTranslation(): TTranslationStore {
const store = useContext(TranslationContext);
if (!store) {
throw new Error("useTranslation must be used within a TranslationProvider");
throw new Error('useTranslation must be used within a TranslationProvider');
}
return {
t: (key: string) => store.t(key),
t: store.t.bind(store),
currentLocale: store.currentLocale,
changeLanguage: (lng: Language) => store.setLanguage(lng),
changeLanguage: (lng: TLanguage) => store.setLanguage(lng),
languages: store.availableLanguages,
};
}
+3 -2
View File
@@ -1,3 +1,4 @@
export * from "./config";
export * from "./components";
export * from "./constants";
export * from "./context";
export * from "./hooks";
export * from "./types";
+19 -19
View File
@@ -19,12 +19,12 @@
"remove_members": "Supprimer des membres",
"add": "Ajouter",
"remove": "Supprimer",
"add_new": "Ajouter nouveau",
"add_new": "Ajouter un nouveau",
"remove_selected": "Supprimer la sélection",
"first_name": "Prénom",
"last_name": "Nom de famille",
"email": "Email",
"display_name": "Nom d'affichage",
"display_name": "Pseudonyme",
"role": "Rôle",
"timezone": "Fuseau horaire",
"avatar": "Avatar",
@@ -32,7 +32,7 @@
"password": "Mot de passe",
"change_cover": "Modifier la couverture",
"language": "Langue",
"saving": "Enregistrement...",
"saving": "Enregistrement en cours...",
"save_changes": "Enregistrer les modifications",
"deactivate_account": "Désactiver le compte",
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
@@ -43,14 +43,14 @@
"activity": "Activité",
"appearance": "Apparence",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Créer un workspace",
"workspaces": "Espaces de travail",
"create_workspace": "Créer un espace de travail",
"invitations": "Invitations",
"summary": "Résumé",
"assigned": "Assigné",
"created": "Créé",
"subscribed": "Souscrit",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions d'accéder à cette page.",
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
"password_changed_successfully": "Mot de passe changé avec succès.",
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
@@ -62,7 +62,7 @@
"this_field_is_required": "Ce champ est requis",
"passwords_dont_match": "Les mots de passe ne correspondent pas",
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit faire au moins 8 caractères.",
"password_is_weak": "Le mot de passe est faible.",
"password_is_strong": "Le mot de passe est fort.",
"load_more": "Charger plus",
@@ -106,8 +106,8 @@
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
"mentions": "Mention",
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
"create_your_workspace": "Créer votre workspace",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
"create_your_workspace": "Créer votre espace de travail",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des espaces de travail",
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
"go_back": "Retour",
"request_instance_admin": "Demander à l'administrateur de l'instance",
@@ -135,7 +135,7 @@
"old_password": "Mot de passe actuel",
"general_settings": "Paramètres généraux",
"sign_out": "Déconnexion",
"signing_out": "Déconnexion",
"signing_out": "Déconnexion en cours",
"active_cycles": "Cycles actifs",
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
@@ -190,17 +190,17 @@
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
"project_cover_image_alt": "Image de couverture du projet",
"name_is_required": "Le nom est requis",
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
"title_should_be_less_than_255_characters": "Le titre ne doit pas dépasser 255 caractères",
"project_name": "Nom du projet",
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
"project_id_must_be_at_least_1_character": "L'ID du projet doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "L'ID du projet doit être au plus de 5 caractères",
"project_id": "ID du projet",
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
"description_placeholder": "Description...",
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
"project_id_is_required": "Le projet ID est requis",
"project_id_is_required": "L'ID du projet est requis",
"select_network": "Sélectionner le réseau",
"lead": "Lead",
"lead": "Responsable",
"private": "Privé",
"public": "Public",
"accessible_only_by_invite": "Accessible uniquement par invitation",
@@ -245,11 +245,11 @@
"contact_sales": "Contacter les ventes",
"hyper_mode": "Mode hyper",
"keyboard_shortcuts": "Raccourcis clavier",
"whats_new": "Nouveautés?",
"whats_new": "Nouveautés ?",
"version": "Version",
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
"our_changelogs": "nos changelogs",
"for_the_latest_updates": "pour les dernières mises à jour.",
"our_changelogs": "Notre journal de modifications",
"for_the_latest_updates": "Pour les dernières mises à jour.",
"please_visit": "Veuillez visiter",
"docs": "Documentation",
"full_changelog": "Journal complet",
@@ -316,5 +316,5 @@
"remove_parent_issue": "Supprimer le problème parent",
"add_parent": "Ajouter un parent",
"loading_members": "Chargement des membres...",
"inbox": "boîte de réception"
"inbox": "Boîte de réception"
}
@@ -0,0 +1,319 @@
{
"submit": "提交",
"cancel": "取消",
"loading": "加载中",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"close": "关闭",
"yes": "是",
"no": "否",
"ok": "确定",
"name": "名称",
"description": "描述",
"search": "搜索",
"add_member": "添加成员",
"remove_member": "移除成员",
"add_members": "添加成员",
"remove_members": "移除成员",
"add": "添加",
"remove": "移除",
"add_new": "新增",
"remove_selected": "移除选中项",
"first_name": "名字",
"last_name": "姓氏",
"email": "电子邮件",
"display_name": "显示名称",
"role": "角色",
"timezone": "时区",
"avatar": "头像",
"cover_image": "封面图片",
"password": "密码",
"change_cover": "更换封面",
"language": "语言",
"saving": "保存中...",
"save_changes": "保存更改",
"deactivate_account": "停用账户",
"deactivate_account_description": "停用账户后,该账户内的所有数据和资源将被永久删除,且无法恢复。",
"profile_settings": "个人资料设置",
"your_account": "账户",
"profile": "个人资料",
"security": "安全",
"activity": "活动",
"appearance": "外观",
"notifications": "通知",
"workspaces": "工作区",
"create_workspace": "创建工作区",
"invitations": "邀请",
"summary": "摘要",
"assigned": "已分配",
"created": "已创建",
"subscribed": "已订阅",
"you_do_not_have_the_permission_to_access_this_page": "您没有权限访问此页面。",
"failed_to_sign_out_please_try_again": "登出失败,请重试。",
"password_changed_successfully": "密码已成功更改。",
"something_went_wrong_please_try_again": "出错了,请重试。",
"change_password": "更改密码",
"passwords_dont_match": "密码不匹配",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_password": "确认密码",
"this_field_is_required": "此字段为必填项",
"changing_password": "正在更改密码",
"please_enter_your_password": "请输入您的密码。",
"password_length_should_me_more_than_8_characters": "密码长度应大于8个字符。",
"password_is_weak": "密码强度较弱。",
"password_is_strong": "密码强度较强。",
"load_more": "加载更多",
"select_or_customize_your_interface_color_scheme": "选择或自定义界面配色方案。",
"theme": "主题",
"system_preference": "系统偏好",
"light": "浅色",
"dark": "深色",
"light_contrast": "浅色高对比",
"dark_contrast": "深色高对比",
"custom": "自定义主题",
"select_your_theme": "选择主题",
"customize_your_theme": "自定义主题",
"background_color": "背景颜色",
"text_color": "文字颜色",
"primary_color": "主色调",
"sidebar_background_color": "侧边栏背景颜色",
"sidebar_text_color": "侧边栏文字颜色",
"set_theme": "设置主题",
"enter_a_valid_hex_code_of_6_characters": "请输入6位有效的十六进制代码",
"background_color_is_required": "背景颜色为必填项",
"text_color_is_required": "文字颜色为必填项",
"primary_color_is_required": "主色调为必填项",
"sidebar_background_color_is_required": "侧边栏背景颜色为必填项",
"sidebar_text_color_is_required": "侧边栏文字颜色为必填项",
"updating_theme": "正在更新主题",
"theme_updated_successfully": "主题已成功更新",
"failed_to_update_the_theme": "主题更新失败",
"email_notifications": "电子邮件通知",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "启用此功能以接收您订阅问题的通知。",
"email_notification_setting_updated_successfully": "电子邮件通知设置已成功更新",
"failed_to_update_email_notification_setting": "电子邮件通知设置更新失败",
"notify_me_when": "在以下情况下通知我",
"property_changes": "属性更改",
"property_changes_description": "当负责人、优先级、估算等属性更改时通知。",
"state_change": "状态更改",
"state_change_description": "当问题状态更改时通知",
"issue_completed": "问题完成",
"issue_completed_description": "仅在问题完成时通知",
"comments": "评论",
"comments_description": "当有人在问题中添加评论时通知",
"mentions": "提及",
"mentions_description": "仅在评论或描述中提及您时通知",
"create_your_workspace": "创建工作区",
"only_your_instance_admin_can_create_workspaces": "只有实例管理员可以创建工作区",
"only_your_instance_admin_can_create_workspaces_description": "如果您知道实例管理员的电子邮件地址,请点击以下按钮联系他们。",
"go_back": "返回",
"request_instance_admin": "请求实例管理员",
"plane_logo": "Plane 徽标",
"workspace_creation_disabled": "工作区创建已禁用",
"workspace_request_subject": "新工作区请求",
"workspace_request_body": "实例管理员:\n\n请在 URL [/workspace-name] 处创建一个新的工作区。[工作区创建目的]\n\n谢谢。\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "正在创建工作区",
"workspace_created_successfully": "工作区已成功创建",
"create_workspace_page": "创建工作区页面",
"workspace_could_not_be_created_please_try_again": "无法创建工作区,请重试。",
"workspace_could_not_be_created_please_try_again_description": "创建工作区时出错,请重试。",
"this_is_a_required_field": "此字段为必填项。",
"name_your_workspace": "为工作区命名",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "工作区名称只能包含空格、破折号和字母数字字符。",
"limit_your_name_to_80_characters": "名称长度限制为80个字符。",
"set_your_workspace_url": "设置工作区URL",
"limit_your_url_to_48_characters": "URL长度限制为48个字符。",
"how_many_people_will_use_this_workspace": "有多少人将使用此工作区?",
"how_many_people_will_use_this_workspace_description": "这将帮助确定购买的席位数量。",
"select_a_range": "选择一个范围",
"urls_can_contain_only_dash_and_alphanumeric_characters": "URL只能包含破折号和字母数字字符。",
"something_familiar_and_recognizable_is_always_best": "熟悉且易于识别的名称总是最佳选择。",
"workspace_url_is_already_taken": "工作区URL已被占用!",
"old_password": "旧密码",
"general_settings": "常规设置",
"sign_out": "登出",
"signing_out": "正在登出",
"active_cycles": "活跃周期",
"active_cycles_description": "监控所有项目的周期,跟踪高优先级问题,并关注需要关注的周期。",
"on_demand_snapshots_of_all_your_cycles": "所有周期的按需快照",
"upgrade": "升级",
"10000_feet_view": "所有活跃周期的概览",
"10000_feet_view_description": "放大查看所有项目的周期,而不是单独查看每个项目的周期。",
"get_snapshot_of_each_active_cycle": "获取每个活跃周期的快照",
"get_snapshot_of_each_active_cycle_description": "跟踪所有活跃周期的高级指标,查看进度并了解范围与截止日期的关系。",
"compare_burndowns": "比较燃尽图",
"compare_burndowns_description": "监控每个团队的表现,查看每个周期的燃尽报告。",
"quickly_see_make_or_break_issues": "快速查看关键问题",
"quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级问题。一键查看所有周期的详细信息。",
"zoom_into_cycles_that_need_attention": "关注需要关注的周期",
"zoom_into_cycles_that_need_attention_description": "一键调查未达预期的周期状态。",
"stay_ahead_of_blockers": "提前解决阻碍",
"stay_ahead_of_blockers_description": "发现项目间的挑战,查看其他视图中不明显的周期依赖关系。",
"analytics": "分析",
"workspace_invites": "工作区邀请",
"workspace_settings": "工作区设置",
"enter_god_mode": "进入上帝模式",
"workspace_logo": "工作区徽标",
"new_issue": "新建问题",
"home": "首页",
"your_work": "您的工作",
"drafts": "草稿",
"projects": "项目",
"views": "视图",
"workspace": "工作区",
"archives": "归档",
"settings": "设置",
"failed_to_move_favorite": "移动收藏失败",
"your_favorites": "您的收藏",
"no_favorites_yet": "尚无收藏",
"create_folder": "创建文件夹",
"new_folder": "新建文件夹",
"favorite_updated_successfully": "收藏已成功更新",
"favorite_created_successfully": "收藏已成功创建",
"folder_already_exists": "文件夹已存在",
"folder_name_cannot_be_empty": "文件夹名称不能为空",
"something_went_wrong": "出错了",
"failed_to_reorder_favorite": "重新排序收藏失败",
"favorite_removed_successfully": "收藏已成功移除",
"failed_to_create_favorite": "创建收藏失败",
"failed_to_rename_favorite": "重命名收藏失败",
"project_link_copied_to_clipboard": "项目链接已复制到剪贴板",
"link_copied": "链接已复制",
"your_projects": "您的项目",
"add_project": "添加项目",
"create_project": "创建项目",
"failed_to_remove_project_from_favorites": "无法从收藏中移除项目,请重试。",
"project_created_successfully": "项目已成功创建",
"project_created_successfully_description": "项目已成功创建。您现在可以开始添加问题。",
"project_cover_image_alt": "项目封面图片",
"name_is_required": "名称为必填项",
"title_should_be_less_than_255_characters": "标题应少于255个字符",
"project_name": "项目名称",
"project_id_must_be_at_least_1_character": "项目ID至少为1个字符",
"project_id_must_be_at_most_5_characters": "项目ID最多为5个字符",
"project_id": "项目ID",
"project_id_tooltip_content": "用于唯一标识项目中的问题。最多5个字符。",
"description_placeholder": "描述...",
"only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。",
"project_id_is_required": "项目ID为必填项",
"select_network": "选择网络",
"lead": "负责人",
"private": "私有",
"public": "公开",
"accessible_only_by_invite": "仅限邀请访问",
"anyone_in_the_workspace_except_guests_can_join": "除访客外,工作区中的任何人都可以加入",
"creating": "创建中",
"creating_project": "正在创建项目",
"adding_project_to_favorites": "正在将项目添加到收藏",
"project_added_to_favorites": "项目已添加到收藏",
"couldnt_add_the_project_to_favorites": "无法将项目添加到收藏,请重试。",
"removing_project_from_favorites": "正在从收藏中移除项目",
"project_removed_from_favorites": "项目已从收藏中移除",
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目,请重试。",
"add_to_favorites": "添加到收藏",
"remove_from_favorites": "从收藏中移除",
"publish_settings": "发布设置",
"publish": "发布",
"copy_link": "复制链接",
"leave_project": "离开项目",
"join_the_project_to_rearrange": "加入项目以重新排序",
"drag_to_rearrange": "拖动以重新排序",
"congrats": "恭喜!",
"project": "项目",
"open_project": "打开项目",
"issues": "问题",
"cycles": "周期",
"modules": "模块",
"pages": "页面",
"intake": "接收",
"time_tracking": "时间跟踪",
"work_management": "工作管理",
"projects_and_issues": "项目与问题",
"projects_and_issues_description": "在此项目上启用或禁用。",
"cycles_description": "按时间段对项目工作进行时间盒管理,并调整频率。",
"modules_description": "将工作分组为类似子项目的设置,拥有自己的负责人和分配人。",
"views_description": "保存排序、过滤和显示选项以供以后使用或共享。",
"pages_description": "写下一些内容。",
"intake_description": "启用此功能以接收您订阅问题的通知。",
"time_tracking_description": "跟踪问题和项目所花费的时间。",
"work_management_description": "轻松管理工作和项目。",
"documentation": "文档",
"message_support": "联系支持",
"contact_sales": "联系销售",
"hyper_mode": "超速模式",
"keyboard_shortcuts": "键盘快捷键",
"whats_new": "新功能",
"version": "版本",
"we_are_having_trouble_fetching_the_updates": "获取更新时出现问题。",
"our_changelogs": "我们的更新日志",
"for_the_latest_updates": "获取最新更新,请访问",
"please_visit": "请访问",
"docs": "文档",
"full_changelog": "完整更新日志",
"support": "支持",
"discord": "Discord",
"powered_by_plane_pages": "由 Plane Pages 提供支持",
"please_select_at_least_one_invitation": "请至少选择一个邀请。",
"please_select_at_least_one_invitation_description": "请至少选择一个邀请以加入工作区。",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "我们发现有人邀请您加入一个工作区",
"join_a_workspace": "加入工作区",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们发现有人邀请您加入一个工作区",
"join_a_workspace_description": "加入工作区",
"accept_and_join": "接受并加入",
"go_home": "返回首页",
"no_pending_invites": "没有待处理的邀请",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在此处查看",
"back_to_home": "返回首页",
"workspace_name": "工作区名称",
"deactivate_your_account": "停用您的账户",
"deactivate_your_account_description": "停用后,您将无法再分配问题,工作区的计费也将停止。要重新激活账户,您需要使用此电子邮件地址收到工作区邀请。",
"deactivating": "正在停用",
"confirm": "确认",
"draft_created": "草稿已创建",
"issue_created_successfully": "问题已成功创建",
"draft_creation_failed": "草稿创建失败",
"issue_creation_failed": "问题创建失败",
"draft_issue": "草稿问题",
"issue_updated_successfully": "问题已成功更新",
"issue_could_not_be_updated": "无法更新问题",
"create_a_draft": "创建草稿",
"save_to_drafts": "保存为草稿",
"save": "保存",
"updating": "正在更新",
"create_new_issue": "创建新问题",
"editor_is_not_ready_to_discard_changes": "编辑器尚未准备好丢弃更改",
"failed_to_move_issue_to_project": "无法将问题移动到项目",
"create_more": "创建更多",
"add_to_project": "添加到项目",
"discard": "丢弃",
"duplicate_issue_found": "发现重复问题",
"duplicate_issues_found": "发现重复问题",
"no_matching_results": "没有匹配的结果",
"title_is_required": "标题为必填项",
"title": "标题",
"state": "状态",
"priority": "优先级",
"none": "无",
"urgent": "紧急",
"high": "高",
"medium": "中",
"low": "低",
"members": "成员",
"assignee": "分配人",
"assignees": "分配人",
"you": "您",
"labels": "标签",
"create_new_label": "创建新标签",
"start_date": "开始日期",
"due_date": "截止日期",
"cycle": "周期",
"estimate": "估算",
"change_parent_issue": "更改父问题",
"remove_parent_issue": "移除父问题",
"add_parent": "添加父问题",
"loading_members": "正在加载成员...",
"inbox": "收件箱"
}
+202
View File
@@ -0,0 +1,202 @@
import IntlMessageFormat from "intl-messageformat";
import get from "lodash/get";
import { makeAutoObservable } from "mobx";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
// types
import { TLanguage, ILanguageOption, ITranslations } from "../types";
/**
* Mobx store class for handling translations and language changes in the application
* Provides methods to translate keys with params and change the language
* Uses IntlMessageFormat to format the translations
*/
export class TranslationStore {
// List of translations for each language
private translations: ITranslations = {};
// Cache for IntlMessageFormat instances
private messageCache: Map<string, IntlMessageFormat> = new Map();
// Current language
currentLocale: TLanguage = FALLBACK_LANGUAGE;
/**
* Constructor for the TranslationStore class
*/
constructor() {
makeAutoObservable(this);
this.initializeLanguage();
this.loadTranslations();
}
/**
* Loads translations from JSON files and initializes the message cache
*/
private async loadTranslations() {
try {
// dynamic import of translations
const translations = {
en: (await import("../locales/en/translations.json")).default,
fr: (await import("../locales/fr/translations.json")).default,
es: (await import("../locales/es/translations.json")).default,
ja: (await import("../locales/ja/translations.json")).default,
"zh-CN": (await import("../locales/zh-CN/translations.json")).default,
};
this.translations = translations;
this.messageCache.clear(); // Clear cache when translations change
} catch (error) {
console.error("Failed to load translations:", error);
}
}
/** Initializes the language based on the local storage or browser language */
private initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
if (this.isValidLanguage(savedLocale)) {
this.setLanguage(savedLocale);
return;
}
const browserLang = this.getBrowserLanguage();
this.setLanguage(browserLang);
}
/** Checks if the language is valid based on the supported languages */
private isValidLanguage(lang: string | null): lang is TLanguage {
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
}
/** Checks if a language code is similar to any supported language */
private findSimilarLanguage(lang: string): TLanguage | null {
// Convert to lowercase for case-insensitive comparison
const normalizedLang = lang.toLowerCase();
// Find a supported language that includes or is included in the browser language
const similarLang = this.availableLanguages.find(
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
);
return similarLang ? similarLang.value : null;
}
/** Gets the browser language based on the navigator.language */
private getBrowserLanguage(): TLanguage {
const browserLang = navigator.language;
// Check exact match first
if (this.isValidLanguage(browserLang)) {
return browserLang;
}
// Check base language without region code
const baseLang = browserLang.split("-")[0];
if (this.isValidLanguage(baseLang)) {
return baseLang as TLanguage;
}
// Try to find a similar language
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
return similarLang || FALLBACK_LANGUAGE;
}
/**
* Gets the cache key for the given key and locale
* @param key - the key to get the cache key for
* @param locale - the locale to get the cache key for
* @returns the cache key for the given key and locale
*/
private getCacheKey(key: string, locale: TLanguage): string {
return `${locale}:${key}`;
}
/**
* Gets the IntlMessageFormat instance for the given key and locale
* Returns cached instance if available
* Throws an error if the key is not found in the translations
*/
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
const cacheKey = this.getCacheKey(key, locale);
// Check if the cache already has the key
if (this.messageCache.has(cacheKey)) {
return this.messageCache.get(cacheKey) || null;
}
// Get the message from the translations
const message = get(this.translations[locale], key);
if (!message) return null;
try {
const formatter = new IntlMessageFormat(message as any, locale);
this.messageCache.set(cacheKey, formatter);
return formatter;
} catch (error) {
console.error(`Failed to create message formatter for key "${key}":`, error);
return null;
}
}
/**
* Translates a key with params using the current locale
* Falls back to the default language if the translation is not found
* Returns the key itself if the translation is not found
* @param key - The key to translate
* @param params - The params to format the translation with
* @returns The translated string
*/
t(key: string, params?: Record<string, any>): string {
try {
// Try current locale
let formatter = this.getMessageInstance(key, this.currentLocale);
// Fallback to default language if necessary
if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) {
formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE);
}
// If we have a formatter, use it
if (formatter) {
return formatter.format(params || {}) as string;
}
// Last resort: return the key itself
return key;
} catch (error) {
console.error(`Translation error for key "${key}":`, error);
return key;
}
}
/**
* Sets the current language and updates the translations
* @param lng - The new language
*/
setLanguage(lng: TLanguage): void {
try {
if (!this.isValidLanguage(lng)) {
throw new Error(`Invalid language: ${lng}`);
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, lng);
}
this.currentLocale = lng;
this.messageCache.clear(); // Clear cache when language changes
if (typeof window !== "undefined") {
document.documentElement.lang = lng;
}
} catch (error) {
console.error("Failed to set language:", error);
}
}
/**
* Gets the available language options for the dropdown
* @returns An array of language options
*/
get availableLanguages(): ILanguageOption[] {
return SUPPORTED_LANGUAGES;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./language";
export * from "./translation";
+6
View File
@@ -0,0 +1,6 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
export interface ILanguageOption {
label: string;
value: TLanguage;
}
+7
View File
@@ -0,0 +1,7 @@
export interface ITranslation {
[key: string]: string | ITranslation;
}
export interface ITranslations {
[locale: string]: ITranslation;
}
-6
View File
@@ -1,6 +0,0 @@
.next
.vercel
.tubro
out/
dis/
build/
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
-8
View File
@@ -15,21 +15,13 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-aspect-ratio": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
}
}
-1
View File
@@ -1 +0,0 @@
export {};
-53
View File
@@ -1,53 +0,0 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@plane/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@plane/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
)
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
)
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
-7
View File
@@ -1,7 +0,0 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
-39
View File
@@ -1,39 +0,0 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@plane/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@plane/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
-90
View File
@@ -1,90 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@plane/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
)
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+16 -6
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@plane/utils";
@@ -9,10 +9,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@@ -39,7 +43,13 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
-41
View File
@@ -1,41 +0,0 @@
import * as React from "react";
import { cn } from "@plane/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
-28
View File
@@ -1,28 +0,0 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@plane/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
-182
View File
@@ -1,182 +0,0 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@plane/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
-33
View File
@@ -1,33 +0,0 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@plane/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
-29
View File
@@ -1,29 +0,0 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@plane/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+1 -1
View File
@@ -10,7 +10,7 @@
"postcss": "^8.4.38",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.2.7",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.6"
}
}
@@ -25,10 +25,6 @@ module.exports = {
},
theme: {
extend: {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
"custom-shadow-xs": "var(--color-shadow-xs)",
@@ -265,14 +261,6 @@ module.exports = {
from: { left: "-100%" },
to: { left: "100%" },
},
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
typography: () => ({
brand: {
+2 -3
View File
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
project_detail: IProjectDetails;
progress: any[];
version: number;
pending_issues: number;
}
export interface CycleIssueResponse {
@@ -120,9 +121,7 @@ export interface CycleIssueResponse {
sub_issues_count: number;
}
export type SelectCycleType =
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined;
export type CycleDateCheckData = {
start_date: string;
+12 -4
View File
@@ -1,8 +1,16 @@
import { TLogoProps } from "./common";
export type TSticky = {
created_at?: string | undefined;
created_by?: string | undefined;
background_color?: string | null | undefined;
description?: object | undefined;
description_html?: string | undefined;
id: string;
logo_props: TLogoProps | undefined;
name?: string;
description_html?: string;
color?: string;
createdAt?: Date;
updatedAt?: Date;
sort_order: number | undefined;
updated_at?: string | undefined;
updated_by?: string | undefined;
workspace: string | undefined;
};
-1
View File
@@ -69,7 +69,6 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^6.0.1",
"storybook": "^8.1.1",
"tailwindcss": "^3.4.3",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
@@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
@@ -15,11 +15,11 @@ import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import { ViewQuickActions } from "@/components/views";
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EViewAccess } from "@/constants/views";
// helpers
import { isIssueFilterActive } from "@/helpers/filter.helper";
import { getPublishViewLink } from "@/helpers/project-views.helpers";
import { truncateText } from "@/helpers/string.helper";
// hooks
import {
@@ -38,6 +38,8 @@ import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const ProjectViewIssuesHeader: React.FC = observer(() => {
// refs
const parentRef = useRef(null);
// router
const { workspaceSlug, projectId, viewId } = useParams();
// store hooks
@@ -133,7 +135,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const publishLink = getPublishViewLink(viewDetails?.anchor);
if (!viewDetails) return;
return (
<Header>
@@ -203,19 +206,14 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<></>
)}
{viewDetails?.anchor && publishLink ? (
<a
href={publishLink}
className="px-3 py-1.5 bg-green-500/20 text-green-500 rounded text-xs font-medium flex items-center gap-1.5"
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-shrink-0 rounded-full size-1.5 bg-green-500" />
Live
</a>
) : (
<></>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.LeftItem>
<Header.RightItem>
{!viewDetails?.is_locked ? (
@@ -246,6 +244,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
projectId={projectId.toString()}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
@@ -0,0 +1,59 @@
"use client";
import { observer } from "mobx-react";
// ui
import { useParams } from "next/navigation";
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { StickySearch } from "@/components/stickies/modal/search";
import { useStickyOperations } from "@/components/stickies/sticky/use-operations";
// plane-web
import { useSticky } from "@/hooks/use-stickies";
export const WorkspaceStickyHeader = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { creatingSticky, toggleShowNewSticky } = useSticky();
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<>
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={`Stickies`}
icon={<RecentStickyIcon className="size-5 rotate-90 text-custom-text-200" />}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<StickySearch />
<Button
variant="primary"
size="sm"
className="items-center gap-1"
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
loading={creatingSticky}
>
Add sticky
</Button>
</Header.RightItem>
</Header>
</>
);
});
@@ -0,0 +1,13 @@
"use client";
import { AppHeader, ContentWrapper } from "@/components/core";
import { WorkspaceStickyHeader } from "./header";
export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<WorkspaceStickyHeader />} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}
@@ -0,0 +1,16 @@
"use client";
// components
import { PageHead } from "@/components/core";
import { StickiesInfinite } from "@/components/stickies";
export default function WorkspaceStickiesPage() {
return (
<>
<PageHead title="Your stickies" />
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
<StickiesInfinite />
</div>
</>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
@@ -16,19 +17,25 @@ const GlobalViewIssuesPage = observer(() => {
const { globalViewId } = useParams();
// store hooks
const { currentWorkspace } = useWorkspace();
// states
const [isLoading, setIsLoading] = useState(false);
// derived values
const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined;
// handlers
const toggleLoading = (value: boolean) => setIsLoading(value);
return (
<>
<PageHead title={pageTitle} />
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
<GlobalViewsHeader />
{globalViewId && <GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} />}
<AllIssueLayoutRoot isDefaultView={!!defaultView} />
{globalViewId && (
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} isLoading={isLoading} />
)}
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
</div>
</div>
</>
@@ -7,6 +7,7 @@ interface Props {
projectId: string;
workspaceSlug: string;
transferrableIssuesCount: number;
cycleName: string;
}
export const EndCycleModal: React.FC<Props> = () => <></>;
@@ -0,0 +1,12 @@
"use client";
import React, { FC } from "react";
type Props = {
issueId: string;
};
export const IssueStats: FC<Props> = (props) => {
const { issueId } = props;
return <></>;
};
+1
View File
@@ -0,0 +1 @@
export type { ICycleStore } from "@/store/cycle.store";
@@ -196,7 +196,7 @@ export const CustomTreeMapContent: React.FC<any> = ({
L${pX},${pY + LAYOUT.RADIUS}
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
`}
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
className={cn("transition-colors duration-200 hover:opacity-90", fillClassName)}
fill={fillColor ?? "currentColor"}
/>
@@ -269,7 +269,7 @@ export const CustomTreeMapContent: React.FC<any> = ({
return (
<g>
<rect x={x} y={y} width={width} height={height} fill="transparent" className="cursor-pointer" />
<rect x={x} y={y} width={width} height={height} fill="transparent" />
{renderContent()}
</g>
);
@@ -9,6 +9,7 @@ interface IContentOverflowWrapper {
buttonClassName?: string;
containerClassName?: string;
fallback?: ReactNode;
customButton?: ReactNode;
}
export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => {
@@ -18,6 +19,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
buttonClassName = "text-sm font-medium text-custom-primary-100",
containerClassName,
fallback = null,
customButton,
} = props;
// states
@@ -131,16 +133,18 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
pointerEvents: isTransitioning ? "none" : "auto",
}}
>
<button
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
buttonClassName
)}
onClick={handleToggle}
disabled={isTransitioning}
>
{showAll ? "Show less" : "Show all"}
</button>
{customButton || (
<button
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
buttonClassName
)}
onClick={handleToggle}
disabled={isTransitioning}
>
{showAll ? "Show less" : "Show all"}
</button>
)}
</div>
)}
</div>
@@ -2,7 +2,7 @@
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { Eye, Users } from "lucide-react";
// types
@@ -58,6 +58,8 @@ const defaultValues: Partial<ICycle> = {
export const CycleListItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
// router
const { projectId: routerProjectId } = useParams();
//states
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// hooks
@@ -80,9 +82,11 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
// derived values
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0;
const showTransferIssues = transferableIssuesCount > 0 && cycleStatus === "completed";
const showTransferIssues = routerProjectId && cycleDetails.pending_issues > 0 && cycleStatus === "completed";
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
@@ -254,7 +258,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
}}
>
<TransferIcon className="fill-custom-primary-200 w-4" />
<span>Transfer {transferableIssuesCount} issues</span>
<span>Transfer {cycleDetails.pending_issues} issues</span>
</div>
)}
+12 -10
View File
@@ -42,7 +42,6 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
const isArchived = !!cycleDetails?.archived_at;
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0;
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -170,18 +169,21 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<EndCycleModal
isOpen={isEndCycleModalOpen}
handleClose={() => setEndCycleModalOpen(false)}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
transferrableIssuesCount={transferableIssuesCount}
/>
{isCurrentCycle && (
<EndCycleModal
isOpen={isEndCycleModalOpen}
handleClose={() => setEndCycleModalOpen(false)}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
transferrableIssuesCount={cycleDetails.pending_issues}
cycleName={cycleDetails.name}
/>
)}
</div>
)}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<CustomMenu ellipsis placement="bottom-end" closeOnSelect maxHeight="lg">
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
@@ -26,7 +26,7 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
const [query, setQuery] = useState("");
// store hooks
const { currentProjectIncompleteCycleIds, getCycleById, fetchCycleDetails } = useCycle();
const { currentProjectIncompleteCycleIds, getCycleById, fetchActiveCycleProgress } = useCycle();
const {
issues: { transferIssuesFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
@@ -57,8 +57,8 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
/**To update issue counts in target cycle and current cycle */
const getCycleDetails = async (newCycleId: string) => {
const cyclesFetch = [
fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId),
fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), newCycleId),
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), cycleId),
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), newCycleId),
];
await Promise.all(cyclesFetch).catch((error) => {
setToast({
@@ -31,10 +31,11 @@ type CycleOptionsProps = {
placement: Placement | undefined;
isOpen: boolean;
canRemoveCycle: boolean;
currentCycleId?: string;
};
export const CycleOptions: FC<CycleOptionsProps> = observer((props) => {
const { projectId, isOpen, referenceElement, placement, canRemoveCycle } = props;
const { projectId, isOpen, referenceElement, placement, canRemoveCycle, currentCycleId } = props;
//state hooks
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -68,6 +69,7 @@ export const CycleOptions: FC<CycleOptionsProps> = observer((props) => {
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
if (currentCycleId && currentCycleId === cycleId) return false;
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
});
@@ -26,6 +26,7 @@ type Props = TDropdownProps & {
value: string | null;
canRemoveCycle?: boolean;
renderByDefault?: boolean;
currentCycleId?: string;
};
export const CycleDropdown: React.FC<Props> = observer((props) => {
@@ -49,6 +50,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
value,
canRemoveCycle = true,
renderByDefault = true,
currentCycleId,
} = props;
// states
@@ -145,6 +147,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
placement={placement}
referenceElement={referenceElement}
canRemoveCycle={canRemoveCycle}
currentCycleId={currentCycleId}
/>
)}
</ComboDropDown>
+7 -7
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { DateRange, DayPicker, Matcher } from "react-day-picker";
import { usePopper } from "react-popper";
import { ArrowRight, CalendarDays } from "lucide-react";
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
import { Combobox } from "@headlessui/react";
// ui
import { Button, ComboDropDown } from "@plane/ui";
@@ -33,7 +33,6 @@ type Props = {
from?: boolean;
to?: boolean;
};
icon?: React.ReactNode;
minDate?: Date;
maxDate?: Date;
onSelect: (range: DateRange | undefined) => void;
@@ -50,6 +49,7 @@ type Props = {
to: Date | undefined;
};
renderByDefault?: boolean;
renderPlaceholder?: boolean;
};
export const DateRangeDropdown: React.FC<Props> = (props) => {
@@ -68,7 +68,6 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
from: true,
to: true,
},
icon = <CalendarDays className="h-3 w-3 flex-shrink-0" />,
minDate,
maxDate,
onSelect,
@@ -82,6 +81,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
tabIndex,
value,
renderByDefault = true,
renderPlaceholder = true,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
@@ -166,15 +166,15 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
<span
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonFromDateClassName)}
>
{!hideIcon.from && icon}
{dateRange.from ? renderFormattedDate(dateRange.from) : placeholder.from}
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonToDateClassName)}
>
{!hideIcon.to && icon}
{dateRange.to ? renderFormattedDate(dateRange.to) : placeholder.to}
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
</span>
</DropdownButton>
</button>
@@ -0,0 +1,78 @@
import { TSticky } from "@plane/types";
export const STICKY_COLORS_LIST: {
key: string;
label: string;
backgroundColor: string;
}[] = [
{
key: "gray",
label: "Gray",
backgroundColor: "var(--editor-colors-gray-background)",
},
{
key: "peach",
label: "Peach",
backgroundColor: "var(--editor-colors-peach-background)",
},
{
key: "pink",
label: "Pink",
backgroundColor: "var(--editor-colors-pink-background)",
},
{
key: "orange",
label: "Orange",
backgroundColor: "var(--editor-colors-orange-background)",
},
{
key: "green",
label: "Green",
backgroundColor: "var(--editor-colors-green-background)",
},
{
key: "light-blue",
label: "Light blue",
backgroundColor: "var(--editor-colors-light-blue-background)",
},
{
key: "dark-blue",
label: "Dark blue",
backgroundColor: "var(--editor-colors-dark-blue-background)",
},
{
key: "purple",
label: "Purple",
backgroundColor: "var(--editor-colors-purple-background)",
},
];
type TProps = {
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
};
export const ColorPalette = (props: TProps) => {
const { handleUpdate } = props;
return (
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
<div className="flex flex-wrap gap-2">
{STICKY_COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
onClick={() => {
handleUpdate({
background_color: color.key,
});
}}
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
style={{
backgroundColor: color.backgroundColor,
}}
/>
))}
</div>
</div>
);
};
@@ -1,36 +0,0 @@
import { TSticky } from "@plane/types";
export const STICKY_COLORS = [
"#D4DEF7", // light periwinkle
"#B4E4FF", // light blue
"#FFF2B4", // light yellow
"#E3E3E3", // light gray
"#FFE2DD", // light pink
"#F5D1A5", // light orange
"#D1F7C4", // light green
"#E5D4FF", // light purple
];
type TProps = {
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
};
export const ColorPalette = (props: TProps) => {
const { handleUpdate } = props;
return (
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
<div className="flex flex-wrap gap-2">
{STICKY_COLORS.map((color, index) => (
<button
key={index}
type="button"
onClick={() => handleUpdate({ color })}
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
);
};
@@ -82,26 +82,28 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleDelete={handleDelete}
handleColorChange={handleColorChange}
editorRef={editorRef}
/>
</div>
{showToolbar && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleDelete={handleDelete}
handleColorChange={handleColorChange}
editorRef={editorRef}
/>
</div>
)}
</div>
);
});
@@ -12,7 +12,7 @@ import { Tooltip } from "@plane/ui";
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { ColorPalette } from "./color-pallete";
import { ColorPalette } from "./color-palette";
type Props = {
executeCommand: (item: ToolbarMenuItem) => void;
@@ -0,0 +1,100 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// ui
import { Button } from "@plane/ui/src/button";
// utils
import { cn } from "@plane/utils";
type EmptyStateSize = "sm" | "md" | "lg";
type ButtonConfig = {
text: string;
prependIcon?: React.ReactNode;
appendIcon?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
};
type Props = {
title: string;
description?: string;
assetPath?: string;
size?: EmptyStateSize;
primaryButton?: ButtonConfig;
secondaryButton?: ButtonConfig;
customPrimaryButton?: React.ReactNode;
customSecondaryButton?: React.ReactNode;
};
const sizeClasses = {
sm: "md:min-w-[24rem] max-w-[45rem]",
md: "md:min-w-[28rem] max-w-[50rem]",
lg: "md:min-w-[30rem] max-w-[60rem]",
} as const;
const CustomButton = ({
config,
variant,
size,
}: {
config: ButtonConfig;
variant: "primary" | "neutral-primary";
size: EmptyStateSize;
}) => (
<Button
variant={variant}
size={size}
onClick={config.onClick}
prependIcon={config.prependIcon}
appendIcon={config.appendIcon}
disabled={config.disabled}
>
{config.text}
</Button>
);
export const DetailedEmptyState: React.FC<Props> = observer((props) => {
const {
title,
description,
size = "lg",
primaryButton,
secondaryButton,
customPrimaryButton,
customSecondaryButton,
assetPath,
} = props;
const hasButtons = primaryButton || secondaryButton || customPrimaryButton || customSecondaryButton;
return (
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 md:px-20 px-5">
<div className={cn("flex flex-col gap-5", sizeClasses[size])}>
<div className="flex flex-col gap-1.5 flex-shrink">
<h3 className={cn("text-xl font-semibold", { "font-medium": !description })}>{title}</h3>
{description && <p className="text-sm">{description}</p>}
</div>
{assetPath && (
<Image src={assetPath} alt={title} width={384} height={250} layout="responsive" lazyBoundary="100%" />
)}
{hasButtons && (
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{/* primary button */}
{customPrimaryButton ??
(primaryButton?.text && <CustomButton config={primaryButton} variant="primary" size={size} />)}
{/* secondary button */}
{customSecondaryButton ??
(secondaryButton?.text && (
<CustomButton config={secondaryButton} variant="neutral-primary" size={size} />
))}
</div>
)}
</div>
</div>
);
});
+23 -11
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import { useTheme } from "next-themes";
// hooks
// components
import { Button, TButtonVariant } from "@plane/ui";
import { Button, TButtonSizes, TButtonVariant } from "@plane/ui";
// constant
import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state";
// helpers
@@ -18,10 +18,14 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { ComicBoxButton } from "./comic-box-button";
export type EmptyStateProps = {
size?: TButtonSizes;
type: EmptyStateType;
size?: "sm" | "md" | "lg";
layout?: "screen-detailed" | "screen-simple";
additionalPath?: string;
primaryButtonConfig?: {
size?: TButtonSizes;
variant?: TButtonVariant;
};
primaryButtonOnClick?: () => void;
primaryButtonLink?: string;
secondaryButtonOnClick?: () => void;
@@ -29,10 +33,14 @@ export type EmptyStateProps = {
export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
const {
type,
size = "lg",
type,
layout = "screen-detailed",
additionalPath = "",
primaryButtonConfig = {
size: "lg",
variant: "primary",
},
primaryButtonOnClick,
primaryButtonLink,
secondaryButtonOnClick,
@@ -67,8 +75,8 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
if (!primaryButton) return null;
const commonProps = {
size: size,
variant: "primary" as TButtonVariant,
size: primaryButtonConfig.size,
variant: primaryButtonConfig.variant,
prependIcon: primaryButton.icon,
onClick: primaryButtonOnClick ? primaryButtonOnClick : undefined,
disabled: !isEditingAllowed,
@@ -145,12 +153,10 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
)}
{anyButton && (
<>
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{renderPrimaryButton()}
{renderSecondaryButton()}
</div>
</>
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{renderPrimaryButton()}
{renderSecondaryButton()}
</div>
)}
</div>
</div>
@@ -175,6 +181,12 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
) : (
<h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3>
)}
{anyButton && (
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{renderPrimaryButton()}
{renderSecondaryButton()}
</div>
)}
</div>
)}
</>
+3
View File
@@ -1,3 +1,6 @@
export * from "./empty-state";
export * from "./helper";
export * from "./comic-box-button";
export * from "./detailed-empty-state-root";
export * from "./simple-empty-state-root";
export * from "./section-empty-state-root";
@@ -0,0 +1,24 @@
"use client";
import { FC } from "react";
type Props = {
icon: React.ReactNode;
title: string;
description?: string;
actionElement?: React.ReactNode;
};
export const SectionEmptyState: FC<Props> = (props) => {
const { title, description, icon, actionElement } = props;
return (
<div className="flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
<span className="text-sm font-medium">{title}</span>
{description && <span className="text-xs text-custom-text-300">{description}</span>}
</div>
{actionElement && <>{actionElement}</>}
</div>
);
};
@@ -0,0 +1,62 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// utils
import { cn } from "@plane/utils";
type EmptyStateSize = "sm" | "md" | "lg";
type Props = {
title: string;
description?: string;
assetPath?: string;
size?: EmptyStateSize;
};
const sizeConfig = {
sm: {
container: "size-20",
dimensions: 78,
},
md: {
container: "size-24",
dimensions: 80,
},
lg: {
container: "size-28",
dimensions: 96,
},
} as const;
const getTitleClassName = (hasDescription: boolean) =>
cn("font-medium whitespace-pre-line", {
"text-sm text-custom-text-400": !hasDescription,
"text-lg text-custom-text-300": hasDescription,
});
export const SimpleEmptyState = observer((props: Props) => {
const { title, description, size = "sm", assetPath } = props;
return (
<div className="text-center flex flex-col gap-2.5 items-center">
{assetPath && (
<div className={sizeConfig[size].container}>
<Image
src={assetPath}
alt={title}
height={sizeConfig[size].dimensions}
width={sizeConfig[size].dimensions}
layout="responsive"
lazyBoundary="100%"
/>
</div>
)}
<h3 className={getTitleClassName(!!description)}>{title}</h3>
{description && <p className="text-base font-medium text-custom-text-400 whitespace-pre-line">{description}</p>}
</div>
);
});
@@ -1,54 +1,96 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
// plane types
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
// components
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useHome } from "@/hooks/store/use-home";
// components
// plane web components
import { HomePageHeader } from "@/plane-web/components/home/header";
import { StickiesWidget } from "../stickies";
import { RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage";
const WIDGETS_LIST: {
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps> | null; fullWidth: boolean };
export const HOME_WIDGETS_LIST: {
[key in THomeWidgetKeys]: {
component: React.FC<THomeWidgetProps> | null;
fullWidth: boolean;
title: string;
};
} = {
quick_links: { component: DashboardQuickLinks, fullWidth: false },
recents: { component: RecentActivityWidget, fullWidth: false },
my_stickies: { component: StickiesWidget, fullWidth: false },
new_at_plane: { component: null, fullWidth: false },
quick_tutorial: { component: null, fullWidth: false },
quick_links: {
component: DashboardQuickLinks,
fullWidth: false,
title: "Quick links",
},
recents: {
component: RecentActivityWidget,
fullWidth: false,
title: "Recents",
},
my_stickies: {
component: StickiesWidget,
fullWidth: false,
title: "Your stickies",
},
new_at_plane: {
component: null,
fullWidth: false,
title: "New at Plane",
},
quick_tutorial: {
component: null,
fullWidth: false,
title: "Quick tutorial",
},
};
export const DashboardWidgets = observer(() => {
// router
const { workspaceSlug } = useParams();
// store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome();
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
if (!workspaceSlug) return null;
return (
<div className="relative flex flex-col gap-7">
<div className="h-full w-full relative flex flex-col gap-7">
<HomePageHeader />
<ManageWidgetsModal
workspaceSlug={workspaceSlug.toString()}
isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)}
/>
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
})}
</div>
{isAnyWidgetEnabled ? (
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
})}
</div>
) : (
<div className="h-full w-full grid place-items-center">
<EmptyState
type={EmptyStateType.HOME_WIDGETS}
layout="screen-simple"
primaryButtonOnClick={() => toggleWidgetSettings(true)}
primaryButtonConfig={{
size: "sm",
variant: "neutral-primary",
}}
/>
</div>
)}
</div>
);
});
+1 -2
View File
@@ -59,12 +59,11 @@ export const WorkspaceHomeView = observer(() => {
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
className={cn("gap-6 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
<DashboardWidgets />
</ContentWrapper>
</>
+6 -7
View File
@@ -1,9 +1,11 @@
import { FC } from "react";
// hooks
import { Shapes } from "lucide-react";
// plane types
import { IUser } from "@plane/types";
// plane ui
import { Button } from "@plane/ui";
// hooks
import { useCurrentTime } from "@/hooks/use-current-time";
// types
export interface IUserGreetingsView {
user: IUser;
@@ -51,13 +53,10 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
</div>
</h6>
</div>
<button
onClick={handleWidgetModal}
className="flex items-center gap-2 font-medium text-custom-text-300 justify-center border border-custom-border-200 rounded p-2 my-auto mb-0"
>
<Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
<Shapes size={16} />
<div className="text-xs font-medium">Manage widgets</div>
</button>
</Button>
</div>
);
};
@@ -1,27 +1,12 @@
import { Link2, Plus } from "lucide-react";
import { Button } from "@plane/ui";
import { Link2 } from "lucide-react";
type TProps = {
handleCreate: () => void;
};
export const LinksEmptyState = (props: TProps) => {
const { handleCreate } = props;
return (
<div className="min-h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
<div className="m-auto">
<div
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<Link2 size={30} className="text-custom-text-400 -rotate-45" />
export const LinksEmptyState = () => (
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
<div className="text-custom-text-400 text-sm text-center my-auto">
Add any links you need for quick access to your work.
</div>
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No quick links yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">
Add any links you need for quick access to your work.{" "}
</div>
<Button variant="accent-primary" size="sm" onClick={handleCreate} className="mx-auto">
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
</Button>
</div>
</div>
);
};
@@ -1,15 +1,38 @@
import { History } from "lucide-react";
import { Briefcase, FileText, History } from "lucide-react";
import { LayersIcon } from "@plane/ui";
export const RecentsEmptyState = () => (
<div className="h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
<div className="m-auto">
<div
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<History size={30} className="text-custom-text-400 -rotate-45" />
export const RecentsEmptyState = ({ type }: { type: string }) => {
const getDisplayContent = () => {
switch (type) {
case "project":
return {
icon: <Briefcase size={30} className="text-custom-text-400/40" />,
text: "Your recent projects will appear here once you visit one.",
};
case "page":
return {
icon: <FileText size={30} className="text-custom-text-400/40" />,
text: "Your recent pages will appear here once you visit one.",
};
case "issue":
return {
icon: <LayersIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
text: "Your recent issues will appear here once you visit one.",
};
default:
return {
icon: <History size={30} className="text-custom-text-400/40" />,
text: "You dont have any recent items yet.",
};
}
};
const { icon, text } = getDisplayContent();
return (
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{text}</div>
</div>
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">You dont have any recent items yet. </div>
</div>
</div>
);
);
};
@@ -2,17 +2,22 @@ import React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Briefcase, Hotel, Users } from "lucide-react";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
export const EmptyWorkspace = () => {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser();
// derived values
const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
@@ -83,6 +88,7 @@ export const EmptyWorkspace = () => {
},
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{EMPTY_STATE_DATA.map((item) => (
@@ -0,0 +1,13 @@
// plane ui
import { RecentStickyIcon } from "@plane/ui";
export const StickiesEmptyState = () => (
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
<div className="text-custom-text-400 text-sm text-center my-auto">
No stickies yet. Add one to start making quick notes.
</div>
</div>
</div>
);
@@ -4,12 +4,11 @@ import { FC } from "react";
// hooks
// ui
import { observer } from "mobx-react";
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react";
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
import { cn, copyTextToClipboard } from "@plane/utils";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useHome } from "@/hooks/store/use-home";
import { TLinkOperations } from "./use-links";
@@ -37,7 +36,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
};
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
copyTextToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
@@ -74,7 +73,10 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
];
return (
<div className="group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md">
<div
onClick={handleOpenInNewTab}
className="cursor-pointer group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"
>
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
</div>
@@ -20,14 +20,14 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
const { linkOperations, workspaceSlug } = props;
// hooks
const {
quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
quickLinks: { getLinksByWorkspaceId },
} = useHome();
const links = getLinksByWorkspaceId(workspaceSlug);
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
if (links.length === 0) return <LinksEmptyState handleCreate={() => toggleLinkModal(true)} />;
if (links.length === 0) return <LinksEmptyState />;
return (
<div className="relative">
@@ -32,7 +32,6 @@ export const useLinks = (workspaceSlug: string) => {
create: async (data: Partial<TProjectLink>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
console.log("data", data, workspaceSlug);
await createLink(workspaceSlug, data);
setToast({
message: "The link has been successfully created",
@@ -11,18 +11,18 @@ import {
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
// plane helpers
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
// ui
// plane types
import { InstructionType, TWidgetEntityData } from "@plane/types";
// components
// plane ui
import { DropIndicator, ToggleSwitch } from "@plane/ui";
// helpers
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
@@ -46,6 +46,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
const { widgetsMap } = useHome();
// derived values
const widget = widgetsMap[widgetId] as TWidgetEntityData;
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
// drag and drop
useEffect(() => {
@@ -119,7 +120,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
<div
ref={elementRef}
className={cn(
"px-2 relative flex items-center py-2 font-medium text-sm capitalize group/widget-item rounded hover:bg-custom-background-80 justify-between",
"px-2 relative flex items-center py-2 font-medium text-sm group/widget-item rounded hover:bg-custom-background-80 justify-between",
{
"cursor-grabbing bg-custom-background-80": isDragging,
}
@@ -127,7 +128,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
>
<div className="flex items-center">
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
<div>{widget.key.replaceAll("_", " ")}</div>
<div>{widgetTitle}</div>
</div>
<ToggleSwitch
value={widget.is_enabled}
@@ -74,7 +74,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div>
<div className="flex flex-col items-center justify-center">
<RecentsEmptyState />
<RecentsEmptyState type={filter} />
</div>
</div>
);
+1
View File
@@ -120,6 +120,7 @@ const HeaderFilters = observer((props: Props) => {
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
projectId={projectId}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
@@ -37,7 +37,8 @@ export const useSubIssueOperations = (
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
// const { updateEpicAnalytics } = useIssueTypes();
const { updateAnalytics } = updateEpicAnalytics();
const { fetchSubIssues, removeSubIssue } = useIssueDetail(issueServiceType);
const { fetchSubIssues } = useIssueDetail();
const { removeSubIssue } = useIssueDetail(issueServiceType);
const { captureIssueEvent } = useEventTracker();
// derived values
@@ -10,7 +10,7 @@ import { EIssueFilterType, EIssuesStoreType } from "@plane/constants";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
//ui
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { Header, EHeaderVariant, Loader } from "@plane/ui";
import { AppliedFiltersList } from "@/components/issues";
import { UpdateViewComponent } from "@/components/views/update-view-component";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
@@ -27,10 +27,11 @@ import { getAreFiltersEqual } from "../../../utils";
type Props = {
globalViewId: string;
isLoading?: boolean;
};
export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
const { globalViewId } = props;
const { globalViewId, isLoading = false } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
@@ -154,14 +155,22 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
}}
/>
<AppliedFiltersList
labels={workspaceLabels ?? undefined}
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
disableEditing={isLocked}
alwaysAllowEditing
/>
{isLoading ? (
<Loader className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
<Loader.Item height="36px" width="150px" />
<Loader.Item height="36px" width="100px" />
<Loader.Item height="36px" width="300px" />
</Loader>
) : (
<AppliedFiltersList
labels={workspaceLabels ?? undefined}
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
disableEditing={isLocked}
alwaysAllowEditing
/>
)}
{!isDefaultView ? (
<UpdateViewComponent

Some files were not shown because too many files have changed in this diff Show More