Merge branch 'develop' of https://github.com/makeplane/plane into stage-release

This commit is contained in:
vamsi 2023-04-04 19:31:12 +05:30
commit 9f4f1cac42
55 changed files with 2008 additions and 474 deletions

115
README.md
View File

@ -2,35 +2,128 @@
<p align="center"> <p align="center">
<a href="https://plane.so"> <a href="https://plane.so">
<img src="https://res.cloudinary.com/dgxawjvpo/image/upload/v1673379660/Plane/plane-logo_0m83xue7R_f0v9r9.png" alt="Plane Logo" width="350"> <img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70">
</a> </a>
</p> </p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
<p align="center"> <p align="center">
<a href="https://discord.com/invite/A92xrEGCge"> <a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" /> <img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a> </a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" /> <img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p> </p>
<br /> <br />
Plane is an open-source project planning tool that is designed to help individuals and teams streamline their issues, sprints, and product roadmaps. It is easy to use and can be accessed by anyone, making it an ideal choice for a wide range of projects and organizations. <p>
<br /> <br /> <a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
alt="Plane Screens"
width="100%"
/>
</a>
</p>
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/29tPNhaV) or GitHub issues, and we will use your feedback to improve on our upcoming releases. > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/29tPNhaV) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
## Getting Started The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
Visit https://app.plane.so to get started with Plane.
## Documentation ## ⚡️ Quick start with Docker Compose
### Docker Compose Setup
- Clone the Repository
```bash
git clone https://github.com/makeplane/plane
```
- Change Directory
```bash
cd plane
```
- Run setup.sh
```bash
./setup.sh localhost
```
> If running in a cloud env replace localhost with public facing IP address of the VM
- Run Docker compose up
```bash
docker-compose up
```
## 🚀 Features
* **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
* **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
* **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
* **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
* **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
* **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
* **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
* **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
* **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
## 📸 Screenshots
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
alt="Plane Issue Details"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
alt="Plane Cycles and Modules"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
alt="Plane Quick Lists"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
alt="Plane Command K"
width="100%"
/>
</a>
</p>
## 📚Documentation
For full documentation, visit [docs.plane.so](https://docs.plane.so/) For full documentation, visit [docs.plane.so](https://docs.plane.so/)
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md). To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
## Status ## 🔋 Status
- [x] Early Community Previews: We are open-sourcing and sharing the development version of Plane - [x] Early Community Previews: We are open-sourcing and sharing the development version of Plane
- [ ] Alpha: We are testing Plane with a closed set of customers - [ ] Alpha: We are testing Plane with a closed set of customers
@ -38,7 +131,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
- [ ] Public Beta: Stable enough for most non-enterprise use-cases - [ ] Public Beta: Stable enough for most non-enterprise use-cases
- [ ] Public: Production-ready - [ ] Public: Production-ready
## Community ## ❤️ Community
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
@ -46,6 +139,6 @@ To chat with other community members you can join the [Plane Discord](https://di
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels. Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
## Security ## ⛓️ Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities. If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.

View File

@ -19,3 +19,6 @@ GITHUB_CLIENT_SECRET=""
# Flags # Flags
DISABLE_COLLECTSTATIC=1 DISABLE_COLLECTSTATIC=1
DOCKERIZED=1 DOCKERIZED=1
# GPT Envs
OPENAI_API_KEY=0
GPT_ENGINE=0

View File

@ -3,7 +3,8 @@ import uuid
import random import random
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment, User, Project from plane.db.models import Issue, IssueComment, User, Project, ProjectMember
# Update description and description html values for old descriptions # Update description and description html values for old descriptions
@ -134,3 +135,42 @@ def update_project_cover_images():
except Exception as e: except Exception as e:
print(e) print(e)
print("Failed") print("Failed")
def update_user_view_property():
try:
project_members = ProjectMember.objects.all()
updated_project_members = []
for project_member in project_members:
project_member.default_props = {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": True,
"showEmptyGroups": True,
}
updated_project_members.append(project_member)
ProjectMember.objects.bulk_update(
updated_project_members, ["default_props"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_label_color():
try:
labels = Label.objects.filter(color="")
updated_labels = []
for label in labels:
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
print("Success")
except Exception as e:
print(e)
print("Failed")

View File

@ -139,6 +139,16 @@ class ModuleLinkSerializer(BaseSerializer):
"module", "module",
] ]
# Validation if url already exists
def create(self, validated_data):
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
class ModuleSerializer(BaseSerializer): class ModuleSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project") project_detail = ProjectSerializer(read_only=True, source="project")

View File

@ -17,6 +17,7 @@ from plane.db.models import (
WorkspaceMemberInvite, WorkspaceMemberInvite,
Issue, Issue,
IssueActivity, IssueActivity,
WorkspaceMember,
) )
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -72,6 +73,20 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id) user = User.objects.get(pk=request.user.id)
user.is_onboarded = request.data.get("is_onboarded", False) user.is_onboarded = request.data.get("is_onboarded", False)
user.save() user.save()
if user.last_workspace_id is not None:
user_role = WorkspaceMember.objects.filter(
workspace_id=user.last_workspace_id, member=request.user.id
).first()
return Response(
{
"message": "Updated successfully",
"role": user_role.company_role
if user_role is not None
else None,
},
status=status.HTTP_200_OK,
)
return Response( return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK {"message": "Updated successfully"}, status=status.HTTP_200_OK
) )

View File

@ -21,10 +21,13 @@ ROLE_CHOICES = (
def get_default_props(): def get_default_props():
return { return {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list", "issueView": "list",
"groupByProperty": None,
"orderBy": None,
"filterIssue": None, "filterIssue": None,
"groupByProperty": True,
"showEmptyGroups": True,
} }

View File

@ -121,7 +121,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
) || "Email ID is not valid", ) || "Email ID is not valid",
}} }}
error={errors.email} error={errors.email}
placeholder="Enter you email Id" placeholder="Enter your Email ID"
/> />
</div> </div>

View File

@ -98,8 +98,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
<Command.Item <Command.Item
key={option.value} key={option.value}
onSelect={() => handleIssueAssignees(option.value)} onSelect={() => handleIssueAssignees(option.value)}
className="focus:bg-slate-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
{option.content} {option.content}
</Command.Item> </Command.Item>

View File

@ -60,8 +60,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }
<Command.Item <Command.Item
key={priority} key={priority}
onSelect={() => handleIssueState(priority)} onSelect={() => handleIssueState(priority)}
className="focus:bg-slate-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{getPriorityIcon(priority)} {getPriorityIcon(priority)}

View File

@ -75,8 +75,7 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
<Command.Item <Command.Item
key={state.id} key={state.id}
onSelect={() => handleIssueState(state.id)} onSelect={() => handleIssueState(state.id)}
className="focus:bg-slate-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{getStateGroupIcon(state.group, "16", "16", state.color)} {getStateGroupIcon(state.group, "16", "16", state.color)}

View File

@ -51,6 +51,8 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules"; import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project"; import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { CreateUpdatePageModal } from "components/pages";
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// helpers // helpers
import { import {
@ -76,6 +78,7 @@ export const CommandPalette: React.FC = () => {
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false); const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false); const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = React.useState<string>(""); const [searchTerm, setSearchTerm] = React.useState<string>("");
const [results, setResults] = useState<IWorkspaceSearchResults>({ const [results, setResults] = useState<IWorkspaceSearchResults>({
@ -193,6 +196,12 @@ export const CommandPalette: React.FC = () => {
} else if (e.key.toLowerCase() === "p") { } else if (e.key.toLowerCase() === "p") {
e.preventDefault(); e.preventDefault();
setIsProjectModalOpen(true); setIsProjectModalOpen(true);
} else if (e.key.toLowerCase() === "v") {
e.preventDefault();
setIsCreateViewModalOpen(true);
} else if (e.key.toLowerCase() === "d") {
e.preventDefault();
setIsCreateUpdatePageModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault(); e.preventDefault();
toggleCollapsed(); toggleCollapsed();
@ -323,6 +332,10 @@ export const CommandPalette: React.FC = () => {
handleClose={() => setIsCreateViewModalOpen(false)} handleClose={() => setIsCreateViewModalOpen(false)}
isOpen={isCreateViewModalOpen} isOpen={isCreateViewModalOpen}
/> />
<CreateUpdatePageModal
isOpen={isCreateUpdatePageModalOpen}
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
/>
</> </>
)} )}
{issueId && issueDetails && ( {issueId && issueDetails && (
@ -479,8 +492,7 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
}} }}
value={value} value={value}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 overflow-hidden text-gray-700"> <div className="flex items-center gap-2 overflow-hidden text-gray-700">
<Icon className="h-4 w-4 text-gray-500" color="#6b7280" /> <Icon className="h-4 w-4 text-gray-500" color="#6b7280" />
@ -506,8 +518,7 @@ export const CommandPalette: React.FC = () => {
setSearchTerm(""); setSearchTerm("");
setPages([...pages, "change-issue-state"]); setPages([...pages, "change-issue-state"]);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<Squares2X2Icon className="h-4 w-4 text-gray-500" /> <Squares2X2Icon className="h-4 w-4 text-gray-500" />
@ -520,8 +531,7 @@ export const CommandPalette: React.FC = () => {
setSearchTerm(""); setSearchTerm("");
setPages([...pages, "change-issue-priority"]); setPages([...pages, "change-issue-priority"]);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<ChartBarIcon className="h-4 w-4 text-gray-500" /> <ChartBarIcon className="h-4 w-4 text-gray-500" />
@ -534,8 +544,7 @@ export const CommandPalette: React.FC = () => {
setSearchTerm(""); setSearchTerm("");
setPages([...pages, "change-issue-assignee"]); setPages([...pages, "change-issue-assignee"]);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<UsersIcon className="h-4 w-4 text-gray-500" /> <UsersIcon className="h-4 w-4 text-gray-500" />
@ -547,8 +556,7 @@ export const CommandPalette: React.FC = () => {
handleIssueAssignees(user.id); handleIssueAssignees(user.id);
setSearchTerm(""); setSearchTerm("");
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
{issueDetails?.assignees.includes(user.id) ? ( {issueDetails?.assignees.includes(user.id) ? (
@ -565,11 +573,7 @@ export const CommandPalette: React.FC = () => {
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item onSelect={deleteIssue} className="focus:outline-none">
onSelect={deleteIssue}
className="focus:bg-gray-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<TrashIcon className="h-4 w-4 text-gray-500" /> <TrashIcon className="h-4 w-4 text-gray-500" />
Delete issue Delete issue
@ -580,8 +584,7 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
copyIssueUrlToClipboard(); copyIssueUrlToClipboard();
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<LinkIcon className="h-4 w-4 text-gray-500" /> <LinkIcon className="h-4 w-4 text-gray-500" />
@ -591,11 +594,7 @@ export const CommandPalette: React.FC = () => {
</> </>
)} )}
<Command.Group heading="Issue"> <Command.Group heading="Issue">
<Command.Item <Command.Item onSelect={createNewIssue} className="focus:bg-gray-200">
onSelect={createNewIssue}
className="focus:bg-gray-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<LayerDiagonalIcon className="h-4 w-4" color="#6b7280" /> <LayerDiagonalIcon className="h-4 w-4" color="#6b7280" />
Create new issue Create new issue
@ -608,8 +607,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="Project"> <Command.Group heading="Project">
<Command.Item <Command.Item
onSelect={createNewProject} onSelect={createNewProject}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" /> <AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" />
@ -625,8 +623,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="Cycle"> <Command.Group heading="Cycle">
<Command.Item <Command.Item
onSelect={createNewCycle} onSelect={createNewCycle}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<ContrastIcon className="h-4 w-4" color="#6b7280" /> <ContrastIcon className="h-4 w-4" color="#6b7280" />
@ -639,8 +636,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="Module"> <Command.Group heading="Module">
<Command.Item <Command.Item
onSelect={createNewModule} onSelect={createNewModule}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<PeopleGroupIcon className="h-4 w-4" color="#6b7280" /> <PeopleGroupIcon className="h-4 w-4" color="#6b7280" />
@ -651,11 +647,7 @@ export const CommandPalette: React.FC = () => {
</Command.Group> </Command.Group>
<Command.Group heading="View"> <Command.Group heading="View">
<Command.Item <Command.Item onSelect={createNewView} className="focus:outline-none">
onSelect={createNewView}
className="focus:bg-gray-200 focus:outline-none"
tabIndex={0}
>
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<ViewListIcon className="h-4 w-4" color="#6b7280" /> <ViewListIcon className="h-4 w-4" color="#6b7280" />
Create new view Create new view
@ -673,8 +665,7 @@ export const CommandPalette: React.FC = () => {
setSearchTerm(""); setSearchTerm("");
setPages([...pages, "settings"]); setPages([...pages, "settings"]);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4" color="#6b7280" /> <SettingIcon className="h-4 w-4" color="#6b7280" />
@ -685,8 +676,7 @@ export const CommandPalette: React.FC = () => {
<Command.Group heading="Account"> <Command.Group heading="Account">
<Command.Item <Command.Item
onSelect={createNewWorkspace} onSelect={createNewWorkspace}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<FolderPlusIcon className="h-4 w-4 text-gray-500" /> <FolderPlusIcon className="h-4 w-4 text-gray-500" />
@ -703,8 +693,7 @@ export const CommandPalette: React.FC = () => {
}); });
document.dispatchEvent(e); document.dispatchEvent(e);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<RocketLaunchIcon className="h-4 w-4 text-gray-500" /> <RocketLaunchIcon className="h-4 w-4 text-gray-500" />
@ -716,8 +705,7 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
window.open("https://docs.plane.so/", "_blank"); window.open("https://docs.plane.so/", "_blank");
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<DocumentIcon className="h-4 w-4 text-gray-500" /> <DocumentIcon className="h-4 w-4 text-gray-500" />
@ -729,8 +717,7 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
window.open("https://discord.com/invite/A92xrEGCge", "_blank"); window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<DiscordIcon className="h-4 w-4" color="#6b7280" /> <DiscordIcon className="h-4 w-4" color="#6b7280" />
@ -745,8 +732,7 @@ export const CommandPalette: React.FC = () => {
"_blank" "_blank"
); );
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<GithubIcon className="h-4 w-4" color="#6b7280" /> <GithubIcon className="h-4 w-4" color="#6b7280" />
@ -758,8 +744,7 @@ export const CommandPalette: React.FC = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
(window as any).$crisp.push(["do", "chat:open"]); (window as any).$crisp.push(["do", "chat:open"]);
}} }}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-gray-500" /> <ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-gray-500" />
@ -774,8 +759,7 @@ export const CommandPalette: React.FC = () => {
<> <>
<Command.Item <Command.Item
onSelect={() => goToSettings()} onSelect={() => goToSettings()}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" /> <SettingIcon className="h-4 w-4 text-gray-500" />
@ -784,8 +768,7 @@ export const CommandPalette: React.FC = () => {
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("members")} onSelect={() => goToSettings("members")}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" /> <SettingIcon className="h-4 w-4 text-gray-500" />
@ -794,8 +777,7 @@ export const CommandPalette: React.FC = () => {
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("billing")} onSelect={() => goToSettings("billing")}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" /> <SettingIcon className="h-4 w-4 text-gray-500" />
@ -804,8 +786,7 @@ export const CommandPalette: React.FC = () => {
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("integrations")} onSelect={() => goToSettings("integrations")}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" /> <SettingIcon className="h-4 w-4 text-gray-500" />
@ -814,8 +795,7 @@ export const CommandPalette: React.FC = () => {
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("import-export")} onSelect={() => goToSettings("import-export")}
className="focus:bg-gray-200 focus:outline-none" className="focus:outline-none"
tabIndex={0}
> >
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<SettingIcon className="h-4 w-4 text-gray-500" /> <SettingIcon className="h-4 w-4 text-gray-500" />

View File

@ -33,6 +33,8 @@ const shortcuts = [
{ keys: "C", description: "To create issue" }, { keys: "C", description: "To create issue" },
{ keys: "Q", description: "To create cycle" }, { keys: "Q", description: "To create cycle" },
{ keys: "M", description: "To create module" }, { keys: "M", description: "To create module" },
{ keys: "V", description: "To create view" },
{ keys: "D", description: "To create page" },
{ keys: "Delete", description: "To bulk delete issues" }, { keys: "Delete", description: "To bulk delete issues" },
{ keys: "H", description: "To open shortcuts guide" }, { keys: "H", description: "To open shortcuts guide" },
{ {

View File

@ -33,6 +33,8 @@ import {
PencilIcon, PencilIcon,
TrashIcon, TrashIcon,
XMarkIcon, XMarkIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
@ -110,8 +112,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false false
); );
else if (moduleId)
if (moduleId)
mutate< mutate<
| { | {
[key: string]: IIssue[]; [key: string]: IIssue[];
@ -123,7 +124,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData), handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
false false
); );
else
mutate< mutate<
| { | {
[key: string]: IIssue[]; [key: string]: IIssue[];
@ -212,6 +213,15 @@ export const SingleBoardIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link Copy issue link
</ContextMenu.Item> </ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu> </ContextMenu>
<div <div
className={`mb-3 rounded bg-white shadow ${ className={`mb-3 rounded bg-white shadow ${

View File

@ -10,6 +10,7 @@ import {
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
RectangleGroupIcon, RectangleGroupIcon,
Squares2X2Icon, Squares2X2Icon,
TrashIcon,
UserIcon, UserIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
@ -77,6 +78,10 @@ const activityDetails: {
message: "set the parent to", message: "set the parent to",
icon: <UserIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />, icon: <UserIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
}, },
issue: {
message: "deleted the issue.",
icon: <TrashIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
},
}; };
export const Feeds: React.FC<any> = ({ activities }) => ( export const Feeds: React.FC<any> = ({ activities }) => (

View File

@ -125,12 +125,12 @@ export const GptAssistantModal: React.FC<Props> = ({
isOpen ? "block" : "hidden" isOpen ? "block" : "hidden"
}`} }`}
> >
{((content && content !== "") || htmlContent) && ( {((content && content !== "") || htmlContent !== "<p></p>") && (
<div className="text-sm page-block-section"> <div className="remirror-section text-sm">
Content: Content:
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>} value={htmlContent ?? <p>{content}</p>}
customClassName="-mx-3 -my-3" customClassName="-m-3"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
editable={false} editable={false}

View File

@ -107,7 +107,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
onChange={(e) => setFormData({ ...formData, search: e.target.value })} onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images" placeholder="Search for images"
/> />
<PrimaryButton className="bg-indigo-600" size="sm"> <PrimaryButton type="submit" className="bg-indigo-600" size="sm">
Search Search
</PrimaryButton> </PrimaryButton>
</form> </form>

View File

@ -18,7 +18,7 @@ import { AllLists, AllBoards, FilterList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { TransferIssuesModal } from "components/cycles"; import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui // ui
import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui"; import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view"; import { CalendarView } from "./calendar-view";
@ -459,23 +459,7 @@ export const IssuesView: React.FC<Props> = ({
{groupedByIssues ? ( {groupedByIssues ? (
isNotEmpty ? ( isNotEmpty ? (
<> <>
{isCompleted && ( {isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<ExclamationIcon height={14} width={14} />
<span>Completed cycles are not editable.</span>
</div>
<div>
<PrimaryButton
onClick={() => setTransferIssuesModal(true)}
className="flex items-center gap-3 rounded-lg"
>
<TransferIcon className="h-4 w-4" />
<span>Transfer Issues</span>
</PrimaryButton>
</div>
</div>
)}
{issueView === "list" ? ( {issueView === "list" ? (
<AllLists <AllLists
type={type} type={type}

View File

@ -27,6 +27,7 @@ import {
PencilIcon, PencilIcon,
TrashIcon, TrashIcon,
XMarkIcon, XMarkIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
@ -178,6 +179,15 @@ export const SingleListIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link Copy issue link
</ContextMenu.Item> </ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
</ContextMenu> </ContextMenu>
<div className="border-b border-gray-300 last:border-b-0"> <div className="border-b border-gray-300 last:border-b-0">
<div <div

View File

@ -8,3 +8,4 @@ export * from "./sidebar";
export * from "./single-cycle-card"; export * from "./single-cycle-card";
export * from "./empty-cycle"; export * from "./empty-cycle";
export * from "./transfer-issues-modal"; export * from "./transfer-issues-modal";
export * from "./transfer-issues";

View File

@ -0,0 +1,56 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// component
import { PrimaryButton, Tooltip } from "components/ui";
// icon
import { ExclamationIcon, TransferIcon } from "components/icons";
// services
import cycleServices from "services/cycles.service";
// fetch-key
import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = {
handleClick: () => void;
};
export const TransferIssues: React.FC<Props> = ({ handleClick }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { data: cycleDetails } = useSWR(
cycleId ? CYCLE_DETAILS(cycleId as string) : null,
workspaceSlug && projectId && cycleId
? () =>
cycleServices.getCycleDetails(
workspaceSlug as string,
projectId as string,
cycleId as string
)
: null
);
const transferableIssuesCount = cycleDetails
? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues
: 0;
return (
<div className="flex items-center justify-between -mt-4 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<ExclamationIcon height={14} width={14} />
<span>Completed cycles are not editable.</span>
</div>
{transferableIssuesCount > 0 && (
<div>
<PrimaryButton onClick={handleClick} className="flex items-center gap-3 rounded-lg">
<TransferIcon className="h-4 w-4" />
<span className="text-white">Transfer Issues</span>
</PrimaryButton>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,607 @@
{
"material_rounded": [
{
"name": "search"
},
{
"name": "home"
},
{
"name": "menu"
},
{
"name": "close"
},
{
"name": "settings"
},
{
"name": "done"
},
{
"name": "check_circle"
},
{
"name": "favorite"
},
{
"name": "add"
},
{
"name": "delete"
},
{
"name": "arrow_back"
},
{
"name": "star"
},
{
"name": "logout"
},
{
"name": "add_circle"
},
{
"name": "cancel"
},
{
"name": "arrow_drop_down"
},
{
"name": "more_vert"
},
{
"name": "check"
},
{
"name": "check_box"
},
{
"name": "toggle_on"
},
{
"name": "open_in_new"
},
{
"name": "refresh"
},
{
"name": "login"
},
{
"name": "radio_button_unchecked"
},
{
"name": "more_horiz"
},
{
"name": "apps"
},
{
"name": "radio_button_checked"
},
{
"name": "download"
},
{
"name": "remove"
},
{
"name": "toggle_off"
},
{
"name": "bolt"
},
{
"name": "arrow_upward"
},
{
"name": "filter_list"
},
{
"name": "delete_forever"
},
{
"name": "autorenew"
},
{
"name": "key"
},
{
"name": "sort"
},
{
"name": "sync"
},
{
"name": "add_box"
},
{
"name": "block"
},
{
"name": "restart_alt"
},
{
"name": "menu_open"
},
{
"name": "shopping_cart_checkout"
},
{
"name": "expand_circle_down"
},
{
"name": "backspace"
},
{
"name": "undo"
},
{
"name": "done_all"
},
{
"name": "do_not_disturb_on"
},
{
"name": "open_in_full"
},
{
"name": "double_arrow"
},
{
"name": "sync_alt"
},
{
"name": "zoom_in"
},
{
"name": "done_outline"
},
{
"name": "drag_indicator"
},
{
"name": "fullscreen"
},
{
"name": "star_half"
},
{
"name": "settings_accessibility"
},
{
"name": "reply"
},
{
"name": "exit_to_app"
},
{
"name": "unfold_more"
},
{
"name": "library_add"
},
{
"name": "cached"
},
{
"name": "select_check_box"
},
{
"name": "terminal"
},
{
"name": "change_circle"
},
{
"name": "disabled_by_default"
},
{
"name": "swap_horiz"
},
{
"name": "swap_vert"
},
{
"name": "app_registration"
},
{
"name": "download_for_offline"
},
{
"name": "close_fullscreen"
},
{
"name": "file_open"
},
{
"name": "minimize"
},
{
"name": "open_with"
},
{
"name": "dataset"
},
{
"name": "add_task"
},
{
"name": "start"
},
{
"name": "keyboard_voice"
},
{
"name": "create_new_folder"
},
{
"name": "forward"
},
{
"name": "download"
},
{
"name": "settings_applications"
},
{
"name": "compare_arrows"
},
{
"name": "redo"
},
{
"name": "zoom_out"
},
{
"name": "publish"
},
{
"name": "html"
},
{
"name": "token"
},
{
"name": "switch_access_shortcut"
},
{
"name": "fullscreen_exit"
},
{
"name": "sort_by_alpha"
},
{
"name": "delete_sweep"
},
{
"name": "indeterminate_check_box"
},
{
"name": "view_timeline"
},
{
"name": "settings_backup_restore"
},
{
"name": "arrow_drop_down_circle"
},
{
"name": "assistant_navigation"
},
{
"name": "sync_problem"
},
{
"name": "clear_all"
},
{
"name": "density_medium"
},
{
"name": "heart_plus"
},
{
"name": "filter_alt_off"
},
{
"name": "expand"
},
{
"name": "subdirectory_arrow_right"
},
{
"name": "download_done"
},
{
"name": "arrow_outward"
},
{
"name": "123"
},
{
"name": "swipe_left"
},
{
"name": "auto_mode"
},
{
"name": "saved_search"
},
{
"name": "place_item"
},
{
"name": "system_update_alt"
},
{
"name": "javascript"
},
{
"name": "search_off"
},
{
"name": "output"
},
{
"name": "select_all"
},
{
"name": "fit_screen"
},
{
"name": "swipe_up"
},
{
"name": "dynamic_form"
},
{
"name": "hide_source"
},
{
"name": "swipe_right"
},
{
"name": "switch_access_shortcut_add"
},
{
"name": "browse_gallery"
},
{
"name": "css"
},
{
"name": "density_small"
},
{
"name": "assistant_direction"
},
{
"name": "check_small"
},
{
"name": "youtube_searched_for"
},
{
"name": "move_up"
},
{
"name": "swap_horizontal_circle"
},
{
"name": "data_thresholding"
},
{
"name": "install_mobile"
},
{
"name": "move_down"
},
{
"name": "dataset_linked"
},
{
"name": "keyboard_command_key"
},
{
"name": "view_kanban"
},
{
"name": "swipe_down"
},
{
"name": "key_off"
},
{
"name": "transcribe"
},
{
"name": "send_time_extension"
},
{
"name": "swipe_down_alt"
},
{
"name": "swipe_left_alt"
},
{
"name": "swipe_right_alt"
},
{
"name": "swipe_up_alt"
},
{
"name": "keyboard_option_key"
},
{
"name": "cycle"
},
{
"name": "rebase"
},
{
"name": "rebase_edit"
},
{
"name": "empty_dashboard"
},
{
"name": "magic_exchange"
},
{
"name": "acute"
},
{
"name": "point_scan"
},
{
"name": "step_into"
},
{
"name": "cheer"
},
{
"name": "emoticon"
},
{
"name": "explosion"
},
{
"name": "water_bottle"
},
{
"name": "weather_hail"
},
{
"name": "syringe"
},
{
"name": "pill"
},
{
"name": "genetics"
},
{
"name": "allergy"
},
{
"name": "medical_mask"
},
{
"name": "body_fat"
},
{
"name": "barefoot"
},
{
"name": "infrared"
},
{
"name": "wrist"
},
{
"name": "metabolism"
},
{
"name": "conditions"
},
{
"name": "taunt"
},
{
"name": "altitude"
},
{
"name": "tibia"
},
{
"name": "footprint"
},
{
"name": "eyeglasses"
},
{
"name": "man_3"
},
{
"name": "woman_2"
},
{
"name": "rheumatology"
},
{
"name": "tornado"
},
{
"name": "landslide"
},
{
"name": "foggy"
},
{
"name": "severe_cold"
},
{
"name": "tsunami"
},
{
"name": "vape_free"
},
{
"name": "sign_language"
},
{
"name": "emoji_symbols"
},
{
"name": "clear_night"
},
{
"name": "emoji_food_beverage"
},
{
"name": "hive"
},
{
"name": "thunderstorm"
},
{
"name": "communication"
},
{
"name": "rocket"
},
{
"name": "pets"
},
{
"name": "public"
},
{
"name": "quiz"
},
{
"name": "mood"
},
{
"name": "gavel"
},
{
"name": "eco"
},
{
"name": "diamond"
},
{
"name": "forest"
},
{
"name": "rainy"
},
{
"name": "skull"
}
]
}

View File

@ -1,10 +1,13 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
// headless ui // headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
// react colors
import { TwitterPicker } from "react-color";
// types // types
import { Props } from "./types"; import { Props } from "./types";
// emojis // emojis
import emojis from "./emojis.json"; import emojis from "./emojis.json";
import icons from "./icons.json";
// helpers // helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers"; import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji } from "helpers/common.helper"; import { getRandomEmoji } from "helpers/common.helper";
@ -22,10 +25,18 @@ const tabOptions = [
}, },
]; ];
const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => { const EmojiIconPicker: React.FC<Props> = ({
label,
value,
onChange,
onIconColorChange,
onIconsClick,
}) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [openColorPicker, setOpenColorPicker] = useState(false);
const [activeColor, setActiveColor] = useState<string>("#020617");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
@ -58,20 +69,25 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-lg bg-white shadow-lg"> <Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] bg-white shadow-lg">
<div className="h-72 w-80 overflow-auto rounded border bg-white p-2 shadow-2xl"> <div className="h-[230px] w-[250px] overflow-auto border rounded-[4px] bg-white p-2 shadow-xl">
<Tab.Group as="div" className="flex h-full w-full flex-col"> <Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 border-b p-1"> <Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => ( {tabOptions.map((tab) => (
<Tab <Tab key={tab.key} as={React.Fragment}>
key={tab.key} {({ selected }) => (
className={({ selected }) => <button
`-my-1 w-1/2 border-b py-2 text-center text-sm font-medium outline-none transition-colors ${ type="button"
onClick={() => {
setOpenColorPicker(false);
}}
className={`-my-1 w-1/2 border-b pb-2 text-center text-sm font-medium outline-none transition-colors ${
selected ? "border-theme" : "border-transparent" selected ? "border-theme" : "border-transparent"
}` }`}
}
> >
{tab.title} {tab.title}
</button>
)}
</Tab> </Tab>
))} ))}
</Tab.List> </Tab.List>
@ -79,12 +95,12 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
<Tab.Panel> <Tab.Panel>
{recentEmojis.length > 0 && ( {recentEmojis.length > 0 && (
<div className="py-2"> <div className="py-2">
<h3 className="mb-2">Recent Emojis</h3> {/* <h3 className="mb-2">Recent Emojis</h3> */}
<div className="grid grid-cols-9 gap-2"> <div className="grid grid-cols-10">
{recentEmojis.map((emoji) => ( {recentEmojis.map((emoji) => (
<button <button
type="button" type="button"
className="select-none text-lg hover:bg-hover-gray" className="h-4 w-4 select-none text-sm hover:bg-hover-gray flex items-center justify-between"
key={emoji} key={emoji}
onClick={() => { onClick={() => {
onChange(emoji); onChange(emoji);
@ -97,13 +113,14 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
</div> </div>
</div> </div>
)} )}
<hr className="w-full h-[1px] mb-2" />
<div> <div>
<h3 className="mb-2">All Emojis</h3> {/* <h3 className="mb-1">All Emojis</h3> */}
<div className="grid grid-cols-9 gap-2"> <div className="grid grid-cols-10 gap-y-1">
{emojis.map((emoji) => ( {emojis.map((emoji) => (
<button <button
type="button" type="button"
className="select-none text-lg hover:bg-hover-gray" className="h-4 w-4 mb-1 select-none text-sm hover:bg-hover-gray flex items-center"
key={emoji} key={emoji}
onClick={() => { onClick={() => {
onChange(emoji); onChange(emoji);
@ -117,9 +134,76 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
</div> </div>
</div> </div>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center"> <div className="py-2">
<p>Coming Soon...</p> <div className="relative">
<div className="pb-2 flex items-center justify-between">
{[
"#D687FF",
"#F7AE59",
"#FF6B00",
"#8CC1FF",
"#FCBE1D",
"#18904F",
"#ADF672",
"#05C3FF",
"#000000",
].map((curCol) => (
<span
className="w-4 h-4 rounded-full cursor-pointer"
style={{ backgroundColor: curCol }}
onClick={() => setActiveColor(curCol)}
/>
))}
<button
type="button"
onClick={() => setOpenColorPicker((prev) => !prev)}
className="flex items-center gap-1"
>
<span
className="w-4 h-4 rounded-full conical-gradient"
style={{ backgroundColor: activeColor }}
/>
</button>
</div>
<div>
<TwitterPicker
className={`m-2 !absolute top-4 left-4 z-10 ${
openColorPicker ? "block" : "hidden"
}`}
color={activeColor}
onChange={(color) => {
setActiveColor(color.hex);
if (onIconColorChange) onIconColorChange(color.hex);
}}
triangle="hide"
width="205px"
/>
</div>
</div>
<hr className="w-full h-[1px] mb-1" />
<Tab.Panel className="flex h-full w-full flex-col justify-center">
<div className="grid grid-cols-10 mt-1 ml-1 gap-1">
{icons.material_rounded.map((icon) => (
<button
type="button"
className="h-4 w-4 mb-1 select-none text-lg hover:bg-hover-gray flex items-center"
key={icon.name}
onClick={() => {
if (onIconsClick) onIconsClick(icon.name);
setIsOpen(false);
}}
>
<span
style={{ color: activeColor }}
className="material-symbols-rounded text-base"
>
{icon.name}
</span>
</button>
))}
</div>
</Tab.Panel> </Tab.Panel>
</div>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -2,4 +2,6 @@ export type Props = {
label: string | React.ReactNode; label: string | React.ReactNode;
value: any; value: any;
onChange: (data: any) => void; onChange: (data: any) => void;
onIconsClick?: (data: any) => void;
onIconColorChange?: (data: any) => void;
}; };

View File

@ -96,7 +96,7 @@ export const AddComment: React.FC = () => {
setValue("comment_json", jsonValue); setValue("comment_json", jsonValue);
setValue("comment_html", htmlValue); setValue("comment_html", htmlValue);
}} }}
// placeholder="Enter Your comment..." placeholder="Enter your comment..."
/> />
)} )}
/> />
@ -104,7 +104,7 @@ export const AddComment: React.FC = () => {
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="rounded-md bg-gray-300 p-2 px-4 text-sm text-black hover:bg-gray-300" className="rounded-md bg-gray-300 p-2 px-4 text-sm text-black hover:bg-gray-300 mt-4"
> >
{isSubmitting ? "Adding..." : "Comment"} {isSubmitting ? "Adding..." : "Comment"}
</button> </button>

View File

@ -6,6 +6,10 @@ import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { import {
@ -83,10 +87,13 @@ export const IssueForm: FC<IssueFormProps> = ({
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -102,6 +109,8 @@ export const IssueForm: FC<IssueFormProps> = ({
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const issueName = watch("name");
const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
const similarIssue = issues?.find((i: IIssue) => cosineSimilarity(i.name, value) > 0.7); const similarIssue = issues?.find((i: IIssue) => cosineSimilarity(i.name, value) > 0.7);
@ -126,6 +135,44 @@ export const IssueForm: FC<IssueFormProps> = ({
setValue("description_html", `${watch("description_html")}<p>${response}</p>`); setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
}; };
const handelAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: issueName,
task: "Generate a proper description for this issue in context of a project management software.",
})
.then((res) => {
if (res.response === "")
setToastAlert({
type: "error",
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message:
"You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
useEffect(() => { useEffect(() => {
setFocus("name"); setFocus("name");
@ -245,10 +292,28 @@ export const IssueForm: FC<IssueFormProps> = ({
)} )}
</div> </div>
<div className="relative"> <div className="relative">
<div className="flex justify-end -mb-2 mr-2"> <div className="flex justify-end -mb-2">
{issueName && issueName !== "" && (
<button <button
type="button" type="button"
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100" className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100 ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handelAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
)}
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
onClick={() => setGptAssistantModal((prevData) => !prevData)} onClick={() => setGptAssistantModal((prevData) => !prevData)}
> >
<SparklesIcon className="h-4 w-4" /> <SparklesIcon className="h-4 w-4" />
@ -267,7 +332,7 @@ export const IssueForm: FC<IssueFormProps> = ({
} }
onJSONChange={(jsonValue) => setValue("description", jsonValue)} onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Description" placeholder="Describe the issue..."
/> />
)} )}
/> />

View File

@ -153,11 +153,20 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
await issuesService await issuesService
.createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload) .createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload)
.then((res) => { .then(() => mutate(ISSUE_DETAILS(issueDetail.id)))
mutate(ISSUE_DETAILS(issueDetail.id));
})
.catch((err) => { .catch((err) => {
console.log(err); if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "This URL already exists for this issue.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
}); });
}; };

View File

@ -107,14 +107,19 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({
await modulesService await modulesService
.createModuleLink(workspaceSlug as string, projectId as string, moduleId as string, payload) .createModuleLink(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then((res) => { .then(() => mutate(MODULE_DETAILS(moduleId as string)))
mutate(MODULE_DETAILS(moduleId as string));
})
.catch((err) => { .catch((err) => {
if (err.status === 400)
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Couldn't create the link. Please try again.", message: "This URL already exists for this module.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
}); });
}); });
}; };

View File

@ -22,9 +22,10 @@ const defaultValues: Partial<IUser> = {
type Props = { type Props = {
user?: IUser; user?: IUser;
setStep: React.Dispatch<React.SetStateAction<number>>; setStep: React.Dispatch<React.SetStateAction<number>>;
setUserRole: React.Dispatch<React.SetStateAction<string | null>>;
}; };
export const UserDetails: React.FC<Props> = ({ user, setStep }) => { export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) => {
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
@ -53,13 +54,15 @@ export const UserDetails: React.FC<Props> = ({ user, setStep }) => {
}; };
useEffect(() => { useEffect(() => {
if (user) if (user) {
reset({ reset({
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
role: user.role, role: user.role,
}); });
}, [user, reset]); setUserRole(user.role);
}
}, [user, reset, setUserRole]);
return ( return (
<form className="flex w-full items-center justify-center" onSubmit={handleSubmit(onSubmit)}> <form className="flex w-full items-center justify-center" onSubmit={handleSubmit(onSubmit)}>
@ -101,7 +104,10 @@ export const UserDetails: React.FC<Props> = ({ user, setStep }) => {
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomSelect <CustomSelect
value={value} value={value}
onChange={onChange} onChange={(value: any) => {
onChange(value);
setUserRole(value ?? null);
}}
label={value ? value.toString() : "Select your role"} label={value ? value.toString() : "Select your role"}
input input
width="w-full" width="w-full"

View File

@ -0,0 +1,210 @@
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import pagesService from "services/pages.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, Loader, PrimaryButton, SecondaryButton } from "components/ui";
// types
import { IPageBlock } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import { useCallback, useEffect } from "react";
import issuesService from "services/issues.service";
type Props = {
handleClose: () => void;
data?: IPageBlock;
setIsSyncing?: React.Dispatch<React.SetStateAction<boolean>>;
focus?: keyof IPageBlock;
};
const defaultValues = {
name: "",
description: "<p></p>",
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="mx-4 mt-6">
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose,
data,
setIsSyncing,
focus,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast();
const {
handleSubmit,
register,
control,
watch,
setValue,
setFocus,
reset,
formState: { isSubmitting },
} = useForm<IPageBlock>({
defaultValues,
});
const onClose = useCallback(() => {
if (data) handleClose();
reset();
}, [handleClose, reset, data]);
const createPageBlock = async (formData: Partial<IPageBlock>) => {
if (!workspaceSlug || !projectId || !pageId) return;
await pagesService
.createPageBlock(workspaceSlug as string, projectId as string, pageId as string, {
name: formData.name,
description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
})
.then((res) => {
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) => [...(prevData as IPageBlock[]), res],
false
);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be created. Please try again.",
});
})
.finally(() => onClose());
};
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
if (!workspaceSlug || !projectId || !pageId || !data) return;
if (data.issue && data.sync && setIsSyncing) setIsSyncing(true);
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === data.id) return { ...p, ...formData };
return p;
}),
false
);
await pagesService
.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, data.id, {
name: formData.name,
description: formData.description,
description_html: formData.description_html,
})
.then((res) => {
mutate(PAGE_BLOCKS_LIST(pageId as string));
if (data.issue && data.sync)
issuesService
.patchIssue(workspaceSlug as string, projectId as string, data.issue, {
name: res.name,
description: res.description,
description_html: res.description_html,
})
.finally(() => {
if (setIsSyncing) setIsSyncing(false);
});
})
.finally(() => onClose());
};
useEffect(() => {
if (focus) setFocus(focus);
if (!data) return;
reset({
...defaultValues,
name: data.name,
description: data.description,
description_html: data.description_html,
});
}, [reset, data, focus, setFocus]);
useEffect(() => {
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
});
return () => {
window.removeEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
});
};
}, [handleClose]);
return (
<div className="border rounded-[10px] p-2 ml-6">
<form onSubmit={data ? handleSubmit(updatePageBlock) : handleSubmit(createPageBlock)}>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-base ring-0 -ml-2 focus:ring-gray-200"
role="textbox"
autoComplete="off"
maxLength={255}
/>
<div className="page-block-section font relative -mx-3 -mt-3">
<Controller
name="description"
control={control}
render={({ field: { value } }) => (
<RemirrorRichTextEditor
value={
!value || (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Description"
customClassName="text-sm"
noBorder
borderOnFocus={false}
/>
)}
/>
</div>
<div className="flex justify-end items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" disabled={watch("name") === ""} loading={isSubmitting}>
{data
? isSubmitting
? "Updating..."
: "Update block"
: isSubmitting
? "Adding..."
: "Add block"}
</PrimaryButton>
</div>
</form>
</div>
);
};

View File

@ -1,4 +1,5 @@
export * from "./pages-list"; export * from "./pages-list";
export * from "./create-update-block-inline";
export * from "./create-update-page-modal"; export * from "./create-update-page-modal";
export * from "./delete-page-modal"; export * from "./delete-page-modal";
export * from "./page-form"; export * from "./page-form";

View File

@ -152,6 +152,32 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
}); });
}; };
const partialUpdatePage = (page: IPage, formData: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return;
mutate<IPage[]>(
ALL_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })),
false
);
mutate<IPage[]>(
MY_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })),
false
);
mutate<IPage[]>(
FAVORITE_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })),
false
);
pagesService
.patchPage(workspaceSlug as string, projectId as string, page.id, formData)
.then(() => {
mutate(RECENT_PAGES_LIST(projectId as string));
});
};
return ( return (
<> <>
<CreateUpdatePageModal <CreateUpdatePageModal
@ -176,6 +202,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)} handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)} handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/> />
))} ))}
</ul> </ul>
@ -189,6 +216,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)} handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)} handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/> />
))} ))}
</div> </div>
@ -202,6 +230,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)} handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)} handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/> />
))} ))}
</div> </div>

View File

@ -8,20 +8,29 @@ import { mutate } from "swr";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// services // services
import pagesService from "services/pages.service"; import pagesService from "services/pages.service";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import aiService from "services/ai.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateIssueModal } from "components/issues";
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages";
// ui // ui
import { CustomMenu, Input, Loader, TextArea } from "components/ui"; import { CustomMenu, Loader } from "components/ui";
// icons // icons
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
import { ArrowPathIcon } from "@heroicons/react/20/solid"; import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { BoltIcon, CheckIcon, SparklesIcon } from "@heroicons/react/24/outline"; import {
BoltIcon,
CheckIcon,
EllipsisVerticalIcon,
PencilIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
@ -32,6 +41,8 @@ import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
type Props = { type Props = {
block: IPageBlock; block: IPageBlock;
projectDetails: IProject | undefined; projectDetails: IProject | undefined;
index: number;
handleNewBlock: () => void;
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -43,9 +54,15 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
), ),
}); });
export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => { export const SinglePageBlock: React.FC<Props> = ({
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); block,
projectDetails,
index,
handleNewBlock,
}) => {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [createBlockForm, setCreateBlockForm] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false);
@ -54,7 +71,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { handleSubmit, watch, reset, setValue, control } = useForm<IPageBlock>({ const { handleSubmit, watch, reset, setValue, register } = useForm<IPageBlock>({
defaultValues: { defaultValues: {
name: "", name: "",
description: {}, description: {},
@ -136,10 +153,6 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
}); });
}; };
const editAndPushBlockIntoIssues = async () => {
setCreateUpdateIssueModal(true);
};
const deletePageBlock = async () => { const deletePageBlock = async () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
@ -160,6 +173,44 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
}); });
}; };
const handelAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: block.name,
task: "Generate a proper description for this issue in context of a project management software.",
})
.then((res) => {
if (res.response === "")
setToastAlert({
type: "error",
title: "Error!",
message:
"Block title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message:
"You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
const handleAiAssistance = async (response: string) => { const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -228,30 +279,52 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
reset({ ...block }); reset({ ...block });
}, [reset, block]); }, [reset, block]);
useEffect(() => {
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !createBlockForm) handleNewBlock();
});
return () => {
window.removeEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !createBlockForm) handleNewBlock();
});
};
}, [handleNewBlock, createBlockForm]);
return ( return (
<div> <Draggable draggableId={block.id} index={index} isDragDisabled={createBlockForm}>
<CreateUpdateIssueModal {(provided, snapshot) => (
isOpen={createUpdateIssueModal} <>
handleClose={() => setCreateUpdateIssueModal(false)} {createBlockForm ? (
prePopulateData={{ <div
name: watch("name"), className="mb-4 pt-4"
description: watch("description"), ref={provided.innerRef}
description_html: watch("description_html"), {...provided.draggableProps}
}} {...provided.dragHandleProps}
>
<CreateUpdateBlockInline
handleClose={() => setCreateBlockForm(false)}
data={block}
setIsSyncing={setIsSyncing}
/> />
<div className="-mx-3 mt-4 flex items-center justify-between gap-2"> </div>
<Input ) : (
id="name" <div
name="name" className={`group relative pl-6 ${
placeholder="Block title" snapshot.isDragging ? "border-2 bg-white border-theme shadow-lg rounded-md p-6" : ""
value={watch("name")} }`}
onBlur={handleSubmit(updatePageBlock)} ref={provided.innerRef}
onChange={(e) => setValue("name", e.target.value)} {...provided.draggableProps}
required={true} >
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-base font-medium ring-0 focus:ring-1 focus:ring-gray-200" <button
role="textbox" type="button"
/> className="absolute top-4 -left-2 p-0.5 hover:bg-gray-100 rounded hidden group-hover:flex"
<div className="flex flex-shrink-0 items-center gap-2"> {...provided.dragHandleProps}
>
<EllipsisVerticalIcon className="h-[18px]" />
<EllipsisVerticalIcon className="h-[18px] -ml-3" />
</button>
<div className="absolute top-4 right-0 items-center gap-2 hidden group-hover:flex bg-white pl-4">
{block.issue && block.sync && ( {block.issue && block.sync && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded bg-gray-100 py-1 px-1.5 text-xs"> <div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded bg-gray-100 py-1 px-1.5 text-xs">
{isSyncing ? ( {isSyncing ? (
@ -262,14 +335,22 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
{isSyncing ? "Syncing..." : "Synced"} {isSyncing ? "Syncing..." : "Synced"}
</div> </div>
)} )}
{block.issue && ( <button
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}> type="button"
<a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs"> className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100 ${
<LayerDiagonalIcon height="16" width="16" color="black" /> iAmFeelingLucky ? "cursor-wait bg-gray-100" : ""
{projectDetails?.identifier}-{block.issue_detail?.sequence_id} }`}
</a> onClick={handelAutoGenerateDescription}
</Link> disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
)} )}
</button>
<button <button
type="button" type="button"
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100" className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
@ -278,60 +359,65 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
<SparklesIcon className="h-4 w-4" /> <SparklesIcon className="h-4 w-4" />
AI AI
</button> </button>
<button
type="button"
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
onClick={() => setCreateBlockForm(true)}
>
<PencilIcon className="h-3.5 w-3.5" />
</button>
<CustomMenu label={<BoltIcon className="h-4.5 w-3.5" />} noBorder noChevron> <CustomMenu label={<BoltIcon className="h-4.5 w-3.5" />} noBorder noChevron>
{block.issue ? ( {block.issue ? (
<> <>
<CustomMenu.MenuItem onClick={handleBlockSync}> <CustomMenu.MenuItem onClick={handleBlockSync}>
<>Turn sync {block.sync ? "off" : "on"}</> <>Turn sync {block.sync ? "off" : "on"}</>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={handleCopyText}>
Copy issue link
</CustomMenu.MenuItem>
</> </>
) : ( ) : (
<>
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}> <CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
Push into issues Push into issues
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{/* <CustomMenu.MenuItem onClick={editAndPushBlockIntoIssues}>
Edit and push into issues
</CustomMenu.MenuItem> */}
</>
)} )}
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
</div> </div>
</div> <div
<div className="page-block-section font relative -mx-3 -mt-3"> className={`flex items-start gap-2 ${
<Controller snapshot.isDragging ? "" : "py-4 [&:not(:last-child)]:border-b"
name="description" }`}
control={control} >
render={({ field: { value } }) => ( {block.issue && (
<RemirrorRichTextEditor <Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
value={ <a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs">
!value || (typeof value === "object" && Object.keys(value).length === 0) <LayerDiagonalIcon height="16" width="16" color="black" />
? watch("description_html") {projectDetails?.identifier}-{block.issue_detail?.sequence_id}
: value </a>
} </Link>
onBlur={handleSubmit(updatePageBlock)}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Block description..."
customClassName="border border-transparent"
noBorder
borderOnFocus
/>
)} )}
/> <h3
className="font-medium text-sm break-all"
onClick={() => setCreateBlockForm(true)}
>
{block.name}
</h3>
</div>
<GptAssistantModal <GptAssistantModal
block={block} block={block}
isOpen={gptAssistantModal} isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)} handleClose={() => setGptAssistantModal(false)}
inset="top-2 left-0" inset="top-8 left-0"
content={block.description_stripped} content={block.description_stripped}
htmlContent={block.description_html} htmlContent={block.description_html}
onResponse={handleAiAssistance} onResponse={handleAiAssistance}
projectId={projectId as string} projectId={projectId as string}
/> />
</div> </div>
</div> )}
</>
)}
</Draggable>
); );
}; };

View File

@ -5,9 +5,15 @@ import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
// ui // ui
import { CustomMenu, Loader } from "components/ui"; import { CustomMenu, Loader, Tooltip } from "components/ui";
// icons // icons
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline"; import {
LockClosedIcon,
LockOpenIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortTime } from "helpers/date-time.helper"; import { renderShortTime } from "helpers/date-time.helper";
@ -20,6 +26,7 @@ type TSingleStatProps = {
handleDeletePage: () => void; handleDeletePage: () => void;
handleAddToFavorites: () => void; handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void; handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -37,19 +44,18 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
handleDeletePage, handleDeletePage,
handleAddToFavorites, handleAddToFavorites,
handleRemoveFromFavorites, handleRemoveFromFavorites,
partialUpdatePage,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
return ( return (
<div className="relative rounded border p-4"> <div className="relative first:rounded-t last:rounded-b border">
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a className="block p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a className="after:absolute after:inset-0">
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p> <p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
</a>
</Link>
{page.label_details.length > 0 && {page.label_details.length > 0 &&
page.label_details.map((label) => ( page.label_details.map((label) => (
<div <div
@ -64,7 +70,8 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
<span <span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full" className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000", backgroundColor:
label?.color && label.color !== "" ? label.color : "#000000",
}} }}
/> />
{label.name} {label.name}
@ -75,26 +82,73 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p> <p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
{page.is_favorite ? ( {page.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10 grid place-items-center"> <button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFromFavorites();
}}
className="z-10 grid place-items-center"
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" /> <StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button> </button>
) : ( ) : (
<button <button
onClick={handleAddToFavorites}
type="button" type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}}
className="z-10 grid place-items-center" className="z-10 grid place-items-center"
> >
<StarIcon className="h-4 w-4 " color="#858E96" /> <StarIcon className="h-4 w-4 " color="#858E96" />
</button> </button>
)} )}
<Tooltip
tooltipContent={`${
page.access
? "This page is only visible to you."
: "This page can be viewed by anyone in the project."
}`}
theme="dark"
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
partialUpdatePage(page, { access: page.access ? 0 : 1 });
}}
>
{page.access ? (
<LockClosedIcon className="h-4 w-4" color="#858e96" />
) : (
<LockOpenIcon className="h-4 w-4" color="#858e96" />
)}
</button>
</Tooltip>
<CustomMenu verticalEllipsis> <CustomMenu verticalEllipsis>
<CustomMenu.MenuItem onClick={handleEditPage}> <CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleEditPage();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<PencilIcon className="h-3.5 w-3.5" /> <PencilIcon className="h-3.5 w-3.5" />
<span>Edit Page</span> <span>Edit Page</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeletePage}> <CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleDeletePage();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<TrashIcon className="h-3.5 w-3.5" /> <TrashIcon className="h-3.5 w-3.5" />
<span>Delete Page</span> <span>Delete Page</span>
@ -103,24 +157,13 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
</CustomMenu> </CustomMenu>
</div> </div>
</div> </div>
<div className="relative mt-6 space-y-2 text-sm text-gray-600"> <div className="relative mt-2 space-y-2 text-sm text-gray-600">
<div className="page-block-section -m-4 -mt-6"> {page.blocks.length > 0
{page.blocks.length > 0 ? ( ? page.blocks.slice(0, 3).map((block) => <h4>{block.name}</h4>)
<RemirrorRichTextEditor : null}
value={
!page.blocks[0].description ||
(typeof page.blocks[0].description === "object" &&
Object.keys(page.blocks[0].description).length === 0)
? page.blocks[0].description_html
: page.blocks[0].description
}
editable={false}
customClassName="text-gray-500"
noBorder
/>
) : null}
</div>
</div> </div>
</a>
</Link>
</div> </div>
); );
}; };

View File

@ -6,7 +6,14 @@ import { useRouter } from "next/router";
// ui // ui
import { CustomMenu, Tooltip } from "components/ui"; import { CustomMenu, Tooltip } from "components/ui";
// icons // icons
import { DocumentTextIcon, PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline"; import {
DocumentTextIcon,
LockClosedIcon,
LockOpenIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortDate, renderShortTime } from "helpers/date-time.helper"; import { renderShortDate, renderShortTime } from "helpers/date-time.helper";
@ -19,6 +26,7 @@ type TSingleStatProps = {
handleDeletePage: () => void; handleDeletePage: () => void;
handleAddToFavorites: () => void; handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void; handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
}; };
export const SinglePageListItem: React.FC<TSingleStatProps> = ({ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
@ -27,6 +35,7 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
handleDeletePage, handleDeletePage,
handleAddToFavorites, handleAddToFavorites,
handleRemoveFromFavorites, handleRemoveFromFavorites,
partialUpdatePage,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -94,6 +103,29 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
<StarIcon className="h-4 w-4 " color="#858e96" /> <StarIcon className="h-4 w-4 " color="#858e96" />
</button> </button>
)} )}
<Tooltip
tooltipContent={`${
page.access
? "This page is only visible to you."
: "This page can be viewed by anyone in the project."
}`}
theme="dark"
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
partialUpdatePage(page, { access: page.access ? 0 : 1 });
}}
>
{page.access ? (
<LockClosedIcon className="h-4 w-4" color="#858e96" />
) : (
<LockOpenIcon className="h-4 w-4" color="#858e96" />
)}
</button>
</Tooltip>
<CustomMenu width="auto" verticalEllipsis> <CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={(e: any) => { onClick={(e: any) => {

View File

@ -185,25 +185,23 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
}; };
return ( return (
<div className="mt-2 mb-4"> <div className="relative">
<Remirror <Remirror
manager={manager} manager={manager}
initialContent={state} initialContent={state}
classNames={[ classNames={[
`p-4 relative focus:outline-none rounded-md focus:border-gray-200 ${ `p-4 relative focus:outline-none rounded-md focus:border-gray-200 ${
noBorder ? "" : "border" noBorder ? "" : "border"
} ${borderOnFocus ? "focus:border" : ""} ${customClassName}`, } ${borderOnFocus ? "focus:border" : "focus:border-0"} ${customClassName}`,
]} ]}
editable={editable} editable={editable}
onBlur={() => { onBlur={() => {
onBlur(jsonValue, htmlValue); onBlur(jsonValue, htmlValue);
}} }}
> >
{/* {(!value || value === "" || value?.content?.[0]?.content === undefined) && ( {(!value || value === "" || value?.content?.[0]?.content === undefined) && placeholder && (
<p className="pointer-events-none absolute top-[8.8rem] left-12 text-gray-300"> <p className="absolute pointer-events-none top-4 left-4 text-gray-300">{placeholder}</p>
{placeholder || "Enter text..."} )}
</p>
)} */}
<EditorComponent /> <EditorComponent />
{imageLoader && ( {imageLoader && (

View File

@ -27,6 +27,10 @@ export const EmptyState: React.FC<Props> = ({ type, title, description, imgURL,
return "P"; return "P";
case "issue": case "issue":
return "C"; return "C";
case "view":
return "V";
case "page":
return "D"
default: default:
return null; return null;
} }

View File

@ -81,8 +81,8 @@ export const SingleViewItem: React.FC<Props> = ({ view, setSelectedView }) => {
}; };
return ( return (
<>
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
<a>
<div className="flex items-center cursor-pointer justify-between border-b bg-white p-4 first:rounded-t-[10px] last:rounded-b-[10px]"> <div className="flex items-center cursor-pointer justify-between border-b bg-white p-4 first:rounded-t-[10px] last:rounded-b-[10px]">
<div className="flex flex-col w-full gap-3"> <div className="flex flex-col w-full gap-3">
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
@ -137,7 +137,7 @@ export const SingleViewItem: React.FC<Props> = ({ view, setSelectedView }) => {
)} )}
</div> </div>
</div> </div>
</a>
</Link> </Link>
</>
); );
}; };

View File

@ -27,6 +27,15 @@ type Props = {
setDefaultValues: Dispatch<SetStateAction<any>>; setDefaultValues: Dispatch<SetStateAction<any>>;
}; };
const restrictedUrls = [
"create-workspace",
"error",
"invitations",
"magic-sign-in",
"onboarding",
"signin",
];
export const CreateWorkspaceForm: React.FC<Props> = ({ export const CreateWorkspaceForm: React.FC<Props> = ({
onSubmit, onSubmit,
defaultValues, defaultValues,
@ -49,7 +58,7 @@ export const CreateWorkspaceForm: React.FC<Props> = ({
await workspaceService await workspaceService
.workspaceSlugCheck(formData.slug) .workspaceSlugCheck(formData.slug)
.then(async (res) => { .then(async (res) => {
if (res.status === true) { if (res.status === true && !restrictedUrls.includes(formData.slug)) {
setSlugError(false); setSlugError(false);
await workspaceService await workspaceService
.createWorkspace(formData) .createWorkspace(formData)

View File

@ -80,11 +80,8 @@ export const handleIssuesMutation: THandleIssuesMutation = (
let newGroup: IIssue[] = []; let newGroup: IIssue[] = [];
if (selectedGroupBy === "priority") { if (selectedGroupBy === "priority") newGroup = prevData[formData.priority ?? ""] ?? [];
newGroup = prevData[formData.priority ?? ""] ?? []; else if (selectedGroupBy === "state") newGroup = prevData[formData.state ?? ""] ?? [];
} else if (selectedGroupBy === "state") {
newGroup = prevData[formData.state ?? ""] ?? [];
}
const updatedIssue = { const updatedIssue = {
...oldGroup[issueIndex], ...oldGroup[issueIndex],

View File

@ -125,7 +125,15 @@ const useIssuesView = () => {
return issuesToGroup ? Object.assign(emptyStatesObject, issuesToGroup) : undefined; return issuesToGroup ? Object.assign(emptyStatesObject, issuesToGroup) : undefined;
return issuesToGroup; return issuesToGroup;
}, [projectIssues, cycleIssues, moduleIssues, groupByProperty, cycleId, moduleId]); }, [
projectIssues,
cycleIssues,
moduleIssues,
groupByProperty,
cycleId,
moduleId,
emptyStatesObject,
]);
const isEmpty = const isEmpty =
Object.values(groupedByIssues ?? {}).every((group) => group.length === 0) || Object.values(groupedByIssues ?? {}).every((group) => group.length === 0) ||

View File

@ -29,10 +29,10 @@ const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
label: "Integrations", label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`, href: `/${workspaceSlug}/settings/integrations`,
}, },
{ // {
label: "Import/Export", // label: "Import/Export",
href: `/${workspaceSlug}/settings/import-export`, // href: `/${workspaceSlug}/settings/import-export`,
}, // },
]; ];
const projectLinks: Array<{ const projectLinks: Array<{

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -10,6 +10,9 @@ import { useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
// react-color // react-color
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// lib // lib
import { requiredAdmin, requiredAuth } from "lib/auth"; import { requiredAdmin, requiredAuth } from "lib/auth";
// services // services
@ -21,16 +24,24 @@ import useToast from "hooks/use-toast";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import { SinglePageBlock } from "components/pages"; import { CreateUpdateBlockInline, SinglePageBlock } from "components/pages";
// ui // ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { CustomSearchSelect, Loader, PrimaryButton, TextArea, Tooltip } from "components/ui"; import { CustomSearchSelect, Loader, PrimaryButton, TextArea, Tooltip } from "components/ui";
// icons // icons
import { ArrowLeftIcon, PlusIcon, ShareIcon, StarIcon } from "@heroicons/react/24/outline"; import {
ArrowLeftIcon,
LockClosedIcon,
LockOpenIcon,
PlusIcon,
ShareIcon,
StarIcon,
} from "@heroicons/react/24/outline";
import { ColorPalletteIcon } from "components/icons"; import { ColorPalletteIcon } from "components/icons";
// helpers // helpers
import { renderShortTime } from "helpers/date-time.helper"; import { renderShortTime } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import { orderArrayBy } from "helpers/array.helper";
// types // types
import type { NextPage, GetServerSidePropsContext } from "next"; import type { NextPage, GetServerSidePropsContext } from "next";
import { IIssueLabels, IPage, IPageBlock, UserAuth } from "types"; import { IIssueLabels, IPage, IPageBlock, UserAuth } from "types";
@ -43,14 +54,16 @@ import {
} from "constants/fetch-keys"; } from "constants/fetch-keys";
const SinglePage: NextPage<UserAuth> = (props) => { const SinglePage: NextPage<UserAuth> = (props) => {
const [isAddingBlock, setIsAddingBlock] = useState(false); const [createBlockForm, setCreateBlockForm] = useState(false);
const scrollToRef = useRef<HTMLDivElement>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query; const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({ const { handleSubmit, reset, watch, setValue } = useForm<IPage>({
defaultValues: { name: "" }, defaultValues: { name: "" },
}); });
@ -131,34 +144,6 @@ const SinglePage: NextPage<UserAuth> = (props) => {
}); });
}; };
const createPageBlock = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
setIsAddingBlock(true);
await pagesService
.createPageBlock(workspaceSlug as string, projectId as string, pageId as string, {
name: "New block",
})
.then((res) => {
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) => [...(prevData as IPageBlock[]), res],
false
);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be created. Please try again.",
});
})
.finally(() => {
setIsAddingBlock(false);
});
};
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
@ -195,6 +180,50 @@ const SinglePage: NextPage<UserAuth> = (props) => {
); );
}; };
const handleOnDragEnd = (result: DropResult) => {
if (!result.destination || !workspaceSlug || !projectId || !pageId || !pageBlocks) return;
const { source, destination } = result;
let newSortOrder = pageBlocks.find((p) => p.id === result.draggableId)?.sort_order ?? 65535;
if (destination.index === 0) newSortOrder = pageBlocks[0].sort_order - 10000;
else if (destination.index === pageBlocks.length - 1)
newSortOrder = pageBlocks[pageBlocks.length - 1].sort_order + 10000;
else {
if (destination.index > source.index)
newSortOrder =
(pageBlocks[destination.index].sort_order +
pageBlocks[destination.index + 1].sort_order) /
2;
else if (destination.index < source.index)
newSortOrder =
(pageBlocks[destination.index - 1].sort_order +
pageBlocks[destination.index].sort_order) /
2;
}
const newBlocksList = pageBlocks.map((p) => ({
...p,
sort_order: p.id === result.draggableId ? newSortOrder : p.sort_order,
}));
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
orderArrayBy(newBlocksList, "sort_order", "ascending"),
false
);
pagesService.patchPageBlock(
workspaceSlug as string,
projectId as string,
pageId as string,
result.draggableId,
{
sort_order: newSortOrder,
}
);
};
const handleCopyText = () => { const handleCopyText = () => {
const originURL = const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
@ -210,6 +239,13 @@ const SinglePage: NextPage<UserAuth> = (props) => {
); );
}; };
const handleNewBlock = () => {
setCreateBlockForm(true);
scrollToRef.current?.scrollIntoView({
behavior: "smooth",
});
};
const options = const options =
labels?.map((label) => ({ labels?.map((label) => ({
value: label.id, value: label.id,
@ -272,7 +308,8 @@ const SinglePage: NextPage<UserAuth> = (props) => {
key={label.id} key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs" className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
style={{ style={{
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000" backgroundColor: `${
label?.color && label.color !== "" ? label.color : "#000000"
}20`, }20`,
}} }}
> >
@ -341,7 +378,8 @@ const SinglePage: NextPage<UserAuth> = (props) => {
<> <>
<Popover.Button <Popover.Button
type="button" type="button"
className={`group inline-flex items-center outline-none ${open ? "text-gray-900" : "text-gray-500" className={`group inline-flex items-center outline-none ${
open ? "text-gray-900" : "text-gray-500"
}`} }`}
> >
{watch("color") && watch("color") !== "" ? ( {watch("color") && watch("color") !== "" ? (
@ -352,7 +390,7 @@ const SinglePage: NextPage<UserAuth> = (props) => {
}} }}
/> />
) : ( ) : (
<ColorPalletteIcon height={16} width={16} /> <ColorPalletteIcon height={16} width={16} color="#000000" />
)} )}
</Popover.Button> </Popover.Button>
@ -376,6 +414,19 @@ const SinglePage: NextPage<UserAuth> = (props) => {
)} )}
</Popover> </Popover>
</div> </div>
{pageDetails.access ? (
<button onClick={() => partialUpdatePage({ access: 0 })} className="z-10">
<LockClosedIcon className="h-4 w-4" />
</button>
) : (
<button
onClick={() => partialUpdatePage({ access: 1 })}
type="button"
className="z-10"
>
<LockOpenIcon className="h-4 w-4" />
</button>
)}
{pageDetails.is_favorite ? ( {pageDetails.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10"> <button onClick={handleRemoveFromFavorites} className="z-10">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" /> <StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
@ -403,34 +454,44 @@ const SinglePage: NextPage<UserAuth> = (props) => {
<div className="px-3"> <div className="px-3">
{pageBlocks ? ( {pageBlocks ? (
<> <>
<DragDropContext onDragEnd={handleOnDragEnd}>
{pageBlocks.length !== 0 && ( {pageBlocks.length !== 0 && (
<div className="space-y-4 divide-y"> <StrictModeDroppable droppableId="blocks-list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{pageBlocks.map((block, index) => ( {pageBlocks.map((block, index) => (
<>
<SinglePageBlock <SinglePageBlock
key={block.id} key={block.id}
block={block} block={block}
projectDetails={projectDetails} projectDetails={projectDetails}
index={index}
handleNewBlock={handleNewBlock}
/> />
</>
))} ))}
{provided.placeholder}
</div> </div>
)} )}
</StrictModeDroppable>
)}
</DragDropContext>
{!createBlockForm && (
<button <button
type="button" type="button"
className="flex items-center gap-1 rounded bg-gray-100 px-2.5 py-1 text-xs hover:bg-gray-200" className="flex items-center gap-1 rounded bg-gray-100 px-2.5 py-1 ml-6 text-xs hover:bg-gray-200 mt-4"
onClick={createPageBlock} onClick={handleNewBlock}
disabled={isAddingBlock}
> >
{isAddingBlock ? (
"Adding block..."
) : (
<>
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
Add new block Add new block
</>
)}
</button> </button>
)}
{createBlockForm && (
<div className="mt-4" ref={scrollToRef}>
<CreateUpdateBlockInline
handleClose={() => setCreateBlockForm(false)}
focus="name"
/>
</div>
)}
</> </>
) : ( ) : (
<Loader> <Loader>

View File

@ -167,7 +167,10 @@ const ProjectPages: NextPage<UserAuth> = (props) => {
right={ right={
<PrimaryButton <PrimaryButton
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => setCreateUpdatePageModal(true)} onClick={() => {
const e = new KeyboardEvent("keydown", { key: "d" });
document.dispatchEvent(e);
}}
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
Create Page Create Page

View File

@ -46,7 +46,7 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const scollToRef = useRef<HTMLDivElement>(null); const scrollToRef = useRef<HTMLDivElement>(null);
const { data: projectDetails } = useSWR( const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
@ -130,7 +130,7 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
setLabelForm={setLabelForm} setLabelForm={setLabelForm}
isUpdating={isUpdating} isUpdating={isUpdating}
labelToUpdate={labelToUpdate} labelToUpdate={labelToUpdate}
ref={scollToRef} ref={scrollToRef}
/> />
)} )}
<> <>
@ -147,7 +147,7 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
addLabelToGroup={() => addLabelToGroup(label)} addLabelToGroup={() => addLabelToGroup(label)}
editLabel={(label) => { editLabel={(label) => {
editLabel(label); editLabel(label);
scollToRef.current?.scrollIntoView({ scrollToRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
}); });
}} }}
@ -163,7 +163,7 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
addLabelToGroup={addLabelToGroup} addLabelToGroup={addLabelToGroup}
editLabel={(label) => { editLabel={(label) => {
editLabel(label); editLabel(label);
scollToRef.current?.scrollIntoView({ scrollToRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
}); });
}} }}

View File

@ -67,7 +67,14 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
} }
right={ right={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PrimaryButton type="button" className="flex items-center gap-2" onClick={() => setIsCreateViewModalOpen(true)}> <PrimaryButton
type="button"
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "v" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
Create View Create View
</PrimaryButton> </PrimaryButton>
@ -100,7 +107,6 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
title="Create New View" title="Create New View"
description="Views aid in saving your issues by applying various filters and grouping options." description="Views aid in saving your issues by applying various filters and grouping options."
imgURL={emptyView} imgURL={emptyView}
action={() => setIsCreateViewModalOpen(true)}
/> />
) )
) : ( ) : (

View File

@ -37,15 +37,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// TODO: cache user info // TODO: cache user info
jitsu jitsu
.id( .id({
{
id: user.id, id: user.id,
email: user.email, email: user.email,
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
}, })
true
)
.then(() => { .then(() => {
jitsu.track(eventName, { jitsu.track(eventName, {
...extra, ...extra,

View File

@ -24,6 +24,7 @@ import type { NextPage, GetServerSidePropsContext } from "next";
const Onboarding: NextPage = () => { const Onboarding: NextPage = () => {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [userRole, setUserRole] = useState<string | null>(null);
const [workspace, setWorkspace] = useState(); const [workspace, setWorkspace] = useState();
@ -40,7 +41,7 @@ const Onboarding: NextPage = () => {
<Image src={Logo} height="50" alt="Plane Logo" /> <Image src={Logo} height="50" alt="Plane Logo" />
</div> </div>
{step === 1 ? ( {step === 1 ? (
<UserDetails user={user} setStep={setStep} /> <UserDetails user={user} setStep={setStep} setUserRole={setUserRole} />
) : step === 2 ? ( ) : step === 2 ? (
<Workspace setStep={setStep} setWorkspace={setWorkspace} /> <Workspace setStep={setStep} setWorkspace={setWorkspace} />
) : ( ) : (
@ -69,7 +70,7 @@ const Onboarding: NextPage = () => {
onClick={() => { onClick={() => {
if (step === 8) { if (step === 8) {
userService userService
.updateUserOnBoard() .updateUserOnBoard({ userRole })
.then(() => { .then(() => {
router.push("/"); router.push("/");
}) })

View File

@ -343,7 +343,7 @@ class ProjectIssuesServices extends APIService {
) )
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response;
}); });
} }

View File

@ -175,7 +175,7 @@ class ProjectIssuesServices extends APIService {
) )
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response;
}); });
} }

View File

@ -14,6 +14,7 @@ import type {
IPageBlock, IPageBlock,
IProject, IProject,
IState, IState,
IView,
IWorkspace, IWorkspace,
} from "types"; } from "types";
@ -37,6 +38,8 @@ type ModuleEventType = "MODULE_CREATE" | "MODULE_UPDATE" | "MODULE_DELETE";
type PagesEventType = "PAGE_CREATE" | "PAGE_UPDATE" | "PAGE_DELETE"; type PagesEventType = "PAGE_CREATE" | "PAGE_UPDATE" | "PAGE_DELETE";
type ViewEventType = "VIEW_CREATE" | "VIEW_UPDATE" | "VIEW_DELETE";
type PageBlocksEventType = type PageBlocksEventType =
| "PAGE_BLOCK_CREATE" | "PAGE_BLOCK_CREATE"
| "PAGE_BLOCK_UPDATE" | "PAGE_BLOCK_UPDATE"
@ -365,6 +368,30 @@ class TrackEventServices extends APIService {
}, },
}); });
} }
async trackViewEvent(data: IView, eventName: ViewEventType): Promise<any> {
let payload: any;
if (eventName === "VIEW_DELETE") payload = data;
else
payload = {
labels: Boolean(data.query_data.labels),
assignees: Boolean(data.query_data.assignees),
priority: Boolean(data.query_data.priority),
state: Boolean(data.query_data.state),
created_by: Boolean(data.query_data.created_by),
};
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: {
...payload,
},
},
});
}
} }
const trackEventServices = new TrackEventServices(); const trackEventServices = new TrackEventServices();

View File

@ -47,10 +47,16 @@ class UserService extends APIService {
}); });
} }
async updateUserOnBoard(): Promise<any> { async updateUserOnBoard({ userRole }: any): Promise<any> {
return this.patch("/api/users/me/onboard/", { is_onboarded: true }) return this.patch("/api/users/me/onboard/", {
is_onboarded: true,
})
.then((response) => { .then((response) => {
if (trackEvent) trackEventServices.trackUserOnboardingCompleteEvent(response.data); if (trackEvent)
trackEventServices.trackUserOnboardingCompleteEvent({
...response.data,
user_role: userRole ?? "None",
});
return response?.data; return response?.data;
}) })
.catch((error) => { .catch((error) => {

View File

@ -1,10 +1,15 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types // types
import { IView } from "types/views"; import { IView } from "types/views";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ViewServices extends APIService { class ViewServices extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
@ -12,7 +17,10 @@ class ViewServices extends APIService {
async createView(workspaceSlug: string, projectId: string, data: IView): Promise<any> { async createView(workspaceSlug: string, projectId: string, data: IView): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/`, data) return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/`, data)
.then((response) => response?.data) .then((response) => {
if (trackEvent) trackEventServices.trackViewEvent(response?.data, "VIEW_CREATE");
return response?.data;
})
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
}); });
@ -25,7 +33,10 @@ class ViewServices extends APIService {
data: IView data: IView
): Promise<any> { ): Promise<any> {
return this.put(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`, data) return this.put(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`, data)
.then((response) => response?.data) .then((response) => {
if (trackEvent) trackEventServices.trackViewEvent(response?.data, "VIEW_UPDATE");
return response?.data;
})
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
}); });
@ -41,7 +52,10 @@ class ViewServices extends APIService {
`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`, `/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`,
data data
) )
.then((response) => response?.data) .then((response) => {
if (trackEvent) trackEventServices.trackViewEvent(response?.data, "VIEW_UPDATE");
return response?.data;
})
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
}); });
@ -49,7 +63,10 @@ class ViewServices extends APIService {
async deleteView(workspaceSlug: string, projectId: string, viewId: string): Promise<any> { async deleteView(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`) return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data) .then((response) => {
if (trackEvent) trackEventServices.trackViewEvent(response?.data, "VIEW_DELETE");
return response?.data;
})
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
}); });
@ -111,7 +128,6 @@ class ViewServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
} }
export default new ViewServices(); export default new ViewServices();

View File

@ -31,5 +31,9 @@
} }
[cmdk-item]:hover { [cmdk-item]:hover {
background-color: rgb(243 244 246); background-color: rgb(229 231 235);
}
[cmdk-item][aria-selected="true"] {
background-color: rgb(229 231 235);
} }

View File

@ -363,6 +363,10 @@ img.ProseMirror-separator {
min-height: 50px; min-height: 50px;
} }
.remirror-section .remirror-editor-wrapper .remirror-editor {
min-height: 0 !important;
}
.remirror-editor-wrapper { .remirror-editor-wrapper {
padding-top: 8px; padding-top: 8px;
} }

View File

@ -128,4 +128,9 @@
.react-datepicker-popper { .react-datepicker-popper {
z-index: 30 !important; z-index: 30 !important;
} }
.conical-gradient{
background: conic-gradient(from 180deg at 50% 50%, #FF6B00 0deg, #F7AE59 70.5deg, #3F76FF 151.12deg, #05C3FF 213deg, #18914F 289.87deg, #F6F172 329.25deg, #FF6B00 360deg);
}
/* end react datepicker styling */ /* end react datepicker styling */