Merge branch 'develop' of github.com:makeplane/plane into chore/api_endpoints

This commit is contained in:
NarayanBavisetti 2023-11-21 20:16:56 +05:30
commit c5788d20e7
43 changed files with 1018 additions and 881 deletions

View File

@ -1,7 +1,7 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseAPIView
@ -10,7 +10,7 @@ from plane.app.serializers import FileAssetSerializer
class FileAssetEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
parser_classes = (MultiPartParser, FormParser, JSONParser,)
"""
A viewset for viewing and editing task instances.
@ -25,7 +25,6 @@ class FileAssetEndpoint(BaseAPIView):
else:
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
def post(self, request, slug):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
@ -34,12 +33,11 @@ class FileAssetEndpoint(BaseAPIView):
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, workspace_id, asset_key):
def patch(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = True
file_asset.is_deleted = request.data.get("is_deleted", file_asset.is_deleted)
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -975,7 +975,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_S3_BUCKET_NAME,
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
@ -987,7 +987,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)

View File

@ -81,13 +81,13 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
)
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
# Create the new url with updated domain and protocol
@ -105,14 +105,14 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
)
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)

View File

@ -42,8 +42,8 @@ def delete_old_s3_link():
# Delete object from S3
if file_name:
if settings.USE_MINIO:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
else:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)

View File

@ -40,7 +40,7 @@ class Command(BaseCommand):
)
# Create an S3 client using the session
s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL)
bucket_name = settings.AWS_S3_BUCKET_NAME
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
self.stdout.write(self.style.NOTICE("Checking bucket..."))
@ -50,7 +50,7 @@ class Command(BaseCommand):
self.set_bucket_public_policy(s3_client, bucket_name)
except ClientError as e:
error_code = int(e.response['Error']['Code'])
bucket_name = settings.AWS_S3_BUCKET_NAME
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
if error_code == 404:
# Bucket does not exist, create it
self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket..."))

View File

@ -224,7 +224,7 @@ STORAGES["default"] = {
}
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
AWS_REGION = os.environ.get("AWS_REGION", "")
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
@ -234,7 +234,7 @@ AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.
)
if AWS_S3_ENDPOINT_URL:
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_S3_BUCKET_NAME}"
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"

View File

@ -1,33 +1,46 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
import { Editor } from "@tiptap/react"
import { DocumentDetails } from "../types/editor-types"
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { DocumentDetails } from "../types/editor-types";
interface IPageRenderer {
sidePeakVisible: boolean,
documentDetails: DocumentDetails ,
editor: Editor,
editorClassNames: string,
editorContentCustomClassNames?: string
sidePeakVisible: boolean;
documentDetails: DocumentDetails;
editor: Editor;
editorClassNames: string;
editorContentCustomClassNames?: string;
}
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
export const PageRenderer = ({
sidePeakVisible,
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
}: IPageRenderer) => {
return (
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
<div
className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${
sidePeakVisible ? "ml-[3%] " : "ml-0"
}`}
>
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
<div className="flex flex-col py-2 max-md:max-w-full">
<h1
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
>{documentDetails.title}</h1>
<h1 className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full">
{documentDetails.title}
</h1>
</div>
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
<div className="flex flex-col max-md:max-w-full">
<div className="border-b border-custom-border-200 self-stretch w-full h-0.5 mt-3" />
<div className="flex flex-col h-full w-full max-md:max-w-full">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<div className="flex flex-col h-full w-full">
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
</div>
</div>
</div>
)
}
);
};

View File

@ -11,7 +11,7 @@ import emptyApiTokens from "public/empty-state/api-token.svg";
const ApiTokenEmptyState = () => {
const router = useRouter();
return (
<div className={`flex items-center justify-center mx-auto border bg-custom-background-90 py-10 px-16 w-full`}>
<div className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API Tokens</h6>

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiscordIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteHelpActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleShortcutModal },
} = useMobxStore();
return (
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any)?.$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
);
};

View File

@ -0,0 +1,6 @@
export * from "./issue-actions";
export * from "./help-actions";
export * from "./project-actions";
export * from "./search-results";
export * from "./theme-actions";
export * from "./workspace-settings-actions";

View File

@ -0,0 +1,166 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issueDetails: IIssue | undefined;
pages: string[];
setPages: (pages: string[]) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (searchTerm: string) => void;
};
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
commandPalette: { toggleCommandPaletteModal, toggleDeleteIssueModal },
issueDetail: { updateIssue },
user: { currentUser },
} = useMobxStore();
const { setToastAlert } = useToast();
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails || !assignee) return;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
};
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
const copyIssueUrlToClipboard = () => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
};
return (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(currentUser?.id ?? "");
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(currentUser?.id ?? "") ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
);
});

View File

@ -0,0 +1,79 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Avatar } from "@plane/ui";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const {
issueDetail: { updateIssue },
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
closePalette();
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,56 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { PriorityIcon } from "@plane/ui";
// types
import { IIssue, TIssuePriorities } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
closePalette();
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,65 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
projectState: { projectStates },
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
closePalette();
};
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./actions-list";
export * from "./change-state";
export * from "./change-priority";
export * from "./change-assignee";

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
} = useMobxStore();
return (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
);
};

View File

@ -0,0 +1,49 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// helpers
import { commandGroups } from "components/command-palette";
// types
import { IWorkspaceSearchResults } from "types";
type Props = {
closePalette: () => void;
results: IWorkspaceSearchResults;
};
export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
const { closePalette, results } = props;
const router = useRouter();
return (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={`${currentSection.title} search`}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
</>
);
};

View File

@ -1,4 +1,4 @@
import React, { FC, Dispatch, SetStateAction, useEffect, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { Command } from "cmdk";
import { useTheme } from "next-themes";
import { Settings } from "lucide-react";
@ -10,22 +10,25 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { THEME_OPTIONS } from "constants/themes";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
closePalette: () => void;
};
export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
const { setIsPaletteOpen } = props;
// store
const { user: userStore } = useMobxStore();
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
const { closePalette } = props;
// states
const [mounted, setMounted] = useState(false);
// store
const {
user: { updateCurrentUserTheme },
} = useMobxStore();
// hooks
const { setTheme } = useTheme();
const { setToastAlert } = useToast();
const updateUserTheme = (newTheme: string) => {
const updateUserTheme = async (newTheme: string) => {
setTheme(newTheme);
return userStore.updateCurrentUserTheme(newTheme).catch(() => {
return updateCurrentUserTheme(newTheme).catch(() => {
setToastAlert({
title: "Failed to save user theme settings!",
type: "error",
@ -47,7 +50,7 @@ export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
key={theme.value}
onSelect={() => {
updateUserTheme(theme.value);
setIsPaletteOpen(false);
closePalette();
}}
className="focus:outline-none"
>

View File

@ -0,0 +1,61 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// icons
import { SettingIcon } from "components/icons";
type Props = {
closePalette: () => void;
};
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const redirect = (path: string) => {
closePalette();
router.push(path);
};
return (
<>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
);
};

View File

@ -1,22 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { Command } from "cmdk";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import {
FileText,
FolderPlus,
LinkIcon,
MessageSquare,
Rocket,
Search,
Settings,
Signal,
Trash2,
UserMinus2,
UserPlus2,
} from "lucide-react";
import { FolderPlus, Search, Settings } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
@ -24,47 +12,29 @@ import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
// hooks
import useDebounce from "hooks/use-debounce";
import useToast from "hooks/use-toast";
// components
import {
ChangeInterfaceTheme,
CommandPaletteThemeActions,
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
commandGroups,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteWorkspaceSettingsActions,
CommandPaletteSearchResults,
} from "components/command-palette";
import {
ContrastIcon,
DiceIcon,
DoubleCircleIcon,
LayersIcon,
Loader,
PhotoFilterIcon,
ToggleSwitch,
Tooltip,
UserGroupIcon,
} from "@plane/ui";
// icons
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
import { IWorkspaceSearchResults } from "types";
// fetch-keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
deleteIssue: () => void;
isPaletteOpen: boolean;
closePalette: () => void;
};
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC<Props> = observer((props) => {
const { deleteIssue, isPaletteOpen, closePalette } = props;
export const CommandModal: React.FC = observer(() => {
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@ -85,8 +55,14 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
const user = userStore.currentUser ?? undefined;
const {
commandPalette: {
isCommandPaletteOpen,
toggleCommandPaletteModal,
toggleCreateIssueModal,
toggleCreateProjectModal,
},
} = useMobxStore();
// router
const router = useRouter();
@ -96,64 +72,16 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { setToastAlert } = useToast();
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
};
const redirect = (path: string) => {
closePalette();
router.push(path);
const closePalette = () => {
toggleCommandPaletteModal(false);
};
const createNewWorkspace = () => {
@ -161,25 +89,6 @@ export const CommandModal: React.FC<Props> = observer((props) => {
router.push("/create-workspace");
};
const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}, [router, setToastAlert]);
useEffect(
() => {
if (!workspaceSlug) return;
@ -189,7 +98,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, {
.searchWorkspace(workspaceSlug.toString(), {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
@ -225,16 +134,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
if (!user) return null;
return (
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
>
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
<Transition.Child
as={React.Fragment}
@ -268,9 +169,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
onKeyDown={(e) => {
// when search is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
closePalette();
}
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
@ -318,9 +218,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 placeholder:text-custom-text-400 outline-none focus:ring-0 text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={1}
/>
@ -340,7 +238,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
<div className="my-4 text-center text-custom-text-200 text-sm">No results found.</div>
)}
{(isLoading || isSearching) && (
@ -354,125 +252,28 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Loading>
)}
{debouncedSearchTerm !== "" &&
Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={currentSection.title}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.name}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
)}
{!page && (
<>
{/* issue actions */}
{issueId && (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
closePalette();
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
pages={pages}
setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateIssueModal(true);
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
@ -489,7 +290,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateProjectModal(true);
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
@ -502,70 +303,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
@ -603,139 +342,37 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
{/* help options */}
<CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
{/* workspace settings actions */}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
)}
{/* issue details page actions */}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
{page === "change-interface-theme" && <ChangeInterfaceTheme setIsPaletteOpen={closePalette} />}
</Command.List>
</Command>
</div>

View File

@ -32,7 +32,6 @@ export const CommandPalette: FC = observer(() => {
// store
const { commandPalette, theme: themeStore } = useMobxStore();
const {
isCommandPaletteOpen,
toggleCommandPaletteModal,
isCreateIssueModalOpen,
toggleCreateIssueModal,
@ -156,11 +155,6 @@ export const CommandPalette: FC = observer(() => {
if (!user) return null;
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
return (
<>
<ShortcutsModal
@ -231,13 +225,7 @@ export const CommandPalette: FC = observer(() => {
}}
user={user}
/>
<CommandModal
deleteIssue={deleteIssue}
isPaletteOpen={isCommandPaletteOpen}
closePalette={() => {
toggleCommandPaletteModal(false);
}}
/>
<CommandModal />
</>
);
});

View File

@ -20,9 +20,7 @@ export const commandGroups: {
icon: <ContrastIcon className="h-3 w-3" />,
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{cycle.project__identifier}</span>
{"- "}
{cycle.name}
<span className="text-custom-text-300 text-xs">{cycle.project__identifier}</span> {cycle.name}
</h6>
),
path: (cycle: IWorkspaceDefaultSearchResult) =>
@ -33,8 +31,9 @@ export const commandGroups: {
icon: <LayersIcon className="h-3 w-3" />,
itemName: (issue: IWorkspaceIssueSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{issue.project__identifier}</span>
{"- "}
<span className="text-custom-text-300 text-xs">
{issue.project__identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</h6>
),
@ -46,9 +45,7 @@ export const commandGroups: {
icon: <PhotoFilterIcon className="h-3 w-3" />,
itemName: (view: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{view.project__identifier}</span>
{"- "}
{view.name}
<span className="text-custom-text-300 text-xs">{view.project__identifier}</span> {view.name}
</h6>
),
path: (view: IWorkspaceDefaultSearchResult) =>
@ -59,9 +56,7 @@ export const commandGroups: {
icon: <DiceIcon className="h-3 w-3" />,
itemName: (module: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{module.project__identifier}</span>
{"- "}
{module.name}
<span className="text-custom-text-300 text-xs">{module.project__identifier}</span> {module.name}
</h6>
),
path: (module: IWorkspaceDefaultSearchResult) =>
@ -72,9 +67,7 @@ export const commandGroups: {
icon: <FileText className="h-3 w-3" />,
itemName: (page: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{page.project__identifier}</span>
{"- "}
{page.name}
<span className="text-custom-text-300 text-xs">{page.project__identifier}</span> {page.name}
</h6>
),
path: (page: IWorkspaceDefaultSearchResult) =>

View File

@ -1,5 +1,4 @@
export * from "./issue";
export * from "./change-interface-theme";
export * from "./actions";
export * from "./command-modal";
export * from "./command-pallette";
export * from "./helpers";

View File

@ -1,111 +0,0 @@
import { Dispatch, SetStateAction, useCallback, FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueService } from "services/issue";
// ui
import { Avatar } from "@plane/ui";
// types
import { IUser, IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
export const ChangeIssueAssignee: FC<Props> = observer((props) => {
const { setIsPaletteOpen, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
// store
const {
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -1,78 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
// types
import { IIssue, IUser, TIssuePriorities } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { PriorityIcon } from "@plane/ui";
import { Check } from "lucide-react";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser;
};
// services
const issueService = new IssueService();
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};

View File

@ -1,93 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
import { ProjectStateService } from "services/project";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IUser, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
const stateService = new ProjectStateService();
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: states, mutate: mutateStates } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null
);
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateStates();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateStates]
);
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
setIsPaletteOpen(false);
};
return (
<>
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};

View File

@ -1,3 +0,0 @@
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";

View File

@ -1,4 +1,6 @@
import { Fragment, useState } from "react";
// next
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { AlertTriangle } from "lucide-react";
@ -27,6 +29,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const router = useRouter();
const { cycleId } = router.query;
const formSubmit = async () => {
setLoader(true);
@ -38,6 +42,9 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
if (cycleId) router.replace(`/${workspaceSlug}/projects/${projectId}/cycles`);
handleClose();
} catch (error) {
setToastAlert({

View File

@ -11,7 +11,7 @@ import { IntegrationService } from "services/integrations";
import useToast from "hooks/use-toast";
import useIntegrationPopup from "hooks/use-integration-popup";
// ui
import { Button, Loader } from "@plane/ui";
import { Button, Loader, Tooltip } from "@plane/ui";
// icons
import GithubLogo from "public/services/github.png";
import SlackLogo from "public/services/slack.png";
@ -46,8 +46,11 @@ const integrationService = new IntegrationService();
export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) => {
const {
appConfig: { envConfig },
user: { currentWorkspaceRole },
} = useMobxStore();
const isUserAdmin = currentWorkspaceRole === 20;
const [deletingIntegration, setDeletingIntegration] = useState(false);
const router = useRouter();
@ -127,13 +130,40 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration })
{workspaceIntegrations ? (
isInstalled ? (
<Button variant="danger" onClick={handleRemoveIntegration} loading={deletingIntegration}>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</Button>
<Tooltip
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="danger"
onClick={() => {
if (!isUserAdmin) return;
handleRemoveIntegration;
}}
disabled={!isUserAdmin}
loading={deletingIntegration}
>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</Button>
</Tooltip>
) : (
<Button variant="primary" onClick={startAuth} loading={isInstalling}>
{isInstalling ? "Installing..." : "Install"}
</Button>
<Tooltip
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="primary"
onClick={() => {
if (!isUserAdmin) return;
startAuth();
}}
loading={isInstalling}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
</Tooltip>
)
) : (
<Loader>

View File

@ -38,7 +38,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return;
createPage(workspaceSlug.toString(), projectId, payload)
await createPage(workspaceSlug.toString(), projectId, payload)
.then((res) => {
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
onClose();
@ -67,7 +67,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
const updateProjectPage = async (payload: IPage) => {
if (!data || !workspaceSlug) return;
return updatePage(workspaceSlug.toString(), projectId, data.id, payload)
await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
.then((res) => {
onClose();
setToastAlert({

View File

@ -1,9 +1,9 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input, Tooltip } from "@plane/ui";
// types
import { IPage } from "types";
// constants
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
type Props = {
@ -18,31 +18,21 @@ const defaultValues = {
access: 0,
};
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, data }) => {
export const PageForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, data } = props;
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<IPage>({
defaultValues,
defaultValues: { ...defaultValues, ...data },
});
const handleCreateUpdatePage = async (formData: IPage) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div className="space-y-4">

View File

@ -38,8 +38,9 @@ export const RecentPagesList: FC = observer(() => {
<>
{Object.keys(recentProjectPages).map((key) => {
if (recentProjectPages[key].length === 0) return null;
return (
<div key={key} className="h-full overflow-hidden pb-9">
<div key={key} className="overflow-hidden">
<h2 className="text-xl font-semibold capitalize mb-2">{replaceUnderscoreIfSnakeCase(key)}</h2>
<PagesListView pages={recentProjectPages[key]} />
</div>

View File

@ -21,22 +21,21 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
// states
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
// store
const { project: projectStore } = useMobxStore();
const {
project: { joinProject },
} = useMobxStore();
// router
const router = useRouter();
const handleJoin = () => {
setIsJoiningLoading(true);
projectStore
.joinProject(workspaceSlug, [project.id])
joinProject(workspaceSlug, [project.id])
.then(() => {
setIsJoiningLoading(false);
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
handleClose();
})
.catch(() => {
.finally(() => {
setIsJoiningLoading(false);
});
};
@ -73,8 +72,9 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
Join Project?
</Dialog.Title>
<p>
Are you sure you want to join the project <span className="font-semibold break-words">{project?.name}</span>?
Please click the &apos;Join Project&apos; button below to continue.
Are you sure you want to join the project{" "}
<span className="font-semibold break-words">{project?.name}</span>? Please click the &apos;Join
Project&apos; button below to continue.
</p>
<div className="space-y-3" />
</div>

View File

@ -1,11 +1,22 @@
import { useState } from "react";
import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { Disclosure, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// icons
import { MoreVertical, PenSquare, LinkIcon, Star, FileText, Settings, Share2, LogOut, ChevronDown } from "lucide-react";
import {
MoreVertical,
PenSquare,
LinkIcon,
Star,
FileText,
Settings,
Share2,
LogOut,
ChevronDown,
MoreHorizontal,
} from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// helpers
@ -17,6 +28,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CustomMenu, Tooltip, ArchiveIcon, PhotoFilterIcon, DiceIcon, ContrastIcon, LayersIcon } from "@plane/ui";
import { LeaveProjectModal, PublishProjectModal } from "components/project";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
type Props = {
project: IProject;
@ -72,12 +84,15 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
const [publishModalOpen, setPublishModal] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const isAdmin = project.member_role === 20;
const isViewerOrGuest = project.member_role === 10 || project.member_role === 5;
const isCollapsed = themeStore.sidebarCollapsed;
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
@ -110,6 +125,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
setLeaveProjectModal(false);
};
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
<PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} />
@ -120,7 +137,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
>
{provided && (
<Tooltip
@ -131,7 +148,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-2.5 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""} ${
isMenuActive ? "!flex" : ""
}`}
{...provided?.dragHandleProps}
>
<MoreVertical className="h-3.5" />
@ -169,9 +188,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
{!isCollapsed && (
<ChevronDown
className={`h-4 w-4 flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
className={`h-4 w-4 flex-shrink-0 hidden ${open ? "rotate-180" : ""} ${
isMenuActive ? "!block" : ""
} group-hover:!block mb-0.5 text-custom-sidebar-text-400 duration-300`}
/>
)}
</Disclosure.Button>
@ -179,7 +198,16 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
{!isCollapsed && (
<CustomMenu
className="hidden group-hover:block flex-shrink-0"
customButton={
<div
ref={actionSectionRef}
className="w-full cursor-pointer text-custom-sidebar-text-400 px-1 mt-1.5 my-auto duration-300"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
}
className={`hidden group-hover:block flex-shrink-0 ${isMenuActive ? "!block" : ""}`}
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis
placement="bottom-start"

View File

@ -1,28 +1,32 @@
import { FC } from "react";
import Link from "next/link";
import { Button } from "@plane/ui";
// next
import { useRouter } from "next/router";
import Image from "next/image";
import EmptyWebhookLogo from "public/empty-state/issue.svg";
// ui
import { Button } from "@plane/ui";
// assets
import EmptyWebhook from "public/empty-state/web-hook.svg";
interface IWebHookLists {
workspaceSlug: string;
}
export const EmptyWebhooks: FC<IWebHookLists> = (props) => {
const { workspaceSlug } = props;
export const EmptyWebhooks = () => {
const router = useRouter();
return (
<div className="flex items-start justify-center">
<div className="flex p-10 flex-col items-center justify-center rounded-[4px] border border-custom-border-200 bg-custom-color-background-90">
<Image width="178" height="116" src={EmptyWebhookLogo} alt="empty-webhook image" />
<div className="mt-4 text-base font-semibold">No Webhooks</div>
<p className="text-sm text-neutral-600">Create webhooks to receive real-time updates and automate actions</p>
<Link href={`/${workspaceSlug}/settings/webhooks/create`}>
<Button variant="primary" className="mt-2">
Add Webhook
</Button>
</Link>
<div className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={EmptyWebhook} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No Webhooks</h6>
{
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create webhooks to receive real-time updates and automate actions
</p>
}
<Button
className="flex items-center gap-1.5"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Webhook
</Button>
</div>
</div>
);

View File

@ -303,7 +303,8 @@ const ProfilePage: NextPageWithLayout = () => {
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : "border-none"}
className="rounded-md border !border-custom-border-200"
width="w-full"
input
>
@ -369,6 +370,8 @@ const ProfilePage: NextPageWithLayout = () => {
options={timeZoneOptions}
onChange={onChange}
optionsClassName="w-full"
buttonClassName={"border-none"}
className="rounded-md border !border-custom-border-200"
input
/>
)}

View File

@ -235,7 +235,7 @@ const PageDetailsPage: NextPageWithLayout = () => {
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
value={!value || value === "" ? "<p></p>" : value}
customClassName="tracking-tight self-center w-full max-w-full px-0"
customClassName="tracking-tight self-center px-0 h-full w-full"
onChange={(_description_json: Object, description_html: string) => {
onChange(description_html);
setIsSubmitting("submitting");

View File

@ -90,7 +90,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
projectId={projectId.toString()}
/>
)}
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
<div className="space-y-5 p-6 h-full overflow-hidden flex flex-col">
<div className="flex gap-4 justify-between">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div>

View File

@ -31,7 +31,7 @@ const WebhooksPage: NextPage = observer(() => {
return (
<AppLayout header={<WorkspaceSettingHeader title="Webhook Settings" />}>
<WorkspaceSettingLayout>
<div className="w-full overflow-y-auto py-3 pr-4">
<div className="w-full overflow-y-auto py-3 pr-9">
{loader ? (
<div className="flex h-full w-ful items-center justify-center">
<Spinner />
@ -41,10 +41,8 @@ const WebhooksPage: NextPage = observer(() => {
{Object.keys(webhooks).length > 0 ? (
<WebhookLists workspaceSlug={workspaceSlug} />
) : (
<div className="flex justify-center w-full h-full items-center">
<div className="w-auto h-fit">
<EmptyWebhooks workspaceSlug={workspaceSlug} />
</div>
<div className="py-5 mx-auto">
<EmptyWebhooks />
</div>
)}
</>

View File

@ -0,0 +1,49 @@
<svg width="128" height="130" viewBox="0 0 128 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.5893 36.7461C84.3294 36.7461 103.575 55.9912 103.575 79.7313C103.575 103.471 84.3294 122.716 60.5893 122.716C36.8493 122.716 3.99396 99.179 17.6042 79.7313C31.2144 60.2836 36.8493 36.7461 60.5893 36.7461Z" fill="#F2F2F2"/>
<path d="M118.122 38.7891C117.736 38.7891 117.423 39.1025 117.423 39.488V61.8543C117.423 62.2398 117.736 62.5532 118.122 62.5532C118.507 62.5532 118.821 62.2398 118.821 61.8543V39.488C118.821 39.1025 118.507 38.7891 118.122 38.7891Z" fill="#525252"/>
<path d="M0.698946 22.0195C0.313467 22.0195 0 22.333 0 22.7185V28.31C0 28.6955 0.313467 29.009 0.698946 29.009C1.08443 29.009 1.39789 28.6955 1.39789 28.31V22.7185C1.39789 22.333 1.08443 22.0195 0.698946 22.0195Z" fill="#525252"/>
<path d="M0.698946 38.7891C0.313467 38.7891 0 39.1025 0 39.488V50.3217C0 50.7072 0.313467 51.0206 0.698946 51.0206C1.08443 51.0206 1.39789 50.7072 1.39789 50.3217V39.488C1.39789 39.1025 1.08443 38.7891 0.698946 38.7891Z" fill="#525252"/>
<path d="M0.698946 54.168C0.313467 54.168 0 54.4814 0 54.8669V65.7006C0 66.0861 0.313467 66.3995 0.698946 66.3995C1.08443 66.3995 1.39789 66.0861 1.39789 65.7006V54.8669C1.39789 54.4814 1.08443 54.168 0.698946 54.168Z" fill="#525252"/>
<path d="M63.385 0C62.9995 0 62.686 0.313467 62.686 0.698946V11.5326C62.686 11.9181 62.9995 12.2316 63.385 12.2316C63.7705 12.2316 64.0839 11.9181 64.0839 11.5326V0.698946C64.0839 0.313467 63.7705 0 63.385 0Z" fill="#525252"/>
<path d="M41.7948 76.8354H17.604V76.1365H41.7948C43.8962 76.1365 45.6059 74.4267 45.6059 72.325V55.9688H46.3048V72.325C46.3048 74.812 44.2817 76.8354 41.7948 76.8354Z" fill="#396AE6"/>
<path d="M87.9532 112.289H32.9336V112.988H87.9532V112.289Z" fill="#396AE6"/>
<path d="M122.861 72.1605H93.4733C91.8874 72.1605 90.5972 70.8703 90.5972 69.2844V40.2551C90.5972 38.6691 91.8874 37.3789 93.4733 37.3789H122.861C124.447 37.3789 125.737 38.6691 125.737 40.2551V69.2844C125.737 70.8703 124.447 72.1605 122.861 72.1605Z" fill="#E6E6E6"/>
<path d="M98.9462 38.2344C94.8084 38.2344 91.4541 41.5887 91.4541 45.7265V68.667C91.4541 70.1223 92.6339 71.3021 94.0892 71.3021H112.059C119.14 71.3021 124.88 65.5618 124.88 58.4809V40.8695C124.88 39.4142 123.701 38.2344 122.245 38.2344L98.9462 38.2344Z" fill="white"/>
<path d="M116.038 46.6717H100.235C99.9333 46.6717 99.688 46.4263 99.688 46.1248C99.688 45.8233 99.9333 45.5781 100.235 45.5781H116.038C116.34 45.5781 116.585 45.8233 116.585 46.1248C116.585 46.4263 116.34 46.6717 116.038 46.6717Z" fill="#E6E6E6"/>
<path d="M116.038 58.3318H100.235C99.9333 58.3318 99.688 58.0865 99.688 57.785C99.688 57.4835 99.9333 57.2383 100.235 57.2383H116.038C116.34 57.2383 116.585 57.4835 116.585 57.785C116.585 58.0865 116.34 58.3318 116.038 58.3318Z" fill="#E6E6E6"/>
<path d="M122.456 52.5076H93.8173C93.5158 52.5076 93.2705 52.2623 93.2705 51.9608C93.2705 51.6593 93.5158 51.4141 93.8173 51.4141H122.456C122.757 51.4141 123.002 51.6593 123.002 51.9608C123.002 52.2623 122.757 52.5076 122.456 52.5076Z" fill="#E6E6E6"/>
<path d="M122.748 63.0701H112.402C112.101 63.0701 111.855 62.8248 111.855 62.5233C111.855 62.2218 112.101 61.9766 112.402 61.9766H122.748C123.049 61.9766 123.295 62.2218 123.295 62.5233C123.295 62.8248 123.049 63.0701 122.748 63.0701Z" fill="#E6E6E6"/>
<path d="M61.1334 130.002H37.7975C36.5382 130.002 35.5137 128.977 35.5137 127.718V104.667C35.5137 103.407 36.5382 102.383 37.7975 102.383H61.1334C62.3928 102.383 63.4173 103.407 63.4173 104.667V127.718C63.4173 128.977 62.3928 130.002 61.1334 130.002Z" fill="#E6E6E6"/>
<path d="M42.1436 103.062C38.8579 103.062 36.1943 105.726 36.1943 109.012V127.228C36.1943 128.384 37.1312 129.321 38.2868 129.321H52.5561C58.1788 129.321 62.737 124.762 62.737 119.14V105.155C62.737 103.999 61.8002 103.063 60.6445 103.063L42.1436 103.062Z" fill="white"/>
<path d="M55.7156 109.759H43.1666C42.9272 109.759 42.7324 109.564 42.7324 109.325C42.7324 109.085 42.9272 108.891 43.1666 108.891H55.7156C55.955 108.891 56.1497 109.085 56.1497 109.325C56.1497 109.564 55.955 109.759 55.7156 109.759Z" fill="#E6E6E6"/>
<path d="M55.7156 119.017H43.1666C42.9272 119.017 42.7324 118.822 42.7324 118.583C42.7324 118.343 42.9272 118.148 43.1666 118.148H55.7156C55.955 118.148 56.1497 118.343 56.1497 118.583C56.1497 118.822 55.955 119.017 55.7156 119.017Z" fill="#E6E6E6"/>
<path d="M60.8116 114.392H38.0709C37.8315 114.392 37.6367 114.197 37.6367 113.958C37.6367 113.718 37.8315 113.523 38.0709 113.523H60.8116C61.051 113.523 61.2457 113.718 61.2457 113.958C61.2457 114.197 61.051 114.392 60.8116 114.392Z" fill="#E6E6E6"/>
<path d="M61.0434 122.786H52.8283C52.5889 122.786 52.394 122.592 52.394 122.352C52.394 122.113 52.5888 121.918 52.8283 121.918H61.0434C61.2828 121.918 61.4775 122.113 61.4775 122.352C61.4775 122.592 61.2828 122.786 61.0434 122.786Z" fill="#E6E6E6"/>
<path d="M44.8224 55.7926H27.5645C26.6332 55.7926 25.8755 55.0349 25.8755 54.1036V37.0562C25.8755 36.1249 26.6332 35.3672 27.5645 35.3672H44.8224C45.7538 35.3672 46.5114 36.1249 46.5114 37.0562V54.1036C46.5114 55.0349 45.7538 55.7926 44.8224 55.7926Z" fill="#E6E6E6"/>
<path d="M30.7787 35.8711C28.3487 35.8711 26.3789 37.8409 26.3789 40.2708V53.7426C26.3789 54.5972 27.0717 55.29 27.9264 55.29H38.4791C42.6374 55.29 46.0084 51.9191 46.0084 47.7608V37.4186C46.0084 36.5639 45.3155 35.8711 44.4609 35.8711L30.7787 35.8711Z" fill="white"/>
<path d="M40.816 40.8219H31.5355C31.3584 40.8219 31.2144 40.6778 31.2144 40.5007C31.2144 40.3237 31.3584 40.1797 31.5355 40.1797H40.816C40.993 40.1797 41.137 40.3237 41.137 40.5007C41.137 40.6778 40.993 40.8219 40.816 40.8219Z" fill="#E6E6E6"/>
<path d="M40.816 47.6695H31.5355C31.3584 47.6695 31.2144 47.5254 31.2144 47.3484C31.2144 47.1713 31.3584 47.0273 31.5355 47.0273H40.816C40.993 47.0273 41.137 47.1713 41.137 47.3484C41.137 47.5254 40.993 47.6695 40.816 47.6695Z" fill="#E6E6E6"/>
<path d="M44.5847 44.2476H27.7669C27.5899 44.2476 27.4458 44.1036 27.4458 43.9265C27.4458 43.7495 27.5899 43.6055 27.7669 43.6055H44.5847C44.7617 43.6055 44.9057 43.7495 44.9057 43.9265C44.9057 44.1036 44.7617 44.2476 44.5847 44.2476Z" fill="#E6E6E6"/>
<path d="M44.756 50.4547H38.6805C38.5034 50.4547 38.3594 50.3106 38.3594 50.1336C38.3594 49.9565 38.5034 49.8125 38.6805 49.8125H44.756C44.933 49.8125 45.077 49.9565 45.077 50.1336C45.077 50.3106 44.933 50.4547 44.756 50.4547Z" fill="#E6E6E6"/>
<path d="M95.1871 77.2824C97.1172 77.2824 98.6818 75.7178 98.6818 73.7877C98.6818 71.8576 97.1172 70.293 95.1871 70.293C93.257 70.293 91.6924 71.8576 91.6924 73.7877C91.6924 75.7178 93.257 77.2824 95.1871 77.2824Z" fill="#396AE6"/>
<path d="M45.9117 59.4621C47.8418 59.4621 49.4065 57.8975 49.4065 55.9674C49.4065 54.0373 47.8418 52.4727 45.9117 52.4727C43.9816 52.4727 42.417 54.0373 42.417 55.9674C42.417 57.8975 43.9816 59.4621 45.9117 59.4621Z" fill="#396AE6"/>
<path d="M62.6861 107.341C64.6162 107.341 66.1809 105.776 66.1809 103.846C66.1809 101.916 64.6162 100.352 62.6861 100.352C60.756 100.352 59.1914 101.916 59.1914 103.846C59.1914 105.776 60.756 107.341 62.6861 107.341Z" fill="#396AE6"/>
<path d="M70.235 10.4378C72.6405 10.4378 74.5905 8.52881 74.5905 6.17397C74.5905 3.81913 72.6405 1.91016 70.235 1.91016C67.8295 1.91016 65.8794 3.81913 65.8794 6.17397C65.8794 8.52881 67.8295 10.4378 70.235 10.4378Z" fill="#E5E5E5"/>
<path d="M105.152 4.71422H81.7077C80.9034 4.71422 80.249 4.05984 80.249 3.25555C80.249 2.45126 80.9034 1.79688 81.7077 1.79688H105.152C105.956 1.79688 106.611 2.45126 106.611 3.25555C106.611 4.05984 105.956 4.71422 105.152 4.71422Z" fill="#E5E5E5"/>
<path d="M123.448 9.65172H81.7077C80.9034 9.65172 80.249 8.99733 80.249 8.19305C80.249 7.38876 80.9034 6.73438 81.7077 6.73438H123.448C124.252 6.73438 124.907 7.38876 124.907 8.19305C124.907 8.99733 124.252 9.65172 123.448 9.65172Z" fill="#E5E5E5"/>
<path d="M7.19143 28.3588C8.74824 28.3588 10.0103 27.1233 10.0103 25.5993C10.0103 24.0753 8.74824 22.8398 7.19143 22.8398C5.63461 22.8398 4.37256 24.0753 4.37256 25.5993C4.37256 27.1233 5.63461 28.3588 7.19143 28.3588Z" fill="#E5E5E5"/>
<path d="M29.7888 24.6537H14.6159C14.0954 24.6537 13.6719 24.2302 13.6719 23.7097C13.6719 23.1891 14.0954 22.7656 14.6159 22.7656H29.7888C30.3093 22.7656 30.7328 23.1891 30.7328 23.7097C30.7328 24.2302 30.3093 24.6537 29.7888 24.6537Z" fill="#E5E5E5"/>
<path d="M41.6297 27.849H14.6159C14.0954 27.849 13.6719 27.4255 13.6719 26.905C13.6719 26.3844 14.0954 25.9609 14.6159 25.9609H41.6297C42.1502 25.9609 42.5737 26.3844 42.5737 26.905C42.5737 27.4255 42.1502 27.849 41.6297 27.849Z" fill="#E5E5E5"/>
<path d="M91.7632 126.562C93.32 126.562 94.5821 125.326 94.5821 123.802C94.5821 122.278 93.32 121.043 91.7632 121.043C90.2064 121.043 88.9443 122.278 88.9443 123.802C88.9443 125.326 90.2064 126.562 91.7632 126.562Z" fill="#E5E5E5"/>
<path d="M114.361 122.857H99.1882C98.6677 122.857 98.2441 122.433 98.2441 121.913C98.2441 121.392 98.6677 120.969 99.1882 120.969H114.361C114.882 120.969 115.305 121.392 115.305 121.913C115.305 122.433 114.882 122.857 114.361 122.857Z" fill="#E5E5E5"/>
<path d="M126.202 126.052H99.1882C98.6677 126.052 98.2441 125.629 98.2441 125.108C98.2441 124.588 98.6677 124.164 99.1882 124.164H126.202C126.722 124.164 127.146 124.588 127.146 125.108C127.146 125.629 126.722 126.052 126.202 126.052Z" fill="#E5E5E5"/>
<path d="M57.8811 37.3906H57.1821V70.5906H57.8811V37.3906Z" fill="#E6E6E6"/>
<path d="M57.5314 72.6859C58.6895 72.6859 59.6283 71.7471 59.6283 70.589C59.6283 69.431 58.6895 68.4922 57.5314 68.4922C56.3734 68.4922 55.4346 69.431 55.4346 70.589C55.4346 71.7471 56.3734 72.6859 57.5314 72.6859Z" fill="#E6E6E6"/>
<path d="M89.3337 96.7991C90.4917 96.7991 91.4305 95.8604 91.4305 94.7023C91.4305 93.5443 90.4917 92.6055 89.3337 92.6055C88.1756 92.6055 87.2368 93.5443 87.2368 94.7023C87.2368 95.8604 88.1756 96.7991 89.3337 96.7991Z" fill="#E6E6E6"/>
<path d="M32.7189 68.1429C33.877 68.1429 34.8157 67.2041 34.8157 66.0461C34.8157 64.888 33.877 63.9492 32.7189 63.9492C31.5609 63.9492 30.6221 64.888 30.6221 66.0461C30.6221 67.2041 31.5609 68.1429 32.7189 68.1429Z" fill="#E6E6E6"/>
<path d="M31.9438 66.0513L20.4766 66.3984L20.4977 67.0974L31.9649 66.7502L31.9438 66.0513Z" fill="#E6E6E6"/>
<path d="M106.392 102.73H89.0034V94.7031H89.7024V102.031H106.392V102.73Z" fill="#E6E6E6"/>
<path d="M31.8581 91.0413C33.0161 91.0413 33.9549 90.1025 33.9549 88.9445C33.9549 87.7864 33.0161 86.8477 31.8581 86.8477C30.7 86.8477 29.7612 87.7864 29.7612 88.9445C29.7612 90.1025 30.7 91.0413 31.8581 91.0413Z" fill="#E6E6E6"/>
<path d="M48.7091 96.9687H31.3208V88.9414H32.0197V96.2698H48.7091V96.9687Z" fill="#E6E6E6"/>
<path d="M101.914 80.9648H18.3965V81.6638H101.914V80.9648Z" fill="#E6E6E6"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -69,7 +69,7 @@ export class ProjectService extends APIService {
}
async joinProject(workspaceSlug: string, project_ids: string[]): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/join/`, { project_ids })
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/invitations/`, { project_ids })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@ -86,39 +86,67 @@ export class PageStore implements IPageStore {
}
get projectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages?.[this.rootStore.project.projectId] || [];
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages?.[projectId] || [];
}
get recentProjectPages() {
if (!this.rootStore.project.projectId) return;
const data: IRecentPages = { today: [], yesterday: [], this_week: [] };
data["today"] = this.pages[this.rootStore.project.projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
data["yesterday"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
const projectId = this.rootStore.project.projectId;
if (!projectId) return undefined;
if (!this.pages[projectId]) return undefined;
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
data["today"] = this.pages[projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
data["yesterday"] = this.pages[projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
data["this_week"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isThisWeek(new Date(p.created_at))) || [];
this.pages[projectId]?.filter(
(p) =>
isThisWeek(new Date(p.created_at)) && !isToday(new Date(p.created_at)) && !isYesterday(new Date(p.created_at))
) || [];
data["older"] =
this.pages[projectId]?.filter(
(p) => !isThisWeek(new Date(p.created_at)) && !isYesterday(new Date(p.created_at))
) || [];
return data;
}
get favoriteProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.is_favorite);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.is_favorite);
}
get privateProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 1);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.access === 1);
}
get sharedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 0);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.access === 0);
}
get archivedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.archivedPages[this.rootStore.project.projectId];
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.archivedPages[projectId]) return undefined;
return this.archivedPages[projectId];
}
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {

View File

@ -30,6 +30,7 @@ export interface IRecentPages {
today: IPage[];
yesterday: IPage[];
this_week: IPage[];
older: IPage[];
[key: string]: IPage[];
}