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">
<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>
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
<p align="center">
<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" />
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<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.
<br /> <br />
<p>
<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.
## 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/)
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
- [ ] 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: Production-ready
## Community
## ❤️ Community
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.
## 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.

View File

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

View File

@ -3,7 +3,8 @@ import uuid
import random
from django.contrib.auth.hashers import make_password
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
@ -134,3 +135,42 @@ def update_project_cover_images():
except Exception as e:
print(e)
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",
]
# 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):
project_detail = ProjectSerializer(read_only=True, source="project")

View File

@ -17,6 +17,7 @@ from plane.db.models import (
WorkspaceMemberInvite,
Issue,
IssueActivity,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
@ -72,6 +73,20 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id)
user.is_onboarded = request.data.get("is_onboarded", False)
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(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,8 @@ const shortcuts = [
{ keys: "C", description: "To create issue" },
{ keys: "Q", description: "To create cycle" },
{ 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: "H", description: "To open shortcuts guide" },
{

View File

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

View File

@ -10,6 +10,7 @@ import {
ChatBubbleLeftEllipsisIcon,
RectangleGroupIcon,
Squares2X2Icon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
@ -77,6 +78,10 @@ const activityDetails: {
message: "set the parent to",
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 }) => (

View File

@ -125,12 +125,12 @@ export const GptAssistantModal: React.FC<Props> = ({
isOpen ? "block" : "hidden"
}`}
>
{((content && content !== "") || htmlContent) && (
<div className="text-sm page-block-section">
{((content && content !== "") || htmlContent !== "<p></p>") && (
<div className="remirror-section text-sm">
Content:
<RemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>}
customClassName="-mx-3 -my-3"
customClassName="-m-3"
noBorder
borderOnFocus={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 })}
placeholder="Search for images"
/>
<PrimaryButton className="bg-indigo-600" size="sm">
<PrimaryButton type="submit" className="bg-indigo-600" size="sm">
Search
</PrimaryButton>
</form>

View File

@ -18,7 +18,7 @@ import { AllLists, AllBoards, FilterList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateViewModal } from "components/views";
import { TransferIssuesModal } from "components/cycles";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view";
@ -459,23 +459,7 @@ export const IssuesView: React.FC<Props> = ({
{groupedByIssues ? (
isNotEmpty ? (
<>
{isCompleted && (
<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>
)}
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
{issueView === "list" ? (
<AllLists
type={type}

View File

@ -27,6 +27,7 @@ import {
PencilIcon,
TrashIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
@ -178,6 +179,15 @@ export const SingleListIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</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>
<div className="border-b border-gray-300 last:border-b-0">
<div

View File

@ -8,3 +8,4 @@ export * from "./sidebar";
export * from "./single-cycle-card";
export * from "./empty-cycle";
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";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// react colors
import { TwitterPicker } from "react-color";
// types
import { Props } from "./types";
// emojis
import emojis from "./emojis.json";
import icons from "./icons.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
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 [isOpen, setIsOpen] = useState(false);
const [openColorPicker, setOpenColorPicker] = useState(false);
const [activeColor, setActiveColor] = useState<string>("#020617");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
@ -58,20 +69,25 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute z-10 mt-2 w-80 rounded-lg bg-white shadow-lg">
<div className="h-72 w-80 overflow-auto rounded border bg-white p-2 shadow-2xl">
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] bg-white shadow-lg">
<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.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) => (
<Tab
key={tab.key}
className={({ selected }) =>
`-my-1 w-1/2 border-b py-2 text-center text-sm font-medium outline-none transition-colors ${
<Tab key={tab.key} as={React.Fragment}>
{({ selected }) => (
<button
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"
}`
}
}`}
>
{tab.title}
</button>
)}
</Tab>
))}
</Tab.List>
@ -79,12 +95,12 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
<Tab.Panel>
{recentEmojis.length > 0 && (
<div className="py-2">
<h3 className="mb-2">Recent Emojis</h3>
<div className="grid grid-cols-9 gap-2">
{/* <h3 className="mb-2">Recent Emojis</h3> */}
<div className="grid grid-cols-10">
{recentEmojis.map((emoji) => (
<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}
onClick={() => {
onChange(emoji);
@ -97,13 +113,14 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
</div>
</div>
)}
<hr className="w-full h-[1px] mb-2" />
<div>
<h3 className="mb-2">All Emojis</h3>
<div className="grid grid-cols-9 gap-2">
{/* <h3 className="mb-1">All Emojis</h3> */}
<div className="grid grid-cols-10 gap-y-1">
{emojis.map((emoji) => (
<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}
onClick={() => {
onChange(emoji);
@ -117,9 +134,76 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
</div>
</div>
</Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
<p>Coming Soon...</p>
<div className="py-2">
<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>
</div>
</Tab.Panels>
</Tab.Group>
</div>

View File

@ -2,4 +2,6 @@ export type Props = {
label: string | React.ReactNode;
value: any;
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_html", htmlValue);
}}
// placeholder="Enter Your comment..."
placeholder="Enter your comment..."
/>
)}
/>
@ -104,7 +104,7 @@ export const AddComment: React.FC = () => {
<button
type="submit"
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"}
</button>

View File

@ -6,6 +6,10 @@ import { useRouter } from "next/router";
// 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
import { GptAssistantModal } from "components/core";
import {
@ -83,10 +87,13 @@ export const IssueForm: FC<IssueFormProps> = ({
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
@ -102,6 +109,8 @@ export const IssueForm: FC<IssueFormProps> = ({
reValidateMode: "onChange",
});
const issueName = watch("name");
const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
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>`);
};
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(() => {
setFocus("name");
@ -245,10 +292,28 @@ export const IssueForm: FC<IssueFormProps> = ({
)}
</div>
<div className="relative">
<div className="flex justify-end -mb-2 mr-2">
<div className="flex justify-end -mb-2">
{issueName && issueName !== "" && (
<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)}
>
<SparklesIcon className="h-4 w-4" />
@ -267,7 +332,7 @@ export const IssueForm: FC<IssueFormProps> = ({
}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
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
.createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload)
.then((res) => {
mutate(ISSUE_DETAILS(issueDetail.id));
})
.then(() => mutate(ISSUE_DETAILS(issueDetail.id)))
.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
.createModuleLink(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then((res) => {
mutate(MODULE_DETAILS(moduleId as string));
})
.then(() => mutate(MODULE_DETAILS(moduleId as string)))
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "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 = {
user?: IUser;
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 {
@ -53,13 +54,15 @@ export const UserDetails: React.FC<Props> = ({ user, setStep }) => {
};
useEffect(() => {
if (user)
if (user) {
reset({
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
});
}, [user, reset]);
setUserRole(user.role);
}
}, [user, reset, setUserRole]);
return (
<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 } }) => (
<CustomSelect
value={value}
onChange={onChange}
onChange={(value: any) => {
onChange(value);
setUserRole(value ?? null);
}}
label={value ? value.toString() : "Select your role"}
input
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 "./create-update-block-inline";
export * from "./create-update-page-modal";
export * from "./delete-page-modal";
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 (
<>
<CreateUpdatePageModal
@ -176,6 +202,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</ul>
@ -189,6 +216,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</div>
@ -202,6 +230,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</div>

View File

@ -8,20 +8,29 @@ import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// services
import pagesService from "services/pages.service";
import issuesService from "services/issues.service";
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CreateUpdateIssueModal } from "components/issues";
import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages";
// ui
import { CustomMenu, Input, Loader, TextArea } from "components/ui";
import { CustomMenu, Loader } from "components/ui";
// icons
import { LayerDiagonalIcon } from "components/icons";
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
import { copyTextToClipboard } from "helpers/string.helper";
// types
@ -32,6 +41,8 @@ import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
type Props = {
block: IPageBlock;
projectDetails: IProject | undefined;
index: number;
handleNewBlock: () => void;
};
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 }) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
export const SinglePageBlock: React.FC<Props> = ({
block,
projectDetails,
index,
handleNewBlock,
}) => {
const [isSyncing, setIsSyncing] = useState(false);
const [createBlockForm, setCreateBlockForm] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
@ -54,7 +71,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
const { setToastAlert } = useToast();
const { handleSubmit, watch, reset, setValue, control } = useForm<IPageBlock>({
const { handleSubmit, watch, reset, setValue, register } = useForm<IPageBlock>({
defaultValues: {
name: "",
description: {},
@ -136,10 +153,6 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
});
};
const editAndPushBlockIntoIssues = async () => {
setCreateUpdateIssueModal(true);
};
const deletePageBlock = async () => {
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) => {
if (!workspaceSlug || !projectId) return;
@ -228,30 +279,52 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
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 (
<div>
<CreateUpdateIssueModal
isOpen={createUpdateIssueModal}
handleClose={() => setCreateUpdateIssueModal(false)}
prePopulateData={{
name: watch("name"),
description: watch("description"),
description_html: watch("description_html"),
}}
<Draggable draggableId={block.id} index={index} isDragDisabled={createBlockForm}>
{(provided, snapshot) => (
<>
{createBlockForm ? (
<div
className="mb-4 pt-4"
ref={provided.innerRef}
{...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">
<Input
id="name"
name="name"
placeholder="Block title"
value={watch("name")}
onBlur={handleSubmit(updatePageBlock)}
onChange={(e) => setValue("name", e.target.value)}
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"
role="textbox"
/>
<div className="flex flex-shrink-0 items-center gap-2">
</div>
) : (
<div
className={`group relative pl-6 ${
snapshot.isDragging ? "border-2 bg-white border-theme shadow-lg rounded-md p-6" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<button
type="button"
className="absolute top-4 -left-2 p-0.5 hover:bg-gray-100 rounded hidden group-hover:flex"
{...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 && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded bg-gray-100 py-1 px-1.5 text-xs">
{isSyncing ? (
@ -262,14 +335,22 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
{isSyncing ? "Syncing..." : "Synced"}
</div>
)}
{block.issue && (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
<a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs">
<LayerDiagonalIcon height="16" width="16" color="black" />
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
</a>
</Link>
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100 ${
iAmFeelingLucky ? "cursor-wait bg-gray-100" : ""
}`}
onClick={handelAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
<button
type="button"
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" />
AI
</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>
{block.issue ? (
<>
<CustomMenu.MenuItem onClick={handleBlockSync}>
<>Turn sync {block.sync ? "off" : "on"}</>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
Copy issue link
</CustomMenu.MenuItem>
</>
) : (
<>
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
Push into issues
</CustomMenu.MenuItem>
{/* <CustomMenu.MenuItem onClick={editAndPushBlockIntoIssues}>
Edit and push into issues
</CustomMenu.MenuItem> */}
</>
)}
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<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
}
onBlur={handleSubmit(updatePageBlock)}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Block description..."
customClassName="border border-transparent"
noBorder
borderOnFocus
/>
<div
className={`flex items-start gap-2 ${
snapshot.isDragging ? "" : "py-4 [&:not(:last-child)]:border-b"
}`}
>
{block.issue && (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
<a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs">
<LayerDiagonalIcon height="16" width="16" color="black" />
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
</a>
</Link>
)}
/>
<h3
className="font-medium text-sm break-all"
onClick={() => setCreateBlockForm(true)}
>
{block.name}
</h3>
</div>
<GptAssistantModal
block={block}
isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)}
inset="top-2 left-0"
inset="top-8 left-0"
content={block.description_stripped}
htmlContent={block.description_html}
onResponse={handleAiAssistance}
projectId={projectId as string}
/>
</div>
</div>
)}
</>
)}
</Draggable>
);
};

View File

@ -5,9 +5,15 @@ import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// ui
import { CustomMenu, Loader } from "components/ui";
import { CustomMenu, Loader, Tooltip } from "components/ui";
// icons
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
LockClosedIcon,
LockOpenIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderShortTime } from "helpers/date-time.helper";
@ -20,6 +26,7 @@ type TSingleStatProps = {
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -37,19 +44,18 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
partialUpdatePage,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
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 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>
</a>
</Link>
{page.label_details.length > 0 &&
page.label_details.map((label) => (
<div
@ -64,7 +70,8 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
backgroundColor:
label?.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
@ -75,26 +82,73 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
<div className="flex items-center gap-2">
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
{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" />
</button>
) : (
<button
onClick={handleAddToFavorites}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}}
className="z-10 grid place-items-center"
>
<StarIcon className="h-4 w-4 " color="#858E96" />
</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.MenuItem onClick={handleEditPage}>
<CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleEditPage();
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon className="h-3.5 w-3.5" />
<span>Edit Page</span>
</span>
</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">
<TrashIcon className="h-3.5 w-3.5" />
<span>Delete Page</span>
@ -103,24 +157,13 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
</CustomMenu>
</div>
</div>
<div className="relative mt-6 space-y-2 text-sm text-gray-600">
<div className="page-block-section -m-4 -mt-6">
{page.blocks.length > 0 ? (
<RemirrorRichTextEditor
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 className="relative mt-2 space-y-2 text-sm text-gray-600">
{page.blocks.length > 0
? page.blocks.slice(0, 3).map((block) => <h4>{block.name}</h4>)
: null}
</div>
</a>
</Link>
</div>
);
};

View File

@ -6,7 +6,14 @@ import { useRouter } from "next/router";
// ui
import { CustomMenu, Tooltip } from "components/ui";
// icons
import { DocumentTextIcon, PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
DocumentTextIcon,
LockClosedIcon,
LockOpenIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderShortDate, renderShortTime } from "helpers/date-time.helper";
@ -19,6 +26,7 @@ type TSingleStatProps = {
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
};
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
@ -27,6 +35,7 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
partialUpdatePage,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -94,6 +103,29 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
<StarIcon className="h-4 w-4 " color="#858e96" />
</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.MenuItem
onClick={(e: any) => {

View File

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

View File

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

View File

@ -81,8 +81,8 @@ export const SingleViewItem: React.FC<Props> = ({ view, setSelectedView }) => {
};
return (
<>
<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 flex-col w-full gap-3">
<div className="flex justify-between w-full">
@ -137,7 +137,7 @@ export const SingleViewItem: React.FC<Props> = ({ view, setSelectedView }) => {
)}
</div>
</div>
</a>
</Link>
</>
);
};

View File

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

View File

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

View File

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

View File

@ -29,10 +29,10 @@ const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`,
},
{
label: "Import/Export",
href: `/${workspaceSlug}/settings/import-export`,
},
// {
// label: "Import/Export",
// href: `/${workspaceSlug}/settings/import-export`,
// },
];
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";
@ -10,6 +10,9 @@ import { useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react";
// react-color
import { TwitterPicker } from "react-color";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// lib
import { requiredAdmin, requiredAuth } from "lib/auth";
// services
@ -21,16 +24,24 @@ import useToast from "hooks/use-toast";
// layouts
import AppLayout from "layouts/app-layout";
// components
import { SinglePageBlock } from "components/pages";
import { CreateUpdateBlockInline, SinglePageBlock } from "components/pages";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { CustomSearchSelect, Loader, PrimaryButton, TextArea, Tooltip } from "components/ui";
// 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";
// helpers
import { renderShortTime } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { orderArrayBy } from "helpers/array.helper";
// types
import type { NextPage, GetServerSidePropsContext } from "next";
import { IIssueLabels, IPage, IPageBlock, UserAuth } from "types";
@ -43,14 +54,16 @@ import {
} from "constants/fetch-keys";
const SinglePage: NextPage<UserAuth> = (props) => {
const [isAddingBlock, setIsAddingBlock] = useState(false);
const [createBlockForm, setCreateBlockForm] = useState(false);
const scrollToRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast();
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
const { handleSubmit, reset, watch, setValue } = useForm<IPage>({
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 = () => {
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 originURL =
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 =
labels?.map((label) => ({
value: label.id,
@ -272,7 +308,8 @@ const SinglePage: NextPage<UserAuth> = (props) => {
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
style={{
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"
backgroundColor: `${
label?.color && label.color !== "" ? label.color : "#000000"
}20`,
}}
>
@ -341,7 +378,8 @@ const SinglePage: NextPage<UserAuth> = (props) => {
<>
<Popover.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") !== "" ? (
@ -352,7 +390,7 @@ const SinglePage: NextPage<UserAuth> = (props) => {
}}
/>
) : (
<ColorPalletteIcon height={16} width={16} />
<ColorPalletteIcon height={16} width={16} color="#000000" />
)}
</Popover.Button>
@ -376,6 +414,19 @@ const SinglePage: NextPage<UserAuth> = (props) => {
)}
</Popover>
</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 ? (
<button onClick={handleRemoveFromFavorites} className="z-10">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
@ -403,34 +454,44 @@ const SinglePage: NextPage<UserAuth> = (props) => {
<div className="px-3">
{pageBlocks ? (
<>
<DragDropContext onDragEnd={handleOnDragEnd}>
{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) => (
<>
<SinglePageBlock
key={block.id}
block={block}
projectDetails={projectDetails}
index={index}
handleNewBlock={handleNewBlock}
/>
</>
))}
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
)}
</DragDropContext>
{!createBlockForm && (
<button
type="button"
className="flex items-center gap-1 rounded bg-gray-100 px-2.5 py-1 text-xs hover:bg-gray-200"
onClick={createPageBlock}
disabled={isAddingBlock}
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={handleNewBlock}
>
{isAddingBlock ? (
"Adding block..."
) : (
<>
<PlusIcon className="h-3 w-3" />
Add new block
</>
)}
</button>
)}
{createBlockForm && (
<div className="mt-4" ref={scrollToRef}>
<CreateUpdateBlockInline
handleClose={() => setCreateBlockForm(false)}
focus="name"
/>
</div>
)}
</>
) : (
<Loader>

View File

@ -167,7 +167,10 @@ const ProjectPages: NextPage<UserAuth> = (props) => {
right={
<PrimaryButton
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" />
Create Page

View File

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

View File

@ -67,7 +67,14 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
}
right={
<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" />
Create View
</PrimaryButton>
@ -100,7 +107,6 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
title="Create New View"
description="Views aid in saving your issues by applying various filters and grouping options."
imgURL={emptyView}
action={() => setIsCreateViewModalOpen(true)}
/>
)
) : (

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import type {
IPageBlock,
IProject,
IState,
IView,
IWorkspace,
} from "types";
@ -37,6 +38,8 @@ type ModuleEventType = "MODULE_CREATE" | "MODULE_UPDATE" | "MODULE_DELETE";
type PagesEventType = "PAGE_CREATE" | "PAGE_UPDATE" | "PAGE_DELETE";
type ViewEventType = "VIEW_CREATE" | "VIEW_UPDATE" | "VIEW_DELETE";
type PageBlocksEventType =
| "PAGE_BLOCK_CREATE"
| "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();

View File

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

View File

@ -1,10 +1,15 @@
// services
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types
import { IView } from "types/views";
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 {
constructor() {
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> {
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) => {
throw error?.response?.data;
});
@ -25,7 +33,10 @@ class ViewServices extends APIService {
data: IView
): Promise<any> {
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) => {
throw error?.response?.data;
});
@ -41,7 +52,10 @@ class ViewServices extends APIService {
`/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) => {
throw error?.response?.data;
});
@ -49,7 +63,10 @@ class ViewServices extends APIService {
async deleteView(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
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) => {
throw error?.response?.data;
});
@ -111,7 +128,6 @@ class ViewServices extends APIService {
throw error?.response?.data;
});
}
}
export default new ViewServices();

View File

@ -31,5 +31,9 @@
}
[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;
}
.remirror-section .remirror-editor-wrapper .remirror-editor {
min-height: 0 !important;
}
.remirror-editor-wrapper {
padding-top: 8px;
}

View File

@ -128,4 +128,9 @@
.react-datepicker-popper {
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 */