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:
parent
a960ddedf7
commit
bef166a65f
395 changed files with 20119 additions and 18322 deletions
|
|
@ -1,4 +1,4 @@
|
|||
NEXT_PUBLIC_API_BASE_URL = "<-- endpoint goes here -->"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID = "<-- google client id goes here -->"
|
||||
NEXT_PUBLIC_GITHUB_ID = "<-- github id goes here -->"
|
||||
NEXT_PUBLIC_APP_ENVIRONMENT=development
|
||||
NEXT_PUBLIC_APP_ENVIRONMENT="<-- production | development -->"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
|
@ -6,17 +6,32 @@ WORKDIR /app
|
|||
|
||||
RUN apk add curl
|
||||
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
COPY ./apps ./apps
|
||||
COPY ./package.json ./package.json
|
||||
COPY ./.eslintrc.json ./.eslintrc.json
|
||||
COPY ./yarn.lock ./yarn.lock
|
||||
COPY ./turbo.json ./turbo.json
|
||||
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
RUN yarn global add turbo
|
||||
RUN pnpm add -g turbo
|
||||
RUN turbo prune --scope=app --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add curl
|
||||
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
ENV PNPM_HOME="pnpm"
|
||||
ENV PATH="${PATH}:./pnpm"
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
|
@ -24,14 +39,14 @@ WORKDIR /app
|
|||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN pnpm install
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN yarn turbo run build --filter=app...
|
||||
RUN pnpm turbo run build --filter=app...
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -1,20 +1,27 @@
|
|||
// react
|
||||
// TODO: Refactor this component: into a different file, use this file to export the components
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
// components
|
||||
import ShortcutsModal from "components/command-palette/shortcuts";
|
||||
import CreateProjectModal from "components/project/create-project-modal";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
|
||||
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
|
||||
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
|
|
@ -37,27 +44,37 @@ const CommandPalette: React.FC = () => {
|
|||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
|
||||
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
|
||||
const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false);
|
||||
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
|
||||
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
|
||||
|
||||
const { activeProject, issues } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { data: myIssues } = useSWR<IIssue[]>(
|
||||
workspaceSlug ? USER_ISSUE(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => userService.userIssues(workspaceSlug as string) : null
|
||||
);
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
? myIssues ?? []
|
||||
: myIssues?.filter(
|
||||
(issue) =>
|
||||
issue.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
`${issue.project_detail.identifier}-${issue.sequence_id}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
) ?? [];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
name: "Add new issue...",
|
||||
icon: RectangleStackIcon,
|
||||
hide: !projectId,
|
||||
shortcut: "I",
|
||||
onClick: () => {
|
||||
setIsIssueModalOpen(true);
|
||||
|
|
@ -66,6 +83,7 @@ const CommandPalette: React.FC = () => {
|
|||
{
|
||||
name: "Add new project...",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
hide: !workspaceSlug,
|
||||
shortcut: "P",
|
||||
onClick: () => {
|
||||
setIsProjectModalOpen(true);
|
||||
|
|
@ -100,7 +118,7 @@ const CommandPalette: React.FC = () => {
|
|||
setIsCreateCycleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
|
||||
e.preventDefault();
|
||||
setisCreateModuleModalOpen(true);
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
|
|
@ -133,28 +151,32 @@ const CommandPalette: React.FC = () => {
|
|||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
|
||||
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
|
||||
{activeProject && (
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
|
||||
)}
|
||||
{projectId && (
|
||||
<>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={isCreateCycleModalOpen}
|
||||
setIsOpen={setIsCreateCycleModalOpen}
|
||||
projectId={activeProject.id}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<CreateUpdateModuleModal
|
||||
isOpen={isCreateModuleModalOpen}
|
||||
setIsOpen={setisCreateModuleModalOpen}
|
||||
projectId={activeProject.id}
|
||||
setIsOpen={setIsCreateModuleModalOpen}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CreateUpdateIssuesModal
|
||||
isOpen={isIssueModalOpen}
|
||||
setIsOpen={setIsIssueModalOpen}
|
||||
projectId={activeProject?.id}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
|
|
@ -190,147 +212,151 @@ const CommandPalette: React.FC = () => {
|
|||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<form>
|
||||
<Combobox>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
onChange={(value: any) => {
|
||||
if (value?.url) router.push(value.url);
|
||||
else if (value?.onClick) value.onClick();
|
||||
handleCommandPaletteClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
autoComplete="off"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<>
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/projects/${activeProject?.id}/issues/${issue.id}`
|
||||
);
|
||||
handleCommandPaletteClose();
|
||||
}}
|
||||
className="flex-shrink-0 text-gray-500"
|
||||
>
|
||||
Jump to...
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{query === "" && (
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<>
|
||||
<li className="p-2">
|
||||
<h2 className="sr-only">Quick actions</h2>
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{quickActions.map((action) => (
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={action.shortcut}
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: action.name,
|
||||
onClick: action.onClick,
|
||||
name: issue.name,
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-default select-none items-center rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<action.icon
|
||||
className={classNames(
|
||||
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
|
||||
active ? "text-opacity-100" : ""
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-3 flex-auto truncate">{action.name}</span>
|
||||
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
|
||||
<kbd className="font-sans">⌘</kbd>
|
||||
<kbd className="font-sans">{action.shortcut}</kbd>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-shrink-0 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-500 transition-opacity duration-75 hover:bg-gray-500 hover:bg-opacity-5 ${
|
||||
active
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
Jump to
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<FolderIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
{query === "" && (
|
||||
<li className="p-2">
|
||||
<h2 className="sr-only">Quick actions</h2>
|
||||
<ul className="text-sm text-gray-700">
|
||||
{quickActions.map(
|
||||
(action) =>
|
||||
!action.hide && (
|
||||
<Combobox.Option
|
||||
key={action.shortcut}
|
||||
value={{
|
||||
name: action.name,
|
||||
onClick: action.onClick,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-default select-none items-center rounded-md px-3 py-2",
|
||||
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<action.icon
|
||||
className={classNames(
|
||||
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
|
||||
active ? "text-opacity-100" : ""
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-3 flex-auto truncate">{action.name}</span>
|
||||
<span className="ml-3 flex-none text-xs font-semibold text-gray-500">
|
||||
<kbd className="font-sans">⌘</kbd>
|
||||
<kbd className="font-sans">{action.shortcut}</kbd>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<div>
|
||||
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
|
||||
Close
|
||||
</Button>
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<FolderIcon
|
||||
className="mx-auto h-6 w-6 text-gray-500 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<div>
|
||||
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react-beautiful-dnd
|
||||
import { DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/";
|
||||
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
import stateService from "lib/services/state.service";
|
||||
import projectService from "lib/services/project.service";
|
||||
// icons
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
import User from "public/user.png";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember, Properties } from "types";
|
||||
import { IIssue, IssueResponse, IWorkspaceMember, Properties } from "types";
|
||||
// common
|
||||
import {
|
||||
addSpaceIfCamelCase,
|
||||
|
|
@ -18,11 +30,7 @@ import {
|
|||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import React from "react";
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
|
|
@ -38,7 +46,7 @@ type Props = {
|
|||
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
|
||||
};
|
||||
|
||||
const SingleIssue: React.FC<Props> = ({
|
||||
const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue,
|
||||
properties,
|
||||
snapshot,
|
||||
|
|
@ -47,42 +55,68 @@ const SingleIssue: React.FC<Props> = ({
|
|||
handleDeleteIssue,
|
||||
partialUpdateIssue,
|
||||
}) => {
|
||||
const { activeProject, states } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR<IssueResponse>(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId 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
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded bg-white shadow-sm ${
|
||||
snapshot && snapshot.isDragging ? "border-theme shadow-lg bg-indigo-50" : ""
|
||||
className={`rounded border bg-white shadow-sm ${
|
||||
snapshot && snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="group/card relative p-2 select-none">
|
||||
<div className="group/card relative select-none p-2">
|
||||
{handleDeleteIssue && (
|
||||
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1.5 right-1.5 z-10">
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 bg-white hover:bg-red-50 duration-300 outline-none"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||
onClick={() => handleDeleteIssue(issue.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="text-xs font-medium text-gray-500 mb-2">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="group-hover:text-theme text-sm mb-3"
|
||||
className="mb-3 text-sm group-hover:text-theme"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{issue.name}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<Listbox
|
||||
as="div"
|
||||
|
|
@ -96,7 +130,7 @@ const SingleIssue: React.FC<Props> = ({
|
|||
<>
|
||||
<div>
|
||||
<Listbox.Button
|
||||
className={`grid place-items-center rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
|
||||
className={`grid cursor-pointer place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
|
|
@ -118,14 +152,14 @@ const SingleIssue: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"flex items-center gap-2 cursor-pointer capitalize select-none px-3 py-2"
|
||||
"flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize"
|
||||
)
|
||||
}
|
||||
value={priority}
|
||||
|
|
@ -137,24 +171,6 @@ const SingleIssue: React.FC<Props> = ({
|
|||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
{/* <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> */}
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
|
|
@ -171,9 +187,9 @@ const SingleIssue: React.FC<Props> = ({
|
|||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button className="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">
|
||||
<Listbox.Button className="flex cursor-pointer items-center gap-1 rounded border 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">
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
|
|
@ -188,20 +204,20 @@ const SingleIssue: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-20 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{states?.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"flex items-center gap-2 cursor-pointer select-none px-3 py-2"
|
||||
"flex cursor-pointer select-none items-center gap-2 px-3 py-2"
|
||||
)
|
||||
}
|
||||
value={state.id}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
|
|
@ -220,19 +236,9 @@ const SingleIssue: React.FC<Props> = ({
|
|||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{properties.start_date && (
|
||||
<div className="group 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="fixed -translate-y-3/4 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 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 ${
|
||||
className={`group flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border 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 ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
|
|
@ -242,18 +248,11 @@ const SingleIssue: React.FC<Props> = ({
|
|||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
|
||||
{/* <div className="fixed -translate-y-3/4 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">Target 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>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border 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">
|
||||
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
|
|
@ -275,7 +274,7 @@ const SingleIssue: React.FC<Props> = ({
|
|||
<>
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index: number) => (
|
||||
<div
|
||||
|
|
@ -285,17 +284,19 @@ const SingleIssue: React.FC<Props> = ({
|
|||
}`}
|
||||
>
|
||||
{assignee.avatar && assignee.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={assignee.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={assignee?.first_name}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
|
||||
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
|
||||
{assignee.first_name && assignee.first_name !== ""
|
||||
? assignee.first_name.charAt(0)
|
||||
: assignee?.email?.charAt(0)}
|
||||
|
|
@ -304,13 +305,15 @@ const SingleIssue: React.FC<Props> = ({
|
|||
</div>
|
||||
))
|
||||
) : (
|
||||
<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%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="No user"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -324,7 +327,7 @@ const SingleIssue: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-20 mt-1 bg-white shadow-lg max-h-28 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-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
|
|
@ -355,10 +358,12 @@ const SingleIssue: React.FC<Props> = ({
|
|||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
|
||||
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name.charAt(0)
|
||||
: person.member.email.charAt(0)}
|
||||
|
|
@ -375,14 +380,6 @@ const SingleIssue: React.FC<Props> = ({
|
|||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
{/* <div className="absolute bottom-full left-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">Assigned to</h5>
|
||||
<div>
|
||||
{issue.assignee_details?.length > 0
|
||||
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
|
||||
: "No one"}
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
|
|
@ -393,4 +390,4 @@ const SingleIssue: React.FC<Props> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default SingleIssue;
|
||||
export default SingleBoardIssue;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
// react hook form
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
import projectService from "lib/services/project.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
|
|
@ -18,9 +20,10 @@ import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
|||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
import { LayerDiagonalIcon } from "ui/icons";
|
||||
|
||||
type FormInput = {
|
||||
issue_ids: string[];
|
||||
|
|
@ -35,25 +38,45 @@ type Props = {
|
|||
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { activeWorkspace, activeProject, issues } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = router;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormInput>();
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormInput>();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleDelete: SubmitHandler<FormInput> = (data) => {
|
||||
const handleDelete: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!data.issue_ids || data.issue_ids.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
|
|
@ -63,9 +86,11 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (activeWorkspace && activeProject) {
|
||||
issuesServices
|
||||
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
|
||||
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
|
||||
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesServices
|
||||
.bulkDeleteIssues(workspaceSlug as string, projectId as string, data)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
|
|
@ -73,7 +98,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
message: res.message,
|
||||
});
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
|
|
@ -129,7 +154,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
|
|
@ -139,58 +164,63 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<>
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to delete
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
|
|
@ -207,16 +237,16 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
)}
|
||||
</Combobox>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
|
||||
Delete selected issues
|
||||
</Button>
|
||||
<div>
|
||||
<Button type="button" size="sm" onClick={handleClose}>
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
|
||||
Delete selected issues
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// services
|
||||
import projectService from "lib/services/project.service";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
|
|
@ -12,9 +16,12 @@ import { Button } from "ui";
|
|||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
import { LayerDiagonalIcon } from "ui/icons";
|
||||
|
||||
type FormInput = {
|
||||
issues: string[];
|
||||
|
|
@ -37,7 +44,15 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { activeProject } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
|
|
@ -80,7 +95,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<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"
|
||||
|
|
@ -116,7 +131,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
|
@ -126,7 +141,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
|
|
@ -135,40 +150,50 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => {
|
||||
if ((type === "cycle" && !issue.issue_cycle) || type === "module")
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
|
|
@ -186,19 +211,21 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
|||
</Combobox>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : `Add to ${type}`}
|
||||
</Button>
|
||||
</div>
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : `Add to ${type}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
|
|
|||
163
apps/app/components/common/image-upload-modal.tsx
Normal file
163
apps/app/components/common/image-upload-modal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileServices from "lib/services/file.service";
|
||||
// icon
|
||||
import { UserCircleIcon } from "ui/icons";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
|
||||
type TImageUploadModalProps = {
|
||||
value?: string | null;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSuccess: (url: string) => void;
|
||||
};
|
||||
|
||||
export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
const { value, onSuccess, isOpen, onClose } = props;
|
||||
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
open: openFileDialog,
|
||||
} = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsImageUploading(true);
|
||||
|
||||
if (image === null || !workspaceSlug) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
fileServices
|
||||
.uploadFile(workspaceSlug as string, formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
onSuccess(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<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:w-full sm:max-w-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Upload Image
|
||||
</Dialog.Title>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative block w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-gray-300 hover:border-gray-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{value && value !== "" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFileDialog}
|
||||
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<NextImage
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
src={image ? URL.createObjectURL(image) : value}
|
||||
alt="image"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" />
|
||||
<span className="mt-2 block text-sm font-medium text-gray-900">
|
||||
{isDragActive
|
||||
? "Drop image here to upload"
|
||||
: "Drag & drop image here"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={isImageUploading || image === null}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
175
apps/app/components/common/list-view/single-issue.tsx
Normal file
175
apps/app/components/common/list-view/single-issue.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
// ui
|
||||
import { CustomMenu } from "ui";
|
||||
// icons
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse, Properties } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// common
|
||||
import {
|
||||
addSpaceIfCamelCase,
|
||||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
handleDeleteIssue: () => void;
|
||||
removeIssue: () => void;
|
||||
};
|
||||
|
||||
const SingleListIssue: React.FC<Props> = ({
|
||||
type,
|
||||
issue,
|
||||
properties,
|
||||
editIssue,
|
||||
handleDeleteIssue,
|
||||
removeIssue,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
let { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR<IssueResponse>(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.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">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
)}
|
||||
<span>{issue.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<div
|
||||
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
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 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium 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 flex-shrink-0 cursor-pointer items-center gap-1 rounded border 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">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue?.state_detail?.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(issue?.state_detail.name)}
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium">State</h5>
|
||||
<div>{issue?.state_detail.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<div
|
||||
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border 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 ${
|
||||
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 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium 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>
|
||||
)}
|
||||
{properties.sub_issue_count && projectId && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border 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">
|
||||
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{type && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => editIssue()}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => removeIssue()}>
|
||||
<>Remove from {type}</>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue()}>
|
||||
Delete permanently
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleListIssue;
|
||||
1
apps/app/components/core/index.ts
Normal file
1
apps/app/components/core/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./not-authorized-view";
|
||||
59
apps/app/components/core/not-authorized-view.tsx
Normal file
59
apps/app/components/core/not-authorized-view.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { LockIcon } from "ui/icons";
|
||||
|
||||
type TNotAuthorizedViewProps = {
|
||||
actionButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
|
||||
const { actionButton } = props;
|
||||
|
||||
const { user } = useUser();
|
||||
const { asPath: currentPath } = useRouter();
|
||||
|
||||
return (
|
||||
<DefaultLayout
|
||||
meta={{
|
||||
title: "Plane - Not Authorized",
|
||||
description: "You are not authorized to view this page",
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<LockIcon className="h-16 w-16 text-gray-400" />
|
||||
<h1 className="text-xl font-medium text-gray-900">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
{user ? (
|
||||
<p className="text-base font-light">
|
||||
You have signed in as <span className="font-medium">{user.email}</span>.{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-base font-light">
|
||||
You need to{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionButton}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
179
apps/app/components/core/view.tsx
Normal file
179
apps/app/components/core/view.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import useIssuesProperties from "lib/hooks/useIssuesProperties";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomMenu } from "ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, NestedKeyOf, Properties } from "types";
|
||||
// common
|
||||
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
|
||||
// constants
|
||||
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
|
||||
|
||||
type Props = {
|
||||
groupByProperty: NestedKeyOf<IIssue> | null;
|
||||
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
setOrderBy: (property: NestedKeyOf<IIssue> | null) => void;
|
||||
filterIssue: "activeIssue" | "backlogIssue" | null;
|
||||
setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void;
|
||||
resetFilterToDefault: () => void;
|
||||
setNewFilterDefaultView: () => void;
|
||||
};
|
||||
|
||||
const View: React.FC<Props> = ({
|
||||
groupByProperty,
|
||||
setGroupByProperty,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
filterIssue,
|
||||
setFilterIssue,
|
||||
resetFilterToDefault,
|
||||
setNewFilterDefaultView,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const [properties, setProperties] = useIssuesProperties(
|
||||
workspaceSlug as string,
|
||||
projectId as string
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={classNames(
|
||||
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
|
||||
"group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
|
||||
)}
|
||||
>
|
||||
<span>View</span>
|
||||
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
|
||||
<div className="relative divide-y-2">
|
||||
<div className="space-y-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Group by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{groupByOptions.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setGroupByProperty(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Order by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
orderByOptions.find((option) => option.key === orderBy)?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{orderByOptions.map((option) =>
|
||||
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setOrderBy(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Issue type</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{filterIssueOptions.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setFilterIssue(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs"
|
||||
onClick={() => resetFilterToDefault()}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-theme"
|
||||
onClick={() => setNewFilterDefaultView()}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default View;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
||||
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
||||
import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||
// theme
|
||||
import { defaultTheme } from "./theme";
|
||||
|
||||
export const initialConfig = {
|
||||
namespace: "LexicalEditor",
|
||||
// The editor theme
|
||||
theme: defaultTheme,
|
||||
// Handling of errors during update
|
||||
onError(error: any) {
|
||||
console.error(error);
|
||||
},
|
||||
// Any custom nodes go here
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
CodeHighlightNode,
|
||||
TableNode,
|
||||
TableCellNode,
|
||||
TableRowNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
],
|
||||
};
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import {
|
||||
EditorState,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
SerializedEditorState,
|
||||
LexicalEditor,
|
||||
} from "lexical";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
|
||||
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
||||
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
||||
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
|
||||
import { TRANSFORMERS, CHECK_LIST } from "@lexical/markdown";
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
|
||||
import { $generateHtmlFromNodes } from "@lexical/html";
|
||||
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
|
||||
|
||||
// custom plugins
|
||||
import { CodeHighlightPlugin } from "./plugins/code-highlight";
|
||||
import { LexicalToolbar } from "./toolbar";
|
||||
// config
|
||||
import { initialConfig } from "./config";
|
||||
// helpers
|
||||
import { getValidatedValue } from "./helpers/editor";
|
||||
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
onChange: (state: string) => void;
|
||||
id: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||
onChange,
|
||||
id,
|
||||
value,
|
||||
placeholder = "Enter some text...",
|
||||
}) => {
|
||||
const handleChange = (editorState: EditorState) => {
|
||||
editorState.read(() => {
|
||||
onChange(JSON.stringify(editorState.toJSON()));
|
||||
});
|
||||
};
|
||||
|
||||
// function handleChange(state: EditorState, editor: LexicalEditor) {
|
||||
// state.read(() => {
|
||||
// onChange(state.toJSON());
|
||||
// });
|
||||
// }
|
||||
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
...initialConfig,
|
||||
namespace: id || "Lexical Editor",
|
||||
editorState: getValidatedValue(value),
|
||||
}}
|
||||
>
|
||||
<div className="border border-[#e2e2e2] rounded-md">
|
||||
<LexicalToolbar />
|
||||
<div className="relative">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className='className="h-[450px] outline-none py-[15px] px-2.5 resize-none overflow-hidden text-ellipsis' />
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
placeholder={
|
||||
<div className="absolute top-4 left-3 pointer-events-none select-none text-gray-400">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
<HistoryPlugin />
|
||||
<CodeHighlightPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<CheckListPlugin />
|
||||
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
|
||||
</div>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
export const positionEditorElement = (editor: any, rect: any) => {
|
||||
if (window) {
|
||||
if (rect === null) {
|
||||
editor.style.opacity = "0";
|
||||
editor.style.top = "-1000px";
|
||||
editor.style.left = "-1000px";
|
||||
} else {
|
||||
editor.style.opacity = "1";
|
||||
editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`;
|
||||
editor.style.left = `${
|
||||
rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2
|
||||
}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getValidatedValue = (value: string) => {
|
||||
const defaultValue =
|
||||
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
|
||||
|
||||
console.log("Value: ", value);
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
const data = JSON.parse(value);
|
||||
return JSON.stringify(data);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
};
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { $isAtNodeEnd } from "@lexical/selection";
|
||||
|
||||
export const getSelectedNode = (selection: any) => {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
if (anchorNode === focusNode) {
|
||||
return anchorNode;
|
||||
}
|
||||
const isBackward = selection.isBackward();
|
||||
if (isBackward) {
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
|
||||
} else {
|
||||
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { registerCodeHighlighting } from "@lexical/code";
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
|
||||
export const CodeHighlightPlugin = () => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
return registerCodeHighlighting(editor);
|
||||
}, [editor]);
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { getValidatedValue } from "../helpers/editor";
|
||||
|
||||
const ReadOnlyPlugin = ({ value }: { value: string }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value) {
|
||||
const initialEditorState = editor?.parseEditorState(
|
||||
getValidatedValue(value) || ""
|
||||
);
|
||||
editor.setEditorState(initialEditorState);
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default ReadOnlyPlugin;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
export const defaultTheme = {
|
||||
ltr: "ltr",
|
||||
rtl: "rtl",
|
||||
placeholder: "editor-placeholder",
|
||||
paragraph: "mb-1",
|
||||
quote: "editor-quote",
|
||||
heading: {
|
||||
h1: "text-3xl font-bold",
|
||||
h2: "text-2xl font-bold",
|
||||
h3: "text-xl font-bold",
|
||||
h4: "text-lg font-bold",
|
||||
h5: "text-base font-bold",
|
||||
},
|
||||
list: {
|
||||
nested: {
|
||||
listitem: "list-item",
|
||||
},
|
||||
ol: "list-decimal pl-4",
|
||||
ul: "list-disc pl-4",
|
||||
listitem: "list-item",
|
||||
},
|
||||
image: "editor-image",
|
||||
link: "editor-link",
|
||||
text: {
|
||||
bold: "font-bold",
|
||||
italic: "italic",
|
||||
overflowed: "editor-text-overflowed",
|
||||
hashtag: "editor-text-hashtag",
|
||||
underline: "underline",
|
||||
strikethrough: "line-through",
|
||||
underlineStrikethrough: "editor-text-underlineStrikethrough",
|
||||
code: "editor-text-code",
|
||||
},
|
||||
code: "editor-code",
|
||||
codeHighlight: {
|
||||
atrule: "editor-tokenAttr",
|
||||
attr: "editor-tokenAttr",
|
||||
boolean: "editor-tokenProperty",
|
||||
builtin: "editor-tokenSelector",
|
||||
cdata: "editor-tokenComment",
|
||||
char: "editor-tokenSelector",
|
||||
class: "editor-tokenFunction",
|
||||
"class-name": "editor-tokenFunction",
|
||||
comment: "editor-tokenComment",
|
||||
constant: "editor-tokenProperty",
|
||||
deleted: "editor-tokenProperty",
|
||||
doctype: "editor-tokenComment",
|
||||
entity: "editor-tokenOperator",
|
||||
function: "editor-tokenFunction",
|
||||
important: "editor-tokenVariable",
|
||||
inserted: "editor-tokenSelector",
|
||||
keyword: "editor-tokenAttr",
|
||||
namespace: "editor-tokenVariable",
|
||||
number: "editor-tokenProperty",
|
||||
operator: "editor-tokenOperator",
|
||||
prolog: "editor-tokenComment",
|
||||
property: "editor-tokenProperty",
|
||||
punctuation: "editor-tokenPunctuation",
|
||||
regex: "editor-tokenVariable",
|
||||
selector: "editor-tokenSelector",
|
||||
string: "editor-tokenSelector",
|
||||
symbol: "editor-tokenProperty",
|
||||
tag: "editor-tokenProperty",
|
||||
url: "editor-tokenOperator",
|
||||
variable: "editor-tokenVariable",
|
||||
},
|
||||
};
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
import { FC, Fragment, useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
REDO_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$createParagraphNode,
|
||||
$getNodeByKey,
|
||||
} from "lexical";
|
||||
import {
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
INSERT_CHECK_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
$isListNode,
|
||||
ListNode,
|
||||
} from "@lexical/list";
|
||||
import { $isParentElementRTL, $isAtNodeEnd, $wrapNodes } from "@lexical/selection";
|
||||
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from "@lexical/rich-text";
|
||||
import {
|
||||
$createCodeNode,
|
||||
$isCodeNode,
|
||||
getDefaultCodeLanguage,
|
||||
getCodeLanguages,
|
||||
} from "@lexical/code";
|
||||
|
||||
const BLOCK_DATA = [
|
||||
{ type: "paragraph", name: "Normal" },
|
||||
{ type: "h1", name: "Large Heading" },
|
||||
{ type: "h2", name: "Small Heading" },
|
||||
{ type: "h3", name: "Heading" },
|
||||
{ type: "h4", name: "Heading" },
|
||||
{ type: "h5", name: "Heading" },
|
||||
{ type: "Quote", name: "quote" },
|
||||
{ type: "ol", name: "Numbered List" },
|
||||
{ type: "ul", name: "Bulleted List" },
|
||||
];
|
||||
|
||||
const supportedBlockTypes = new Set(["paragraph", "quote", "code", "h1", "h2", "ul", "ol"]);
|
||||
|
||||
const blockTypeToBlockName: any = {
|
||||
code: "Code Block",
|
||||
h1: "Large Heading",
|
||||
h2: "Small Heading",
|
||||
h3: "Heading",
|
||||
h4: "Heading",
|
||||
h5: "Heading",
|
||||
ol: "Numbered List",
|
||||
paragraph: "Normal",
|
||||
quote: "Quote",
|
||||
ul: "Bulleted List",
|
||||
};
|
||||
|
||||
export interface BlockTypeSelectProps {
|
||||
editor: any;
|
||||
toolbarRef: any;
|
||||
blockType: string;
|
||||
}
|
||||
|
||||
export const BlockTypeSelect: FC<BlockTypeSelectProps> = (props) => {
|
||||
const { editor, toolbarRef, blockType } = props;
|
||||
// refs
|
||||
const dropDownRef = useRef<any>(null);
|
||||
// states
|
||||
const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const toolbar = toolbarRef.current;
|
||||
const dropDown = dropDownRef.current;
|
||||
|
||||
if (toolbar !== null && dropDown !== null) {
|
||||
const { top, left } = toolbar.getBoundingClientRect();
|
||||
dropDown.style.top = `${top + 40}px`;
|
||||
dropDown.style.left = `${left}px`;
|
||||
}
|
||||
}, [dropDownRef, toolbarRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const dropDown = dropDownRef.current;
|
||||
const toolbar = toolbarRef.current;
|
||||
|
||||
if (dropDown !== null && toolbar !== null) {
|
||||
const handle = (event: any) => {
|
||||
const target = event.target;
|
||||
|
||||
if (!dropDown.contains(target) && !toolbar.contains(target)) {
|
||||
setShowBlockOptionsDropDown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handle);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handle);
|
||||
};
|
||||
}
|
||||
}, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]);
|
||||
|
||||
const formatParagraph = () => {
|
||||
if (blockType !== "paragraph") {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createParagraphNode());
|
||||
}
|
||||
});
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatLargeHeading = () => {
|
||||
console.log("blockType ", blockType);
|
||||
if (blockType !== "h1") {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createHeadingNode("h1"));
|
||||
}
|
||||
});
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatSmallHeading = () => {
|
||||
if (blockType !== "h2") {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createHeadingNode("h2"));
|
||||
}
|
||||
});
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatBulletList = () => {
|
||||
if (blockType !== "ul") {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND);
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatNumberedList = () => {
|
||||
if (blockType !== "ol") {
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND);
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND);
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatQuote = () => {
|
||||
if (blockType !== "quote") {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createQuoteNode());
|
||||
}
|
||||
});
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
const formatCode = () => {
|
||||
if (blockType !== "code") {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createCodeNode());
|
||||
}
|
||||
});
|
||||
}
|
||||
setShowBlockOptionsDropDown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 mr-2 text-sm flex items-center"
|
||||
onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)}
|
||||
aria-label="Formatting Options"
|
||||
>
|
||||
<span className="mr-2">{blockTypeToBlockName[blockType]}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-chevron-down"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M1.646 4.646a.5.5 0 01.708 0L8 10.293l5.646-5.647a.5.5 0 01.708.708l-6 6a.5.5 0 01-.708 0l-6-6a.5.5 0 010-.708z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
{showBlockOptionsDropDown && (
|
||||
<ul
|
||||
className="absolute mt-1 w-full min-w-[160px] overflow-auto rounded-md bg-white z-10 p-1 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
ref={dropDownRef}
|
||||
>
|
||||
<li className="p-1 cursor-pointer" onClick={formatParagraph}>
|
||||
<span className="icon paragraph" />
|
||||
<span className="text">Normal</span>
|
||||
{blockType === "paragraph" && <span className="active" />}
|
||||
</li>
|
||||
<li className="p-1 cursor-pointer" onClick={formatLargeHeading}>
|
||||
<span className="icon large-heading" />
|
||||
<span className="text">Large Heading</span>
|
||||
{blockType === "h1" && <span className="active" />}
|
||||
</li>
|
||||
<li className="p-1 cursor-pointer" onClick={formatSmallHeading}>
|
||||
<span className="icon small-heading" />
|
||||
<span className="text">Small Heading</span>
|
||||
{blockType === "h2" && <span className="active" />}
|
||||
</li>
|
||||
<li className="p-1 cursor-pointer" onClick={formatBulletList}>
|
||||
<span className="icon bullet-list" />
|
||||
<span className="text">Bullet List</span>
|
||||
{blockType === "ul" && <span className="active" />}
|
||||
</li>
|
||||
<li className="p-1 cursor-pointer" onClick={formatNumberedList}>
|
||||
<span className="icon numbered-list" />
|
||||
<span className="text">Numbered List</span>
|
||||
{blockType === "ol" && <span className="active" />}
|
||||
</li>
|
||||
|
||||
<li className="p-1 cursor-pointer" onClick={formatQuote}>
|
||||
<span className="icon quote" />
|
||||
<span className="text">Quote</span>
|
||||
{blockType === "quote" && <span className="active" />}
|
||||
</li>
|
||||
{/* <button className="item" onClick={formatCode}>
|
||||
<span className="icon code" />
|
||||
<span className="text">Code Block</span>
|
||||
{blockType === 'code' && <span className="active" />}
|
||||
</button> */}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// export const BlockTypeSelect: FC<any> = () => {
|
||||
// const [selected, setSelected] = useState(BLOCK_DATA[0]);
|
||||
|
||||
// return (
|
||||
// <div className="inline-flex pr-1">
|
||||
// <Listbox value={selected} onChange={setSelected}>
|
||||
// <div className="relative">
|
||||
// <Listbox.Button className="relative w-full min-w-[160px] cursor-default rounded border border-[#e2e2e2] bg-white py-1 pl-3 pr-10 text-left outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 text-xs">
|
||||
// <span className="block truncate">{selected.name}</span>
|
||||
// <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
// <svg
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// width="16"
|
||||
// height="16"
|
||||
// fill="currentColor"
|
||||
// className="bi bi-chevron-down"
|
||||
// viewBox="0 0 16 16"
|
||||
// >
|
||||
// <path
|
||||
// fillRule="evenodd"
|
||||
// d="M1.646 4.646a.5.5 0 01.708 0L8 10.293l5.646-5.647a.5.5 0 01.708.708l-6 6a.5.5 0 01-.708 0l-6-6a.5.5 0 010-.708z"
|
||||
// ></path>
|
||||
// </svg>
|
||||
// </span>
|
||||
// </Listbox.Button>
|
||||
// <Transition
|
||||
// as={Fragment}
|
||||
// leave="transition ease-in duration-100"
|
||||
// leaveFrom="opacity-100"
|
||||
// leaveTo="opacity-0"
|
||||
// >
|
||||
// <Listbox.Options className="absolute mt-1 max-h-60 w-full min-w-[160px] overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
// {BLOCK_DATA.map((blockType, index) => (
|
||||
// <Listbox.Option
|
||||
// key={index}
|
||||
// className={({ active }) =>
|
||||
// `relative cursor-default select-none py-2 px-2 ${
|
||||
// active ? 'bg-amber-100 text-amber-900' : 'text-gray-900'
|
||||
// }`
|
||||
// }
|
||||
// value={blockType}
|
||||
// >
|
||||
// {({ selected }) => (
|
||||
// <>
|
||||
// <span
|
||||
// className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}
|
||||
// >
|
||||
// {blockType.name}
|
||||
// </span>
|
||||
// </>
|
||||
// )}
|
||||
// </Listbox.Option>
|
||||
// ))}
|
||||
// </Listbox.Options>
|
||||
// </Transition>
|
||||
// </div>
|
||||
// </Listbox>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { SELECTION_CHANGE_COMMAND, $getSelection, $isRangeSelection } from 'lexical';
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
|
||||
// helper functions
|
||||
import { positionEditorElement } from '../helpers/editor';
|
||||
import { getSelectedNode } from '../helpers/node';
|
||||
|
||||
const LowPriority = 1;
|
||||
|
||||
export interface FloatingLinkEditorProps {
|
||||
editor: any;
|
||||
}
|
||||
|
||||
export const FloatingLinkEditor = ({ editor }: FloatingLinkEditorProps) => {
|
||||
// refs
|
||||
const editorRef = useRef<any>(null);
|
||||
const inputRef = useRef<any>(null);
|
||||
const mouseDownRef = useRef(false);
|
||||
// states
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const [lastSelection, setLastSelection] = useState<any>(null);
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL());
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL());
|
||||
} else {
|
||||
setLinkUrl('');
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current;
|
||||
const nativeSelection = window?.getSelection();
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (editorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
if (
|
||||
selection !== null &&
|
||||
!nativeSelection?.isCollapsed &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection?.anchorNode)
|
||||
) {
|
||||
const domRange = nativeSelection?.getRangeAt(0);
|
||||
let rect;
|
||||
if (nativeSelection?.anchorNode === rootElement) {
|
||||
let inner = rootElement;
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild;
|
||||
}
|
||||
rect = inner.getBoundingClientRect();
|
||||
} else {
|
||||
rect = domRange?.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!mouseDownRef.current) {
|
||||
positionEditorElement(editorElem, rect);
|
||||
}
|
||||
setLastSelection(selection);
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
positionEditorElement(editorElem, null);
|
||||
setLastSelection(null);
|
||||
setEditMode(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }: any) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor();
|
||||
return true;
|
||||
},
|
||||
LowPriority
|
||||
)
|
||||
);
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef?.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className="link-editor">
|
||||
{isEditMode ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="link-input"
|
||||
value={linkUrl}
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (lastSelection !== null) {
|
||||
if (linkUrl !== '') {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
|
||||
}
|
||||
setEditMode(false);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditMode(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="link-input">
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{linkUrl}
|
||||
</a>
|
||||
<div
|
||||
className="link-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
REDO_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$createParagraphNode,
|
||||
$getNodeByKey,
|
||||
RangeSelection,
|
||||
NodeSelection,
|
||||
GridSelection,
|
||||
} from "lexical";
|
||||
import {
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
$isListNode,
|
||||
ListNode,
|
||||
} from "@lexical/list";
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { $isParentElementRTL, $wrapNodes, $isAtNodeEnd } from "@lexical/selection";
|
||||
import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils";
|
||||
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from "@lexical/rich-text";
|
||||
// custom elements
|
||||
import { FloatingLinkEditor } from "./floating-link-editor";
|
||||
import { BlockTypeSelect } from "./block-type-select";
|
||||
|
||||
const LowPriority = 1;
|
||||
|
||||
function getSelectedNode(selection: any) {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
if (anchorNode === focusNode) {
|
||||
return anchorNode;
|
||||
}
|
||||
const isBackward = selection.isBackward();
|
||||
if (isBackward) {
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
|
||||
} else {
|
||||
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
|
||||
}
|
||||
}
|
||||
|
||||
export const LexicalToolbar = () => {
|
||||
// editor
|
||||
const [editor] = useLexicalComposerContext();
|
||||
// ref
|
||||
const toolbarRef = useRef(null);
|
||||
// states
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [blockType, setBlockType] = useState("paragraph");
|
||||
const [selectedElementKey, setSelectedElementKey] = useState<string | null>(null);
|
||||
const [isRTL, setIsRTL] = useState(false);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
const [isCode, setIsCode] = useState(false);
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const element =
|
||||
anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow();
|
||||
const elementKey = element.getKey();
|
||||
const elementDOM = editor.getElementByKey(elementKey);
|
||||
if (elementDOM !== null) {
|
||||
setSelectedElementKey(elementKey);
|
||||
if ($isListNode(element)) {
|
||||
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
|
||||
const type = parentList ? parentList.getTag() : element.getTag();
|
||||
setBlockType(type);
|
||||
} else {
|
||||
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
|
||||
setBlockType(type);
|
||||
}
|
||||
}
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat("bold"));
|
||||
setIsItalic(selection.hasFormat("italic"));
|
||||
setIsUnderline(selection.hasFormat("underline"));
|
||||
setIsStrikethrough(selection.hasFormat("strikethrough"));
|
||||
setIsRTL($isParentElementRTL(selection));
|
||||
|
||||
// Update links
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateToolbar();
|
||||
});
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar();
|
||||
return false;
|
||||
},
|
||||
LowPriority
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_UNDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanUndo(payload);
|
||||
return false;
|
||||
},
|
||||
LowPriority
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_REDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanRedo(payload);
|
||||
return false;
|
||||
},
|
||||
LowPriority
|
||||
)
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
|
||||
const insertLink = useCallback(
|
||||
(e: any) => {
|
||||
e.preventDefault();
|
||||
if (!isLink) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}
|
||||
},
|
||||
[editor, isLink]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center mb-1 p-1 w-full flex-wrap border-b " ref={toolbarRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUndo}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Undo"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-arrow-counterclockwise"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 3a5 5 0 11-4.546 2.914.5.5 0 00-.908-.417A6 6 0 108 2v1z"
|
||||
></path>
|
||||
<path d="M8 4.466V.534a.25.25 0 00-.41-.192L5.23 2.308a.25.25 0 000 .384l2.36 1.966A.25.25 0 008 4.466z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRedo}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Redo"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-arrow-clockwise"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 3a5 5 0 104.546 2.914.5.5 0 01.908-.417A6 6 0 118 2v1z"
|
||||
></path>
|
||||
<path d="M8 4.466V.534a.25.25 0 01.41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 018 4.466z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<BlockTypeSelect editor={editor} toolbarRef={toolbarRef} blockType={blockType} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||
}}
|
||||
className={`p-2 mr-2 ${isBold ? "active" : ""}`}
|
||||
aria-label="Format Bold"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-type-bold"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 001.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||
}}
|
||||
className={"p-2 mr-2" + (isItalic ? "active" : "")}
|
||||
aria-label="Format Italics"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-type-italic"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M7.991 11.674L9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
|
||||
}}
|
||||
className={"p-2 mr-2" + (isUnderline ? "active" : "")}
|
||||
aria-label="Format Underline"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-type-underline"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
|
||||
}}
|
||||
className={"p-2 mr-2" + (isStrikethrough ? "active" : "")}
|
||||
aria-label="Format Strikethrough"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-type-strikethrough"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 01-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
|
||||
}}
|
||||
className={"p-2 mr-2 " + (isCode ? "active" : "")}
|
||||
aria-label="Insert Code"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-code"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertLink}
|
||||
className={"p-2 mr-2 " + (isLink ? "active" : "")}
|
||||
aria-label="Insert Link"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-link"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M6.354 5.5H4a3 3 0 000 6h3a3 3 0 002.83-4H9c-.086 0-.17.01-.25.031A2 2 0 017 10.5H4a2 2 0 110-4h1.535c.218-.376.495-.714.82-1z"></path>
|
||||
<path d="M9 5.5a3 3 0 00-2.83 4h1.098A2 2 0 019 6.5h3a2 2 0 110 4h-1.535a4.02 4.02 0 01-.82 1H12a3 3 0 100-6H9z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Left Align"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-text-left"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center");
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Center Align"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-text-center"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm2-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right");
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Right Align"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-text-right"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm4-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify");
|
||||
}}
|
||||
className="p-2 mr-2"
|
||||
aria-label="Justify Align"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-justify"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 12.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
|
||||
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
||||
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
||||
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
|
||||
import { TRANSFORMERS } from "@lexical/markdown";
|
||||
// custom plugins
|
||||
import { CodeHighlightPlugin } from "./plugins/code-highlight";
|
||||
import ReadOnlyPlugin from "./plugins/read-only";
|
||||
// config
|
||||
import { initialConfig } from "./config";
|
||||
// helpers
|
||||
import { getValidatedValue } from "./helpers/editor";
|
||||
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
|
||||
|
||||
export interface RichTextViewerProps {
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const RichTextViewer: React.FC<RichTextViewerProps> = ({ value, id }) => {
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
...initialConfig,
|
||||
namespace: id || "Lexical Editor",
|
||||
editorState: getValidatedValue(value),
|
||||
editable: false,
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className='className="h-[450px] outline-none resize-none overflow-hidden text-ellipsis' />
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
placeholder={
|
||||
<div className="absolute top-[15px] left-[10px] pointer-events-none select-none text-gray-400">
|
||||
Enter some text...
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ReadOnlyPlugin value={value} />
|
||||
<HistoryPlugin />
|
||||
<CodeHighlightPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextViewer;
|
||||
35
apps/app/components/onboarding/break-into-modules.tsx
Normal file
35
apps/app/components/onboarding/break-into-modules.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// images
|
||||
import Module from "public/onboarding/module.png";
|
||||
|
||||
const BreakIntoModules: React.FC = () => {
|
||||
return (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="relative h-1/2">
|
||||
<div
|
||||
className="absolute bottom-0 z-10 h-8 w-full bg-white"
|
||||
style={{
|
||||
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
|
||||
}}
|
||||
></div>
|
||||
<Image
|
||||
src={Module}
|
||||
className="h-full"
|
||||
objectFit="contain"
|
||||
layout="fill"
|
||||
alt="Plane- Modules"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto h-1/2 space-y-4 lg:w-1/2">
|
||||
<h2 className="text-2xl font-medium">Break into Modules</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Modules break your big think into Projects or Features, to help you organize better.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">4/5</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreakIntoModules;
|
||||
26
apps/app/components/onboarding/command-menu.tsx
Normal file
26
apps/app/components/onboarding/command-menu.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// images
|
||||
import Commands from "public/onboarding/command-menu.png";
|
||||
|
||||
const CommandMenu: React.FC = () => {
|
||||
return (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="h-1/2 space-y-4">
|
||||
<h5 className="text-sm text-gray-500">Open the contextual menu with:</h5>
|
||||
<div className="relative h-1/2">
|
||||
<Image src={Commands} objectFit="contain" layout="fill" alt="Plane- Issues" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
|
||||
<h2 className="text-2xl font-medium">Command Menu</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
With Command Menu, you can create, update and navigate across the platform.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">5/5</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandMenu;
|
||||
81
apps/app/components/onboarding/invite-members.tsx
Normal file
81
apps/app/components/onboarding/invite-members.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// types
|
||||
import useToast from "lib/hooks/useToast";
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IUser } from "types";
|
||||
import MultiInput from "ui/multi-input";
|
||||
import OutlineButton from "ui/outline-button";
|
||||
|
||||
type Props = {
|
||||
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
workspace: any;
|
||||
};
|
||||
|
||||
const InviteMembers: React.FC<Props> = ({ setStep, workspace }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
setValue,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<IUser>();
|
||||
|
||||
const onSubmit = async (formData: IUser) => {
|
||||
await workspaceService
|
||||
.inviteWorkspace(workspace.slug, formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Invitations sent!",
|
||||
});
|
||||
setStep(4);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid w-full place-items-center space-y-8"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Enter") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="w-full space-y-8 rounded-lg bg-white p-8 md:w-2/5">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-medium">Invite co-workers to your team</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<MultiInput
|
||||
label="Enter e-mails to invite"
|
||||
name="emails"
|
||||
placeholder="dummy@plane.so"
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex h-1/4 gap-2 lg:w-1/2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Inviting..." : "Invite"}
|
||||
</button>
|
||||
<OutlineButton theme="secondary" className="w-full" onClick={() => setStep(4)}>
|
||||
Skip
|
||||
</OutlineButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteMembers;
|
||||
36
apps/app/components/onboarding/move-with-cycles.tsx
Normal file
36
apps/app/components/onboarding/move-with-cycles.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// images
|
||||
import Cycle from "public/onboarding/cycle.png";
|
||||
|
||||
const MoveWithCycles: React.FC = () => {
|
||||
return (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="relative h-1/2">
|
||||
<div
|
||||
className="absolute bottom-0 z-10 h-8 w-full bg-white"
|
||||
style={{
|
||||
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
|
||||
}}
|
||||
></div>
|
||||
<Image
|
||||
src={Cycle}
|
||||
className="h-full"
|
||||
objectFit="contain"
|
||||
layout="fill"
|
||||
alt="Plane- Cycles"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
|
||||
<h2 className="text-2xl font-medium">Move with Cycles</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Cycles help you and your team to progress faster, similar to the sprints commonly used in
|
||||
agile development.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">3/5</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoveWithCycles;
|
||||
36
apps/app/components/onboarding/plan-with-issues.tsx
Normal file
36
apps/app/components/onboarding/plan-with-issues.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// images
|
||||
import Issue from "public/onboarding/issue.png";
|
||||
|
||||
const PlanWithIssues: React.FC = () => {
|
||||
return (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="relative h-1/2">
|
||||
<div
|
||||
className="absolute bottom-0 z-10 h-8 w-full bg-white"
|
||||
style={{
|
||||
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
|
||||
}}
|
||||
></div>
|
||||
<Image
|
||||
src={Issue}
|
||||
className="h-full"
|
||||
objectFit="contain"
|
||||
layout="fill"
|
||||
alt="Plane- Issues"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
|
||||
<h2 className="text-2xl font-medium">Plan with Issues</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
The issue is the building block of the Plane. Most concepts in Plane are either associated
|
||||
with issues and their properties.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">2/5</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanWithIssues;
|
||||
119
apps/app/components/onboarding/user-details.tsx
Normal file
119
apps/app/components/onboarding/user-details.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// hooks
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
// ui
|
||||
import { Input } from "ui";
|
||||
// types
|
||||
import { IUser } from "types";
|
||||
|
||||
const defaultValues: Partial<IUser> = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user?: IUser;
|
||||
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
const UserDetails: React.FC<Props> = ({ user, setStep }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IUser>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = (formData: IUser) => {
|
||||
userService
|
||||
.updateUser(formData)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
title: "User details updated successfully!",
|
||||
type: "success",
|
||||
});
|
||||
setStep(2);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user)
|
||||
reset({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
});
|
||||
}, [user, reset]);
|
||||
|
||||
return (
|
||||
<form className="grid w-full place-items-center" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="w-full space-y-8 rounded-lg bg-white p-8 md:w-2/5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label="First Name"
|
||||
name="first_name"
|
||||
placeholder="Enter first name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "First name is required",
|
||||
}}
|
||||
error={errors.first_name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Last Name"
|
||||
name="last_name"
|
||||
placeholder="Enter last name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Last name is required",
|
||||
}}
|
||||
error={errors.last_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Role"
|
||||
name="role"
|
||||
placeholder="What is your role?"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Role is required",
|
||||
}}
|
||||
error={errors.role}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto h-1/4 lg:w-1/2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetails;
|
||||
23
apps/app/components/onboarding/welcome.tsx
Normal file
23
apps/app/components/onboarding/welcome.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// icons
|
||||
import Logo from "public/logo.png";
|
||||
|
||||
const Welcome: React.FC = () => {
|
||||
return (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="h-1/2">
|
||||
<Image src={Logo} height={100} width={100} alt="Plane Logo" />
|
||||
</div>
|
||||
<div className="mx-auto h-1/2 space-y-4 lg:w-2/3">
|
||||
<h2 className="text-2xl font-medium">Welcome to Plane</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Plane helps you plan your issues, cycles, and product modules to ship faster.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">1/5</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
297
apps/app/components/onboarding/workspace.tsx
Normal file
297
apps/app/components/onboarding/workspace.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
// hooks
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// ui
|
||||
import { CustomSelect, Input } from "ui";
|
||||
// constants
|
||||
import { companySize } from "constants/";
|
||||
// fetch-keys
|
||||
import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
// types
|
||||
import { IWorkspace, IWorkspaceMemberInvitation } from "types";
|
||||
|
||||
type Props = {
|
||||
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
setWorkspace: React.Dispatch<React.SetStateAction<any>>;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IWorkspace> = {
|
||||
name: "",
|
||||
slug: "",
|
||||
company_size: null,
|
||||
};
|
||||
|
||||
const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
|
||||
const [slugError, setSlugError] = useState(false);
|
||||
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
|
||||
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
|
||||
workspaceService.userWorkspaceInvitations()
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IWorkspace>({ defaultValues });
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
.workspaceSlugCheck(formData.slug)
|
||||
.then((res) => {
|
||||
if (res.status === true) {
|
||||
workspaceService
|
||||
.createWorkspace(formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Workspace created successfully!",
|
||||
});
|
||||
setWorkspace(res);
|
||||
setStep(3);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
} else setSlugError(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleInvitation = (
|
||||
workspace_invitation: IWorkspaceMemberInvitation,
|
||||
action: "accepted" | "withdraw"
|
||||
) => {
|
||||
if (action === "accepted") {
|
||||
setInvitationsRespond((prevData) => {
|
||||
return [...prevData, workspace_invitation.id];
|
||||
});
|
||||
} else if (action === "withdraw") {
|
||||
setInvitationsRespond((prevData) => {
|
||||
return prevData.filter((item: string) => item !== workspace_invitation.id);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submitInvitations = async () => {
|
||||
if (invitationsRespond.length <= 0) return;
|
||||
setIsJoiningWorkspaces(true);
|
||||
await workspaceService
|
||||
.joinWorkspaces({ invitations: invitationsRespond })
|
||||
.then(async () => {
|
||||
await mutate();
|
||||
setStep(4);
|
||||
setIsJoiningWorkspaces(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsJoiningWorkspaces(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<div className="grid w-full place-items-center">
|
||||
<Tab.Group as="div" className="w-full rounded-lg bg-white p-8 md:w-2/5">
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="grid grid-cols-2 items-center gap-2 rounded-lg bg-gray-100 p-2 text-sm"
|
||||
>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
|
||||
}
|
||||
>
|
||||
New workspace
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
|
||||
}
|
||||
>
|
||||
Invited workspaces
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<form className="mt-4 space-y-8" onSubmit={handleSubmit(handleCreateWorkspace)}>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Workspace name"
|
||||
name="name"
|
||||
placeholder="Enter name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
onChange={(e) =>
|
||||
setValue("slug", e.target.value.toLocaleLowerCase().replace(/ /g, "-"))
|
||||
}
|
||||
validations={{
|
||||
required: "Workspace name is required",
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-gray-500">Workspace slug</h6>
|
||||
<div className="flex items-center rounded-md border border-gray-300 px-3">
|
||||
<span className="text-sm text-slate-600">{"https://app.plane.so/"}</span>
|
||||
<Input
|
||||
name="slug"
|
||||
mode="transparent"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
className="block w-full rounded-md bg-transparent py-2 px-0 text-sm focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<span className="-mt-3 text-sm text-red-500">
|
||||
Workspace URL is already taken!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="company_size"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={value ? value.toString() : "Select company size"}
|
||||
input
|
||||
>
|
||||
{companySize?.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.company_size && (
|
||||
<span className="text-sm text-red-500">{errors.company_size.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto h-1/4 lg:w-1/2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-gray-200 px-4 py-2 text-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<div className="mt-4 space-y-8">
|
||||
<div className="divide-y">
|
||||
{invitations && invitations.length > 0 ? (
|
||||
invitations.map((invitation) => (
|
||||
<div key={invitation.id}>
|
||||
<label
|
||||
className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent py-4`}
|
||||
htmlFor={invitation.id}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<Image
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center rounded bg-gray-500 p-4 uppercase text-white">
|
||||
{invitation.workspace.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{invitation.workspace.name}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Invited by {invitation.workspace.owner.first_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<input
|
||||
id={invitation.id}
|
||||
aria-describedby="workspaces"
|
||||
name={invitation.id}
|
||||
checked={invitationsRespond.includes(invitation.id)}
|
||||
value={invitation.workspace.name}
|
||||
onChange={(e) => {
|
||||
handleInvitation(
|
||||
invitation,
|
||||
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
|
||||
);
|
||||
}}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h3 className="text-gray-400">You have no invitations</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto h-1/4 lg:w-1/2">
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full rounded-md bg-gray-200 px-4 py-2 text-sm ${
|
||||
isJoiningWorkspaces || invitationsRespond.length === 0
|
||||
? "cursor-not-allowed opacity-80"
|
||||
: ""
|
||||
}`}
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
onClick={submitInvitations}
|
||||
>
|
||||
Join Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
|
|
@ -1,39 +1,53 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
// headless ui
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import projectService from "lib/services/project.service";
|
||||
// constants
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button, Input } from "ui";
|
||||
// types
|
||||
import type { IProject } from "types";
|
||||
import type { IProject, IWorkspace } from "types";
|
||||
|
||||
type Props = {
|
||||
type TConfirmProjectDeletionProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
data: IProject | null;
|
||||
};
|
||||
|
||||
const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
|
||||
const ConfirmProjectDeletion: React.FC<TConfirmProjectDeletionProps> = (props) => {
|
||||
const { isOpen, data, onClose, onSuccess } = props;
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
|
||||
|
||||
const [confirmProjectName, setConfirmProjectName] = useState("");
|
||||
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
|
||||
|
||||
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
|
||||
|
||||
const { activeWorkspace, mutateProjects } = useUser();
|
||||
const workspaceSlug = (data?.workspace as IWorkspace)?.slug;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectedProject(data);
|
||||
else {
|
||||
const timer = setTimeout(() => {
|
||||
setSelectedProject(null);
|
||||
clearTimeout(timer);
|
||||
}, 300);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
|
|
@ -47,12 +61,15 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
|
|||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !activeWorkspace || !canDelete) return;
|
||||
if (!data || !workspaceSlug || !canDelete) return;
|
||||
await projectService
|
||||
.deleteProject(activeWorkspace.slug, data.id)
|
||||
.deleteProject(workspaceSlug, data.id)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
mutateProjects((prevData) => (prevData ?? []).filter((item) => item.id !== data.id), false);
|
||||
mutate<IProject[]>(PROJECTS_LIST(workspaceSlug), (prevData) =>
|
||||
prevData?.filter((project: IProject) => project.id !== data.id)
|
||||
);
|
||||
if (onSuccess) onSuccess();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
|
|
@ -65,16 +82,6 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectedProject(data);
|
||||
else {
|
||||
const timer = setTimeout(() => {
|
||||
setSelectedProject(null);
|
||||
clearTimeout(timer);
|
||||
}, 300);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
|
|
@ -127,7 +134,7 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
|
|||
removed. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-0.5 bg-gray-200 my-3" />
|
||||
<div className="my-3 h-0.5 bg-gray-200" />
|
||||
<div className="mt-3">
|
||||
<p className="text-sm">
|
||||
Enter the project name{" "}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
// react hook form
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// headless
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectServices from "lib/services/project.service";
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// common
|
||||
import { createSimilarString, getRandomEmoji } from "constants/common";
|
||||
import { getRandomEmoji } from "constants/common";
|
||||
// constants
|
||||
import { NETWORK_CHOICES } from "constants/";
|
||||
// fetch keys
|
||||
import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
import { PROJECTS_LIST, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// ui
|
||||
import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui";
|
||||
import { Button, Input, TextArea, EmojiIconPicker, CustomSelect } from "ui";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ const defaultValues: Partial<IProject> = {
|
|||
name: "",
|
||||
identifier: "",
|
||||
description: "",
|
||||
network: 0,
|
||||
network: 2,
|
||||
icon: getRandomEmoji(),
|
||||
};
|
||||
|
||||
|
|
@ -52,53 +53,65 @@ const IsGuestCondition: React.FC<{
|
|||
return null;
|
||||
};
|
||||
|
||||
const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, setIsOpen } = props;
|
||||
|
||||
const { activeWorkspace, user } = useUser();
|
||||
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
|
||||
|
||||
const { data: workspaceMembers } = useSWR(
|
||||
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
|
||||
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
query: { workspaceSlug },
|
||||
} = useRouter();
|
||||
|
||||
const { data: myWorkspaceMembership } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug as string) : null,
|
||||
{
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
const [recommendedIdentifier, setRecommendedIdentifier] = useState<string[]>([]);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
clearErrors,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IProject>({
|
||||
defaultValues,
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const projectName = watch("name") ?? "";
|
||||
const projectIdentifier = watch("identifier") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (projectName && isChangeIdentifierRequired) {
|
||||
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
|
||||
}
|
||||
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setIsChangeIdentifierRequired(true);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: IProject) => {
|
||||
if (!activeWorkspace) return;
|
||||
if (!workspaceSlug) return;
|
||||
await projectServices
|
||||
.createProject(activeWorkspace.slug, formData)
|
||||
.createProject(workspaceSlug as string, formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(activeWorkspace.slug),
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
|
|
@ -129,45 +142,14 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const projectName = watch("name") ?? "";
|
||||
const projectIdentifier = watch("identifier") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (projectName && isChangeIdentifierRequired) {
|
||||
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
|
||||
}
|
||||
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectName) return;
|
||||
const suggestedIdentifier = createSimilarString(
|
||||
projectName.replace(/ /g, "").toUpperCase().substring(0, 3)
|
||||
);
|
||||
|
||||
setRecommendedIdentifier([
|
||||
suggestedIdentifier + Math.floor(Math.random() * 101),
|
||||
suggestedIdentifier + Math.floor(Math.random() * 101),
|
||||
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
|
||||
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
|
||||
]);
|
||||
}, [errors.identifier, projectIdentifier, projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setIsChangeIdentifierRequired(true);
|
||||
}, [isOpen]);
|
||||
|
||||
if (workspaceMembers) {
|
||||
const isMember = workspaceMembers.find((member) => member.member.id === user?.id);
|
||||
const isGuest = workspaceMembers.find(
|
||||
(member) => member.member.id === user?.id && member.role === 5
|
||||
);
|
||||
|
||||
if ((!isMember || isGuest) && isOpen) return <IsGuestCondition setIsOpen={setIsOpen} />;
|
||||
// FIXME: remove this and authorize using getServerSideProps
|
||||
if (myWorkspaceMembership && isOpen) {
|
||||
if (myWorkspaceMembership.role <= 10) return <IsGuestCondition setIsOpen={setIsOpen} />;
|
||||
}
|
||||
|
||||
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"
|
||||
|
|
@ -180,7 +162,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
<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}
|
||||
|
|
@ -203,51 +185,67 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<label htmlFor="icon" className="mb-2 text-gray-500">
|
||||
Icon
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="icon"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiIconPicker
|
||||
label={
|
||||
value ? String.fromCodePoint(parseInt(value)) : "Select Icon"
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
name="name"
|
||||
type="name"
|
||||
placeholder="Enter name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="icon" className="text-gray-500 mb-2">
|
||||
Icon
|
||||
</label>
|
||||
<h6 className="text-gray-500">Network</h6>
|
||||
<Controller
|
||||
name="network"
|
||||
control={control}
|
||||
name="icon"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiIconPicker
|
||||
label={value ? String.fromCodePoint(parseInt(value)) : "Select Icon"}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
label={
|
||||
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
|
||||
? NETWORK_CHOICES[
|
||||
value.toString() as keyof typeof NETWORK_CHOICES
|
||||
]
|
||||
: "Select network"
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.keys(NETWORK_CHOICES).map((key) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
name="name"
|
||||
type="name"
|
||||
placeholder="Enter name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
name="network"
|
||||
id="network"
|
||||
options={Object.keys(NETWORK_CHOICES).map((key) => ({
|
||||
value: key,
|
||||
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
|
||||
}))}
|
||||
label="Network"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Network is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
id="description"
|
||||
|
|
@ -270,6 +268,8 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
onChange={() => setIsChangeIdentifierRequired(false)}
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
validate: (value) =>
|
||||
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
|
|
@ -280,31 +280,10 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
},
|
||||
}}
|
||||
/>
|
||||
{errors.identifier && (
|
||||
<div className="mt-2">
|
||||
<p>Ops! Identifier is already taken. Try one of the following:</p>
|
||||
<div className="flex gap-x-2">
|
||||
{recommendedIdentifier.map((identifier) => (
|
||||
<button
|
||||
key={identifier}
|
||||
type="button"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 border p-2 py-0.5 rounded"
|
||||
onClick={() => {
|
||||
clearErrors("identifier");
|
||||
setValue("identifier", identifier);
|
||||
setIsChangeIdentifierRequired(false);
|
||||
}}
|
||||
>
|
||||
{identifier}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
|
@ -321,5 +300,3 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProjectModal;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import React, { useCallback } from "react";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
// constants
|
||||
import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
// components
|
||||
import SingleBoard from "components/project/cycles/board-view/single-board";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import { CycleIssueResponse, IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
|
||||
import { useRouter } from "next/router";
|
||||
import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
||||
import issuesService from "lib/services/issues.service";
|
||||
|
||||
type Props = {
|
||||
groupedByIssues: {
|
||||
|
|
@ -28,64 +37,140 @@ type Props = {
|
|||
>;
|
||||
};
|
||||
|
||||
const CyclesBoardView: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
properties,
|
||||
selectedGroup,
|
||||
members,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromCycle,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
}) => {
|
||||
const { states } = useUser();
|
||||
const CyclesBoardView: React.FC<Props> = (props) => {
|
||||
const {
|
||||
groupedByIssues,
|
||||
properties,
|
||||
selectedGroup,
|
||||
members,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromCycle,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = 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 (!cycleId) return;
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId 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, cycleId]
|
||||
);
|
||||
|
||||
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}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
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}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import React, { useState } from "react";
|
|||
import useSWR from "swr";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import SingleIssue from "components/common/board-view/single-issue";
|
||||
// ui
|
||||
|
|
@ -18,6 +16,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;
|
||||
|
|
@ -59,10 +60,12 @@ const SingleModuleBoard: React.FC<Props> = ({
|
|||
setPreloadedData,
|
||||
stateId,
|
||||
}) => {
|
||||
// Collapse/Expand
|
||||
const [show, setState] = useState(true);
|
||||
// TODO: will use this to collapse/expand the board
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
|
|
@ -74,26 +77,26 @@ const SingleModuleBoard: 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 ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "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" : ""
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full flex justify-between items-center ${
|
||||
!show ? "flex-col gap-2" : "gap-1"
|
||||
className={`flex w-full items-center justify-between ${
|
||||
!isCollapsed ? "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 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `2px solid ${bgColor}`,
|
||||
|
|
@ -103,7 +106,7 @@ const SingleModuleBoard: React.FC<Props> = ({
|
|||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
style={{
|
||||
writingMode: !show ? "vertical-rl" : "horizontal-tb",
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{groupTitle === null || groupTitle === "null"
|
||||
|
|
@ -112,7 +115,7 @@ const SingleModuleBoard: 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 +141,85 @@ const SingleModuleBoard: 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" : ""
|
||||
} ${!isCollapsed ? "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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// headless ui
|
||||
|
|
@ -7,8 +9,6 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||
import cycleService from "lib/services/cycles.service";
|
||||
// fetch api
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
|
|
@ -16,18 +16,24 @@ import { Button } from "ui";
|
|||
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
type Props = {
|
||||
type TConfirmCycleDeletionProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
data?: ICycle;
|
||||
};
|
||||
|
||||
const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => {
|
||||
const { isOpen, setIsOpen, data } = props;
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
useEffect(() => {
|
||||
data && setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
|
|
@ -36,9 +42,9 @@ const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
|
|||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !activeWorkspace) return;
|
||||
if (!data || !workspaceSlug) return;
|
||||
await cycleService
|
||||
.deleteCycle(activeWorkspace.slug, data.project, data.id)
|
||||
.deleteCycle(workspaceSlug as string, data.project, data.id)
|
||||
.then(() => {
|
||||
mutate<ICycle[]>(
|
||||
CYCLE_LIST(data.project),
|
||||
|
|
@ -53,10 +59,6 @@ const ConfirmCycleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
data && setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import React, { useEffect } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import cycleService from "lib/services/cycles.service";
|
||||
// fetch keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// common
|
||||
import { renderDateFormat } from "constants/common";
|
||||
// ui
|
||||
import { Button, Input, TextArea, Select } from "ui";
|
||||
import { Button, Input, TextArea, Select, CustomSelect } from "ui";
|
||||
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
|
|
@ -28,31 +28,37 @@ type Props = {
|
|||
const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "draft",
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
};
|
||||
|
||||
const CreateUpdateCycleModal: 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,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<ICycle>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setIsOpen(true);
|
||||
reset(data);
|
||||
} else {
|
||||
reset(defaultValues);
|
||||
}
|
||||
}, [data, setIsOpen, reset]);
|
||||
|
||||
const onSubmit = async (formData: ICycle) => {
|
||||
if (!activeWorkspace) return;
|
||||
if (!workspaceSlug) return;
|
||||
const payload = {
|
||||
...formData,
|
||||
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
|
||||
|
|
@ -60,7 +66,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
|||
};
|
||||
if (!data) {
|
||||
await cycleService
|
||||
.createCycle(activeWorkspace.slug, projectId, payload)
|
||||
.createCycle(workspaceSlug as string, projectId, payload)
|
||||
.then((res) => {
|
||||
mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false);
|
||||
handleClose();
|
||||
|
|
@ -74,7 +80,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
|||
});
|
||||
} else {
|
||||
await cycleService
|
||||
.updateCycle(activeWorkspace.slug, projectId, data.id, payload)
|
||||
.updateCycle(workspaceSlug as string, projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
mutate<ICycle[]>(
|
||||
CYCLE_LIST(projectId),
|
||||
|
|
@ -101,18 +107,14 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
|||
}
|
||||
};
|
||||
|
||||
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-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -169,20 +171,29 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
id="status"
|
||||
<h6 className="text-gray-500">Status</h6>
|
||||
<Controller
|
||||
name="status"
|
||||
label="Status"
|
||||
error={errors.status}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Status is required",
|
||||
}}
|
||||
options={[
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Started", value: "started" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
]}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CustomSelect
|
||||
{...field}
|
||||
label={
|
||||
<span className="capitalize">{field.value ?? "Select Status"}</span>
|
||||
}
|
||||
input
|
||||
>
|
||||
{[
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Started", value: "started" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
].map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
|
|
@ -217,7 +228,7 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
// react
|
||||
import { useEffect } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// ui
|
||||
import { Loader } from "ui";
|
||||
// icons
|
||||
import { CalendarDaysIcon, ChartPieIcon, LinkIcon, UserIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle } from "types";
|
||||
// common
|
||||
import { copyTextToClipboard, groupBy } from "constants/common";
|
||||
import { mutate } from "swr";
|
||||
import cyclesService from "lib/services/cycles.service";
|
||||
import { CYCLE_DETAIL } from "constants/api-routes";
|
||||
|
||||
type Props = {
|
||||
cycle: ICycle | undefined;
|
||||
isOpen: boolean;
|
||||
cycleIssues: CycleIssueResponse[];
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
};
|
||||
|
||||
const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = router;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { reset, control } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const groupedIssues = {
|
||||
backlog: [],
|
||||
unstarted: [],
|
||||
started: [],
|
||||
cancelled: [],
|
||||
completed: [],
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
cyclesService
|
||||
.patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate(CYCLE_DETAIL);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle)
|
||||
reset({
|
||||
...cycle,
|
||||
});
|
||||
}, [cycle, reset]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 ${
|
||||
isOpen ? "right-0" : "-right-[24rem]"
|
||||
} z-20 h-full w-[24rem] overflow-y-auto border-l bg-gray-50 p-5 duration-300`}
|
||||
>
|
||||
{cycle ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">{cycle.name}</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`
|
||||
)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Cycle link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-gray-100 text-xs">
|
||||
<div className="py-1">
|
||||
<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>Owned by</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
{cycle.owned_by.first_name !== "" ? (
|
||||
<>
|
||||
{cycle.owned_by.first_name} {cycle.owned_by.last_name}
|
||||
</>
|
||||
) : (
|
||||
cycle.owned_by.email
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Progress</p>
|
||||
</div>
|
||||
<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}/{cycleIssues?.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<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="h-4 w-4 flex-shrink-0" />
|
||||
<p>Start date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="cycleStartDate"
|
||||
value={value ?? ""}
|
||||
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 flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>End date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="moduleEndDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ end_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>
|
||||
<div className="py-1"></div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleDetailSidebar;
|
||||
|
|
@ -1,30 +1,27 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// headless ui
|
||||
import { Disclosure, Transition, Menu } from "@headlessui/react";
|
||||
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// constants
|
||||
import { addSpaceIfCamelCase } from "constants/common";
|
||||
import { WORKSPACE_MEMBERS, STATE_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import SingleListIssue from "components/common/list-view/single-issue";
|
||||
// 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,
|
||||
classNames,
|
||||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
|
||||
type Props = {
|
||||
groupedByIssues: {
|
||||
|
|
@ -56,11 +53,19 @@ const CyclesListView: 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 (
|
||||
|
|
@ -74,17 +79,17 @@ const CyclesListView: 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)}
|
||||
|
|
@ -92,7 +97,7 @@ const CyclesListView: 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>
|
||||
|
|
@ -128,159 +133,22 @@ const CyclesListView: 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={() => removeIssueFromCycle(issue.bridge ?? "")}
|
||||
>
|
||||
Remove from cycle
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => handleDeleteIssue(issue.id)}
|
||||
>
|
||||
Delete permanently
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
type="cycle"
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
editIssue={() => openCreateIssueModal(issue, "edit")}
|
||||
handleDeleteIssue={() => handleDeleteIssue(issue.id)}
|
||||
removeIssue={() => removeIssueFromCycle(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>
|
||||
)}
|
||||
|
|
@ -296,7 +164,7 @@ const CyclesListView: React.FC<Props> = ({
|
|||
</span>
|
||||
}
|
||||
optionsPosition="left"
|
||||
withoutBorder
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -5,20 +5,23 @@ import SingleStat from "components/project/cycles/stats-view/single-stat";
|
|||
import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion";
|
||||
// types
|
||||
import { ICycle, SelectSprintType } from "types";
|
||||
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "ui/icons";
|
||||
|
||||
type Props = {
|
||||
type TCycleStatsViewProps = {
|
||||
cycles: ICycle[];
|
||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectSprintType>>;
|
||||
type: "current" | "upcoming" | "completed";
|
||||
};
|
||||
|
||||
const CycleStatsView: React.FC<Props> = ({
|
||||
const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
cycles,
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
||||
type,
|
||||
}) => {
|
||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectSprintType>();
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectSprintType>();
|
||||
|
||||
const handleDeleteCycle = (cycle: ICycle) => {
|
||||
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||
|
|
@ -41,14 +44,30 @@ const CycleStatsView: React.FC<Props> = ({
|
|||
setIsOpen={setCycleDeleteModal}
|
||||
data={selectedCycleForDelete}
|
||||
/>
|
||||
{cycles.map((cycle) => (
|
||||
<SingleStat
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))}
|
||||
{cycles.length > 0 ? (
|
||||
cycles.map((cycle) => (
|
||||
<SingleStat
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
{type === "upcoming" ? (
|
||||
<UpcomingCycleIcon height="56" width="56" />
|
||||
) : type === "completed" ? (
|
||||
<CompletedCycleIcon height="56" width="56" />
|
||||
) : (
|
||||
<CurrentCycleIcon height="56" width="56" />
|
||||
)}
|
||||
<h3 className="text-gray-500">
|
||||
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
|
|
@ -11,16 +12,17 @@ import cyclesService from "lib/services/cycles.service";
|
|||
import useUser from "lib/hooks/useUser";
|
||||
// ui
|
||||
import { Button, CustomMenu } from "ui";
|
||||
// icons
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowPathIcon, CheckIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
// common
|
||||
import { groupBy, renderShortNumericDateFormat } from "constants/common";
|
||||
import { ArrowPathIcon, CheckIcon, UserIcon } from "@heroicons/react/24/outline";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
type TSingleStatProps = {
|
||||
cycle: ICycle;
|
||||
handleEditCycle: () => void;
|
||||
handleDeleteCycle: () => void;
|
||||
|
|
@ -36,63 +38,48 @@ const stateGroupColours: {
|
|||
completed: "#096e8d",
|
||||
};
|
||||
|
||||
const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle }) => {
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
const { cycle, handleEditCycle, handleDeleteCycle } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
|
||||
activeWorkspace && activeProject && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
|
||||
activeWorkspace && activeProject && cycle.id
|
||||
? () =>
|
||||
cyclesService.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycle.id as string)
|
||||
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
|
||||
workspaceSlug && projectId && cycle.id
|
||||
? () => cyclesService.getCycleIssues(workspaceSlug as string, projectId as string, cycle.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
|
||||
const groupedIssues = {
|
||||
backlog: [],
|
||||
unstarted: [],
|
||||
started: [],
|
||||
cancelled: [],
|
||||
completed: [],
|
||||
...groupBy(cycleIssues ?? [], "issue_details.state_detail.group"),
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border bg-white p-3 rounded-md">
|
||||
<div className="grid grid-cols-8 gap-2 divide-x">
|
||||
<div className="col-span-3 space-y-3">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
|
||||
<div className="rounded-md border bg-white p-3">
|
||||
<div className="grid grid-cols-9 gap-2 divide-x">
|
||||
<div className="col-span-3 flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}>
|
||||
<a>
|
||||
<h2 className="font-medium">{cycle.name}</h2>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs border px-3 py-0.5 rounded-xl ${
|
||||
today < startDate
|
||||
? "text-orange-500 border-orange-500"
|
||||
: today > endDate
|
||||
? "text-red-500 border-red-500"
|
||||
: "text-green-500 border-green-500"
|
||||
}`}
|
||||
>
|
||||
{today < startDate ? "Not started" : today > endDate ? "Over" : "Active"}
|
||||
</span>
|
||||
</div>
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
Delete cycle permanently
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>Edit cycle</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
Delete cycle permanently
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-3 text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
|
|
@ -116,47 +103,40 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
|
|||
alt={cycle.owned_by.first_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="h-5 w-5 capitalize bg-gray-700 text-white grid place-items-center rounded-full">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{cycle.owned_by.first_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
{cycle.owned_by.first_name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
Active members
|
||||
</div>
|
||||
<div className="col-span-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button theme="secondary" className="flex items-center gap-2" disabled>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
Participating
|
||||
</Button>
|
||||
<div className="flex h-full items-end">
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => router.push(`/projects/${activeProject?.id}/cycles/${cycle.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)
|
||||
}
|
||||
>
|
||||
<ArrowPathIcon className="h-3 w-3" />
|
||||
Open Cycle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 px-5 space-y-3">
|
||||
<div className="col-span-2 space-y-3 px-5">
|
||||
<h4 className="text-sm tracking-widest">PROGRESS</h4>
|
||||
<div className="text-xs space-y-3">
|
||||
<div className="space-y-3 text-xs">
|
||||
{Object.keys(groupedIssues).map((group) => {
|
||||
return (
|
||||
<div key={group} className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 basis-2/3">
|
||||
<div className="flex basis-2/3 items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 block rounded-full"
|
||||
className="block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: stateGroupColours[group],
|
||||
}}
|
||||
></span>
|
||||
<h6 className="capitalize text-xs">{group}</h6>
|
||||
<h6 className="text-xs capitalize">{group}</h6>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
|
|
@ -176,7 +156,6 @@ const SingleStat: React.FC<Props> = ({ cycle, handleEditCycle, handleDeleteCycle
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
1
apps/app/components/project/index.ts
Normal file
1
apps/app/components/project/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./create-project-modal";
|
||||
|
|
@ -1,79 +1,84 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
// next
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react beautiful dnd
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import type { DropResult } from "react-beautiful-dnd";
|
||||
import { DragDropContext } from "react-beautiful-dnd";
|
||||
// hook
|
||||
import useIssuesProperties from "lib/hooks/useIssuesProperties";
|
||||
// services
|
||||
import stateServices from "lib/services/state.service";
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import projectService from "lib/services/project.service";
|
||||
// fetching keys
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
import { STATE_LIST, PROJECT_ISSUES_LIST, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// components
|
||||
import SingleBoard from "components/project/issues/BoardView/single-board";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
|
||||
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// types
|
||||
import type { IState, IIssue, Properties, NestedKeyOf, IProjectMember } from "types";
|
||||
import ConfirmIssueDeletion from "../confirm-issue-deletion";
|
||||
import type { IState, IIssue, NestedKeyOf, IssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
properties: Properties;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
members: IProjectMember[] | undefined;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
};
|
||||
|
||||
const BoardView: React.FC<Props> = ({
|
||||
properties,
|
||||
selectedGroup,
|
||||
groupedByIssues,
|
||||
members,
|
||||
handleDeleteIssue,
|
||||
partialUpdateIssue,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [isIssueOpen, setIsIssueOpen] = useState(false);
|
||||
const [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false);
|
||||
const [issueDeletionData, setIssueDeletionData] = useState<IIssue | undefined>();
|
||||
|
||||
const [preloadedData, setPreloadedData] = useState<
|
||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { projectId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: states, mutate: mutateState } = useSWR<IState[]>(
|
||||
projectId && activeWorkspace ? STATE_LIST(projectId as string) : null,
|
||||
activeWorkspace
|
||||
? () => stateServices.getStates(activeWorkspace.slug, projectId as string)
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
workspaceSlug
|
||||
? () => stateServices.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
{
|
||||
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
|
||||
if (err?.status === 403) return;
|
||||
setTimeout(() => revalidate(revalidateOpts), 5000);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const { source, destination, type } = result;
|
||||
const draggedItem = groupedByIssues[source.droppableId][source.index];
|
||||
|
||||
if (destination.droppableId === "trashBox") {
|
||||
const removedItem = groupedByIssues[source.droppableId][source.index];
|
||||
|
||||
setIssueDeletionData(removedItem);
|
||||
setIssueDeletionData(draggedItem);
|
||||
setIsIssueDeletionOpen(true);
|
||||
} else {
|
||||
if (type === "state") {
|
||||
|
|
@ -95,10 +100,10 @@ const BoardView: React.FC<Props> = ({
|
|||
newStates[destination.index].sequence = sequenceNumber;
|
||||
|
||||
mutateState(newStates, false);
|
||||
if (!activeWorkspace) return;
|
||||
if (!workspaceSlug) return;
|
||||
stateServices
|
||||
.patchState(
|
||||
activeWorkspace.slug,
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
newStates[destination.index].id,
|
||||
{
|
||||
|
|
@ -115,20 +120,18 @@ const BoardView: React.FC<Props> = ({
|
|||
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 (!sourceGroup || !destinationGroup) return;
|
||||
|
||||
if (selectedGroup === "priority") {
|
||||
// update the removed item for mutation
|
||||
removedItem.priority = destinationGroup;
|
||||
draggedItem.priority = destinationGroup;
|
||||
|
||||
// patch request
|
||||
issuesServices.patchIssue(
|
||||
activeWorkspace!.slug,
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
removedItem.id,
|
||||
draggedItem.id,
|
||||
{
|
||||
priority: destinationGroup,
|
||||
}
|
||||
|
|
@ -139,39 +142,52 @@ const BoardView: React.FC<Props> = ({
|
|||
|
||||
// update the removed item for mutation
|
||||
if (!destinationStateId || !destinationState) return;
|
||||
removedItem.state = destinationStateId;
|
||||
removedItem.state_detail = destinationState;
|
||||
draggedItem.state = destinationStateId;
|
||||
draggedItem.state_detail = destinationState;
|
||||
|
||||
// patch request
|
||||
issuesServices.patchIssue(
|
||||
activeWorkspace!.slug,
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
removedItem.id,
|
||||
draggedItem.id,
|
||||
{
|
||||
state: destinationStateId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// mutate the issues
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updatedIssues = prevData.results.map((issue) => {
|
||||
if (issue.id === draggedItem.id)
|
||||
return {
|
||||
...draggedItem,
|
||||
state_detail: destinationState,
|
||||
state: destinationStateId,
|
||||
};
|
||||
|
||||
return issue;
|
||||
});
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
results: updatedIssues,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeWorkspace, mutateState, groupedByIssues, projectId, selectedGroup, states]
|
||||
[workspaceSlug, mutateState, groupedByIssues, projectId, selectedGroup, states]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPreloadedData(undefined);
|
||||
clearTimeout(timer);
|
||||
}, 500);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmIssueDeletion
|
||||
|
|
@ -198,7 +214,7 @@ const BoardView: React.FC<Props> = ({
|
|||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
|
||||
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden pb-3">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => (
|
||||
<SingleBoard
|
||||
key={singleGroup}
|
||||
|
|
@ -223,7 +239,7 @@ const BoardView: React.FC<Props> = ({
|
|||
bgColor={
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.color
|
||||
: undefined
|
||||
: "#000000"
|
||||
}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
|
|
@ -238,7 +254,7 @@ const BoardView: React.FC<Props> = ({
|
|||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// react-beautiful-dnd
|
||||
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
|
|
@ -61,9 +60,10 @@ const SingleBoard: React.FC<Props> = ({
|
|||
partialUpdateIssue,
|
||||
}) => {
|
||||
// Collapse/Expand
|
||||
const [show, setShow] = useState(true);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
|
|
@ -75,40 +75,42 @@ const SingleBoard: 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 (
|
||||
<Draggable draggableId={groupTitle} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`rounded flex-shrink-0 h-full ${
|
||||
className={`h-full flex-shrink-0 rounded ${
|
||||
snapshot.isDragging ? "border-theme shadow-lg" : ""
|
||||
} ${!show ? "" : "w-80 bg-gray-50 border"}`}
|
||||
} ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
|
||||
<div
|
||||
className={`${!isCollapsed ? "" : "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" : ""
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<button
|
||||
type="button"
|
||||
{...provided.dragHandleProps}
|
||||
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
|
||||
!show ? "" : "rotate-90"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
!isCollapsed ? "" : "rotate-90"
|
||||
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
|
||||
<EllipsisHorizontalIcon className="mt-[-0.7rem] h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<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 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `2px solid ${bgColor}`,
|
||||
|
|
@ -118,7 +120,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
<h2
|
||||
className={`text-[0.9rem] font-medium capitalize`}
|
||||
style={{
|
||||
writingMode: !show ? "vertical-rl" : "horizontal-tb",
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{groupTitle === null || groupTitle === "null"
|
||||
|
|
@ -127,21 +129,21 @@ const SingleBoard: 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>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${!show ? "flex-col pb-2" : ""}`}>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setShow(!show);
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
>
|
||||
{show ? (
|
||||
{isCollapsed ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
|
|
@ -149,7 +151,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setIsIssueOpen(true);
|
||||
if (selectedGroup !== null)
|
||||
|
|
@ -167,9 +169,9 @@ const SingleBoard: React.FC<Props> = ({
|
|||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
|
||||
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"}`}
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
|
|
@ -212,7 +214,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
{provided.placeholder}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
|
||||
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setIsIssueOpen(true);
|
||||
if (selectedGroup !== null) {
|
||||
|
|
@ -224,7 +226,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 mr-1" />
|
||||
<PlusIcon className="mr-1 h-3 w-3" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// headless ui
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import stateServices from "lib/services/state.service";
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// fetch api
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import { STATE_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// common
|
||||
import { groupBy } from "constants/common";
|
||||
// icons
|
||||
|
|
@ -26,10 +27,19 @@ type Props = {
|
|||
|
||||
const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [issuesWithThisStateExist, setIssuesWithThisStateExist] = useState(true);
|
||||
|
||||
const { activeWorkspace, issues } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
|
|
@ -40,9 +50,9 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
|
|||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !activeWorkspace || issuesWithThisStateExist) return;
|
||||
if (!data || !workspaceSlug || issuesWithThisStateExist) return;
|
||||
await stateServices
|
||||
.deleteState(activeWorkspace.slug, data.project, data.id)
|
||||
.deleteState(workspaceSlug as string, data.project, data.id)
|
||||
.then(() => {
|
||||
mutate<IState[]>(
|
||||
STATE_LIST(data.project),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useEffect } from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// react color
|
||||
|
||||
import { TwitterPicker } from "react-color";
|
||||
// headless
|
||||
|
||||
import { Dialog, Popover, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
|
|
@ -13,8 +15,6 @@ import stateService from "lib/services/state.service";
|
|||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { GROUP_CHOICES } from "constants/";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// ui
|
||||
import { Button, Input, Select, TextArea } from "ui";
|
||||
// icons
|
||||
|
|
@ -37,15 +37,8 @@ const defaultValues: Partial<IState> = {
|
|||
};
|
||||
|
||||
const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, handleClose }) => {
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -59,14 +52,27 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
|
|||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
} else {
|
||||
reset(defaultValues);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: IState) => {
|
||||
if (!activeWorkspace) return;
|
||||
if (!workspaceSlug) return;
|
||||
const payload: IState = {
|
||||
...formData,
|
||||
};
|
||||
if (!data) {
|
||||
await stateService
|
||||
.createState(activeWorkspace.slug, projectId, payload)
|
||||
.createState(workspaceSlug as string, projectId, payload)
|
||||
.then((res) => {
|
||||
mutate<IState[]>(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false);
|
||||
onClose();
|
||||
|
|
@ -80,7 +86,7 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
|
|||
});
|
||||
} else {
|
||||
await stateService
|
||||
.updateState(activeWorkspace.slug, projectId, data.id, payload)
|
||||
.updateState(workspaceSlug as string, projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
mutate<IState[]>(
|
||||
STATE_LIST(projectId),
|
||||
|
|
@ -107,17 +113,9 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
} else {
|
||||
reset(defaultValues);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -185,14 +183,14 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
|
|||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group bg-white rounded-md inline-flex items-center text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
||||
className={`group inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
open ? "text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span>Color</span>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="w-4 h-4 ml-2 rounded"
|
||||
className="ml-2 h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "green",
|
||||
}}
|
||||
|
|
@ -215,7 +213,7 @@ const CreateUpdateStateModal: React.FC<Props> = ({ isOpen, data, projectId, hand
|
|||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="fixed z-50 transform left-5 mt-3 px-2 w-screen max-w-xs sm:px-0">
|
||||
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
// swr
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// fetching keys
|
||||
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES } from "constants/fetch-keys";
|
||||
// services
|
||||
import issueServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// types
|
||||
import type { IIssue, IssueResponse } from "types";
|
||||
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -23,14 +24,20 @@ type Props = {
|
|||
data?: IIssue;
|
||||
};
|
||||
|
||||
const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
const ConfirmIssueDeletion: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, data } = props;
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
useEffect(() => {
|
||||
setIsDeleteLoading(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
|
|
@ -39,13 +46,13 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
|
|||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !activeWorkspace) return;
|
||||
if (!data || !workspaceSlug) return;
|
||||
const projectId = data.project;
|
||||
await issueServices
|
||||
.deleteIssue(activeWorkspace.slug, projectId, data.id)
|
||||
.deleteIssue(workspaceSlug as string, projectId, data.id)
|
||||
.then(() => {
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId),
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
|
|
@ -55,7 +62,25 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
|
|||
},
|
||||
false
|
||||
);
|
||||
mutate(CYCLE_ISSUES(data.issue_cycle?.id ?? ""));
|
||||
|
||||
const moduleId = data.issue_module?.module;
|
||||
const cycleId = data.issue_cycle?.cycle;
|
||||
|
||||
if (moduleId) {
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId),
|
||||
(prevData) => prevData?.filter((i) => i.issue !== data.id),
|
||||
false
|
||||
);
|
||||
}
|
||||
if (cycleId) {
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId),
|
||||
(prevData) => prevData?.filter((i) => i.issue !== data.id),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
|
|
@ -99,7 +124,7 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
|
|||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div>
|
||||
<div className="mx-auto h-16 w-16 grid place-items-center rounded-full bg-red-100">
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full bg-red-100">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-8 w-8 text-red-600"
|
||||
aria-hidden="true"
|
||||
|
|
@ -107,10 +132,10 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
|
|||
</div>
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900 mt-3"
|
||||
className="mt-3 text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
Are you sure you want to delete {`"`}
|
||||
{activeProject?.identifier}-{data?.sequence_id} - {data?.name}?{`"`}
|
||||
{data?.project_detail.identifier}-{data?.sequence_id} - {data?.name}?{`"`}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// next
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless
|
||||
|
||||
import { Dialog, Menu, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
|
|
@ -15,9 +15,9 @@ import issuesServices from "lib/services/issues.service";
|
|||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// ui
|
||||
import { Button, TextArea } from "ui";
|
||||
import { Button, Input, Loader } from "ui";
|
||||
// icons
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisHorizontalIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import SelectState from "components/project/issues/create-update-issue-modal/select-state";
|
||||
import SelectCycles from "components/project/issues/create-update-issue-modal/select-cycle";
|
||||
|
|
@ -36,12 +36,21 @@ import {
|
|||
PROJECT_ISSUES_LIST,
|
||||
CYCLE_ISSUES,
|
||||
USER_ISSUE,
|
||||
PROJECTS_LIST,
|
||||
MODULE_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// common
|
||||
import { renderDateFormat, cosineSimilarity } from "constants/common";
|
||||
import projectService from "lib/services/project.service";
|
||||
import modulesService from "lib/services/modules.service";
|
||||
|
||||
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader>
|
||||
<Loader.Item height="12rem" width="100%"></Loader.Item>
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
|
|
@ -56,9 +65,10 @@ type Props = {
|
|||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
name: "",
|
||||
// description: "",
|
||||
description: "",
|
||||
description_html: "<p></p>",
|
||||
state: "",
|
||||
sprints: null,
|
||||
cycle: null,
|
||||
priority: null,
|
||||
labels_list: [],
|
||||
};
|
||||
|
|
@ -71,31 +81,31 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
prePopulateData,
|
||||
isUpdatingSingleIssue = false,
|
||||
}) => {
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
|
||||
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>();
|
||||
|
||||
// const [issueDescriptionValue, setIssueDescriptionValue] = useState("");
|
||||
// const handleDescriptionChange: any = (value: any) => {
|
||||
// console.log(value);
|
||||
// setIssueDescriptionValue(value);
|
||||
// };
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (data) {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const { activeWorkspace, activeProject, user, issues } = useUser();
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: projects } = useSWR(
|
||||
workspaceSlug ? PROJECTS_LIST(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
|
|
@ -104,21 +114,52 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
setError,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues,
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects && projects.length > 0)
|
||||
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
|
||||
}, [projectId, projects]);
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...watch(),
|
||||
...data,
|
||||
project: activeProject ?? "",
|
||||
...prePopulateData,
|
||||
});
|
||||
}, [data, prePopulateData, reset, activeProject, isOpen, watch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setMostSimilarIssue(undefined);
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
reset({ ...defaultValues, project: activeProject ?? undefined });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (data) {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const addIssueToCycle = async (issueId: string, cycleId: string) => {
|
||||
if (!activeWorkspace || !activeProject) return;
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await issuesServices
|
||||
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
|
||||
.addIssueToCycle(workspaceSlug as string, projectId, cycleId, {
|
||||
issues: [issueId],
|
||||
})
|
||||
.then((res) => {
|
||||
|
|
@ -131,7 +172,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
);
|
||||
} else
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
|
|
@ -143,36 +184,49 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
},
|
||||
false
|
||||
);
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Issue added to cycle successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issueId: string, moduleId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await modulesService
|
||||
.addIssuesToModule(workspaceSlug as string, projectId, moduleId as string, {
|
||||
issues: [issueId],
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate(MODULE_ISSUES(moduleId as string));
|
||||
})
|
||||
.catch((e) => console.log(e));
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: IIssue) => {
|
||||
if (!activeWorkspace || !activeProject) return;
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null,
|
||||
// description: formData.description ? JSON.parse(formData.description) : null,
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
await issuesServices
|
||||
.createIssues(activeWorkspace.slug, activeProject.id, payload)
|
||||
.then(async (res) => {
|
||||
console.log(res);
|
||||
mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
|
||||
|
||||
if (formData.sprints && formData.sprints !== null) {
|
||||
await addIssueToCycle(res.id, formData.sprints);
|
||||
.createIssues(workspaceSlug as string, projectId, payload)
|
||||
.then((res) => {
|
||||
mutate<IssueResponse>(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId));
|
||||
if (formData.cycle && formData.cycle !== null) {
|
||||
addIssueToCycle(res.id, formData.cycle);
|
||||
}
|
||||
handleClose();
|
||||
|
||||
if (formData.module && formData.module !== null) {
|
||||
addIssueToModule(res.id, formData.module);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
if (!createMore) handleClose();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
|
|
@ -183,20 +237,31 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.detail) {
|
||||
setToastAlert({
|
||||
title: "Join the project.",
|
||||
type: "error",
|
||||
message: "Click select to join from projects page to start making changes",
|
||||
});
|
||||
}
|
||||
Object.keys(err).map((key) => {
|
||||
setError(key as keyof IIssue, { message: err[key].join(", ") });
|
||||
const message = err[key];
|
||||
if (!message) return;
|
||||
|
||||
setError(key as keyof IIssue, {
|
||||
message: Array.isArray(message) ? message.join(", ") : message,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await issuesServices
|
||||
.updateIssue(activeWorkspace.slug, activeProject.id, data.id, payload)
|
||||
.then(async (res) => {
|
||||
console.log(res);
|
||||
.updateIssue(workspaceSlug as string, projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
if (isUpdatingSingleIssue) {
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
} else
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
|
|
@ -205,14 +270,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
return issue;
|
||||
}),
|
||||
};
|
||||
},
|
||||
false
|
||||
}
|
||||
);
|
||||
if (formData.sprints && formData.sprints !== null) {
|
||||
await addIssueToCycle(res.id, formData.sprints);
|
||||
if (formData.cycle && formData.cycle !== null) {
|
||||
addIssueToCycle(res.id, formData.cycle);
|
||||
}
|
||||
handleClose();
|
||||
resetForm();
|
||||
if (!createMore) handleClose();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
|
|
@ -227,42 +291,24 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...watch(),
|
||||
...data,
|
||||
project: activeProject?.id ?? projectId,
|
||||
...prePopulateData,
|
||||
});
|
||||
}, [data, prePopulateData, reset, projectId, activeProject, isOpen, watch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setMostSimilarIssue(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeProject && (
|
||||
{projectId && (
|
||||
<>
|
||||
<CreateUpdateStateModal
|
||||
isOpen={isStateModalOpen}
|
||||
handleClose={() => setIsStateModalOpen(false)}
|
||||
projectId={activeProject?.id}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={isCycleModalOpen}
|
||||
setIsOpen={setIsCycleModalOpen}
|
||||
projectId={activeProject?.id}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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"
|
||||
|
|
@ -286,23 +332,53 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
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 rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SelectProject control={control} />
|
||||
<SelectProject
|
||||
control={control}
|
||||
activeProject={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
/>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
{data ? "Update" : "Create"} Issue
|
||||
</h3>
|
||||
</div>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-gray-100 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues?.results.find(
|
||||
(i) => i.id === watch("parent")
|
||||
)?.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-gray-600">
|
||||
{projects?.find((p) => p.id === activeProject)?.identifier}-
|
||||
{issues?.results.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues?.results
|
||||
.find((i) => i.id === watch("parent"))
|
||||
?.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<TextArea
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
label="Title"
|
||||
name="name"
|
||||
rows={1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const similarIssue = issues?.results.find(
|
||||
|
|
@ -311,19 +387,23 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
setMostSimilarIssue(similarIssue?.id);
|
||||
}}
|
||||
className="resize-none"
|
||||
placeholder="Enter name"
|
||||
placeholder="Enter title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
<Link
|
||||
href={`/projects/${activeProject?.id}/issues/${mostSimilarIssue}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue}`}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
|
|
@ -360,37 +440,31 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Enter description"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
{/* <Controller
|
||||
<label htmlFor={"description"} className="mb-2 text-gray-500">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RichTextEditor {...field} id="issueDescriptionEditor" />
|
||||
<RemirrorRichTextEditor
|
||||
{...field}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("description", jsonValue);
|
||||
setValue("description_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your Text..."
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* <Input
|
||||
id="target_date"
|
||||
label="Target Date"
|
||||
name="target_date"
|
||||
type="date"
|
||||
placeholder="Enter name"
|
||||
autoComplete="off"
|
||||
error={errors.target_date}
|
||||
register={register}
|
||||
/> */}
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SelectState control={control} setIsOpen={setIsStateModalOpen} />
|
||||
<SelectCycles control={control} setIsOpen={setIsCycleModalOpen} />
|
||||
<SelectCycles
|
||||
control={control}
|
||||
setIsOpen={setIsCycleModalOpen}
|
||||
activeProject={activeProject ?? ""}
|
||||
/>
|
||||
<SelectPriority control={control} />
|
||||
<SelectAssignee control={control} />
|
||||
<SelectLabels control={control} />
|
||||
|
|
@ -404,7 +478,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
onChange={(e: any) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="hover:bg-gray-100 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"
|
||||
className="cursor-pointer rounded-md border px-2 py-[3px] text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -415,7 +489,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
issues={issues?.results ?? []}
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<Menu.Button className="grid place-items-center p-1 hover:bg-gray-100 border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
|
||||
<Menu.Button className="grid cursor-pointer place-items-center rounded-md border p-1 py-0.5 shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm">
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" />
|
||||
</Menu.Button>
|
||||
|
||||
|
|
@ -428,22 +502,40 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
|
||||
<div className="p-1">
|
||||
<Menu.Item as="div">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap "
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
{watch("parent") && watch("parent") !== ""
|
||||
? `${activeProject?.identifier}-${
|
||||
issues?.results.find((i) => i.id === watch("parent"))
|
||||
?.sequence_id
|
||||
}`
|
||||
: "Select Parent Issue"}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<>
|
||||
<Menu.Item as="div">
|
||||
<button
|
||||
type="button"
|
||||
className="whitespace-nowrap p-2 text-left text-xs text-gray-900 hover:bg-indigo-50"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item as="div">
|
||||
<button
|
||||
type="button"
|
||||
className="whitespace-nowrap p-2 text-left text-xs text-gray-900 hover:bg-indigo-50"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item as="div">
|
||||
<button
|
||||
type="button"
|
||||
className="whitespace-nowrap p-2 text-left text-xs text-gray-900 hover:bg-indigo-50"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Select Parent Issue
|
||||
</button>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
|
@ -452,25 +544,49 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
resetForm();
|
||||
}}
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => setCreateMore((prevData) => !prevData)}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating Issue..."
|
||||
: "Update Issue"
|
||||
: isSubmitting
|
||||
? "Creating Issue..."
|
||||
: "Create Issue"}
|
||||
</Button>
|
||||
<span className="text-xs">Create more</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
|
||||
createMore ? "bg-theme" : "bg-gray-300"
|
||||
} transition-colors duration-300 ease-in-out focus:outline-none`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="sr-only">Create more</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-3 w-3 ${
|
||||
createMore ? "translate-x-3" : "translate-x-0"
|
||||
} transform rounded-full bg-white shadow ring-0 transition duration-300 ease-in-out`}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating Issue..."
|
||||
: "Update Issue"
|
||||
: isSubmitting
|
||||
? "Creating Issue..."
|
||||
: "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
|
||||
import { Controller } from "react-hook-form";
|
||||
// service
|
||||
import projectServices from "lib/services/project.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// fetch keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// types
|
||||
|
|
@ -21,12 +21,13 @@ type Props = {
|
|||
};
|
||||
|
||||
const SelectAssignee: 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
|
||||
);
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
|
||||
assignee
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,95 +1,71 @@
|
|||
import React from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CreateUpdateSprintsModal from "components/project/cycles/create-update-cycle-modal";
|
||||
// services
|
||||
import cycleServices from "lib/services/cycles.service";
|
||||
// constants
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
// ui
|
||||
import { CustomListbox } from "ui";
|
||||
// icons
|
||||
import { CheckIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activeProject: string;
|
||||
};
|
||||
|
||||
const SelectSprint: React.FC<Props> = ({ control, setIsOpen }) => {
|
||||
const { cycles } = useUser();
|
||||
const SelectCycle: React.FC<Props> = ({ control, setIsOpen, activeProject }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: cycles } = useSWR(
|
||||
workspaceSlug && activeProject ? CYCLE_LIST(activeProject) : null,
|
||||
workspaceSlug && activeProject
|
||||
? () => cycleServices.getCycles(workspaceSlug as string, activeProject)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sprints"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox as="div" value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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 sm:text-sm duration-300">
|
||||
<ArrowPathIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block truncate">
|
||||
{cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<div className="py-1">
|
||||
{cycles?.map((cycle) => (
|
||||
<Listbox.Option
|
||||
key={cycle.id}
|
||||
value={cycle.id}
|
||||
className={({ active }) =>
|
||||
`text-gray-900 cursor-pointer select-none p-2 ${
|
||||
active ? "bg-indigo-50" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ active, selected }) => (
|
||||
<>
|
||||
<span className={`block ${selected && "font-semibold"}`}>
|
||||
{cycle.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative select-none py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create cycle</span>
|
||||
</span>
|
||||
</button>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<Controller
|
||||
control={control}
|
||||
name="cycle"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomListbox
|
||||
title={cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"}
|
||||
options={cycles?.map((cycle) => {
|
||||
return { value: cycle.id, display: cycle.name };
|
||||
})}
|
||||
value={value}
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={<ArrowPathIcon className="h-3 w-3 text-gray-500" />}
|
||||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex select-none items-center gap-x-2 py-2 pl-3 pr-9 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create cycle</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectSprint;
|
||||
export default SelectCycle;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import type { Control } from "react-hook-form";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// fetching keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { CheckIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
// ui
|
||||
import { Button, Input } from "ui";
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { IIssue, IIssueLabels } from "types";
|
||||
import { Input, CustomListbox } from "ui";
|
||||
// icons
|
||||
import { TagIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IIssue, IIssueLabels } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -29,21 +28,22 @@ const defaultValues: Partial<IIssueLabels> = {
|
|||
};
|
||||
|
||||
const SelectLabels: React.FC<Props> = ({ control }) => {
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: issueLabels, mutate: issueLabelsMutate } = useSWR<IIssueLabels[]>(
|
||||
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
|
||||
activeProject && activeWorkspace
|
||||
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(workspaceSlug as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const onSubmit = async (data: IIssueLabels) => {
|
||||
if (!activeProject || !activeWorkspace || isSubmitting) return;
|
||||
if (!projectId || !workspaceSlug || isSubmitting) return;
|
||||
await issuesServices
|
||||
.createIssueLabel(activeWorkspace.slug, activeProject.id, data)
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, data)
|
||||
.then((response) => {
|
||||
issueLabelsMutate((prevData) => [...(prevData ?? []), response], false);
|
||||
setIsOpen(false);
|
||||
|
|
@ -71,119 +71,66 @@ const SelectLabels: React.FC<Props> = ({ control }) => {
|
|||
control={control}
|
||||
name="labels_list"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox
|
||||
<CustomListbox
|
||||
title="Labels"
|
||||
options={issueLabels?.map((label) => {
|
||||
return { value: label.id, display: label.name, color: label.colour };
|
||||
})}
|
||||
value={value}
|
||||
onChange={(data: any) => {
|
||||
const valueCopy = [...(value ?? [])];
|
||||
if (valueCopy.some((i) => i === data)) onChange(valueCopy.filter((i) => i !== data));
|
||||
else onChange([...valueCopy, data]);
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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 sm:text-sm duration-300">
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block truncate">
|
||||
{value && value.length > 0
|
||||
? value.map((id) => issueLabels?.find((i) => i.id === id)?.name).join(", ")
|
||||
: "Labels"}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={<TagIcon className="h-3 w-3 text-gray-500" />}
|
||||
footerOption={
|
||||
<div className="relative min-w-[12rem] cursor-default select-none p-2">
|
||||
{isOpen ? (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
className="w-full"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-12 place-items-center rounded-md bg-green-600 text-white"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-12 place-items-center rounded-md bg-red-600 text-white"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels?.map((label) => (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} flex items-center gap-2 text-gray-900 cursor-pointer select-none w-full p-2`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: label.colour }}
|
||||
></span>
|
||||
<span
|
||||
className={`${
|
||||
selected || (value ?? []).some((i) => i === label.id)
|
||||
? "font-semibold"
|
||||
: "font-normal"
|
||||
} block`}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
<div className="cursor-default select-none relative p-2 min-w-[12rem]">
|
||||
{isOpen ? (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
className="w-full"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-green-600 text-white h-8 w-12 rounded-md grid place-items-center"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white h-8 w-12 rounded-md grid place-items-center"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create label</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create label</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import React from "react";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/";
|
||||
import { capitalizeFirstLetter } from "constants/common";
|
||||
// icons
|
||||
import { ChartBarIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { CustomListbox } from "ui";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { ChartBarIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -23,58 +24,22 @@ const SelectPriority: React.FC<Props> = ({ control }) => {
|
|||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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 sm:text-sm duration-300">
|
||||
<ChartBarIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block capitalize">
|
||||
{value && value !== "" ? value : "Priority"}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 w-full w-[5rem] bg-white shadow-lg max-h-28 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-xs">
|
||||
<div className="py-1">
|
||||
{PRIORITIES.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-pointer select-none p-2`
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`block capitalize ${
|
||||
selected ? "font-medium" : "font-normal"
|
||||
}`}
|
||||
>
|
||||
{priority}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
<CustomListbox
|
||||
title="State"
|
||||
options={PRIORITIES?.map((priority) => {
|
||||
return {
|
||||
value: priority,
|
||||
display: capitalizeFirstLetter(priority ?? "none"),
|
||||
icon: getPriorityIcon(priority),
|
||||
};
|
||||
})}
|
||||
value={value}
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={getPriorityIcon(value)}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,39 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { Control } from "react-hook-form";
|
||||
// services
|
||||
import projectService from "lib/services/project.service";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
activeProject: string;
|
||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
};
|
||||
|
||||
const SelectProject: React.FC<Props> = ({ control }) => {
|
||||
const { projects, setActiveProject } = useUser();
|
||||
const SelectProject: React.FC<Props> = ({ control, setActiveProject }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: projects } = useSWR(
|
||||
workspaceSlug ? PROJECTS_LIST(workspaceSlug as string) : null,
|
||||
() => (workspaceSlug ? projectService.getProjects(workspaceSlug as string) : null)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -28,15 +43,15 @@ const SelectProject: React.FC<Props> = ({ control }) => {
|
|||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
setActiveProject(projects?.find((i) => i.id === value));
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 bg-white relative 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 sm:text-sm">
|
||||
<Listbox.Button className="relative flex cursor-pointer items-center gap-1 rounded-md border bg-white px-2 py-1 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm">
|
||||
<ClipboardDocumentListIcon className="h-3 w-3" />
|
||||
<span className="block truncate">
|
||||
{projects?.find((i) => i.id === value)?.identifier ?? "Project"}
|
||||
|
|
@ -50,7 +65,7 @@ const SelectProject: React.FC<Props> = ({ control }) => {
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-28 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">
|
||||
{projects ? (
|
||||
projects.length > 0 ? (
|
||||
|
|
@ -60,7 +75,7 @@ const SelectProject: React.FC<Props> = ({ control }) => {
|
|||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-indigo-50" : ""
|
||||
} text-gray-900 cursor-pointer select-none p-2`
|
||||
} cursor-pointer select-none p-2 text-gray-900`
|
||||
}
|
||||
value={project.id}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
// constants
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
// ui
|
||||
import { CustomListbox } from "ui";
|
||||
// icons
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { IIssue } from "types";
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -18,7 +26,15 @@ type Props = {
|
|||
};
|
||||
|
||||
const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
|
||||
const { states } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = 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
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
|
|
@ -37,7 +53,7 @@ const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
|
|||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
className="relative flex select-none items-center gap-x-2 py-2 pl-3 pr-9 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -3,16 +3,12 @@ import React from "react";
|
|||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// constants
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
import { addSpaceIfCamelCase, timeAgo } from "constants/common";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
// common
|
||||
import { addSpaceIfCamelCase, renderShortNumericDateFormat, timeAgo } from "constants/common";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
import { Loader } from "ui";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -21,47 +17,115 @@ import {
|
|||
Squares2X2Icon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IssueResponse, IState } from "types";
|
||||
import { BlockedIcon, BlockerIcon, TagIcon, UserGroupIcon } from "ui/icons";
|
||||
import { IIssueActivity, IIssueComment } from "types";
|
||||
// components
|
||||
import CommentCard from "components/project/issues/issue-detail/comment/issue-comment-card";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
|
||||
const activityIcons: {
|
||||
[key: string]: JSX.Element;
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message?: string;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
} = {
|
||||
state: <Squares2X2Icon className="h-3.5 w-3.5" />,
|
||||
priority: <ChartBarIcon className="h-3.5 w-3.5" />,
|
||||
name: <ChatBubbleBottomCenterTextIcon className="h-3.5 w-3.5" />,
|
||||
description: <ChatBubbleBottomCenterTextIcon className="h-3.5 w-3.5" />,
|
||||
target_date: <CalendarDaysIcon className="h-3.5 w-3.5" />,
|
||||
parent: <UserIcon className="h-3.5 w-3.5" />,
|
||||
assignee: {
|
||||
message: "removed the assignee",
|
||||
icon: <UserGroupIcon className="h-4 w-4" />,
|
||||
},
|
||||
assignees: {
|
||||
message: "added a new assignee",
|
||||
icon: <UserGroupIcon className="h-4 w-4" />,
|
||||
},
|
||||
blocks: {
|
||||
message: "marked this issue being blocked by",
|
||||
icon: <BlockedIcon height="16" width="16" />,
|
||||
},
|
||||
blocking: {
|
||||
message: "marked this issue is blocking",
|
||||
icon: <BlockerIcon height="16" width="16" />,
|
||||
},
|
||||
labels: {
|
||||
icon: <TagIcon height="16" width="16" />,
|
||||
},
|
||||
state: {
|
||||
message: "set the state to",
|
||||
icon: <Squares2X2Icon className="h-4 w-4" />,
|
||||
},
|
||||
priority: {
|
||||
message: "set the priority to",
|
||||
icon: <ChartBarIcon className="h-4 w-4" />,
|
||||
},
|
||||
name: {
|
||||
message: "set the name to",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
|
||||
},
|
||||
description: {
|
||||
message: "updated the description.",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
|
||||
},
|
||||
target_date: {
|
||||
message: "set the due date to",
|
||||
icon: <CalendarDaysIcon className="h-4 w-4" />,
|
||||
},
|
||||
parent: {
|
||||
message: "set the parent to",
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
const IssueActivitySection: React.FC = () => {
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_html: "",
|
||||
comment_json: "",
|
||||
};
|
||||
|
||||
const IssueActivitySection: React.FC<{
|
||||
issueActivities: IIssueActivity[];
|
||||
mutate: KeyedMutator<IIssueActivity[]>;
|
||||
}> = ({ issueActivities, mutate }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { issueId, projectId } = router.query;
|
||||
let { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { activeWorkspace, states, issues } = useUser();
|
||||
const onCommentUpdate = async (comment: IIssueComment) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
await issuesServices
|
||||
.patchIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
comment.id,
|
||||
comment
|
||||
)
|
||||
.then((response) => {
|
||||
mutate();
|
||||
});
|
||||
};
|
||||
|
||||
const { data: issueActivities } = useSWR<any[]>(
|
||||
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null,
|
||||
activeWorkspace && projectId && issueId
|
||||
? () =>
|
||||
issuesServices.getIssueActivities(
|
||||
activeWorkspace.slug,
|
||||
projectId as string,
|
||||
issueId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
const onCommentDelete = async (commentId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
await issuesServices
|
||||
.deleteIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
commentId
|
||||
)
|
||||
.then((response) => {
|
||||
mutate();
|
||||
console.log(response);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueActivities ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{issueActivities.map((activity, index) => {
|
||||
if (activity.field !== "updated_by")
|
||||
if ("field" in activity && activity.field !== "updated_by") {
|
||||
return (
|
||||
<div key={activity.id} className="relative flex gap-x-2 w-full">
|
||||
<div key={activity.id} className="relative flex w-full items-center gap-x-2">
|
||||
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-5 left-2.5 h-full w-0.5 bg-gray-200"
|
||||
|
|
@ -69,89 +133,114 @@ const IssueActivitySection: React.FC = () => {
|
|||
/>
|
||||
) : null}
|
||||
{activity.field ? (
|
||||
<div className="relative z-10 flex-shrink-0 -ml-1">
|
||||
<div
|
||||
className={`h-7 w-7 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||
>
|
||||
{activityIcons[activity.field as keyof typeof activityIcons]}
|
||||
<div className="relative z-10 -ml-1 flex-shrink-0">
|
||||
<div className="grid h-8 w-8 place-items-center bg-white">
|
||||
{activityDetails[activity.field as keyof typeof activityDetails]?.icon}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 flex-shrink-0 border-2 border-white rounded-full h-[34px] -ml-1.5">
|
||||
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`h-8 w-8 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||
>
|
||||
{activity.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-10 -ml-4 flex-shrink-0 rounded-full border-2 border-white">
|
||||
<div className="grid h-12 w-12 place-items-center">
|
||||
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-gray-700 text-white`}
|
||||
>
|
||||
{activity.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full text-xs">
|
||||
<div className={`${activity.field ? "ml-1.5" : ""} w-full text-xs`}>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{activity.actor_detail.first_name} {activity.actor_detail.last_name}
|
||||
</span>
|
||||
<span> {activity.verb} </span>
|
||||
{activity.verb !== "created" ? (
|
||||
<span>{activity.field ?? "commented"}</span>
|
||||
) : (
|
||||
" this issue"
|
||||
)}
|
||||
<span>
|
||||
{" "}
|
||||
{activity.field === "labels"
|
||||
? activity.new_value !== ""
|
||||
? "added a new label"
|
||||
: "removed the label"
|
||||
: activity.field === "blocking"
|
||||
? activity.new_value !== ""
|
||||
? "marked this issue is blocking"
|
||||
: "removed the issue from blocking"
|
||||
: activity.field === "blocks"
|
||||
? activity.new_value !== ""
|
||||
? "marked this issue being blocked by"
|
||||
: "removed blocker"
|
||||
: activityDetails[activity.field as keyof typeof activityDetails]
|
||||
?.message}{" "}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{activity.verb === "created" ? (
|
||||
<span className="text-gray-600">created this issue.</span>
|
||||
) : activity.field === "description" ? null : activity.field === "state" ? (
|
||||
activity.new_value ? (
|
||||
addSpaceIfCamelCase(activity.new_value)
|
||||
) : (
|
||||
"None"
|
||||
)
|
||||
) : activity.field === "labels" ||
|
||||
activity.field === "blocking" ||
|
||||
activity.field === "blocks" ? (
|
||||
activity.new_value !== "" ? (
|
||||
activity.new_value
|
||||
) : (
|
||||
activity.old_value
|
||||
)
|
||||
) : activity.field === "assignee" ? (
|
||||
activity.old_value
|
||||
) : activity.field === "target_date" ? (
|
||||
renderShortNumericDateFormat(activity.new_value as string)
|
||||
) : activity.field === "description" ? (
|
||||
""
|
||||
) : (
|
||||
activity.new_value ?? "None"
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-2 text-gray-500">{timeAgo(activity.created_at)}</span>
|
||||
</p>
|
||||
<div className="w-full mt-2">
|
||||
{activity.verb !== "created" && (
|
||||
<div>
|
||||
<div>
|
||||
<span className="text-gray-500">From: </span>
|
||||
{activity.field === "state"
|
||||
? activity.old_value
|
||||
? addSpaceIfCamelCase(
|
||||
states?.find((s) => s.id === activity.old_value)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.field === "parent"
|
||||
? activity.old_value
|
||||
? issues?.results.find((i) => i.id === activity.old_value)?.name
|
||||
: "None"
|
||||
: activity.old_value ?? "None"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">To: </span>
|
||||
{activity.field === "state"
|
||||
? activity.new_value
|
||||
? addSpaceIfCamelCase(
|
||||
states?.find((s) => s.id === activity.new_value)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.field === "parent"
|
||||
? activity.new_value
|
||||
? issues?.results.find((i) => i.id === activity.new_value)?.name
|
||||
: "None"
|
||||
: activity.new_value ?? "None"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if ("comment_json" in activity) {
|
||||
return (
|
||||
<CommentCard
|
||||
key={activity.id}
|
||||
comment={activity as any}
|
||||
onSubmit={onCommentUpdate}
|
||||
handleCommentDeletion={onCommentDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<Loader className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="30px" width="40%"></Loader.Item>
|
||||
<Loader.Item height="15px" width="60%"></Loader.Item>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="30px" width="40%"></Loader.Item>
|
||||
<Loader.Item height="15px" width="60%"></Loader.Item>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="30px" width="40%"></Loader.Item>
|
||||
<Loader.Item height="15px" width="60%"></Loader.Item>
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
// commons
|
||||
|
|
@ -25,15 +22,20 @@ type Props = {
|
|||
parent: IIssue | undefined;
|
||||
};
|
||||
|
||||
type FormInput = {
|
||||
issue_ids: string[];
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { activeWorkspace, activeProject, issues } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
|
|
@ -41,23 +43,18 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
|||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<FormInput>();
|
||||
|
||||
const handleCommandPaletteClose = () => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const addAsSubIssue = (issueId: string) => {
|
||||
if (activeWorkspace && activeProject) {
|
||||
if (workspaceSlug && projectId) {
|
||||
issuesServices
|
||||
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, { parent: parent?.id })
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
|
||||
.then((res) => {
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((p) =>
|
||||
|
|
@ -106,7 +103,7 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
|
@ -139,7 +136,7 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
|||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
|
|
@ -149,7 +146,7 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
|||
}}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 block rounded-full`}
|
||||
className={`block h-1.5 w-1.5 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// next
|
||||
import Image from "next/image";
|
||||
// headless ui
|
||||
import { Menu } from "@headlessui/react";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// common
|
||||
import { timeAgo } from "constants/common";
|
||||
// ui
|
||||
import { TextArea } from "ui";
|
||||
// icon
|
||||
import { CheckIcon, EllipsisHorizontalIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
|
||||
type Props = {
|
||||
comment: IIssueComment;
|
||||
onSubmit: (comment: IIssueComment) => void;
|
||||
handleCommentDeletion: (comment: string) => void;
|
||||
};
|
||||
|
||||
const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
|
||||
const { user } = useUser();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
});
|
||||
|
||||
const onEnter = (formData: IIssueComment) => {
|
||||
if (isSubmitting) return;
|
||||
setIsEditing(false);
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isEditing && setFocus("comment");
|
||||
}, [isEditing, setFocus]);
|
||||
|
||||
return (
|
||||
<div key={comment.id}>
|
||||
<div className="w-full h-full flex justify-between">
|
||||
<div className="flex gap-x-2 w-full">
|
||||
<div className="flex-shrink-0">
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={comment.actor_detail.avatar}
|
||||
alt={comment.actor_detail.name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`h-8 w-8 bg-gray-500 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||
>
|
||||
{comment.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
|
||||
<TextArea
|
||||
id="comment"
|
||||
name="comment"
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
autoComplete="off"
|
||||
mode="transparent"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex self-end gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group bg-green-100 hover:bg-green-500 border border-green-500 duration-300 p-2 rounded shadow-md"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 text-green-500 group-hover:text-white duration-300" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group bg-red-100 hover:bg-red-500 border border-red-500 duration-300 p-2 rounded shadow-md"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-red-500 group-hover:text-white duration-300" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{comment.comment.split("\n").map((item, index) => (
|
||||
<p key={index} className="text-sm">
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 flex items-center gap-2 mt-1">
|
||||
<span>
|
||||
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
|
||||
</span>
|
||||
<span>{timeAgo(comment.created_at)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{user?.id === comment.actor && (
|
||||
<div className="relative">
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<EllipsisHorizontalIcon className="w-5 h-5 text-gray-500" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24 -top-20">
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleCommentDeletion(comment.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCard;
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import React from "react";
|
||||
// router
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CommentCard from "components/project/issues/issue-detail/comment/IssueCommentCard";
|
||||
// ui
|
||||
import { TextArea, Button, Spinner } from "ui";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment: "",
|
||||
};
|
||||
|
||||
const IssueCommentSection: React.FC = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let { issueId, projectId } = router.query;
|
||||
|
||||
const { activeWorkspace } = useUser();
|
||||
|
||||
const { data: comments, mutate } = useSWR<IIssueComment[]>(
|
||||
activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS(issueId as string) : null,
|
||||
activeWorkspace && projectId && issueId
|
||||
? () =>
|
||||
issuesServices.getIssueComments(
|
||||
activeWorkspace.slug,
|
||||
projectId as string,
|
||||
issueId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const onSubmit = async (formData: IIssueComment) => {
|
||||
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
|
||||
await issuesServices
|
||||
.createIssueComment(activeWorkspace.slug, projectId as string, issueId as string, formData)
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
mutate((prevData) => [response, ...(prevData ?? [])]);
|
||||
reset(defaultValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const onCommentUpdate = async (comment: IIssueComment) => {
|
||||
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
|
||||
await issuesServices
|
||||
.patchIssueComment(
|
||||
activeWorkspace.slug,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
comment.id,
|
||||
comment
|
||||
)
|
||||
.then((response) => {
|
||||
mutate((prevData) => {
|
||||
const updatedComments = prevData?.map((c) => {
|
||||
if (c.id === comment.id) {
|
||||
return comment;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
return updatedComments;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onCommentDelete = async (commentId: string) => {
|
||||
if (!activeWorkspace || !projectId || !issueId || isSubmitting) return;
|
||||
await issuesServices
|
||||
.deleteIssueComment(activeWorkspace.slug, projectId as string, issueId as string, commentId)
|
||||
.then((response) => {
|
||||
mutate((prevData) => (prevData ?? []).filter((c) => c.id !== commentId));
|
||||
console.log(response);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{comments ? (
|
||||
comments.length > 0 ? (
|
||||
<div className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onSubmit={onCommentUpdate}
|
||||
handleCommentDeletion={onCommentDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm">No comments yet. Be the first to comment.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full flex justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex items-start gap-2 border rounded-md p-2">
|
||||
<TextArea
|
||||
id="comment"
|
||||
name="comment"
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
mode="transparent"
|
||||
error={errors.comment}
|
||||
placeholder="Enter your comment"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const value = e.currentTarget.value;
|
||||
const start = e.currentTarget.selectionStart;
|
||||
const end = e.currentTarget.selectionEnd;
|
||||
setValue("comment", `${value.substring(0, start)}\r ${value.substring(end)}`);
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
isSubmitting || handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" className="whitespace-nowrap" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Adding comment..." : "Add comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueCommentSection;
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
// react
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
// next
|
||||
import Image from "next/image";
|
||||
import dynamic from "next/dynamic";
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// ui
|
||||
import { CustomMenu } from "ui";
|
||||
// icons
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
// common
|
||||
import { timeAgo } from "constants/common";
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false });
|
||||
|
||||
type Props = {
|
||||
comment: IIssueComment;
|
||||
onSubmit: (comment: IIssueComment) => void;
|
||||
handleCommentDeletion: (comment: string) => void;
|
||||
};
|
||||
|
||||
const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
|
||||
const { user } = useUser();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
});
|
||||
|
||||
const onEnter = (formData: IIssueComment) => {
|
||||
if (isSubmitting) return;
|
||||
setIsEditing(false);
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isEditing && setFocus("comment");
|
||||
}, [isEditing, setFocus]);
|
||||
|
||||
return (
|
||||
<div className="-ml-1 flex h-full w-full justify-between">
|
||||
<div className="flex w-full gap-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={comment.actor_detail.avatar}
|
||||
alt={comment.actor_detail.name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
|
||||
>
|
||||
{comment.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<p className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
|
||||
</span>
|
||||
<span>{timeAgo(comment.created_at)}</span>
|
||||
</p>
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
|
||||
<RemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
/>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group rounded border border-green-500 bg-green-100 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-100 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<RemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.id === comment.actor && (
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setIsEditing(true)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleCommentDeletion(comment.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCard;
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import React, { useMemo } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { KeyedMutator } from "swr";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// ui
|
||||
import { Loader } from "ui";
|
||||
// types
|
||||
import type { IIssueActivity, IIssueComment } from "types";
|
||||
// common
|
||||
import { debounce } from "constants/common";
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mb-5">
|
||||
<Loader.Item height="12rem" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_html: "",
|
||||
comment_json: "",
|
||||
};
|
||||
const AddIssueComment: React.FC<{
|
||||
mutate: KeyedMutator<IIssueActivity[]>;
|
||||
}> = ({ mutate }) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const onSubmit = async (formData: IIssueComment) => {
|
||||
if (
|
||||
!workspaceSlug ||
|
||||
!projectId ||
|
||||
!issueId ||
|
||||
isSubmitting ||
|
||||
!formData.comment_html ||
|
||||
!formData.comment_json
|
||||
)
|
||||
return;
|
||||
await issuesServices
|
||||
.createIssueComment(workspaceSlug as string, projectId as string, issueId as string, formData)
|
||||
.then(() => {
|
||||
mutate();
|
||||
reset(defaultValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const updateDescription = useMemo(
|
||||
() =>
|
||||
debounce((key: any, val: any) => {
|
||||
setValue(key, val);
|
||||
}, 1000),
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const updateDescriptionHTML = useMemo(
|
||||
() =>
|
||||
debounce((key: any, val: any) => {
|
||||
setValue(key, val);
|
||||
}, 1000),
|
||||
[setValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="rounded-md p-2 pt-3">
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={value}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-gray-300 p-2 px-4 text-sm text-black hover:bg-gray-300"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddIssueComment;
|
||||
|
|
@ -1,30 +1,33 @@
|
|||
import React, { useState } from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
import dynamic from "next/dynamic";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// react hook form
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { useForm, Controller, UseFormWatch } from "react-hook-form";
|
||||
|
||||
import { TwitterPicker } from "react-color";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// fetching keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
// commons
|
||||
import { copyTextToClipboard } from "constants/common";
|
||||
// components
|
||||
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
|
||||
import SelectState from "components/project/issues/issue-detail/issue-detail-sidebar/select-state";
|
||||
import SelectPriority from "components/project/issues/issue-detail/issue-detail-sidebar/select-priority";
|
||||
import SelectParent from "components/project/issues/issue-detail/issue-detail-sidebar/select-parent";
|
||||
import SelectCycle from "components/project/issues/issue-detail/issue-detail-sidebar/select-cycle";
|
||||
import SelectAssignee from "components/project/issues/issue-detail/issue-detail-sidebar/select-assignee";
|
||||
import SelectBlocker from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocker";
|
||||
import SelectBlocked from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocked";
|
||||
// headless ui
|
||||
import { Popover, Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, Button, Spinner } from "ui";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// icons
|
||||
import {
|
||||
TagIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardDocumentIcon,
|
||||
LinkIcon,
|
||||
CalendarDaysIcon,
|
||||
TrashIcon,
|
||||
|
|
@ -33,16 +36,11 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { ICycle, IIssue, IIssueLabels, NestedKeyOf } from "types";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { positionEditorElement } from "components/lexical/helpers/editor";
|
||||
import SelectState from "./select-state";
|
||||
import SelectPriority from "./select-priority";
|
||||
import SelectParent from "./select-parent";
|
||||
import SelectCycle from "./select-cycle";
|
||||
import SelectAssignee from "./select-assignee";
|
||||
import SelectBlocker from "./select-blocker";
|
||||
import SelectBlocked from "./select-blocked";
|
||||
import type { ICycle, IIssue, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// common
|
||||
import { copyTextToClipboard } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -63,19 +61,28 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
watch: watchIssue,
|
||||
}) => {
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
|
||||
const { activeWorkspace, activeProject, issues } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
|
||||
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
|
||||
activeProject && activeWorkspace
|
||||
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -89,49 +96,51 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
});
|
||||
|
||||
const handleNewLabel = (formData: any) => {
|
||||
if (!activeWorkspace || !activeProject || isSubmitting) return;
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
issuesServices
|
||||
.createIssueLabel(activeWorkspace.slug, activeProject.id, formData)
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
reset(defaultValues);
|
||||
issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
|
||||
submitChanges({ labels_list: [res.id] });
|
||||
submitChanges({ labels_list: [...(issueDetail?.labels ?? []), res.id] });
|
||||
setCreateLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCycleChange = (cycleDetail: ICycle) => {
|
||||
if (activeWorkspace && activeProject && issueDetail) {
|
||||
submitChanges({ cycle: cycleDetail.id, cycle_detail: cycleDetail });
|
||||
issuesServices
|
||||
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleDetail.id, {
|
||||
issues: [issueDetail.id],
|
||||
})
|
||||
.then(() => {
|
||||
submitChanges({});
|
||||
});
|
||||
}
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
|
||||
issuesServices.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
|
||||
issues: [issueDetail.id],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmIssueDeletion
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueDetail}
|
||||
/>
|
||||
<div className="h-full w-full divide-y-2 divide-gray-100">
|
||||
<div className="flex justify-between items-center pb-3">
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h4 className="text-sm font-medium">
|
||||
{activeProject?.identifier}-{issueDetail?.sequence_id}
|
||||
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
|
||||
</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}/issues/${issueDetail?.id}`
|
||||
`https://app.plane.so/${workspaceSlug}/projects/${issueDetail?.project_detail?.id}/issues/${issueDetail?.id}`
|
||||
)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
title: "Issue link copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -146,28 +155,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
</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(issueDetail?.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={() => setDeleteIssueModal(true)}
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
|
|
@ -196,14 +184,14 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
issueDetail?.parent_detail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 bg-gray-100 px-3 py-2 text-xs rounded"
|
||||
className="flex items-center gap-2 rounded bg-gray-100 px-3 py-2 text-xs"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
{issueDetail.parent_detail?.name}
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-block bg-gray-100 px-3 py-2 text-xs rounded">
|
||||
<div className="inline-block rounded bg-gray-100 px-3 py-2 text-xs">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
|
|
@ -216,13 +204,13 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
watch={watchIssue}
|
||||
/>
|
||||
<SelectBlocked
|
||||
issueDetail={issueDetail}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
/>
|
||||
<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>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
|
|
@ -238,7 +226,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
submitChanges({ target_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="hover:bg-gray-100 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 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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -246,17 +234,22 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<SelectCycle control={control} handleCycleChange={handleCycleChange} />
|
||||
<SelectCycle
|
||||
issueDetail={issueDetail}
|
||||
control={control}
|
||||
handleCycleChange={handleCycleChange}
|
||||
watch={watchIssue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-x-2 text-sm basis-1/2">
|
||||
<TagIcon className="w-4 h-4" />
|
||||
<div className="space-y-3 pt-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>Label</p>
|
||||
</div>
|
||||
<div className="basis-1/2">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watchIssue("labels_list")?.map((label) => {
|
||||
const singleLabel = issueLabels?.find((l) => l.id === label);
|
||||
|
||||
|
|
@ -265,7 +258,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
return (
|
||||
<span
|
||||
key={singleLabel.id}
|
||||
className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer"
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label);
|
||||
submitChanges({
|
||||
|
|
@ -274,7 +267,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: singleLabel.colour ?? "green" }}
|
||||
></span>
|
||||
{singleLabel.name}
|
||||
|
|
@ -297,7 +290,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
<>
|
||||
<Listbox.Label className="sr-only">Label</Listbox.Label>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-2 border rounded-2xl text-xs px-2 py-0.5 hover:bg-gray-100 cursor-pointer">
|
||||
<Listbox.Button className="flex cursor-pointer items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs hover:bg-gray-100">
|
||||
Select Label
|
||||
</Listbox.Button>
|
||||
|
||||
|
|
@ -308,7 +301,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 right-0 mt-1 w-40 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 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">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
|
|
@ -318,12 +311,12 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-indigo-50" : ""
|
||||
} flex items-center gap-2 text-gray-900 cursor-pointer select-none relative p-2 truncate`
|
||||
} relative flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: label.colour ?? "green" }}
|
||||
></span>
|
||||
{label.name}
|
||||
|
|
@ -346,7 +339,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 border rounded-2xl text-xs px-2 py-0.5 hover:bg-gray-100 cursor-pointer"
|
||||
className="flex cursor-pointer items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs hover:bg-gray-100"
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
|
|
@ -369,11 +362,11 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`bg-white flex items-center gap-1 rounded-md p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
|
||||
className={`flex items-center gap-1 rounded-md bg-white p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
|
||||
>
|
||||
{watch("colour") && watch("colour") !== "" && (
|
||||
<span
|
||||
className="w-5 h-5 rounded"
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("colour") ?? "green",
|
||||
}}
|
||||
|
|
@ -391,7 +384,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 transform right-0 mt-1 px-2 max-w-xs sm:px-0">
|
||||
<Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0">
|
||||
<Controller
|
||||
name="colour"
|
||||
control={controlLabel}
|
||||
|
|
@ -418,18 +411,16 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
|||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button type="submit" theme="danger" onClick={() => setCreateLabelForm(false)}>
|
||||
<XMarkIcon className="h-4 w-4 text-white" />
|
||||
</Button>
|
||||
<Button type="submit" theme="success" disabled={isSubmitting}>
|
||||
+
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmIssueDeletion
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueDetail}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// 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";
|
||||
|
|
@ -29,17 +29,18 @@ type Props = {
|
|||
};
|
||||
|
||||
const SelectAssignee: 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" />
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Assignees</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
|
|
@ -58,14 +59,14 @@ const SelectAssignee: 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 ? (
|
||||
|
|
@ -82,7 +83,7 @@ const SelectAssignee: 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 +94,7 @@ const SelectAssignee: 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`}
|
||||
className={`grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 text-white`}
|
||||
>
|
||||
{person?.first_name.charAt(0)}
|
||||
</div>
|
||||
|
|
@ -102,7 +103,7 @@ const SelectAssignee: 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%"
|
||||
|
|
@ -128,7 +129,7 @@ const SelectAssignee: 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 ? (
|
||||
|
|
@ -138,7 +139,7 @@ const SelectAssignee: 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}
|
||||
>
|
||||
|
|
@ -153,7 +154,7 @@ const SelectAssignee: 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)}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,58 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// react-hook-form
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
// constants
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { FolderIcon, MagnifyingGlassIcon, FlagIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon } from "ui/icons";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
import { classNames } from "constants/common";
|
||||
import issuesService from "lib/services/issues.service";
|
||||
|
||||
type FormInput = {
|
||||
issue_ids: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
issueDetail: IIssue | undefined;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
issuesList: IIssue[];
|
||||
watch: UseFormWatch<IIssue>;
|
||||
};
|
||||
|
||||
const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
||||
const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
|
||||
|
||||
const { activeWorkspace, activeProject, issues, mutateIssues } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { register, handleSubmit, reset, watch: watchIssues } = useForm<FormInput>();
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { register, handleSubmit, reset, watch: watchBlocked } = useForm<FormInput>();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsBlockedModalOpen(false);
|
||||
|
|
@ -51,86 +69,50 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
data.issue_ids.map((issue) => {
|
||||
if (!activeWorkspace || !activeProject || !issueDetail) return;
|
||||
|
||||
const currentBlockers =
|
||||
issues?.results
|
||||
.find((i) => i.id === issue)
|
||||
?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? [];
|
||||
|
||||
issuesService
|
||||
.patchIssue(activeWorkspace.slug, activeProject.id, issue, {
|
||||
blockers_list: [...currentBlockers, issueDetail.id],
|
||||
})
|
||||
.then((response) => {
|
||||
mutateIssues((prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((issue) => {
|
||||
if (issue.id === issueDetail.id) {
|
||||
return { ...issue, ...response };
|
||||
}
|
||||
return issue;
|
||||
}),
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
});
|
||||
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
|
||||
|
||||
const newBlocked = [...watch("blocked_list"), ...data.issue_ids];
|
||||
submitChanges({ blocks_list: newBlocked });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const removeBlocked = (issueId: string) => {
|
||||
if (!activeWorkspace || !activeProject || !issueDetail) return;
|
||||
|
||||
const currentBlockers =
|
||||
issues?.results
|
||||
.find((i) => i.id === issueId)
|
||||
?.blocker_issues.map((b) => b.blocker_issue_detail?.id ?? "") ?? [];
|
||||
|
||||
const updatedBlockers = currentBlockers.filter((b) => b !== issueDetail.id);
|
||||
|
||||
issuesService
|
||||
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, {
|
||||
blockers_list: updatedBlockers,
|
||||
})
|
||||
.then((response) => {
|
||||
mutateIssues((prevData) => ({
|
||||
...(prevData as IssueResponse),
|
||||
results: (prevData?.results ?? []).map((issue) => {
|
||||
if (issue.id === issueDetail.id) {
|
||||
return { ...issue, ...response };
|
||||
}
|
||||
return issue;
|
||||
}),
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start py-2 flex-wrap">
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<FlagIcon className="flex-shrink-0 h-4 w-4" />
|
||||
<BlockedIcon height={16} width={16} />
|
||||
<p>Blocked by</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2 space-y-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocked_list") && watch("blocked_list").length > 0
|
||||
? watch("blocked_list").map((issue) => (
|
||||
<span
|
||||
key={issue}
|
||||
className="group flex items-center gap-1 border rounded-2xl text-xs px-1.5 py-0.5 text-red-500 hover:bg-red-50 border-red-500 cursor-pointer"
|
||||
onClick={() => removeBlocked(issue)}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-white px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
const updatedBlocked: string[] = watch("blocked_list").filter(
|
||||
(i) => i !== issue
|
||||
);
|
||||
submitChanges({
|
||||
blocks_list: updatedBlocked,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{`${activeProject?.identifier}-${
|
||||
issues?.results.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.results.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${
|
||||
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
|
||||
</a>
|
||||
</Link>
|
||||
<span className="opacity-0 duration-300 group-hover:opacity-100">
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
|
|
@ -173,7 +155,7 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
|
|
@ -201,40 +183,40 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
|||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
htmlFor={`blocked-issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/projects/${issue.project}/issues/${issue.id}`,
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`blocked-issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{
|
||||
issues?.results.find((i) => i.id === issue.id)
|
||||
?.project_detail?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
|
|
@ -258,15 +240,15 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
|||
)}
|
||||
</Combobox>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<Button onClick={handleSubmit(onSubmit)} size="sm">
|
||||
Add selected issues
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<div>
|
||||
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSubmit(onSubmit)} size="sm">
|
||||
Add selected issues
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
|
|
@ -276,7 +258,7 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
|
|||
</Transition.Root>
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-between items-center gap-1 hover:bg-gray-100 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="flex w-full cursor-pointer items-center justify-between gap-1 rounded-md border 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"
|
||||
onClick={() => setIsBlockedModalOpen(true)}
|
||||
>
|
||||
Select issues
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// react-hook-form
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
|
||||
// constants
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { FlagIcon, FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockerIcon, LayerDiagonalIcon } from "ui/icons";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
|
|
@ -30,9 +38,20 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
const [query, setQuery] = useState("");
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
const { activeProject, issues } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormInput>();
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -49,36 +68,54 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
|
||||
|
||||
const newBlockers = [...watch("blockers_list"), ...data.issue_ids];
|
||||
submitChanges({ blockers_list: newBlockers });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start py-2 flex-wrap">
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
|
||||
<FlagIcon className="flex-shrink-0 h-4 w-4" />
|
||||
<BlockerIcon height={16} width={16} />
|
||||
<p>Blocking</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2 space-y-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blockers_list") && watch("blockers_list").length > 0
|
||||
? watch("blockers_list").map((issue) => (
|
||||
<span
|
||||
<div
|
||||
key={issue}
|
||||
className="group flex items-center gap-1 border rounded-2xl text-xs px-1.5 py-0.5 text-yellow-500 hover:bg-yellow-50 border-yellow-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
const updatedBlockers = watch("blockers_list").filter((i) => i !== issue);
|
||||
submitChanges({
|
||||
blockers_list: updatedBlockers,
|
||||
});
|
||||
}}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-white px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500 hover:bg-yellow-50"
|
||||
>
|
||||
{`${activeProject?.identifier}-${
|
||||
issues?.results.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.results.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${
|
||||
issues?.results.find((i) => i.id === issue)?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === issue)?.sequence_id}`}
|
||||
</a>
|
||||
</Link>
|
||||
<span
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers: string[] = watch("blockers_list").filter(
|
||||
(i) => i !== issue
|
||||
);
|
||||
submitChanges({
|
||||
blockers_list: updatedBlockers,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
|
|
@ -120,7 +157,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
|
|
@ -130,65 +167,73 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{issuesList.length > 0 && (
|
||||
<>
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select blocker issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{issuesList.map((issue) => {
|
||||
if (
|
||||
!watch("blockers_list").includes(issue.id) &&
|
||||
!watch("blocked_list").includes(issue.id)
|
||||
) {
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
{issuesList.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select blocker issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{issuesList.map((issue) => {
|
||||
if (
|
||||
!watch("blockers_list").includes(issue.id) &&
|
||||
!watch("blocked_list").includes(issue.id)
|
||||
)
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`blocker-issue-${issue.id}`}
|
||||
value={{
|
||||
name: issue.name,
|
||||
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("issue_ids")}
|
||||
id={`blocker-issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
/>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{
|
||||
issues?.results.find((i) => i.id === issue.id)
|
||||
?.project_detail?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
|
|
@ -205,15 +250,15 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
)}
|
||||
</Combobox>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<Button onClick={handleSubmit(onSubmit)} size="sm">
|
||||
Add selected issues
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<div>
|
||||
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSubmit(onSubmit)} size="sm">
|
||||
Add selected issues
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
|
|
@ -223,7 +268,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
|||
</Transition.Root>
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-between items-center gap-1 hover:bg-gray-100 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="flex w-full cursor-pointer items-center justify-between gap-1 rounded-md border 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"
|
||||
onClick={() => setIsBlockerModalOpen(true)}
|
||||
>
|
||||
Select issues
|
||||
|
|
|
|||
|
|
@ -1,33 +1,67 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { Control, Controller, UseFormWatch } from "react-hook-form";
|
||||
// constants
|
||||
import { CYCLE_ISSUES, CYCLE_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import issuesService from "lib/services/issues.service";
|
||||
import cyclesService from "lib/services/cycles.service";
|
||||
// ui
|
||||
import { Spinner, CustomSelect } from "ui";
|
||||
// icons
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { ICycle, IIssue } from "types";
|
||||
import { CycleIssueResponse, ICycle, IIssue, IssueResponse } from "types";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
issueDetail: IIssue | undefined;
|
||||
control: Control<IIssue, any>;
|
||||
handleCycleChange: (cycle: ICycle) => void;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
};
|
||||
|
||||
const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
|
||||
const { cycles } = useUser();
|
||||
const SelectCycle: React.FC<Props> = ({ issueDetail, control, handleCycleChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: cycles } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => cyclesService.getCycles(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const removeIssueFromCycle = (bridgeId: string, cycleId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
|
||||
issuesService
|
||||
.removeIssueFromCycle(workspaceSlug as string, projectId as string, cycleId, bridgeId)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
|
||||
mutate(CYCLE_ISSUES(cycleId));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<ArrowPathIcon className="flex-shrink-0 h-4 w-4" />
|
||||
<ArrowPathIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Cycle</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_cycle"
|
||||
|
|
@ -38,24 +72,34 @@ const SelectCycle: React.FC<Props> = ({ control, handleCycleChange }) => {
|
|||
<span
|
||||
className={classNames(
|
||||
value ? "" : "text-gray-900",
|
||||
"hidden truncate sm:block text-left"
|
||||
"hidden truncate text-left sm:block"
|
||||
)}
|
||||
>
|
||||
{value ? cycles?.find((c) => c.id === value.cycle_detail.id)?.name : "None"}
|
||||
{value ? value?.cycle_detail?.name : "None"}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
handleCycleChange(cycles?.find((c) => c.id === value) as any);
|
||||
value === null
|
||||
? removeIssueFromCycle(
|
||||
issueDetail?.issue_cycle?.id ?? "",
|
||||
issueDetail?.issue_cycle?.cycle ?? ""
|
||||
)
|
||||
: handleCycleChange(cycles?.find((c) => c.id === value) as any);
|
||||
}}
|
||||
>
|
||||
{cycles ? (
|
||||
cycles.length > 0 ? (
|
||||
cycles.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
<>
|
||||
<CustomSelect.Option value={null} className="capitalize">
|
||||
<>None</>
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
{cycles.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">No cycles found</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// constants
|
||||
import { MODULE_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import modulesService from "lib/services/modules.service";
|
||||
// ui
|
||||
import { Spinner, CustomSelect } from "ui";
|
||||
// icons
|
||||
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IModule } from "types";
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
handleModuleChange: (module: IModule) => void;
|
||||
};
|
||||
|
||||
const SelectModule: React.FC<Props> = ({ control, handleModuleChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: modules } = useSWR(
|
||||
workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => modulesService.getModules(workspaceSlug as string, projectId 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">
|
||||
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Module</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_module"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
className={classNames(
|
||||
value ? "" : "text-gray-900",
|
||||
"hidden truncate text-left sm:block"
|
||||
)}
|
||||
>
|
||||
{value ? modules?.find((m) => m.id === value?.module_detail.id)?.name : "None"}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
handleModuleChange(modules?.find((m) => m.id === value) as any);
|
||||
}}
|
||||
>
|
||||
{modules ? (
|
||||
modules.length > 0 ? (
|
||||
modules.map((option) => (
|
||||
<CustomSelect.Option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No cycles found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectModule;
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// react-hook-form
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Control, Controller, UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.service";
|
||||
// components
|
||||
import IssuesListModal from "components/project/issues/issues-list-modal";
|
||||
// icons
|
||||
|
|
@ -28,12 +33,22 @@ const SelectParent: React.FC<Props> = ({
|
|||
}) => {
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
|
||||
const { activeProject, issues } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesServices.getIssues(workspaceSlug as string, projectId 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">
|
||||
<UserIcon className="flex-shrink-0 h-4 w-4" />
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Parent</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
|
|
@ -57,13 +72,13 @@ const SelectParent: React.FC<Props> = ({
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-between items-center gap-1 hover:bg-gray-100 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="flex w-full cursor-pointer items-center justify-between gap-1 rounded-md border 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"
|
||||
onClick={() => setIsParentModalOpen(true)}
|
||||
>
|
||||
{watch("parent") && watch("parent") !== ""
|
||||
? `${activeProject?.identifier}-${
|
||||
issues?.results.find((i) => i.id === watch("parent"))?.sequence_id
|
||||
}`
|
||||
? `${
|
||||
issues?.results.find((i) => i.id === watch("parent"))?.project_detail?.identifier
|
||||
}-${issues?.results.find((i) => i.id === watch("parent"))?.sequence_id}`
|
||||
: "Select issue"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
import React from "react";
|
||||
// react-hook-form
|
||||
import { Control, Controller, UseFormWatch } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomSelect } from "ui";
|
||||
// icons
|
||||
import { ChevronDownIcon, ChartBarIcon } from "@heroicons/react/24/outline";
|
||||
import { ChartBarIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
// common
|
||||
import { classNames } from "constants/common";
|
||||
import { PRIORITIES } from "constants/";
|
||||
import CustomSelect from "ui/custom-select";
|
||||
// constants
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
import { PRIORITIES } from "constants/";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -22,9 +22,9 @@ type Props = {
|
|||
|
||||
const SelectPriority: 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">
|
||||
<ChartBarIcon className="flex-shrink-0 h-4 w-4" />
|
||||
<ChartBarIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Priority</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
|
|
@ -37,7 +37,7 @@ const SelectPriority: 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"
|
||||
)}
|
||||
>
|
||||
{getPriorityIcon(
|
||||
|
|
@ -58,7 +58,7 @@ const SelectPriority: React.FC<Props> = ({ control, submitChanges, watch }) => {
|
|||
<CustomSelect.Option key={option} value={option} className="capitalize">
|
||||
<>
|
||||
{getPriorityIcon(option, "text-sm")}
|
||||
{option}
|
||||
{option ?? "None"}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
// react-hook-form
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
// icons
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
// constants
|
||||
import { classNames } from "constants/common";
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { classNames } from "constants/common";
|
||||
import { CustomMenu, Spinner } from "ui";
|
||||
import React from "react";
|
||||
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import CustomSelect from "ui/custom-select";
|
||||
// ui
|
||||
import { Spinner, CustomSelect } from "ui";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
|
|
@ -18,12 +23,20 @@ type Props = {
|
|||
};
|
||||
|
||||
const SelectState: React.FC<Props> = ({ control, submitChanges }) => {
|
||||
const { states } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = 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
|
||||
);
|
||||
|
||||
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>State</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
|
|
@ -42,7 +55,7 @@ const SelectState: React.FC<Props> = ({ control, submitChanges }) => {
|
|||
{value ? (
|
||||
<>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: states?.find((option) => option.id === value)?.color,
|
||||
}}
|
||||
|
|
@ -66,7 +79,7 @@ const SelectState: React.FC<Props> = ({ control, submitChanges }) => {
|
|||
<>
|
||||
{option.color && (
|
||||
<span
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
></span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// constants
|
||||
import { classNames } from "constants/common";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { classNames } from "constants/common";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import { LayerDiagonalIcon } from "ui/icons";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -35,8 +34,6 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
const [query, setQuery] = useState("");
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
|
||||
const { activeProject } = useUser();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
|
|
@ -51,7 +48,7 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<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"
|
||||
|
|
@ -77,21 +74,14 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
{multiple ? (
|
||||
<>
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
// setValues(val);
|
||||
console.log(val);
|
||||
}}
|
||||
multiple
|
||||
>
|
||||
<Combobox value={value} onChange={(val) => {}} multiple>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
displayValue={() => ""}
|
||||
|
|
@ -116,7 +106,7 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
|
|
@ -125,13 +115,13 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.id}
|
||||
</>
|
||||
|
|
@ -155,7 +145,7 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
@ -172,7 +162,7 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
displayValue={() => ""}
|
||||
|
|
@ -183,7 +173,7 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
|
|
@ -197,30 +187,39 @@ const IssuesListModal: React.FC<Props> = ({
|
|||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
onClick={() => handleClose()}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.name}
|
||||
</>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,19 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
// swr
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// headless ui
|
||||
import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomMenu, Spinner } from "ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PlusIcon,
|
||||
CalendarDaysIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import User from "public/user.png";
|
||||
// components
|
||||
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
|
||||
import { Disclosure, Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// fetch keys
|
||||
import { PRIORITIES } from "constants/";
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
import useIssuesProperties from "lib/hooks/useIssuesProperties";
|
||||
// services
|
||||
import stateService from "lib/services/state.service";
|
||||
import issuesService from "lib/services/issues.service";
|
||||
import projectService from "lib/services/project.service";
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// constants
|
||||
import {
|
||||
addSpaceIfCamelCase,
|
||||
|
|
@ -35,10 +21,26 @@ import {
|
|||
findHowManyDaysLeft,
|
||||
renderShortNumericDateFormat,
|
||||
} from "constants/common";
|
||||
import { PRIORITIES } from "constants/";
|
||||
import { getPriorityIcon } from "constants/global";
|
||||
import {
|
||||
PROJECT_DETAILS,
|
||||
PROJECT_ISSUES_LIST,
|
||||
STATE_LIST,
|
||||
WORKSPACE_MEMBERS,
|
||||
} from "constants/fetch-keys";
|
||||
// ui
|
||||
import { CustomMenu, CustomSelect, Spinner } from "ui";
|
||||
// icons
|
||||
import User from "public/user.png";
|
||||
import { ChevronDownIcon, PlusIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
|
||||
// types
|
||||
import { IIssue, IProject, IssueResponse, IWorkspaceMember, NestedKeyOf } from "types";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
properties: Properties;
|
||||
groupedByIssues: any;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
setSelectedIssue: any;
|
||||
|
|
@ -47,7 +49,6 @@ type Props = {
|
|||
};
|
||||
|
||||
const ListView: React.FC<Props> = ({
|
||||
properties,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
setSelectedIssue,
|
||||
|
|
@ -59,13 +60,39 @@ const ListView: React.FC<Props> = ({
|
|||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
const { activeWorkspace, activeProject, states } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR<IssueResponse>(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssues(workspaceSlug as string, projectId 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
|
||||
);
|
||||
|
||||
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: projectDetails } = useSWR<IProject>(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssuesModal
|
||||
|
|
@ -74,23 +101,23 @@ const ListView: React.FC<Props> = ({
|
|||
prePopulateData={{
|
||||
...preloadedData,
|
||||
}}
|
||||
projectId={activeProject?.id as string}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<div className="flex flex-col space-y-5">
|
||||
{Object.keys(groupedByIssues).map((singleGroup) => (
|
||||
<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"
|
||||
: selectedGroup === "created_by"
|
||||
|
|
@ -101,7 +128,7 @@ const ListView: 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>
|
||||
|
|
@ -136,34 +163,36 @@ const ListView: React.FC<Props> = ({
|
|||
};
|
||||
});
|
||||
|
||||
const totalChildren = issues?.results.filter(
|
||||
(i) => i.parent === issue.id
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
|
||||
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/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}
|
||||
{projectDetails?.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">
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<Listbox
|
||||
as="div"
|
||||
|
|
@ -177,7 +206,7 @@ const ListView: React.FC<Props> = ({
|
|||
<>
|
||||
<div>
|
||||
<Listbox.Button
|
||||
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
|
||||
className={`flex cursor-pointer items-center gap-x-2 rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
|
||||
issue.priority === "urgent"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
|
|
@ -189,7 +218,12 @@ const ListView: React.FC<Props> = ({
|
|||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{issue.priority ?? "None"}
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== ""
|
||||
? issue.priority ?? ""
|
||||
: "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
|
|
@ -199,26 +233,27 @@ const ListView: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"cursor-pointer capitalize select-none px-3 py-2"
|
||||
"flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize"
|
||||
)
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
{priority}
|
||||
{getPriorityIcon(priority, "text-sm")}
|
||||
{priority ?? "None"}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
<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">
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium text-gray-900">
|
||||
Priority
|
||||
</h5>
|
||||
<div
|
||||
|
|
@ -242,77 +277,43 @@ const ListView: React.FC<Props> = ({
|
|||
</Listbox>
|
||||
)}
|
||||
{properties.state && (
|
||||
<Listbox
|
||||
as="div"
|
||||
<CustomSelect
|
||||
label={
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(issue.state_detail.name)}
|
||||
</>
|
||||
}
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ state: data }, issue.id);
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
maxHeight="md"
|
||||
noChevron
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button className="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)}
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
{states?.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-indigo-50" : "bg-white",
|
||||
"cursor-pointer select-none px-3 py-2"
|
||||
)
|
||||
}
|
||||
value={state.id}
|
||||
>
|
||||
{addSpaceIfCamelCase(state.name)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{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>
|
||||
{states?.map((state) => (
|
||||
<CustomSelect.Option key={state.id} value={state.id}>
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
></span>
|
||||
{addSpaceIfCamelCase(state.name)}
|
||||
</>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
{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 ${
|
||||
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border 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 ${
|
||||
issue.target_date === null
|
||||
? ""
|
||||
: issue.target_date < new Date().toISOString()
|
||||
|
|
@ -325,8 +326,8 @@ const ListView: React.FC<Props> = ({
|
|||
{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">
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium text-gray-900">
|
||||
Target date
|
||||
</h5>
|
||||
<div>
|
||||
|
|
@ -347,6 +348,12 @@ const ListView: React.FC<Props> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border 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">
|
||||
{totalChildren}{" "}
|
||||
{totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<Listbox
|
||||
as="div"
|
||||
|
|
@ -366,7 +373,7 @@ const ListView: React.FC<Props> = ({
|
|||
<>
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index: number) => (
|
||||
<div
|
||||
|
|
@ -376,18 +383,20 @@ const ListView: React.FC<Props> = ({
|
|||
}`}
|
||||
>
|
||||
{assignee.avatar && assignee.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={assignee.avatar}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={assignee?.first_name}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
|
||||
className={`grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 text-white`}
|
||||
>
|
||||
{assignee.first_name?.charAt(0)}
|
||||
</div>
|
||||
|
|
@ -395,13 +404,15 @@ const ListView: React.FC<Props> = ({
|
|||
</div>
|
||||
))
|
||||
) : (
|
||||
<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%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="No user"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -415,7 +426,7 @@ const ListView: React.FC<Props> = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
|
|
@ -447,10 +458,12 @@ const ListView: React.FC<Props> = ({
|
|||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
|
||||
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{person.member.first_name &&
|
||||
person.member.first_name !== ""
|
||||
? person.member.first_name.charAt(0)
|
||||
|
|
@ -469,8 +482,8 @@ const ListView: React.FC<Props> = ({
|
|||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
<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">Assigned to</h5>
|
||||
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
|
||||
<h5 className="mb-1 font-medium">Assigned to</h5>
|
||||
<div>
|
||||
{issue.assignee_details?.length > 0
|
||||
? issue.assignee_details
|
||||
|
|
@ -507,10 +520,10 @@ const ListView: React.FC<Props> = ({
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -520,7 +533,7 @@ const ListView: React.FC<Props> = ({
|
|||
<div className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setIsCreateIssuesModalOpen(true);
|
||||
if (selectedGroup !== null) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
|
|
@ -25,11 +26,12 @@ type Props = {
|
|||
};
|
||||
|
||||
const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
||||
const { activeWorkspace } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: states } = useSWR<IState[]>(
|
||||
activeWorkspace ? STATE_LIST(issue.project) : null,
|
||||
activeWorkspace ? () => stateServices.getStates(activeWorkspace.slug, issue.project) : null
|
||||
workspaceSlug ? STATE_LIST(issue.project) : null,
|
||||
workspaceSlug ? () => stateServices.getStates(workspaceSlug as string, issue.project) : null
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -38,8 +40,8 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
|||
as="div"
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
if (!activeWorkspace) return;
|
||||
updateIssues(activeWorkspace.slug, issue.project, issue.id, {
|
||||
if (!workspaceSlug) return;
|
||||
updateIssues(workspaceSlug as string, issue.project, issue.id, {
|
||||
state: data,
|
||||
state_detail: states?.find((state) => state.id === data),
|
||||
});
|
||||
|
|
@ -50,7 +52,7 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
|||
<>
|
||||
<div>
|
||||
<Listbox.Button
|
||||
className="inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 border"
|
||||
className="inline-flex items-center whitespace-nowrap rounded-full border bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100"
|
||||
style={{
|
||||
border: `2px solid ${issue.state_detail.color}`,
|
||||
backgroundColor: `${issue.state_detail.color}20`,
|
||||
|
|
@ -59,7 +61,7 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
|||
<span
|
||||
className={classNames(
|
||||
issue.state ? "" : "text-gray-900",
|
||||
"hidden capitalize sm:block w-16"
|
||||
"hidden w-16 capitalize sm:block"
|
||||
)}
|
||||
>
|
||||
{addSpaceIfCamelCase(issue.state_detail.name)}
|
||||
|
|
@ -73,7 +75,7 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="fixed z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<Listbox.Options className="fixed z-10 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{states?.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
|
|
|
|||
89
apps/app/components/project/join-project-modal.tsx
Normal file
89
apps/app/components/project/join-project-modal.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
|
||||
// type
|
||||
import type { IProject } from "types";
|
||||
type TJoinProjectModalProps = {
|
||||
data?: IProject;
|
||||
onClose: () => void;
|
||||
onJoin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
|
||||
const { onClose, onJoin, data } = props;
|
||||
|
||||
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
|
||||
|
||||
const handleJoin = () => {
|
||||
setIsJoiningLoading(true);
|
||||
onJoin()
|
||||
.then(() => {
|
||||
setIsJoiningLoading(false);
|
||||
handleClose();
|
||||
})
|
||||
.catch(() => {
|
||||
setIsJoiningLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={Boolean(data)} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<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:w-full sm:max-w-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Join Project?
|
||||
</Dialog.Title>
|
||||
<p>
|
||||
Are you sure you want to join{" "}
|
||||
<span className="font-semibold">{data?.name}</span>?
|
||||
</p>
|
||||
<div className="space-y-3"></div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={handleJoin} disabled={isJoiningLoading}>
|
||||
{isJoiningLoading ? "Joining..." : "Join Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,7 +14,6 @@ import { Button } from "ui";
|
|||
import {
|
||||
CalendarDaysIcon,
|
||||
CheckIcon,
|
||||
EyeIcon,
|
||||
MinusIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
|
|
@ -28,114 +27,92 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
|||
// common
|
||||
import { renderShortNumericDateFormat } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
type TProjectCardProps = {
|
||||
workspaceSlug: string;
|
||||
project: IProject;
|
||||
slug: string;
|
||||
invitationsRespond: string[];
|
||||
handleInvitation: (project_invitation: any, action: "accepted" | "withdraw") => void;
|
||||
setToJoinProject: (id: string | null) => void;
|
||||
setDeleteProject: (id: string | null) => void;
|
||||
};
|
||||
|
||||
const ProjectMemberInvitations: React.FC<Props> = ({
|
||||
project,
|
||||
slug,
|
||||
invitationsRespond,
|
||||
handleInvitation,
|
||||
setDeleteProject,
|
||||
}) => {
|
||||
const { user } = useUser();
|
||||
const ProjectMemberInvitations: React.FC<TProjectCardProps> = (props) => {
|
||||
const { workspaceSlug, project, setToJoinProject, setDeleteProject } = props;
|
||||
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: members } = useSWR<any[]>(PROJECT_MEMBERS(project.id), () =>
|
||||
projectService.projectMembers(slug, project.id)
|
||||
const { data: members } = useSWR(PROJECT_MEMBERS(project.id), () =>
|
||||
projectService.projectMembers(workspaceSlug, project.id)
|
||||
);
|
||||
|
||||
const isMember = members?.some((item: any) => item.member.id === (user as any)?.id);
|
||||
|
||||
const [selected, setSelected] = useState<any>(false);
|
||||
const canEdit = members?.some(
|
||||
(item) => (item.member.id === (user as any)?.id && item.role === 20) || item.role === 15
|
||||
);
|
||||
const canDelete = members?.some(
|
||||
(item) => item.member.id === (user as any)?.id && item.role === 20
|
||||
);
|
||||
|
||||
if (!members) {
|
||||
return (
|
||||
<div className="w-full h-36 flex flex-col px-4 py-3 rounded-md bg-white">
|
||||
<div className="w-full h-full bg-gray-50 animate-pulse" />
|
||||
<div className="flex h-36 w-full flex-col rounded-md bg-white px-4 py-3">
|
||||
<div className="h-full w-full animate-pulse bg-gray-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`w-full h-full flex flex-col px-4 py-3 rounded-md border bg-white ${
|
||||
selected ? "ring-2 ring-indigo-400" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="font-medium text-lg flex gap-2">
|
||||
{!isMember ? (
|
||||
<input
|
||||
id={project.id}
|
||||
className="h-3 w-3 rounded border-gray-300 text-theme focus:ring-indigo-500 mt-2 hidden"
|
||||
aria-describedby="workspaces"
|
||||
name={project.id}
|
||||
checked={invitationsRespond.includes(project.id)}
|
||||
value={project.name}
|
||||
onChange={(e) => {
|
||||
setSelected(e.target.checked);
|
||||
handleInvitation(
|
||||
project,
|
||||
invitationsRespond.includes(project.id) ? "withdraw" : "accepted"
|
||||
);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
) : null}
|
||||
<Link href={`/projects/${project.id}/issues`}>
|
||||
<a className="flex flex-col">
|
||||
{project.name}
|
||||
<span className="text-xs">({project.identifier})</span>
|
||||
<div className="flex h-full w-full flex-col rounded-md border bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 text-lg font-medium">
|
||||
<Link href={`/${workspaceSlug}/projects/${project.id}/issues`}>
|
||||
<a className="flex items-center gap-x-3">
|
||||
{project.icon && (
|
||||
<span className="text-base">{String.fromCodePoint(parseInt(project.icon))}</span>
|
||||
)}
|
||||
<span>{project.name}</span>
|
||||
<span className="text-xs text-gray-500">{project.identifier}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{isMember ? (
|
||||
<div className="flex">
|
||||
<Link href={`/projects/${project.id}/settings`}>
|
||||
<a className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 cursor-pointer">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none"
|
||||
onClick={() => setDeleteProject(project.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link href={`/${workspaceSlug}/projects/${project.id}/settings`}>
|
||||
<a className="grid h-7 w-7 cursor-pointer place-items-center rounded p-1 duration-300 hover:bg-gray-100">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={() => setDeleteProject(project.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm">{project.description}</p>
|
||||
</div>
|
||||
<div className="mt-3 h-full flex justify-between items-end">
|
||||
<div className="mt-3 flex h-full items-end justify-between">
|
||||
<div className="flex gap-2">
|
||||
{!isMember ? (
|
||||
<label
|
||||
htmlFor={project.id}
|
||||
className="flex items-center gap-1 text-xs font-medium border hover:bg-gray-100 p-2 rounded duration-300 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToJoinProject(project.id);
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-1 rounded border p-2 text-xs font-medium duration-300 hover:bg-gray-100"
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<MinusIcon className="h-3 w-3" />
|
||||
Remove
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Select to Join
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
<span>Select to Join</span>
|
||||
</button>
|
||||
) : (
|
||||
<Button theme="secondary" className="flex items-center gap-1" disabled>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
|
|
@ -145,13 +122,13 @@ const ProjectMemberInvitations: React.FC<Props> = ({
|
|||
<Button
|
||||
theme="secondary"
|
||||
className="flex items-center gap-1"
|
||||
onClick={() => router.push(`/projects/${project.id}/issues`)}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${project.id}/issues`)}
|
||||
>
|
||||
<ClipboardDocumentListIcon className="h-3 w-3" />
|
||||
Open Project
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs mb-1">
|
||||
<div className="mb-1 flex items-center gap-1 text-xs">
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{renderShortNumericDateFormat(project.created_at)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
170
apps/app/components/project/modules/module-link-modal.tsx
Normal file
170
apps/app/components/project/modules/module-link-modal.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from "react";
|
||||
// swr
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
// react hook form
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// headless
|
||||
|
||||
import { Dialog, Transition, Listbox } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
|
|
@ -42,24 +44,17 @@ const defaultValues: Partial<ProjectMember> = {
|
|||
};
|
||||
|
||||
const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, members }) => {
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
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,
|
||||
{
|
||||
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
|
||||
if (err?.status === 403) return;
|
||||
if (err?.status === 403 || err?.status === 401) return;
|
||||
setTimeout(() => revalidate(revalidateOpts), 5000);
|
||||
},
|
||||
}
|
||||
|
|
@ -70,19 +65,22 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
|||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
setValue,
|
||||
control,
|
||||
} = useForm<ProjectMember>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const uninvitedPeople = people?.filter((person) => {
|
||||
const isInvited = members?.find((member) => member.email === person.member.email);
|
||||
return !isInvited;
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: ProjectMember) => {
|
||||
if (!activeWorkspace || !activeProject || isSubmitting) return;
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
await projectService
|
||||
.inviteProject(activeWorkspace.slug, activeProject.id, formData)
|
||||
.inviteProject(workspaceSlug as string, projectId as string, formData)
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
setIsOpen(false);
|
||||
mutate(
|
||||
PROJECT_INVITATIONS,
|
||||
|
|
@ -102,6 +100,14 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
|||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
|
|
@ -156,12 +162,12 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
|||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label className="text-gray-500 mb-2">
|
||||
<Listbox.Label className="mb-2 text-gray-500">
|
||||
Email
|
||||
</Listbox.Label>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`bg-white relative w-full border rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm ${
|
||||
className={`relative w-full cursor-default rounded-md border bg-white py-2 pl-3 pr-10 text-left shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm ${
|
||||
errors.user_id ? "border-red-500 bg-red-50" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -170,7 +176,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
|||
? people?.find((p) => p.member.id === value)?.member.email
|
||||
: "Select email"}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
|
|
@ -185,50 +191,51 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map(
|
||||
(person) =>
|
||||
!members.some(
|
||||
(m: any) => m.email === person.member.email
|
||||
) && (
|
||||
<Listbox.Option
|
||||
key={person.member.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-default select-none relative py-2 pl-3 pr-9 text-left`
|
||||
}
|
||||
value={{
|
||||
id: person.member.id,
|
||||
email: person.member.email,
|
||||
}}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.email}
|
||||
</span>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{uninvitedPeople?.length === 0 ? (
|
||||
<div className="relative cursor-default select-none py-2 pl-3 pr-9 text-left text-gray-600">
|
||||
Invite to workspace to add members
|
||||
</div>
|
||||
) : (
|
||||
uninvitedPeople?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.member.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-theme text-white" : "text-gray-900"
|
||||
} relative cursor-default select-none py-2 pl-3 pr-9 text-left`
|
||||
}
|
||||
value={{
|
||||
id: person.member.id,
|
||||
email: person.member.email,
|
||||
}}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.email}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-theme"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
|
|
|
|||
193
apps/app/components/rich-text-editor/index.tsx
Normal file
193
apps/app/components/rich-text-editor/index.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { useCallback, FC, useState, useEffect } from "react";
|
||||
import { InvalidContentHandler } from "remirror";
|
||||
import {
|
||||
BoldExtension,
|
||||
ItalicExtension,
|
||||
CalloutExtension,
|
||||
PlaceholderExtension,
|
||||
CodeBlockExtension,
|
||||
CodeExtension,
|
||||
HistoryExtension,
|
||||
LinkExtension,
|
||||
UnderlineExtension,
|
||||
HeadingExtension,
|
||||
OrderedListExtension,
|
||||
ListItemExtension,
|
||||
BulletListExtension,
|
||||
ImageExtension,
|
||||
DropCursorExtension,
|
||||
StrikeExtension,
|
||||
MentionAtomExtension,
|
||||
FontSizeExtension,
|
||||
} from "remirror/extensions";
|
||||
import {
|
||||
Remirror,
|
||||
useRemirror,
|
||||
EditorComponent,
|
||||
OnChangeJSON,
|
||||
TableComponents,
|
||||
OnChangeHTML,
|
||||
} from "@remirror/react";
|
||||
import { tableControllerPluginKey, TableExtension } from "@remirror/extension-react-tables";
|
||||
// components`
|
||||
import { RichTextToolbar } from "./toolbar";
|
||||
import { MentionAutoComplete } from "./mention-autocomplete";
|
||||
import fileService from "lib/services/file.service";
|
||||
import { Spinner } from "ui";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export interface IRemirrorRichTextEditor {
|
||||
placeholder?: string;
|
||||
mentions?: any[];
|
||||
tags?: any[];
|
||||
onBlur: (jsonValue: any, htmlValue: any) => void;
|
||||
value?: any;
|
||||
showToolbar?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = ({
|
||||
placeholder,
|
||||
mentions = [],
|
||||
tags = [],
|
||||
onBlur,
|
||||
value = "",
|
||||
showToolbar = true,
|
||||
editable = true,
|
||||
}) => {
|
||||
const [imageLoader, setImageLoader] = useState(false);
|
||||
const [jsonValue, setJsonValue] = useState<any>();
|
||||
const [htmlValue, setHtmlValue] = useState<any>();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// remirror error handler
|
||||
const onError: InvalidContentHandler = useCallback(
|
||||
({ json, invalidContent, transformers }: any) => {
|
||||
// Automatically remove all invalid nodes and marks.
|
||||
return transformers.remove(json, invalidContent);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const uploadImageHandler = (value: any): any => {
|
||||
setImageLoader(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("asset", value[0].file);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
setImageLoader(true);
|
||||
|
||||
return [
|
||||
() => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const imageUrl = await fileService
|
||||
.uploadFile(workspaceSlug as string, formData)
|
||||
.then((response) => {
|
||||
return response.asset;
|
||||
});
|
||||
|
||||
resolve({
|
||||
align: "left",
|
||||
alt: "Not Found",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
src: imageUrl,
|
||||
});
|
||||
|
||||
setImageLoader(false);
|
||||
});
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// remirror manager
|
||||
const { manager, state } = useRemirror({
|
||||
extensions: () => [
|
||||
new BoldExtension(),
|
||||
new ItalicExtension(),
|
||||
new UnderlineExtension(),
|
||||
new HeadingExtension({ levels: [1, 2, 3] }),
|
||||
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
|
||||
new OrderedListExtension(),
|
||||
new ListItemExtension(),
|
||||
new BulletListExtension({ enableSpine: true }),
|
||||
new CalloutExtension({ defaultType: "warn" }),
|
||||
new CodeBlockExtension(),
|
||||
new CodeExtension(),
|
||||
new PlaceholderExtension({ placeholder: placeholder || `Enter text...` }),
|
||||
new HistoryExtension(),
|
||||
new LinkExtension({ autoLink: true }),
|
||||
new ImageExtension({
|
||||
enableResizing: true,
|
||||
uploadHandler: uploadImageHandler,
|
||||
}),
|
||||
new DropCursorExtension(),
|
||||
new StrikeExtension(),
|
||||
new MentionAtomExtension({
|
||||
matchers: [
|
||||
{ name: "at", char: "@" },
|
||||
{ name: "tag", char: "#" },
|
||||
],
|
||||
}),
|
||||
new TableExtension(),
|
||||
],
|
||||
content: value,
|
||||
selection: "start",
|
||||
stringHandler: "html",
|
||||
onError,
|
||||
});
|
||||
|
||||
const updateState = useCallback(
|
||||
(value: any) => {
|
||||
// Clear out old state when setting data from outside
|
||||
// This prevents e.g. the user from using CTRL-Z to go back to the old state
|
||||
manager.view.updateState(manager.createState({ content: value }));
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateState(value);
|
||||
}, [updateState, value]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-4">
|
||||
<Remirror
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
classNames={["p-4 focus:outline-none"]}
|
||||
editable={editable}
|
||||
onBlur={() => {
|
||||
onBlur(jsonValue, htmlValue);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-md border">
|
||||
{showToolbar && editable && (
|
||||
<div className="box-border w-full border-b py-2">
|
||||
<RichTextToolbar />
|
||||
</div>
|
||||
)}
|
||||
<EditorComponent />
|
||||
{imageLoader && (
|
||||
<div className="p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{/* <TableComponents /> */}
|
||||
<MentionAutoComplete mentions={mentions} tags={tags} />
|
||||
{<OnChangeJSON onChange={setJsonValue} />}
|
||||
{<OnChangeHTML onChange={setHtmlValue} />}
|
||||
</div>
|
||||
</Remirror>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemirrorRichTextEditor;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { useState, useEffect, FC } from "react";
|
||||
// remirror imports
|
||||
import { cx } from "@remirror/core";
|
||||
import {
|
||||
useMentionAtom,
|
||||
MentionAtomNodeAttributes,
|
||||
FloatingWrapper,
|
||||
} from "@remirror/react";
|
||||
|
||||
// export const;
|
||||
|
||||
export interface IMentionAutoComplete {
|
||||
mentions?: any[];
|
||||
tags?: any[];
|
||||
}
|
||||
|
||||
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
|
||||
const { mentions = [], tags = [] } = props;
|
||||
// states
|
||||
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
|
||||
|
||||
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } =
|
||||
useMentionAtom({
|
||||
items: options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const searchTerm = state.query.full.toLowerCase();
|
||||
let filteredOptions: MentionAtomNodeAttributes[] = [];
|
||||
|
||||
if (state.name === "tag") {
|
||||
filteredOptions = tags.filter((tag) =>
|
||||
tag?.label.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
} else if (state.name === "at") {
|
||||
filteredOptions = mentions.filter((user) =>
|
||||
user?.label.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
filteredOptions = filteredOptions.sort().slice(0, 5);
|
||||
setOptions(filteredOptions);
|
||||
}, [state]);
|
||||
|
||||
const enabled = Boolean(state);
|
||||
return (
|
||||
<FloatingWrapper
|
||||
positioner="cursor"
|
||||
enabled={enabled}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div {...getMenuProps()} className="suggestions">
|
||||
{enabled &&
|
||||
options.map((user, index) => {
|
||||
const isHighlighted = indexIsSelected(index);
|
||||
const isHovered = indexIsHovered(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className={cx(
|
||||
"suggestion",
|
||||
isHighlighted && "highlighted",
|
||||
isHovered && "hovered"
|
||||
)}
|
||||
{...getItemProps({
|
||||
item: user,
|
||||
index,
|
||||
})}
|
||||
>
|
||||
{user.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FloatingWrapper>
|
||||
);
|
||||
};
|
||||
147
apps/app/components/rich-text-editor/sample.tsx
Normal file
147
apps/app/components/rich-text-editor/sample.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import type { AnyExtension } from "remirror";
|
||||
import { TableExtension } from "@remirror/extension-react-tables";
|
||||
import {
|
||||
EditorComponent,
|
||||
ReactComponentExtension,
|
||||
Remirror,
|
||||
TableComponents,
|
||||
tableControllerPluginKey,
|
||||
ThemeProvider,
|
||||
useCommands,
|
||||
useRemirror,
|
||||
useRemirrorContext,
|
||||
} from "@remirror/react";
|
||||
|
||||
const CommandMenu: React.FC = () => {
|
||||
const { createTable, ...commands } = useCommands();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>commands:</p>
|
||||
<p
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyItems: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-3"
|
||||
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
|
||||
>
|
||||
insert a 3*3 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-3-headers"
|
||||
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
|
||||
>
|
||||
insert a 3*3 table with headers
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-4-10"
|
||||
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
|
||||
>
|
||||
insert a 4*10 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-30"
|
||||
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
|
||||
>
|
||||
insert a 3*30 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-8-100"
|
||||
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
|
||||
>
|
||||
insert a 8*100 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.addTableColumnAfter()}
|
||||
>
|
||||
add a column after the current one
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.addTableRowBefore()}
|
||||
>
|
||||
add a row before the current one
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.deleteTable()}
|
||||
>
|
||||
delete the table
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProsemirrorDocData: React.FC = () => {
|
||||
const ctx = useRemirrorContext({ autoUpdate: false });
|
||||
const [jsonPluginState, setJsonPluginState] = useState("");
|
||||
const [jsonDoc, setJsonDoc] = useState("");
|
||||
const { addHandler, view } = ctx;
|
||||
|
||||
useEffect(() => {
|
||||
addHandler("updated", () => {
|
||||
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
|
||||
|
||||
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
|
||||
setJsonPluginState(
|
||||
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
|
||||
);
|
||||
});
|
||||
}, [addHandler, view]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>tableControllerPluginKey.getState(view.state)</p>
|
||||
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
|
||||
<code>{jsonPluginState}</code>
|
||||
</pre>
|
||||
<p>view.state.doc.toJSON()</p>
|
||||
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
|
||||
<code>{jsonDoc}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Table = ({
|
||||
children,
|
||||
extensions,
|
||||
}: {
|
||||
children?: React.ReactElement;
|
||||
extensions: () => AnyExtension[];
|
||||
}): JSX.Element => {
|
||||
const { manager, state } = useRemirror({ extensions });
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Remirror manager={manager} initialContent={state}>
|
||||
<EditorComponent />
|
||||
<TableComponents />
|
||||
<CommandMenu />
|
||||
<ProsemirrorDocData />
|
||||
{children}
|
||||
</Remirror>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Basic = (): JSX.Element => {
|
||||
return <Table extensions={defaultExtensions} />;
|
||||
};
|
||||
|
||||
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
|
||||
|
||||
export default Basic;
|
||||
27
apps/app/components/rich-text-editor/toolbar/bold.tsx
Normal file
27
apps/app/components/rich-text-editor/toolbar/bold.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useCommands, useActive } from "@remirror/react";
|
||||
|
||||
export const BoldButton = () => {
|
||||
const { toggleBold, focus } = useCommands();
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleBold();
|
||||
focus();
|
||||
}}
|
||||
className={`${active.bold() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="18"
|
||||
width="18"
|
||||
viewBox="0 0 48 48"
|
||||
fill="black"
|
||||
>
|
||||
<path d="M14 36V8h11.4q3.3 0 5.725 2.1t2.425 5.3q0 1.9-1.05 3.5t-2.8 2.45v.3q2.15.7 3.475 2.5 1.325 1.8 1.325 4.05 0 3.4-2.625 5.6Q29.25 36 25.75 36Zm4.3-16.15h6.8q1.75 0 3.025-1.15t1.275-2.9q0-1.75-1.275-2.925Q26.85 11.7 25.1 11.7h-6.8Zm0 12.35h7.2q1.9 0 3.3-1.25t1.4-3.15q0-1.85-1.4-3.1t-3.3-1.25h-7.2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// remirror
|
||||
import { useCommands, useActive } from "@remirror/react";
|
||||
// ui
|
||||
import { CustomMenu } from "ui";
|
||||
|
||||
const HeadingControls = () => {
|
||||
const { toggleHeading, focus } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomMenu
|
||||
width="lg"
|
||||
label={`${
|
||||
active.heading({ level: 1 })
|
||||
? "Heading 1"
|
||||
: active.heading({ level: 2 })
|
||||
? "Heading 2"
|
||||
: active.heading({ level: 3 })
|
||||
? "Heading 3"
|
||||
: "Normal text"
|
||||
}`}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 1 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 1
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 2 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 2
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 3 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 3
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadingControls;
|
||||
46
apps/app/components/rich-text-editor/toolbar/index.tsx
Normal file
46
apps/app/components/rich-text-editor/toolbar/index.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// history
|
||||
import { RedoButton } from "./redo";
|
||||
import { UndoButton } from "./undo";
|
||||
// formats
|
||||
import { BoldButton } from "./bold";
|
||||
import { ItalicButton } from "./italic";
|
||||
import { UnderlineButton } from "./underline";
|
||||
import { StrikeButton } from "./strike";
|
||||
import { LinkButton } from "./link";
|
||||
// headings
|
||||
import HeadingControls from "./heading-controls";
|
||||
// list
|
||||
import { OrderedListButton } from "./ordered-list";
|
||||
import { UnorderedListButton } from "./unordered-list";
|
||||
// table
|
||||
import { TableControls } from "./table-controls";
|
||||
|
||||
export const RichTextToolbar: React.FC = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-y-2 divide-x">
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<UndoButton />
|
||||
<RedoButton />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<HeadingControls />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<BoldButton />
|
||||
<ItalicButton />
|
||||
<UnderlineButton />
|
||||
<StrikeButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<OrderedListButton />
|
||||
<UnorderedListButton />
|
||||
</div>
|
||||
{/* <div className="px-2">
|
||||
<TableControls />
|
||||
</div> */}
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<LinkButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
apps/app/components/rich-text-editor/toolbar/italic.tsx
Normal file
28
apps/app/components/rich-text-editor/toolbar/italic.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useCommands, useActive } from "@remirror/react";
|
||||
|
||||
export const ItalicButton = () => {
|
||||
const { toggleItalic, focus } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleItalic();
|
||||
focus();
|
||||
}}
|
||||
className={`${active.italic() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="18"
|
||||
width="18"
|
||||
viewBox="0 0 48 48"
|
||||
fill="black"
|
||||
>
|
||||
<path d="M10 40v-5h6.85l8.9-22H18V8h20v5h-6.85l-8.9 22H30v5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
241
apps/app/components/rich-text-editor/toolbar/link.tsx
Normal file
241
apps/app/components/rich-text-editor/toolbar/link.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { useCommands, useActive } from "@remirror/react";
|
||||
|
||||
export const LinkButton = () => {
|
||||
const { focus } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// toggleLink();
|
||||
focus();
|
||||
}}
|
||||
className={`${active.link() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="18"
|
||||
width="18"
|
||||
fill="black"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path d="M22.5 34H14q-4.15 0-7.075-2.925T4 24q0-4.15 2.925-7.075T14 14h8.5v3H14q-2.9 0-4.95 2.05Q7 21.1 7 24q0 2.9 2.05 4.95Q11.1 31 14 31h8.5Zm-6.25-8.5v-3h15.5v3ZM25.5 34v-3H34q2.9 0 4.95-2.05Q41 26.9 41 24q0-2.9-2.05-4.95Q36.9 17 34 17h-8.5v-3H34q4.15 0 7.075 2.925T44 24q0 4.15-2.925 7.075T34 34Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// import type { ChangeEvent, HTMLProps, KeyboardEvent } from 'react';
|
||||
// import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
// import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from 'remirror/extensions';
|
||||
// import {
|
||||
// CommandButton,
|
||||
// EditorComponent,
|
||||
// FloatingToolbar,
|
||||
// FloatingWrapper,
|
||||
// Remirror,
|
||||
// ThemeProvider,
|
||||
// useActive,
|
||||
// useAttrs,
|
||||
// useChainedCommands,
|
||||
// useCurrentSelection,
|
||||
// useExtensionEvent,
|
||||
// useRemirror,
|
||||
// useUpdateReason,
|
||||
// } from '@remirror/react';
|
||||
|
||||
// function useLinkShortcut() {
|
||||
// const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
|
||||
// const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// useExtensionEvent(
|
||||
// LinkExtension,
|
||||
// 'onShortcut',
|
||||
// useCallback(
|
||||
// (props) => {
|
||||
// if (!isEditing) {
|
||||
// setIsEditing(true);
|
||||
// }
|
||||
|
||||
// return setLinkShortcut(props);
|
||||
// },
|
||||
// [isEditing],
|
||||
// ),
|
||||
// );
|
||||
|
||||
// return { linkShortcut, isEditing, setIsEditing };
|
||||
// }
|
||||
|
||||
// function useFloatingLinkState() {
|
||||
// const chain = useChainedCommands();
|
||||
// const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
|
||||
// const { to, empty } = useCurrentSelection();
|
||||
|
||||
// const url = (useAttrs().link()?.href as string) ?? '';
|
||||
// const [href, setHref] = useState<string>(url);
|
||||
|
||||
// // A positioner which only shows for links.
|
||||
// const linkPositioner = useMemo(() => createMarkPositioner({ type: 'link' }), []);
|
||||
|
||||
// const onRemove = useCallback(() => {
|
||||
// return chain.removeLink().focus().run();
|
||||
// }, [chain]);
|
||||
|
||||
// const updateReason = useUpdateReason();
|
||||
|
||||
// useLayoutEffect(() => {
|
||||
// if (!isEditing) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (updateReason.doc || updateReason.selection) {
|
||||
// setIsEditing(false);
|
||||
// }
|
||||
// }, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
|
||||
|
||||
// useEffect(() => {
|
||||
// setHref(url);
|
||||
// }, [url]);
|
||||
|
||||
// const submitHref = useCallback(() => {
|
||||
// setIsEditing(false);
|
||||
// const range = linkShortcut ?? undefined;
|
||||
|
||||
// if (href === '') {
|
||||
// chain.removeLink();
|
||||
// } else {
|
||||
// chain.updateLink({ href, auto: false }, range);
|
||||
// }
|
||||
|
||||
// chain.focus(range?.to ?? to).run();
|
||||
// }, [setIsEditing, linkShortcut, chain, href, to]);
|
||||
|
||||
// const cancelHref = useCallback(() => {
|
||||
// setIsEditing(false);
|
||||
// }, [setIsEditing]);
|
||||
|
||||
// const clickEdit = useCallback(() => {
|
||||
// if (empty) {
|
||||
// chain.selectLink();
|
||||
// }
|
||||
|
||||
// setIsEditing(true);
|
||||
// }, [chain, empty, setIsEditing]);
|
||||
|
||||
// return useMemo(
|
||||
// () => ({
|
||||
// href,
|
||||
// setHref,
|
||||
// linkShortcut,
|
||||
// linkPositioner,
|
||||
// isEditing,
|
||||
// clickEdit,
|
||||
// onRemove,
|
||||
// submitHref,
|
||||
// cancelHref,
|
||||
// }),
|
||||
// [href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref],
|
||||
// );
|
||||
// }
|
||||
|
||||
// const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
|
||||
// const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!autoFocus) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const frame = window.requestAnimationFrame(() => {
|
||||
// inputRef.current?.focus();
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// window.cancelAnimationFrame(frame);
|
||||
// };
|
||||
// }, [autoFocus]);
|
||||
|
||||
// return <input ref={inputRef} {...rest} />;
|
||||
// };
|
||||
|
||||
// const FloatingLinkToolbar = () => {
|
||||
// const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
|
||||
// useFloatingLinkState();
|
||||
// const active = useActive();
|
||||
// const activeLink = active.link();
|
||||
// const { empty } = useCurrentSelection();
|
||||
|
||||
// const handleClickEdit = useCallback(() => {
|
||||
// clickEdit();
|
||||
// }, [clickEdit]);
|
||||
|
||||
// const linkEditButtons = activeLink ? (
|
||||
// <>
|
||||
// <CommandButton
|
||||
// commandName='updateLink'
|
||||
// onSelect={handleClickEdit}
|
||||
// icon='pencilLine'
|
||||
// enabled
|
||||
// />
|
||||
// <CommandButton commandName='removeLink' onSelect={onRemove} icon='linkUnlink' enabled />
|
||||
// </>
|
||||
// ) : (
|
||||
// <CommandButton commandName='updateLink' onSelect={handleClickEdit} icon='link' enabled />
|
||||
// );
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// {!isEditing && <FloatingToolbar>{linkEditButtons}</FloatingToolbar>}
|
||||
// {!isEditing && empty && (
|
||||
// <FloatingToolbar positioner={linkPositioner}>{linkEditButtons}</FloatingToolbar>
|
||||
// )}
|
||||
|
||||
// <FloatingWrapper
|
||||
// positioner='always'
|
||||
// placement='bottom'
|
||||
// enabled={isEditing}
|
||||
// renderOutsideEditor
|
||||
// >
|
||||
// <DelayAutoFocusInput
|
||||
// style={{ zIndex: 20 }}
|
||||
// autoFocus
|
||||
// placeholder='Enter link...'
|
||||
// onChange={(event: ChangeEvent<HTMLInputElement>) => setHref(event.target.value)}
|
||||
// value={href}
|
||||
// onKeyPress={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// const { code } = event;
|
||||
|
||||
// if (code === 'Enter') {
|
||||
// submitHref();
|
||||
// }
|
||||
|
||||
// if (code === 'Escape') {
|
||||
// cancelHref();
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
// </FloatingWrapper>
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const EditDialog = (): JSX.Element => {
|
||||
// const { manager, state } = useRemirror({
|
||||
// extensions: () => [new LinkExtension({ autoLink: true })],
|
||||
// content: `Click this <a href="https://remirror.io" target="_blank">link</a> to edit it`,
|
||||
// stringHandler: 'html',
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <ThemeProvider>
|
||||
// <Remirror manager={manager} initialContent={state}>
|
||||
// <EditorComponent />
|
||||
// <FloatingLinkToolbar />
|
||||
// </Remirror>
|
||||
// </ThemeProvider>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default EditDialog;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useCommands, useActive } from "@remirror/react";
|
||||
|
||||
export const OrderedListButton = () => {
|
||||
const { toggleOrderedList, focus } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleOrderedList();
|
||||
focus();
|
||||
}}
|
||||
className={`${active.orderedList() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 48 48"
|
||||
fill="black"
|
||||
>
|
||||
<path d="M6 40v-1.7h4.2V37H8.1v-1.7h2.1V34H6v-1.7h5.9V40Zm10.45-2.45v-3H42v3ZM6 27.85v-1.6l3.75-4.4H6v-1.7h5.9v1.6l-3.8 4.4h3.8v1.7Zm10.45-2.45v-3H42v3ZM8.1 15.8V9.7H6V8h3.8v7.8Zm8.35-2.55v-3H42v3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
18
apps/app/components/rich-text-editor/toolbar/redo.tsx
Normal file
18
apps/app/components/rich-text-editor/toolbar/redo.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useCommands } from "@remirror/react";
|
||||
|
||||
export const RedoButton = () => {
|
||||
const { redo } = useCommands();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
redo();
|
||||
}}
|
||||
className="rounded p-1 hover:bg-gray-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 0 48 48">
|
||||
<path d="M19.6 38q-4.75 0-8.175-3.2Q8 31.6 8 26.9t3.425-7.9q3.425-3.2 8.175-3.2h14.7l-5.7-5.7L30.7 8l9.3 9.3-9.3 9.3-2.1-2.1 5.7-5.7H19.55q-3.5 0-6.025 2.325Q11 23.45 11 26.9t2.525 5.775Q16.05 35 19.55 35H34v3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
26
apps/app/components/rich-text-editor/toolbar/strike.tsx
Normal file
26
apps/app/components/rich-text-editor/toolbar/strike.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useCommands, useActive } from "@remirror/react";
|
||||
|
||||
export const StrikeButton = () => {
|
||||
const { toggleStrike } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleStrike}
|
||||
className={`${active.strike() ? "bg-gray-200" : "hover:bg-gray-100"} rounded p-1`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
fill="currentColor"
|
||||
className="bi bi-type-strikethrough"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z" />{" "}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue