dev: promote stage release to production (#155)

* refractor: removed modules from user.context

* refractor: removed cycles from user context

* refractor: removed state from user context

* feat: implement channel protocol for tracking issue-activites

* refactor: remove blocking code and add todo

* refactor: refactor the consumer with function modules

* feat: add columns for identifiers for easier redirection

* style: minor padding, coloring and consistency changes

* feat: track blocker issues

* feat: track issue after creation

* feat: add runworker in procfile

* refractor: moved all context provider to _app for more clarity

* dev: added our icons

* refractor: removed issues from user context

* refactor: rename db names to plural and remove admin register file

* refactor: integrate permission layer in endpoints

* feat: create product email html templates

* refractor: changed to getServerSide from getInitialProps, removed unused component imports and minor refractoring

* feat: remirror added

* feat: workspace member user details endpoint

* fix: resolved build issue

* refactor:  remove www

* feat: workspace details on user endpoint

* feat: added authorization in project settings

refractor: improved code readability

* fix: removed hard-coded workspace slug value, and added workspace in user interface

* refactor: invitation workflow for already existing users

* feat: modified remirror, fix: issue details sidebar

* fix: merge conflicts

* fix: merge conflicts

* fix: added missing dependencies

* refactor: remove user dependency from invitations

* refactor: issue description context is updated with manager

* dev: redis instance rewrite for ssl settings and remove REDIS_TLS env variable

* chore: upgrade python package requirements

* dev: added new migrations for changes

* dev: ssl config for django channels redis connection

* chore: upgrade channels requirements

* refactor: better function for connecting with redis ssl django channels

* chore: cleanup on manifest file

* revert: user endpoint changes

* build: setup asgi

* refactor: update invitation endpoint to do bulk operations

* style: cycles page, custom listbox, issue details page

* refractor: removed folder that were moved to workspaceSlug

* dev: uvicorn in requirements

* Update index.tsx

* refactor: get workspace slug on user endpoint

* fix: workspace slug redirections and slug value in user context

* fix: user context bugs, drag and drop in cycles and modules

* fix: merge conflicts

* fix: user context and create issue modal

* refactor: add extra columns for json and html description and script for back migrating old issues

* refactor: move all 500 errors to 400

* refractor: removed active project, active workspace, projects, and workspaces from user context

* refractor: change from /home to /, added home page redirection logic

added explict GET method on fetch request, and fixed invitation page not fetching all invitations

* fix: passing project id in command palette

* style: home page, feat: image in remirror

* fix: bugs

* chore: remove test_runner workflow from github actions

* dev: update Procfile worker count and python runtime upgrade

* refactor: update response from 404 to 403

* feat: filtering using both name and issue identifier in command palette

showing my issues instead of project issue in command palette, hiding again according to route in command palette

* fix: mutation on different CRUD operations

* fix: redirection in my issues pages

* feat: added authorization in workspace settings, moved command palette to app-layout

* feat: endpoint and column to store my issue props

* style: authorization new design,

fix: made whole button on authorization page clickable, lib/auth on unsuccessful api call redirecting to error page

* feat: return project details on modules and cycles

* fix: create cycle and state coming below issue modal, showing loader for rich text editor

refractor: changed from sprint to cycle in issue type

* fix: issue delete mustation

and some code refractor

* fix: mutation bugs, remirror bugs, style: consistent droopdowns and buttons

* feat: user role in model

* dev: added new migrations

* fix: add url for workspace availability check

* feat: onboarding screens

* fix: update url for workspace name check and add authentication layer and
fix invitation endpoint

* refactor: bulk invitations message

* refactor: response on workspace invitarions

* refactor: update identifier endpoint

* refactor: invitations endpoint

* feat: onboarding logic and validations

* fix: email striep

* dev: added workspace space member unique_together

* chore: back populate neccesary data for description field

* feat: emoji-picker gets close on select, public will be default option in create project

* fix: update error in project creation

* fix: mutation error on issue count in kanban view

some minor code refractoring

* fix: module bugs

* fix: issue activities and issue comments mutation handled at issue detail

* fix: error message for creating updates without permissions

* fix: showing no user left to invite in project invite

fix: - mutation in project settings control, style: - showing loader in project settings controller, - showing request pending for user that hasn't accepted invitation

* refactor: file asset upload directory

* fix: update last workspace id on user invitation accept

* style: onboarding screens

* style: cycles, issue activity

* feat: add json and html column in issue comments

* fix: submitting create issue modal on enter click, project not getting deselected

* feat: file size validator

* fix: emoji picker not closing on all emoji select

* feat: added validation in identifier such that it only accept uppercase text

* dev: commenting is now richer

* fix: shortcuts not getting opened in settings layouts

* style: showing sidebar on unauthorized pages

* fix: error code on exception

* fix: add issue button is working on my issues pages

* feat: new way of assets

* fix: updated activity content for description field

* fix: mutation on project settings control

style: blocker and blocked changed to outline button

* fix: description activity logging

* refactor: check for workspace slug on workspace creation

* fix: typo on workspace url check

* fix: workspace name uniqueness

* fix: remove workspace from read only field

* fix: file upload endpoint, workspace slug check

* chore: drop unique_together constraint for name and workspace

* chore: settings files cleanup and use PubSub backend on django channels

* chore: change in channels backend

* refactor: issue activity api to combine comments

* fix: instance created at key

* fix: result list

* style: create project, cycle modal, view dropdown

* feat: merged issue activities and issue comments into a single section

* fix: remirror dynamic update of issue description

* fix: removed commented code

* fix: issue acitivties mutation

* fix: empty comments cant be submitted

* fix: workspace avatar has been updated while loading

* refactor: update docker-compose to run redis and database in heroku and docker environment

* refactor: removesingle docker file configuration

* refactor: update take off script to run in asgi

* docs: added workspace, quickstart documentation

* fix: reading editor values on focus out

* refactor: cleanup environment variables and create .env.example

* refactor: add extra variables in example env

* fix: warning and erros on console

lazy loading images with low priority, added validation on onboarding for user to either join or create workspace, on onboarding user can't click button while form is getting submitted, profile page going into loading state when updated, refractor: made some state local, removed unnecessary console logs and comments, changed some variable and function name to make more sence

* feat: env examples

* fix: workspace member does not exist

* fi: remove pagination from issue list api

* refactor: remove env example from root

* feat: documentation for projects on plane

* feat: create code of conduct and contributing guidelines

* fix: update docker setup to check handle redis

* revert: bring back pagination to avoid breaking

* feat: made image uploader modal, used it in profile page and workspace page,

delete project from project settings page, join project modal in project list page

* feat: create workspace page, style: made ui consistent

* style: updated onboarding and create workspace page design

* style: responsive sidebar

* fix: updated ui imports
This commit is contained in:
Vamsi Kurama 2023-01-10 23:55:47 +05:30 committed by GitHub
parent a960ddedf7
commit bef166a65f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
395 changed files with 20119 additions and 18322 deletions

View file

@ -1,10 +1,22 @@
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import stateService from "lib/services/state.service";
import issuesService from "lib/services/issues.service";
// constants
import { STATE_LIST, MODULE_ISSUES } from "constants/fetch-keys";
// components
import SingleBoard from "components/project/modules/board-view/single-board";
// ui
import { Spinner } from "ui";
// types
import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
import useUser from "lib/hooks/useUser";
import { IIssue, IProjectMember, ModuleIssueResponse, NestedKeyOf, Properties } from "types";
type Props = {
groupedByIssues: {
@ -40,52 +52,126 @@ const ModulesBoardView: React.FC<Props> = ({
handleDeleteIssue,
setPreloadedData,
}) => {
const { states } = useUser();
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
// removed/dragged item
const removedItem = groupedByIssues[source.droppableId][source.index];
if (selectedGroup === "priority") {
// update the removed item for mutation
removedItem.priority = destinationGroup;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
priority: destinationGroup,
});
} else if (selectedGroup === "state_detail.name") {
const destinationState = states?.find((s) => s.name === destinationGroup);
const destinationStateId = destinationState?.id;
// update the removed item for mutation
if (!destinationStateId || !destinationState) return;
removedItem.state = destinationStateId;
removedItem.state_detail = destinationState;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
state: destinationStateId,
});
if (!moduleId) return;
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === removedItem.id) {
return {
...issue,
issue_detail: removedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
}
// remove item from the source group
groupedByIssues[source.droppableId].splice(source.index, 1);
// add item to the destination group
groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem);
}
},
[workspaceSlug, groupedByIssues, projectId, selectedGroup, states, moduleId]
);
return (
<>
{groupedByIssues ? (
<div className="h-full w-full">
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000"
}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
</div>
</div>
</div>
</div>
</DragDropContext>
</div>
) : (
<div className="h-full w-full flex justify-center items-center">
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}

View file

@ -18,6 +18,9 @@ import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase } from "constants/common";
import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
type Props = {
properties: Properties;
@ -62,7 +65,8 @@ const SingleCycleBoard: React.FC<Props> = ({
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace } = useUser();
const router = useRouter();
const { workspaceSlug } = router.query;
if (selectedGroup === "priority")
groupTitle === "high"
@ -74,26 +78,26 @@ const SingleCycleBoard: React.FC<Props> = ({
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div className={`h-full flex-shrink-0 rounded ${!show ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!show ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
!show ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div
className={`w-full flex justify-between items-center ${
className={`flex w-full items-center justify-between ${
!show ? "flex-col gap-2" : "gap-1"
}`}
>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!show ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
@ -112,7 +116,7 @@ const SingleCycleBoard: React.FC<Props> = ({
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
<span className="ml-0.5 text-sm text-gray-500">
{groupedByIssues[groupTitle].length}
</span>
</div>
@ -138,68 +142,84 @@ const SingleCycleBoard: React.FC<Props> = ({
</CustomMenu>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleIssue
key={childIssue.id}
issue={childIssue}
properties={properties}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
);
})}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 h-full space-y-3 overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!show ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<SingleIssue
issue={childIssue}
properties={properties}
snapshot={snapshot}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</StrictModeDroppable>
</div>
</div>
);

View file

@ -6,8 +6,6 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
@ -28,9 +26,10 @@ type Props = {
const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const router = useRouter();
const {
query: { workspaceSlug },
} = router;
const cancelButtonRef = useRef(null);
@ -42,12 +41,12 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!activeWorkspace || !data) return;
if (!workspaceSlug || !data) return;
await modulesService
.deleteModule(activeWorkspace.slug, data.project, data.id)
.deleteModule(workspaceSlug as string, data.project, data.id)
.then(() => {
mutate(MODULE_LIST(data.project));
router.push(`/projects/${data.project}/modules`);
router.push(`/${workspaceSlug}/projects/${data.project}/modules`);
handleClose();
})
.catch((error) => {

View file

@ -1,16 +1,16 @@
import React, { useEffect } from "react";
// swr
import { useRouter } from "next/router";
import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, Input, TextArea, Select } from "ui";
import { Button, Input, TextArea } from "ui";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// types
import type { IModule } from "types";
// common
@ -37,15 +37,8 @@ const defaultValues: Partial<IModule> = {
};
const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const handleClose = () => {
setIsOpen(false);
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace } = useUser();
const router = useRouter();
const { workspaceSlug } = router.query;
const {
register,
@ -58,8 +51,17 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
defaultValues,
});
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
const onSubmit = async (formData: IModule) => {
if (!activeWorkspace) return;
if (!workspaceSlug) return;
const payload = {
...formData,
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
@ -67,7 +69,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
};
if (!data) {
await modulesService
.createModule(activeWorkspace.slug, projectId, payload)
.createModule(workspaceSlug as string, projectId, payload)
.then((res) => {
mutate<IModule[]>(
MODULE_LIST(projectId),
@ -85,7 +87,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
});
} else {
await modulesService
.updateModule(activeWorkspace.slug, projectId, data.id, payload)
.updateModule(workspaceSlug as string, projectId, data.id, payload)
.then((res) => {
mutate<IModule[]>(
MODULE_LIST(projectId),
@ -112,18 +114,14 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
}
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
const handleClose = () => {
setIsOpen(false);
reset(defaultValues);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -136,7 +134,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
@ -189,6 +187,9 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
placeholder="Enter start date"
error={errors.start_date}
register={register}
validations={{
required: "Start date is required",
}}
/>
</div>
<div className="w-full">
@ -200,17 +201,20 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
placeholder="Enter target date"
error={errors.target_date}
register={register}
validations={{
required: "Target date is required",
}}
/>
</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<SelectStatus control={control} />
<div className="flex flex-wrap items-center gap-2">
<SelectStatus control={control} error={errors.status} />
<SelectLead control={control} />
<SelectMembers control={control} />
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>

View file

@ -1,14 +1,13 @@
// react
import React from "react";
// swr
import { useRouter } from "next/router";
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
@ -23,12 +22,13 @@ type Props = {
};
const SelectLead: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectServices.projectMembers(workspaceSlug as string, projectId as string)
: null
);

View file

@ -1,14 +1,13 @@
// react
import React from "react";
// swr
import { useRouter } from "next/router";
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
@ -23,12 +22,13 @@ type Props = {
};
const SelectMembers: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectServices.projectMembers(workspaceSlug as string, projectId as string)
: null
);

View file

@ -1,7 +1,7 @@
// react
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
import { Controller, FieldError } from "react-hook-form";
import type { Control } from "react-hook-form";
// ui
import { CustomListbox } from "ui";
@ -13,24 +13,36 @@ import { MODULE_STATUS } from "constants/";
type Props = {
control: Control<IModule, any>;
error?: FieldError;
};
const SelectStatus: React.FC<Props> = ({ control }) => {
const SelectStatus: React.FC<Props> = (props) => {
const { control, error } = props;
return (
<Controller
control={control}
rules={{ required: true }}
name="status"
render={({ field: { value, onChange } }) => (
<CustomListbox
title="State"
options={MODULE_STATUS.map((status) => {
return { value: status.value, display: status.label };
})}
value={value}
optionsFontsize="sm"
onChange={onChange}
icon={<Squares2X2Icon className="h-3 w-3 text-gray-400" />}
/>
<div>
<CustomListbox
className={`${
error
? "border-red-300 text-red-900 placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500"
: ""
}`}
title="Status"
options={MODULE_STATUS.map((status) => {
return { value: status.value, display: status.label, color: status.color };
})}
value={value}
optionsFontsize="sm"
onChange={onChange}
icon={<Squares2X2Icon className="h-3 w-3 text-gray-400" />}
/>
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
</div>
)}
/>
);

View file

@ -1,29 +1,26 @@
// react
import React from "react";
// next
import Link from "next/link";
// swr
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
import stateService from "lib/services/state.service";
// common
import { addSpaceIfCamelCase } from "constants/common";
// components
import SingleListIssue from "components/common/list-view/single-issue";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { CustomMenu, Spinner } from "ui";
// icons
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import workspaceService from "lib/services/workspace.service";
// fetch-keys
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
groupedByIssues: {
@ -55,15 +52,23 @@ const ModulesListView: React.FC<Props> = ({
handleDeleteIssue,
setPreloadedData,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
return (
<div className="flex flex-col space-y-5">
<div className="flex h-full flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
@ -73,17 +78,17 @@ const ModulesListView: React.FC<Props> = ({
return (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium leading-5 capitalize">
<h2 className="font-medium capitalize leading-5">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
@ -91,7 +96,7 @@ const ModulesListView: React.FC<Props> = ({
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-gray-500 text-sm">
<p className="text-sm text-gray-500">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
@ -127,159 +132,22 @@ const ModulesListView: React.FC<Props> = ({
});
return (
<div
<SingleListIssue
key={issue.id}
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div> */}
</a>
</Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Due date</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Due date")}
</div>
</div>
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => openCreateIssueModal(issue, "edit")}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => removeIssueFromModule(issue.bridge ?? "")}
>
Remove from module
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => handleDeleteIssue(issue.id)}
>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
type="module"
issue={issue}
properties={properties}
editIssue={() => openCreateIssueModal(issue, "edit")}
handleDeleteIssue={() => handleDeleteIssue(issue.id)}
removeIssue={() => removeIssueFromModule(issue.bridge ?? "")}
/>
);
})
) : (
<p className="text-sm px-4 py-3 text-gray-500">No issues.</p>
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
@ -295,7 +163,7 @@ const ModulesListView: React.FC<Props> = ({
</span>
}
optionsPosition="left"
withoutBorder
noBorder
>
<CustomMenu.MenuItem
onClick={() => {

View file

@ -1,34 +1,37 @@
// react
import { useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react-hook-form
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Controller, useForm } from "react-hook-form";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import SelectLead from "components/project/modules/module-detail-sidebar/select-lead";
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
import ModuleLinkModal from "components/project/modules/module-link-modal";
// ui
import { Spinner } from "ui";
import { Loader } from "ui";
// icons
import {
CalendarDaysIcon,
ClipboardDocumentIcon,
ChartPieIcon,
LinkIcon,
PlusIcon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
// types
import { IModule } from "types";
import { IModule, ModuleIssueResponse } from "types";
// fetch-keys
import { MODULE_DETAIL } from "constants/fetch-keys";
import { MODULE_LIST } from "constants/fetch-keys";
// common
import { copyTextToClipboard } from "constants/common";
import { copyTextToClipboard, groupBy } from "constants/common";
const defaultValues: Partial<IModule> = {
members_list: [],
@ -40,11 +43,20 @@ const defaultValues: Partial<IModule> = {
type Props = {
module?: IModule;
isOpen: boolean;
moduleIssues: ModuleIssueResponse[] | undefined;
handleDeleteModule: () => void;
};
const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModule }) => {
const { activeWorkspace, activeProject } = useUser();
const ModuleDetailSidebar: React.FC<Props> = ({
module,
isOpen,
moduleIssues,
handleDeleteModule,
}) => {
const [moduleLinkModal, setModuleLinkModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { setToastAlert } = useToast();
@ -52,51 +64,70 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
defaultValues,
});
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((m) => m.id),
});
}, [module, reset]);
const groupedIssues = {
backlog: [],
unstarted: [],
started: [],
cancelled: [],
completed: [],
...groupBy(moduleIssues ?? [], "issue_detail.state_detail.group"),
};
const submitChanges = (data: Partial<IModule>) => {
if (!activeWorkspace || !activeProject || !module) return;
if (!workspaceSlug || !projectId || !module) return;
modulesService
.patchModule(activeWorkspace.slug, activeProject.id, module.id, data)
.patchModule(workspaceSlug as string, projectId as string, module.id, data)
.then((res) => {
console.log(res);
mutate(MODULE_DETAIL);
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...data };
return module;
})
);
})
.catch((e) => {
console.log(e);
});
};
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((member) => member.id),
});
}, [module, reset]);
return (
<>
<ModuleLinkModal
isOpen={moduleLinkModal}
handleClose={() => setModuleLinkModal(false)}
module={module}
/>
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-30 bg-gray-50 border-l h-full p-5 w-[24rem] overflow-y-auto duration-300`}
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
>
{module ? (
<>
<div className="flex justify-between items-center pb-3">
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">{module.name}</h4>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
className="rounded-md border p-2 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/modules/${module.id}`
`https://app.plane.so/${workspaceSlug}/projects/${projectId}/modules/${module.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
title: "Module link copied to clipboard",
});
})
.catch(() => {
@ -111,28 +142,7 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(module.id)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-50 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onClick={() => handleDeleteModule()}
>
<TrashIcon className="h-3.5 w-3.5" />
@ -141,27 +151,25 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<SelectLead control={control} submitChanges={submitChanges} />
<SelectMembers control={control} submitChanges={submitChanges} />
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="flex-shrink-0 h-4 w-4" />
<p>Lead</p>
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
<p>Progress</p>
</div>
<div className="sm:basis-1/2">
{module.lead_detail.first_name !== "" ? (
<>
{module.lead_detail.first_name} {module.lead_detail.last_name}
</>
) : (
module.lead_detail.email
)}
<div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-r-blue-500"></span>
</div>
{groupedIssues.completed.length}/{moduleIssues?.length}
</div>
</div>
<SelectMembers control={control} submitChanges={submitChanges} />
</div>
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
@ -171,18 +179,21 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
render={({ field: { value, onChange } }) => (
<input
type="date"
id="issueDate"
id="moduleStartDate"
value={value ?? ""}
onChange={onChange}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
onChange={(e: any) => {
submitChanges({ start_date: e.target.value });
onChange(e.target.value);
}}
className="w-full cursor-pointer rounded-md border bg-transparent px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
)}
/>
</div>
</div>
<div className="flex items-center py-2 flex-wrap">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>End date</p>
</div>
<div className="sm:basis-1/2">
@ -192,12 +203,13 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
render={({ field: { value, onChange } }) => (
<input
type="date"
id="moduleTargetDate"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
className="w-full cursor-pointer rounded-md border bg-transparent px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
)}
/>
@ -208,35 +220,66 @@ const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModu
<SelectStatus control={control} submitChanges={submitChanges} watch={watch} />
</div>
<div className="py-1">
<div className="flex justify-between items-center gap-2">
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100"
onClick={() => setModuleLinkModal(true)}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-2 space-y-2">
<div className="flex gap-2 border bg-gray-100 rounded-md p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>Aaryan Khandelwal</h5>
<p className="text-gray-500 mt-0.5">
Added 2 days ago by aaryan.khandelwal@caravel.tech
</p>
</div>
</div>
{module.link_module && module.link_module.length > 0
? module.link_module.map((link) => (
<div key={link.id} className="group relative">
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-gray-100 p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => {
const updatedLinks = module.link_module.filter(
(l) => l.id !== link.id
);
submitChanges({ links_list: updatedLinks });
}}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<Link href={link.url} target="_blank">
<a className="group relative flex gap-2 rounded-md border bg-gray-100 p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>{link.title}</h5>
<p className="mt-0.5 text-gray-500">
Added 2 days ago by {link.created_by_detail.email}
</p>
</div>
</a>
</Link>
</div>
))
: null}
</div>
</div>
</div>
</>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
<Loader>
<div className="space-y-2">
<Loader.Item height="15px" width="50%"></Loader.Item>
<Loader.Item height="15px" width="30%"></Loader.Item>
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px"></Loader.Item>
<Loader.Item height="30px"></Loader.Item>
<Loader.Item height="30px"></Loader.Item>
</div>
</Loader>
)}
</div>
</>

View file

@ -0,0 +1,162 @@
import React from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Control, Controller } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { UserGroupIcon, UserIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IModule } from "types";
// constants
import { classNames } from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<Partial<IModule>, any>;
submitChanges: (formData: Partial<IModule>) => void;
};
const SelectLead: React.FC<Props> = ({ control, submitChanges }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: people } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Lead</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="lead"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
onChange={(value: any) => {
submitChanges({ lead: value });
}}
className="flex-shrink-0"
>
{({ open }) => {
const person = people?.find((p) => p.member.id === value)?.member;
return (
<div className="relative">
<Listbox.Button className="flex w-full cursor-pointer items-center gap-1 text-xs">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate text-left sm:block"
)}
>
<div className="flex cursor-pointer items-center gap-1 text-xs">
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-transparent">
<Image
src={person.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={person.first_name}
/>
</div>
) : (
<div
className={`grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white`}
>
{person?.first_name && person.first_name !== ""
? person.first_name.charAt(0)
: person?.email.charAt(0)}
</div>
)}
{person?.first_name && person.first_name !== ""
? person?.first_name + " " + person?.last_name
: person?.email}
</div>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 w-full overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (
people.map((option) => (
<Listbox.Option
key={option.member.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
}
value={option.member.id}
>
{option.member.avatar && option.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={option.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
</div>
)}
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name
: option.member.email}
</Listbox.Option>
))
) : (
<div className="text-center">No members found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
);
}}
</Listbox>
)}
/>
</div>
</div>
);
};
export default SelectLead;

View file

@ -1,15 +1,13 @@
// react
import React from "react";
// next
import Image from "next/image";
// swr
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
@ -29,18 +27,19 @@ type Props = {
};
const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
const { activeWorkspace } = useUser();
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Members</p>
</div>
<div className="sm:basis-1/2">
<Controller
@ -58,21 +57,19 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="w-full flex items-center gap-1 text-xs cursor-pointer">
<Listbox.Button className="flex w-full cursor-pointer items-center gap-1 text-xs">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left"
"hidden truncate text-left sm:block"
)}
>
<div className="flex items-center gap-1 text-xs cursor-pointer">
<div className="flex cursor-pointer items-center gap-1 text-xs">
{value && Array.isArray(value) ? (
<>
{value.length > 0 ? (
value.map((assignee, index: number) => {
const person = people?.find(
(p) => p.member.id === assignee
)?.member;
value.map((member, index: number) => {
const person = people?.find((p) => p.member.id === member)?.member;
return (
<div
@ -82,7 +79,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
}`}
>
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={person.avatar}
height="100%"
@ -93,7 +90,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize`}
className={`grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white`}
>
{person?.first_name && person.first_name !== ""
? person.first_name.charAt(0)
@ -104,7 +101,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
);
})
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
@ -130,7 +127,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute z-10 left-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute left-0 z-10 mt-1 max-h-48 w-auto overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (
@ -140,7 +137,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none p-2 truncate`
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
}
value={option.member.id}
>
@ -155,7 +152,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
/>
</div>
) : (
<div className="flex-shrink-0 h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
@ -167,7 +164,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
</Listbox.Option>
))
) : (
<div className="text-center">No assignees found</div>
<div className="text-center">No members found</div>
)
) : (
<Spinner />

View file

@ -21,9 +21,9 @@ type Props = {
const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="flex-shrink-0 h-4 w-4" />
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
<p>Status</p>
</div>
<div className="sm:basis-1/2">
@ -36,9 +36,16 @@ const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => {
<span
className={classNames(
value ? "" : "text-gray-900",
"text-left capitalize flex items-center gap-2"
"flex items-center gap-2 text-left capitalize"
)}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS?.find((option) => option.value === value)
?.color,
}}
></span>
{watch("status")}
</span>
}
@ -49,7 +56,13 @@ const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => {
>
{MODULE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
{option.label}
<>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: option.color }}
></span>
{option.label}
</>
</CustomSelect.Option>
))}
</CustomSelect>

View file

@ -0,0 +1,170 @@
import React from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Button, Input } from "ui";
// types
import type { IModule, ModuleLink } from "types";
// fetch-keys
import { MODULE_DETAIL, MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
module: IModule | undefined;
handleClose: () => void;
};
const defaultValues: ModuleLink = {
title: "",
url: "",
};
const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
setError,
} = useForm<ModuleLink>({
defaultValues,
});
const onSubmit = async (formData: ModuleLink) => {
if (!workspaceSlug || !projectId || !module) return;
const previousLinks = module.link_module.map((l) => {
return { title: l.title, url: l.url };
});
const payload: Partial<IModule> = {
links_list: [...previousLinks, formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, payload)
.then((res) => {
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...payload };
return module;
})
);
onClose();
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof ModuleLink, {
message: err[key].join(", "),
});
});
});
};
const onClose = () => {
handleClose();
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Add Link
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="title"
label="Title"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
validations={{
required: "Title is required",
}}
/>
</div>
<div>
<Input
id="url"
label="URL"
name="url"
type="url"
placeholder="Enter URL"
autoComplete="off"
error={errors.url}
register={register}
validations={{
required: "URL is required",
}}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ModuleLinkModal;

View file

@ -1,6 +1,6 @@
// next
import Image from "next/image";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// icons
import User from "public/user.png";
// types
@ -8,94 +8,108 @@ import { IModule } from "types";
// common
import { renderShortNumericDateFormat } from "constants/common";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import { MODULE_STATUS } from "constants/";
type Props = {
module: IModule;
};
const SingleModuleCard: React.FC<Props> = ({ module }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div key={module.id} className="border bg-white p-3 rounded-md">
<Link href={`/projects/${module.project}/modules/${module.id}`}>
<a>{module.name}</a>
</Link>
<div className="grid grid-cols-4 gap-2 text-xs mt-4">
<div className="space-y-2">
<h6 className="text-gray-500">LEAD</h6>
<div>
{module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? (
<div className="h-5 w-5 border-2 border-white rounded-full">
<Image
src={module.lead_detail.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={module.lead_detail.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{module.lead_detail?.first_name && module.lead_detail.first_name !== ""
? module.lead_detail.first_name.charAt(0)
: module.lead_detail?.email.charAt(0)}
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">MEMBERS</h6>
<div className="flex items-center gap-1 text-xs">
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${index !== 0 ? "-ml-2.5" : ""}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="block cursor-pointer rounded-md border bg-white p-3">
{module.name}
<div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
<div className="space-y-2">
<h6 className="text-gray-500">LEAD</h6>
<div>
{module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white">
<Image
src={module.lead_detail.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={module.lead_detail.first_name}
/>
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{module.lead_detail?.first_name && module.lead_detail.first_name !== ""
? module.lead_detail.first_name.charAt(0)
: module.lead_detail?.email.charAt(0)}
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">MEMBERS</h6>
<div className="flex items-center gap-1 text-xs">
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex w-min cursor-pointer items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 text-xs shadow-sm">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="flex items-center gap-2 capitalize">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color,
}}
></span>
{module.status}
</div>
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex items-center gap-1 border rounded shadow-sm px-1.5 py-0.5 cursor-pointer text-xs w-min whitespace-nowrap">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="capitalize">{module.status}</div>
</div>
</div>
</div>
</a>
</Link>
);
};