chore: move all services inside the apps folder (#7321)

* chore: move all services inside the apps folder

* chore: rename apiserver to server
This commit is contained in:
sriram veeraghanta 2025-07-03 00:44:13 +05:30 committed by GitHub
parent 6000639921
commit 944b873184
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3442 changed files with 1 additions and 4 deletions

12
apps/space/.env.example Normal file
View file

@ -0,0 +1,12 @@
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
NEXT_PUBLIC_LIVE_BASE_PATH="/live"

6
apps/space/.eslintrc.js Normal file
View file

@ -0,0 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};

42
apps/space/.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# env
.env
# Sentry Config File
.env.sentry-build-plugin

View file

@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dis/
build/

View file

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

16
apps/space/Dockerfile.dev Normal file
View file

@ -0,0 +1,16 @@
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 4000
ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
VOLUME [ "/app/node_modules", "/app/space/node_modules"]
CMD ["yarn","dev", "--filter=space"]

View file

@ -0,0 +1,85 @@
FROM node:20-alpine as base
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=space --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
FROM base AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
RUN yarn turbo run build --filter=space
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
COPY --from=installer /app/space/next.config.js .
COPY --from=installer /app/space/package.json .
COPY --from=installer /app/space/.next/standalone ./
COPY --from=installer /app/space/.next ./space/.next
COPY --from=installer /app/space/public ./space/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED 1
ENV TURBO_TELEMETRY_DISABLED 1
EXPOSE 3000

10
apps/space/README.md Normal file
View file

@ -0,0 +1,10 @@
<br /><br />
<p align="center">
<a href="https://plane.so">
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
</a>
</p>
<h3 align="center"><b>Plane Space</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>

2
apps/space/additional.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// additional.d.ts
/// <reference types="next-images" />

View file

@ -0,0 +1,41 @@
import { notFound, redirect } from "next/navigation";
// plane imports
import { SitesProjectPublishService } from "@plane/services";
import { TProjectPublishSettings } from "@plane/types";
const publishService = new SitesProjectPublishService();
type Props = {
params: {
workspaceSlug: string;
projectId: string;
};
searchParams: any;
};
export default async function IssuesPage(props: Props) {
const { params, searchParams } = props;
// query params
const { workspaceSlug, projectId } = params;
const { board, peekId } = searchParams;
let response: TProjectPublishSettings | undefined = undefined;
try {
response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId);
} catch (error) {
// redirect to 404 page on error
notFound();
}
let url = "";
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
if (board) params.append("board", board);
if (peekId) params.append("peekId", peekId);
if (params.toString()) url += `?${params.toString()}`;
redirect(url);
} else {
notFound();
}
}

47
apps/space/app/error.tsx Normal file
View file

@ -0,0 +1,47 @@
"use client";
// ui
import { Button } from "@plane/ui";
const ErrorPage = () => {
const handleRetry = () => {
window.location.reload();
};
return (
<div className="grid h-screen place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
details, please write to{" "}
<a href="mailto:support@plane.so" className="text-custom-primary">
support@plane.so
</a>{" "}
or on our{" "}
<a
href="https://discord.com/invite/A92xrEGCge"
target="_blank"
className="text-custom-primary"
rel="noopener noreferrer"
>
Discord
</a>
.
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Button variant="primary" size="md" onClick={handleRetry}>
Refresh
</Button>
{/* <Button variant="neutral-primary" size="md" onClick={() => {}}>
Sign out
</Button> */}
</div>
</div>
</div>
);
};
export default ErrorPage;

View file

@ -0,0 +1,57 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner, PoweredBy } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
type Props = {
children: React.ReactNode;
anchor: string;
};
export const IssuesClientLayout = observer((props: Props) => {
const { children, anchor } = props;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
const { updateLayoutOptions } = useIssueFilter();
// fetch publish settings
const { error } = useSWR(
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
anchor
? async () => {
const response = await fetchPublishSettings(anchor);
if (response.view_props) {
updateLayoutOptions({
list: !!response.view_props.list,
kanban: !!response.view_props.kanban,
calendar: !!response.view_props.calendar,
gantt: !!response.view_props.gantt,
spreadsheet: !!response.view_props.spreadsheet,
});
}
}
: null
);
if (!publishSettings && !error) return <LogoSpinner />;
if (error) return <SomethingWentWrongError />;
return (
<>
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssuesNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
</div>
<PoweredBy />
</>
);
});

View file

@ -0,0 +1,52 @@
"use server";
import { IssuesClientLayout } from "./client-layout";
type Props = {
children: React.ReactNode;
params: {
anchor: string;
};
};
export async function generateMetadata({ params }: Props) {
const { anchor } = params;
const DEFAULT_TITLE = "Plane";
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
const data = await response.json();
return {
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
openGraph: {
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
type: "website",
images: [
{
url: data?.cover_image,
width: 800,
height: 600,
alt: data?.name || DEFAULT_TITLE,
},
],
},
twitter: {
card: "summary_large_image",
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
images: [data?.cover_image],
},
};
} catch {
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
}
}
export default async function IssuesLayout(props: Props) {
const { children, params } = props;
const { anchor } = params;
return <IssuesClientLayout anchor={anchor}>{children}</IssuesClientLayout>;
}

View file

@ -0,0 +1,37 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
// components
import { IssuesLayoutsRoot } from "@/components/issues";
// hooks
import { usePublish, useLabel, useStates } from "@/hooks/store";
type Props = {
params: {
anchor: string;
};
};
const IssuesPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
// store
const { fetchStates } = useStates();
const { fetchLabels } = useLabel();
useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null);
useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null);
const publishSettings = usePublish(anchor);
if (!publishSettings) return null;
return <IssuesLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
});
export default IssuesPage;

43
apps/space/app/layout.tsx Normal file
View file

@ -0,0 +1,43 @@
import { Metadata } from "next";
// helpers
import { SPACE_BASE_PATH } from "@plane/constants";
// styles
import "@/styles/globals.css";
// components
import { AppProvider } from "./provider";
export const metadata: Metadata = {
title: "Plane Publish | Make your Plane boards public with one-click",
description: "Plane Publish is a customer feedback management tool built on top of plane.so",
openGraph: {
title: "Plane Publish | Make your Plane boards public with one-click",
description: "Plane Publish is a customer feedback management tool built on top of plane.so",
url: "https://sites.plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" sizes="180x180" href={`${SPACE_BASE_PATH}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${SPACE_BASE_PATH}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${SPACE_BASE_PATH}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${SPACE_BASE_PATH}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${SPACE_BASE_PATH}/favicon/favicon.ico`} />
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<div id="editor-portal" />
<AppProvider>
<>{children}</>
</AppProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import Image from "next/image";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
const NotFound = () => (
<div className="h-screen w-screen grid place-items-center">
<div className="text-center">
<div className="mx-auto size-32 md:size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="size-16 md:size-32 grid place-items-center">
<Image src={SomethingWentWrongImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-8 md:mt-12 text-xl md:text-3xl font-semibold">That didn{"'"}t work</h1>
<p className="mt-2 md:mt-4 text-sm md:text-base">
Check the URL you are entering in the browser{"'"}s address bar and try again.
</p>
</div>
</div>
);
export default NotFound;

21
apps/space/app/page.tsx Normal file
View file

@ -0,0 +1,21 @@
"use client";
import { observer } from "mobx-react";
// components
import { UserLoggedIn } from "@/components/account";
import { LogoSpinner } from "@/components/common";
import { AuthView } from "@/components/views";
// hooks
import { useUser } from "@/hooks/store";
const HomePage = observer(() => {
const { data: currentUser, isAuthenticated, isLoading } = useUser();
if (isLoading) return <LogoSpinner />;
if (currentUser && isAuthenticated) return <UserLoggedIn />;
return <AuthView />;
});
export default HomePage;

View file

@ -0,0 +1,26 @@
"use client";
import { FC, ReactNode } from "react";
// components
import { TranslationProvider } from "@plane/i18n";
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { ToastProvider } from "@/lib/toast-provider";
interface IAppProvider {
children: ReactNode;
}
export const AppProvider: FC<IAppProvider> = (props) => {
const { children } = props;
return (
<StoreProvider>
<TranslationProvider>
<ToastProvider>
<InstanceProvider>{children}</InstanceProvider>
</ToastProvider>
</TranslationProvider>
</StoreProvider>
);
};

View file

@ -0,0 +1,58 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner, PoweredBy } from "@/components/common";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { usePublish, usePublishList } from "@/hooks/store";
// Plane web
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
import { useView } from "@/plane-web/hooks/store";
type Props = {
children: React.ReactNode;
params: {
anchor: string;
};
};
const IssuesLayout = observer((props: Props) => {
const { children, params } = props;
// params
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const { viewData, fetchViewDetails } = useView();
const publishSettings = usePublish(anchor);
// fetch publish settings && view details
const { error } = useSWR(
anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null,
anchor
? async () => {
const promises = [];
promises.push(fetchPublishSettings(anchor));
promises.push(fetchViewDetails(anchor));
await Promise.all(promises);
}
: null
);
if (error) return <SomethingWentWrongError />;
if (!publishSettings || !viewData) return <LogoSpinner />;
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<ViewNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<PoweredBy />
</div>
);
});
export default IssuesLayout;

View file

@ -0,0 +1,37 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// components
import { PoweredBy } from "@/components/common";
// hooks
import { usePublish } from "@/hooks/store";
// plane-web
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
type Props = {
params: {
anchor: string;
};
};
const IssuesPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
const publishSettings = usePublish(anchor);
if (!publishSettings) return null;
return (
<>
<ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />
<PoweredBy />
</>
);
});
export default IssuesPage;

View file

@ -0,0 +1 @@
export * from "./mentions";

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,4 @@
// plane editor
import { TMentionComponentProps } from "@plane/editor";
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;

View file

@ -0,0 +1 @@
export * from "./embeds";

View file

@ -0,0 +1,10 @@
import { PageNotFound } from "@/components/ui/not-found";
import { PublishStore } from "@/store/publish/publish.store";
type Props = {
peekId: string | undefined;
publishSettings: PublishStore;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ViewLayoutsRoot = (props: Props) => <PageNotFound />;

View file

@ -0,0 +1,8 @@
import { PublishStore } from "@/store/publish/publish.store";
type Props = {
publishSettings: PublishStore;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ViewNavbarRoot = (props: Props) => <></>;

View file

@ -0,0 +1 @@
export * from "./use-published-view";

View file

@ -0,0 +1,5 @@
export const useView = () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fetchViewDetails: (anchor: string) => {},
viewData: {},
});

View file

@ -0,0 +1,8 @@
// store
import { CoreRootStore } from "@/store/root.store";
export class RootStore extends CoreRootStore {
constructor() {
super();
}
}

View file

@ -0,0 +1,31 @@
"use client";
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};

View file

@ -0,0 +1,57 @@
"use client";
import { FC, ReactNode } from "react";
// helpers
import { EAuthModes } from "@/types/auth";
type TAuthHeader = {
authMode: EAuthModes;
children: ReactNode;
};
type TAuthHeaderContent = {
header: string;
subHeader: string;
};
type TAuthHeaderDetails = {
[mode in EAuthModes]: TAuthHeaderContent;
};
const Titles: TAuthHeaderDetails = {
[EAuthModes.SIGN_IN]: {
header: "Sign in to upvote or comment",
subHeader: "Contribute in nudging the features you want to get built.",
},
[EAuthModes.SIGN_UP]: {
header: "View, comment, and do more",
subHeader: "Sign up or log in to work with Plane work items and Pages.",
},
};
export const AuthHeader: FC<TAuthHeader> = (props) => {
const { authMode, children } = props;
const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => {
if (mode) {
return Titles[mode];
}
return {
header: "Comment or react to work itemss",
subHeader: "Use plane to add your valuable inputs to features.",
};
};
const { header, subHeader } = getHeaderSubHeader(authMode);
return (
<>
<div className="space-y-1 text-center">
<h3 className="text-xl sm:text-2xl md:text-3xl font-bold text-onboarding-text-100">{header}</h3>
<p className="text-xs sm:text-sm md:text-base font-medium text-onboarding-text-400">{subHeader}</p>
</div>
{children}
</>
);
};

View file

@ -0,0 +1,190 @@
"use client";
import React, { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane imports
import { SitesAuthService } from "@plane/services";
import { IEmailCheckData } from "@plane/types";
// components
import {
AuthHeader,
AuthBanner,
AuthEmailForm,
AuthUniqueCodeForm,
AuthPasswordForm,
OAuthOptions,
TermsAndConditions,
} from "@/components/account";
// helpers
import {
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
authErrorHandler,
} from "@/helpers/authentication.helper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
const authService = new SitesAuthService();
export const AuthRoot: FC = observer(() => {
// router params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
const error_code = searchParams.get("error_code") || undefined;
const nextPath = searchParams.get("next_path") || undefined;
// states
const [authMode, setAuthMode] = useState<EAuthModes>(EAuthModes.SIGN_UP);
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const [isPasswordAutoset, setIsPasswordAutoset] = useState(true);
// hooks
const { config } = useInstance();
useEffect(() => {
if (error_code) {
const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes);
if (errorhandler) {
if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN) {
setAuthMode(EAuthModes.SIGN_IN);
setAuthStep(EAuthSteps.PASSWORD);
}
if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP) {
setAuthMode(EAuthModes.SIGN_UP);
setAuthStep(EAuthSteps.PASSWORD);
}
if (
[
EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN,
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN,
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN,
].includes(errorhandler.code)
) {
setAuthMode(EAuthModes.SIGN_IN);
setAuthStep(EAuthSteps.UNIQUE_CODE);
}
if (
[
EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP,
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
].includes(errorhandler.code)
) {
setAuthMode(EAuthModes.SIGN_UP);
setAuthStep(EAuthSteps.UNIQUE_CODE);
}
setErrorInfo(errorhandler);
}
}
}, [error_code]);
const isSMTPConfigured = config?.is_smtp_configured || false;
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
const isOAuthEnabled =
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
setEmail(data.email);
await authService
.emailCheck(data)
.then(async (response) => {
let currentAuthMode: EAuthModes = response.existing ? EAuthModes.SIGN_IN : EAuthModes.SIGN_UP;
if (response.existing) {
currentAuthMode = EAuthModes.SIGN_IN;
setAuthMode(() => EAuthModes.SIGN_IN);
} else {
currentAuthMode = EAuthModes.SIGN_UP;
setAuthMode(() => EAuthModes.SIGN_UP);
}
if (currentAuthMode === EAuthModes.SIGN_IN) {
if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
setIsPasswordAutoset(false);
setAuthStep(EAuthSteps.PASSWORD);
} else {
const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes);
setErrorInfo(errorhandler);
}
} else {
if (isSMTPConfigured && isMagicLoginEnabled) {
setAuthStep(EAuthSteps.UNIQUE_CODE);
generateEmailUniqueCode(data.email);
} else if (isEmailPasswordEnabled) {
setAuthStep(EAuthSteps.PASSWORD);
} else {
const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes);
setErrorInfo(errorhandler);
}
}
})
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
if (errorhandler?.type) setErrorInfo(errorhandler);
});
};
// generating the unique code
const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => {
const payload = { email: email };
return await authService
.generateUniqueCode(payload)
.then(() => ({ code: "" }))
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code.toString());
if (errorhandler?.type) setErrorInfo(errorhandler);
throw error;
});
};
return (
<div className="relative flex flex-col space-y-6">
<AuthHeader authMode={authMode}>
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
)}
{authStep === EAuthSteps.EMAIL && <AuthEmailForm defaultEmail={email} onSubmit={handleEmailVerification} />}
{authStep === EAuthSteps.UNIQUE_CODE && (
<AuthUniqueCodeForm
mode={authMode}
email={email}
nextPath={nextPath}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
}}
generateEmailUniqueCode={generateEmailUniqueCode}
/>
)}
{authStep === EAuthSteps.PASSWORD && (
<AuthPasswordForm
mode={authMode}
isPasswordAutoset={isPasswordAutoset}
isSMTPConfigured={isSMTPConfigured}
email={email}
nextPath={nextPath}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
}}
handleAuthStep={(step: EAuthSteps) => {
if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email);
setAuthStep(step);
}}
/>
)}
{isOAuthEnabled && <OAuthOptions />}
<TermsAndConditions isSignUp={authMode === EAuthModes.SIGN_UP ? true : false} />
</AuthHeader>
</div>
);
});

View file

@ -0,0 +1,102 @@
"use client";
import { FC, FormEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { CircleAlert, XCircle } from "lucide-react";
// types
import { IEmailCheckData } from "@plane/types";
// ui
import { Button, Input, Spinner } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
import { checkEmailValidity } from "@/helpers/string.helper";
type TAuthEmailForm = {
defaultEmail: string;
onSubmit: (data: IEmailCheckData) => Promise<void>;
};
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
const { onSubmit, defaultEmail } = props;
// states
const [isSubmitting, setIsSubmitting] = useState(false);
const [email, setEmail] = useState(defaultEmail);
const emailError = useMemo(
() => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined),
[email]
);
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
const payload: IEmailCheckData = {
email: email,
};
await onSubmit(payload);
setIsSubmitting(false);
};
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
const [isFocused, setIsFocused] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
return (
<form onSubmit={handleFormSubmit} className="mt-5 space-y-4">
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email
</label>
<div
className={cn(
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
)}
onFocus={() => {
setIsFocused(true);
}}
onBlur={() => {
setIsFocused(false);
}}
>
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
autoComplete="on"
autoFocus
ref={inputRef}
/>
{email.length > 0 && (
<button
type="button"
aria-label="Clear email"
onClick={() => {
setEmail("");
inputRef.current?.focus();
}}
tabIndex={-1}
>
<XCircle className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs" />
</button>
)}
</div>
{emailError?.email && !isFocused && (
<p className="flex items-center gap-1 text-xs text-red-600 px-0.5">
<CircleAlert height={12} width={12} />
{emailError.email}
</p>
)}
</div>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</form>
);
});

View file

@ -0,0 +1,8 @@
export * from "./auth-root";
export * from "./auth-header";
export * from "./auth-banner";
export * from "./email";
export * from "./password";
export * from "./unique-code";

View file

@ -0,0 +1,244 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
// helpers
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
type Props = {
email: string;
isPasswordAutoset: boolean;
isSMTPConfigured: boolean;
mode: EAuthModes;
nextPath: string | undefined;
handleEmailClear: () => void;
handleAuthStep: (step: EAuthSteps) => void;
};
type TPasswordFormValues = {
email: string;
password: string;
confirm_password?: string;
};
const defaultValues: TPasswordFormValues = {
email: "",
password: "",
};
const authService = new AuthService();
export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
const { email, nextPath, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props;
// ref
const formRef = useRef<HTMLFormElement>(null);
// states
const [csrfPromise, setCsrfPromise] = useState<Promise<{ csrf_token: string }> | undefined>(undefined);
const [passwordFormData, setPasswordFormData] = useState<TPasswordFormValues>({ ...defaultValues, email });
const [showPassword, setShowPassword] = useState({
password: false,
retypePassword: false,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleFormChange = (key: keyof TPasswordFormValues, value: string) =>
setPasswordFormData((prev) => ({ ...prev, [key]: value }));
useEffect(() => {
if (csrfPromise === undefined) {
const promise = authService.requestCSRFToken();
setCsrfPromise(promise);
}
}, [csrfPromise]);
const redirectToUniqueCodeSignIn = async () => {
handleAuthStep(EAuthSteps.UNIQUE_CODE);
};
const passwordSupport = passwordFormData.password.length > 0 &&
mode === EAuthModes.SIGN_UP &&
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={passwordFormData.password} isFocused={isPasswordInputFocused} />
);
const isButtonDisabled = useMemo(
() =>
!isSubmitting &&
!!passwordFormData.password &&
(mode === EAuthModes.SIGN_UP
? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID &&
passwordFormData.password === passwordFormData.confirm_password
: true)
? false
: true,
[isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password]
);
const password = passwordFormData.password ?? "";
const confirmPassword = passwordFormData.confirm_password ?? "";
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
const handleCSRFToken = async () => {
if (!formRef || !formRef.current) return;
const token = await csrfPromise;
if (!token?.csrf_token) return;
const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]");
csrfElement?.setAttribute("value", token?.csrf_token);
};
return (
<form
ref={formRef}
className="mt-5 space-y-4"
method="POST"
action={`${API_BASE_URL}/auth/spaces/${mode === EAuthModes.SIGN_IN ? "sign-in" : "sign-up"}/`}
onSubmit={async (event) => {
event.preventDefault();
await handleCSRFToken();
formRef.current && formRef.current.submit();
setIsSubmitting(true);
}}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" />
<input type="hidden" value={passwordFormData.email} name="email" />
<input type="hidden" value={nextPath} name="next_path" />
<div className="space-y-1">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
Email
</label>
<div
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
>
<Input
id="email"
name="email"
type="email"
value={passwordFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="name@company.com"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`}
disabled
/>
{passwordFormData.email.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
)}
</div>
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
{mode === EAuthModes.SIGN_IN ? "Password" : "Set a password"}
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword?.password ? "text" : "password"}
name="password"
value={passwordFormData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
placeholder="Enter password"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
autoFocus
/>
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
</div>
{mode === EAuthModes.SIGN_UP && (
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
Confirm password
</label>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword?.retypePassword ? "text" : "password"}
name="confirm_password"
value={passwordFormData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword?.retypePassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("retypePassword")}
/>
)}
</div>
{!!passwordFormData.confirm_password &&
passwordFormData.password !== passwordFormData.confirm_password &&
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
)}
<div className="space-y-2.5">
{mode === EAuthModes.SIGN_IN ? (
<>
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isSubmitting ? (
<Spinner height="20px" width="20px" />
) : isSMTPConfigured ? (
"Continue"
) : (
"Go to workspace"
)}
</Button>
{isSMTPConfigured && (
<Button
type="button"
onClick={redirectToUniqueCodeSignIn}
variant="outline-primary"
className="w-full"
size="lg"
>
Sign in with unique code
</Button>
)}
</>
) : (
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Create account"}
</Button>
)}
</div>
</form>
);
});

View file

@ -0,0 +1,152 @@
"use client";
import React, { useEffect, useState } from "react";
import { CircleCheck, XCircle } from "lucide-react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// hooks
import useTimer from "@/hooks/use-timer";
// types
import { EAuthModes } from "@/types/auth";
// services
const authService = new AuthService();
type TAuthUniqueCodeForm = {
mode: EAuthModes;
email: string;
nextPath: string | undefined;
handleEmailClear: () => void;
generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>;
};
type TUniqueCodeFormValues = {
email: string;
code: string;
};
const defaultValues: TUniqueCodeFormValues = {
email: "",
code: "",
};
export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
const { mode, email, nextPath, handleEmailClear, generateEmailUniqueCode } = props;
// derived values
const defaultResetTimerValue = 5;
// states
const [uniqueCodeFormData, setUniqueCodeFormData] = useState<TUniqueCodeFormValues>({ ...defaultValues, email });
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) =>
setUniqueCodeFormData((prev) => ({ ...prev, [key]: value }));
const generateNewCode = async (email: string) => {
try {
setIsRequestingNewCode(true);
const uniqueCode = await generateEmailUniqueCode(email);
setResendCodeTimer(defaultResetTimerValue);
handleFormChange("code", uniqueCode?.code || "");
setIsRequestingNewCode(false);
} catch {
setResendCodeTimer(0);
console.error("Error while requesting new code");
setIsRequestingNewCode(false);
}
};
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting;
return (
<form
className="mt-5 space-y-4"
method="POST"
action={`${API_BASE_URL}/auth/spaces/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" value={uniqueCodeFormData.email} name="email" />
<input type="hidden" value={nextPath} name="next_path" />
<div className="space-y-1">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
Email
</label>
<div
className={`relative flex items-center rounded-md bg-onboarding-background-200 border border-onboarding-border-100`}
>
<Input
id="email"
name="email"
type="email"
value={uniqueCodeFormData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="name@company.com"
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`}
disabled
/>
{uniqueCodeFormData.email.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
)}
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="code">
Unique code
</label>
<Input
name="code"
value={uniqueCodeFormData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
placeholder="gets-sets-flys"
className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
<div className="flex w-full items-center justify-between px-1 text-xs pt-1">
<p className="flex items-center gap-1 font-medium text-green-700">
<CircleCheck height={12} width={12} />
Paste the code sent to your email
</p>
<button
type="button"
onClick={() => generateNewCode(uniqueCodeFormData.email)}
className={`${
isRequestNewCodeDisabled
? "text-onboarding-text-400"
: "font-medium text-custom-primary-300 hover:text-custom-primary-200"
}`}
disabled={isRequestNewCodeDisabled}
>
{resendTimerCode > 0
? `Resend in ${resendTimerCode}s`
: isRequestingNewCode
? "Requesting new code"
: "Resend"}
</button>
</div>
</div>
<div className="space-y-2.5">
<Button type="submit" variant="primary" className="w-full" size="lg" disabled={isButtonDisabled}>
{isRequestingNewCode ? "Sending code" : isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</div>
</form>
);
};

View file

@ -0,0 +1 @@
export * from "./password-strength-meter";

View file

@ -0,0 +1,94 @@
"use client";
import { FC, useMemo } from "react";
// import { CircleCheck } from "lucide-react";
// helpers
import { cn } from "@plane/utils";
import {
E_PASSWORD_STRENGTH,
// PASSWORD_CRITERIA,
getPasswordStrength,
} from "@/helpers/password.helper";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};

View file

@ -0,0 +1,5 @@
export * from "./auth-forms";
export * from "./oauth";
export * from "./terms-and-conditions";
export * from "./helpers";
export * from "./user-logged-in";

View file

@ -0,0 +1,41 @@
import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
import githubLightModeImage from "/public/logos/github-black.png";
import githubDarkModeImage from "/public/logos/github-dark.svg";
export type GithubOAuthButtonProps = {
text: string;
};
export const GithubOAuthButton: FC<GithubOAuthButtonProps> = (props) => {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next_path") || undefined;
const { text } = props;
// hooks
const { resolvedTheme } = useTheme();
const handleSignIn = () => {
window.location.assign(`${API_BASE_URL}/auth/spaces/github/${nextPath ? `?next_path=${nextPath}` : ``}`);
};
return (
<button
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
}`}
onClick={handleSignIn}
>
<Image
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
{text}
</button>
);
};

View file

@ -0,0 +1,35 @@
import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
import GitlabLogo from "/public/logos/gitlab-logo.svg";
export type GitlabOAuthButtonProps = {
text: string;
};
export const GitlabOAuthButton: FC<GitlabOAuthButtonProps> = (props) => {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next_path") || undefined;
const { text } = props;
// hooks
const { resolvedTheme } = useTheme();
const handleSignIn = () => {
window.location.assign(`${API_BASE_URL}/auth/spaces/gitlab/${nextPath ? `?next_path=${nextPath}` : ``}`);
};
return (
<button
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
}`}
onClick={handleSignIn}
>
<Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />
{text}
</button>
);
};

View file

@ -0,0 +1,35 @@
import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
import GoogleLogo from "/public/logos/google-logo.svg";
export type GoogleOAuthButtonProps = {
text: string;
};
export const GoogleOAuthButton: FC<GoogleOAuthButtonProps> = (props) => {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next_path") || undefined;
const { text } = props;
// hooks
const { resolvedTheme } = useTheme();
const handleSignIn = () => {
window.location.assign(`${API_BASE_URL}/auth/spaces/google/${nextPath ? `?next_path=${nextPath}` : ``}`);
};
return (
<button
className={`flex h-[42px] w-full items-center justify-center gap-2 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-onboarding-background-300 ${
resolvedTheme === "dark" ? "border-[#43484F] bg-[#2F3135]" : "border-[#D9E4FF]"
}`}
onClick={handleSignIn}
>
<Image src={GoogleLogo} height={18} width={18} alt="Google Logo" />
{text}
</button>
);
};

View file

@ -0,0 +1,4 @@
export * from "./oauth-options";
export * from "./google-button";
export * from "./github-button";
export * from "./gitlab-button";

View file

@ -0,0 +1,29 @@
import { observer } from "mobx-react";
// components
import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account";
// hooks
import { useInstance } from "@/hooks/store";
export const OAuthOptions: React.FC = observer(() => {
// hooks
const { config } = useInstance();
return (
<>
<div className="mt-4 flex items-center">
<hr className="w-full border-onboarding-border-100" />
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">or</p>
<hr className="w-full border-onboarding-border-100" />
</div>
<div className={`mt-7 grid gap-4 overflow-hidden`}>
{config?.is_google_enabled && (
<div className="flex h-[42px] items-center !overflow-hidden">
<GoogleOAuthButton text="Sign in with Google" />
</div>
)}
{config?.is_github_enabled && <GithubOAuthButton text="Sign in with GitHub" />}
{config?.is_gitlab_enabled && <GitlabOAuthButton text="Sign in with GitLab" />}
</div>
</>
);
});

View file

@ -0,0 +1,27 @@
"use client";
import React, { FC } from "react";
import Link from "next/link";
type Props = {
isSignUp?: boolean;
};
export const TermsAndConditions: FC<Props> = (props) => {
const { isSignUp = false } = props;
return (
<span className="flex items-center justify-center py-6">
<p className="text-center text-sm text-onboarding-text-200 whitespace-pre-line">
{isSignUp ? "By creating an account" : "By signing in"}, you agree to our{" \n"}
<Link href="https://plane.so/legals/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Terms of Service</span>
</Link>{" "}
and{" "}
<Link href="https://plane.so/legals/privacy-policy" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Privacy Policy</span>
</Link>
{"."}
</p>
</span>
);
};

View file

@ -0,0 +1,52 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
// components
import { PoweredBy } from "@/components/common";
import { UserAvatar } from "@/components/issues";
// hooks
import { useUser } from "@/hooks/store";
// assets
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = observer(() => {
// store hooks
const { data: user } = useUser();
// next-themes
const { resolvedTheme } = useTheme();
const logo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
if (!user) return null;
return (
<div className="flex flex-col h-screen w-screen">
<div className="relative flex w-full items-center justify-between gap-4 border-b border-custom-border-200 px-6 py-5">
<div className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</div>
<UserAvatar />
</div>
<div className="size-full grid place-items-center p-6">
<div className="text-center">
<div className="mx-auto size-32 md:size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="size-16 md:size-32 grid place-items-center">
<Image src={UserLoggedInImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-8 md:mt-12 text-xl md:text-3xl font-semibold">Nice! Just one more step.</h1>
<p className="mt-2 md:mt-4 text-sm md:text-base">
Enter the public-share URL or link of the view or Page you are trying to see in the browser{"'"}s address
bar.
</p>
</div>
</div>
<PoweredBy />
</div>
);
});

View file

@ -0,0 +1,3 @@
export * from "./project-logo";
export * from "./logo-spinner";
export * from "./powered-by";

View file

@ -0,0 +1,20 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const LogoSpinner = () => {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="h-screen w-full flex min-h-[600px] justify-center items-center">
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" />
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { WEBSITE_URL } from "@plane/constants";
// assets
import planeLogo from "@/public/plane-logo.svg";
type TPoweredBy = {
disabled?: boolean;
};
export const PoweredBy: FC<TPoweredBy> = (props) => {
// props
const { disabled = false } = props;
if (disabled || !WEBSITE_URL) return null;
return (
<a
href={WEBSITE_URL}
className="fixed bottom-2.5 right-5 !z-[999999] flex items-center gap-1 rounded border border-custom-border-200 bg-custom-background-100 px-2 py-1 shadow-custom-shadow-2xs"
target="_blank"
rel="noreferrer noopener"
>
<div className="relative grid h-6 w-6 place-items-center">
<Image src={planeLogo} alt="Plane logo" className="h-6 w-6" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <span className="font-semibold">Plane Publish</span>
</div>
</a>
);
};

View file

@ -0,0 +1,34 @@
// types
import { TLogoProps } from "@plane/types";
// helpers
import { cn } from "@plane/utils";
type Props = {
className?: string;
logo: TLogoProps;
};
export const ProjectLogo: React.FC<Props> = (props) => {
const { className, logo } = props;
if (logo.in_use === "icon" && logo.icon)
return (
<span
style={{
color: logo.icon.color,
}}
className={cn("material-symbols-rounded text-base", className)}
>
{logo.icon.name}
</span>
);
if (logo.in_use === "emoji" && logo.emoji)
return (
<span className={cn("text-base", className)}>
{logo.emoji.value?.split("-").map((emoji) => String.fromCodePoint(parseInt(emoji, 10)))}
</span>
);
return <span />;
};

View file

@ -0,0 +1 @@
export * from "./mentions";

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,17 @@
// plane editor
import { TMentionComponentProps } from "@plane/editor";
// plane web components
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
// local components
import { EditorUserMention } from "./user";
export const EditorMentionsRoot: React.FC<TMentionComponentProps> = (props) => {
const { entity_identifier, entity_name } = props;
switch (entity_name) {
case "user_mention":
return <EditorUserMention id={entity_identifier} />;
default:
return <EditorAdditionalMentionsRoot {...props} />;
}
};

View file

@ -0,0 +1,39 @@
import { observer } from "mobx-react";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useMember, useUser } from "@/hooks/store";
type Props = {
id: string;
};
export const EditorUserMention: React.FC<Props> = observer((props) => {
const { id } = props;
// store hooks
const { data: currentUser } = useUser();
const { getMemberById } = useMember();
// derived values
const userDetails = getMemberById(id);
if (!userDetails) {
return (
<div className="not-prose inline px-1 py-0.5 rounded bg-custom-background-80 text-custom-text-300 no-underline">
@deactivated user
</div>
);
}
return (
<div
className={cn(
"not-prose inline px-1 py-0.5 rounded bg-custom-primary-100/20 text-custom-primary-100 no-underline",
{
"bg-yellow-500/20 text-yellow-500": id === currentUser?.id,
}
)}
>
@{userDetails?.member__display_name}
</div>
);
});

View file

@ -0,0 +1,5 @@
export * from "./embeds";
export * from "./lite-text-editor";
export * from "./lite-text-read-only-editor";
export * from "./rich-text-read-only-editor";
export * from "./toolbar";

View file

@ -0,0 +1,80 @@
import React from "react";
// plane imports
import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
import { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
interface LiteTextEditorWrapperProps
extends MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> {
anchor: string;
workspaceId: string;
isSubmitting?: boolean;
showSubmitButton?: boolean;
uploadFile: TFileHandler["upload"];
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
const {
anchor,
containerClassName,
workspaceId,
isSubmitting = false,
showSubmitButton = true,
uploadFile,
disabledExtensions,
flaggedExtensions,
...rest
} = props;
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
// derived values
const isEmpty = isCommentEmpty(props.initialValue);
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
return (
<div className="border border-custom-border-200 rounded p-3 space-y-3">
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions ?? []}
flaggedExtensions={flaggedExtensions ?? []}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
}}
{...rest}
// overriding the containerClassName to add relative class passed
containerClassName={cn(containerClassName, "relative")}
/>
<IssueCommentToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
isSubmitting={isSubmitting}
showSubmitButton={showSubmitButton}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
editorRef={editorRef}
/>
</div>
);
});
LiteTextEditor.displayName = "LiteTextEditor";

View file

@ -0,0 +1,48 @@
import React from "react";
// plane imports
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
import { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// store hooks
import { useMember } from "@/hooks/store";
type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
Omit<ILiteTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
anchor: string;
workspaceId: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {
const { getMemberById } = useMember();
return (
<LiteTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions ?? []}
flaggedExtensions={flaggedExtensions ?? []}
fileHandler={getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({
display_name: getMemberById(id)?.member__display_name ?? "",
}),
}}
{...props}
// overriding the customClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative p-2")}
/>
);
}
);
LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor";

View file

@ -0,0 +1,49 @@
import React, { forwardRef } from "react";
// plane imports
import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
import { MakeOptional } from "@plane/types";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// store hooks
import { useMember } from "@/hooks/store";
interface RichTextEditorWrapperProps
extends MakeOptional<
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> {
anchor: string;
uploadFile: TFileHandler["upload"];
workspaceId: string;
}
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props;
const { getMemberById } = useMember();
return (
<RichTextEditorWithRef
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({
display_name: getMemberById(id)?.member__display_name ?? "",
}),
}}
ref={ref}
disabledExtensions={disabledExtensions ?? []}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile,
workspaceId,
})}
flaggedExtensions={flaggedExtensions ?? []}
{...rest}
containerClassName={containerClassName}
editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden"
displayConfig={{ fontSize: "large-font" }}
/>
);
});
RichTextEditor.displayName = "RichTextEditor";

View file

@ -0,0 +1,48 @@
import React from "react";
// plane imports
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps, RichTextReadOnlyEditorWithRef } from "@plane/editor";
import { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// store hooks
import { useMember } from "@/hooks/store";
type RichTextReadOnlyEditorWrapperProps = MakeOptional<
Omit<IRichTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
anchor: string;
workspaceId: string;
};
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {
const { getMemberById } = useMember();
return (
<RichTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions ?? []}
flaggedExtensions={flaggedExtensions ?? []}
fileHandler={getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({
display_name: getMemberById(id)?.member__display_name ?? "",
}),
}}
{...props}
// overriding the customClassName to add relative class passed
containerClassName={cn("relative p-0 border-none", props.containerClassName)}
/>
);
}
);
RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor";

View file

@ -0,0 +1,114 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
// plane imports
import { TOOLBAR_ITEMS, ToolbarMenuItem, EditorRefApi } from "@plane/editor";
import { Button, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
executeCommand: (item: ToolbarMenuItem) => void;
handleSubmit: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
isCommentEmpty: boolean;
isSubmitting: boolean;
showSubmitButton: boolean;
editorRef: EditorRefApi | null;
};
const toolbarItems = TOOLBAR_ITEMS.lite;
export const IssueCommentToolbar: React.FC<Props> = (props) => {
const { executeCommand, handleSubmit, isCommentEmpty, editorRef, isSubmitting, showSubmitButton } = props;
// states
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
// Function to update active states
const updateActiveStates = useCallback(() => {
if (!editorRef) return;
const newActiveStates: Record<string, boolean> = {};
Object.values(toolbarItems)
.flat()
.forEach((item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
itemKey: item.itemKey,
...item.extraProps,
});
});
setActiveStates(newActiveStates);
}, [editorRef]);
// useEffect to call updateActiveStates when isActive prop changes
useEffect(() => {
if (!editorRef) return;
const unsubscribe = editorRef.onStateChange(updateActiveStates);
updateActiveStates();
return () => unsubscribe();
}, [editorRef, updateActiveStates]);
return (
<div className="flex h-9 w-full items-stretch gap-1.5 bg-custom-background-90 overflow-x-scroll">
<div className="flex w-full items-stretch justify-between gap-2 rounded border-[0.5px] border-custom-border-200 p-1">
<div className="flex items-stretch">
{Object.keys(toolbarItems).map((key, index) => (
<div
key={key}
className={cn("flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5", {
"pl-0": index === 0,
})}
>
{toolbarItems[key].map((item) => {
const isItemActive = activeStates[item.renderKey];
return (
<Tooltip
key={item.renderKey}
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">{item.name}</span>
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
</p>
}
>
<button
type="button"
onClick={() => executeCommand(item)}
className={cn(
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-400 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": isItemActive,
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": isItemActive,
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
);
})}
</div>
))}
</div>
{showSubmitButton && (
<div className="sticky right-1">
<Button
type="button"
variant="primary"
className="px-2.5 py-1.5 text-xs"
onClick={handleSubmit}
disabled={isCommentEmpty}
loading={isSubmitting}
>
Comment
</Button>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1 @@
export * from "./instance-failure-view";

View file

@ -0,0 +1,39 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/ui";
// assets
import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "public/instance/instance-failure.svg";
export const InstanceFailureView: FC = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
const handleRetry = () => {
window.location.reload();
};
return (
<div className="relative h-screen overflow-x-hidden overflow-y-auto container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<Image src={instanceImage} alt="Plane instance failure image" />
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3>
<p className="font-medium text-base text-center">
We were unable to fetch the details of the instance. <br />
Fret not, it might just be a connectivity work items.
</p>
</div>
<div className="flex justify-center">
<Button size="md" onClick={handleRetry}>
Retry
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,82 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// types
import { useTranslation } from "@plane/i18n";
import { TFilters } from "@/types/issue";
// components
import { AppliedPriorityFilters } from "./priority";
import { AppliedStateFilters } from "./state";
type Props = {
appliedFilters: TFilters;
handleRemoveAllFilters: () => void;
handleRemoveFilter: (key: keyof TFilters, value: string | null) => void;
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props;
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-stretch gap-2">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TFilters;
const filterValue = value as TFilters[keyof TFilters];
if (!filterValue) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{filterKey === "priority" && (
<AppliedPriorityFilters
handleRemove={(val) => handleRemoveFilter("priority", val)}
values={filterValue ?? []}
/>
)}
{/* {filterKey === "labels" && labels && (
<AppliedLabelsFilters
handleRemove={(val) => handleRemoveFilter("labels", val)}
labels={labels}
values={value}
/>
)} */}
{filterKey === "state" && (
<AppliedStateFilters
handleRemove={(val) => handleRemoveFilter("state", val)}
values={filterValue ?? []}
/>
)}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={handleRemoveAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</button>
</div>
);
});

View file

@ -0,0 +1,44 @@
"use client";
import { X } from "lucide-react";
// types
import { IIssueLabel } from "@/types/issue";
type Props = {
handleRemove: (val: string) => void;
labels: IIssueLabel[] | undefined;
values: string[];
};
export const AppliedLabelsFilters: React.FC<Props> = (props) => {
const { handleRemove, labels, values } = props;
return (
<>
{values.map((labelId) => {
const labelDetails = labels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div key={labelId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labelDetails.color,
}}
/>
<span className="normal-case">{labelDetails.name}</span>
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(labelId)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
);
})}
</>
);
};

View file

@ -0,0 +1,33 @@
"use client";
import { X } from "lucide-react";
import { PriorityIcon } from "@plane/ui";
type Props = {
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedPriorityFilters: React.FC<Props> = (props) => {
const { handleRemove, values } = props;
return (
<>
{values &&
values.length > 0 &&
values.map((priority) => (
<div key={priority} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<PriorityIcon priority={priority as any} className={`h-3 w-3`} />
{priority}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(priority)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
))}
</>
);
};

View file

@ -0,0 +1,94 @@
"use client";
import { FC, useCallback } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// hooks
import { useIssueFilter } from "@/hooks/store";
// store
import { TIssueQueryFilters } from "@/types/issue";
// components
import { AppliedFiltersList } from "./filters-list";
type TIssueAppliedFilters = {
anchor: string;
};
export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();
// store hooks
const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const userFilters = issueFilters?.filters || {};
const appliedFilters: any = {};
Object.entries(userFilters).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key] = value;
});
const updateRouteParams = useCallback(
(key: keyof TIssueQueryFilters, value: string[]) => {
const state = key === "state" ? value : issueFilters?.filters?.state ?? [];
const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? [];
const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? [];
let params: any = { board: activeLayout || "list" };
if (priority.length > 0) params = { ...params, priority: priority.join(",") };
if (state.length > 0) params = { ...params, states: state.join(",") };
if (labels.length > 0) params = { ...params, labels: labels.join(",") };
params = new URLSearchParams(params).toString();
router.push(`/issues/${anchor}?${params}`);
},
[activeLayout, anchor, issueFilters, router]
);
const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string | null) => {
let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (value === null) newValues = [];
else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues);
},
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
);
const handleRemoveAllFilters = () => {
initIssueFilters(
anchor,
{
display_filters: { layout: activeLayout || "list" },
filters: {
state: [],
priority: [],
labels: [],
},
},
true
);
router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`);
};
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="border-b border-custom-border-200 bg-custom-background-100 p-4">
<AppliedFiltersList
appliedFilters={appliedFilters || {}}
handleRemoveFilter={handleFilters as any}
handleRemoveAllFilters={handleRemoveAllFilters}
/>
</div>
);
});

View file

@ -0,0 +1,44 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/ui";
// hooks
import { useStates } from "@/hooks/store";
type Props = {
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedStateFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values } = props;
const { sortedStates: states } = useStates();
return (
<>
{values.map((stateId) => {
const stateDetails = states?.find((s) => s.id === stateId);
if (!stateDetails) return null;
return (
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} size={EIconSize.SM} />
{stateDetails.name}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(stateId)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
);
})}
</>
);
});

View file

@ -0,0 +1,65 @@
"use client";
import React, { Fragment, useState } from "react";
import { Placement } from "@popperjs/core";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
type Props = {
children: React.ReactNode;
title?: string;
placement?: Placement;
};
export const FiltersDropdown: React.FC<Props> = (props) => {
const { children, title = "Dropdown", placement } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button ref={setReferenceElement} variant="neutral-primary" size="sm">
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span>
</div>
</Button>
</Popover.Button>
<Transition
as={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>
<div
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex max-h-[37.5rem] w-[18.75rem] flex-col overflow-hidden">{children}</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View file

@ -0,0 +1,24 @@
"use client";
import React from "react";
// lucide icons
import { ChevronDown, ChevronUp } from "lucide-react";
interface IFilterHeader {
title: string;
isPreviewEnabled: boolean;
handleIsPreviewEnabled: () => void;
}
export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => (
<div className="sticky top-0 flex items-center justify-between gap-2 bg-custom-background-100">
<div className="flex-grow truncate text-xs font-medium text-custom-text-300">{title}</div>
<button
type="button"
className="grid h-5 w-5 flex-shrink-0 place-items-center rounded hover:bg-custom-background-80"
onClick={handleIsPreviewEnabled}
>
{isPreviewEnabled ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
);

View file

@ -0,0 +1,37 @@
"use client";
import React from "react";
// lucide icons
import { Check } from "lucide-react";
type Props = {
icon?: React.ReactNode;
isChecked: boolean;
title: React.ReactNode;
onClick?: () => void;
multiple?: boolean;
};
export const FilterOption: React.FC<Props> = (props) => {
const { icon, isChecked, multiple = true, onClick, title } = props;
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded p-1.5 hover:bg-custom-background-80"
onClick={onClick}
>
<div
className={`grid h-3 w-3 flex-shrink-0 place-items-center border bg-custom-background-90 ${
isChecked ? "border-custom-primary-100 bg-custom-primary-100 text-white" : "border-custom-border-300"
} ${multiple ? "rounded-sm" : "rounded-full"}`}
>
{isChecked && <Check size={10} strokeWidth={3} />}
</div>
<div className="flex items-center gap-2 truncate">
{icon && <div className="grid w-5 flex-shrink-0 place-items-center">{icon}</div>}
<div className="flex-grow truncate text-xs text-custom-text-200">{title}</div>
</div>
</button>
);
};

View file

@ -0,0 +1,3 @@
export * from "./dropdown";
export * from "./filter-header";
export * from "./filter-option";

View file

@ -0,0 +1,11 @@
// filters
export * from "./root";
export * from "./selection";
// properties
export * from "./state";
export * from "./priority";
export * from "./labels";
// helpers
export * from "./helpers";

View file

@ -0,0 +1,83 @@
"use client";
import React, { useState } from "react";
import { Loader } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
// types
import { IIssueLabel } from "@/types/issue";
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: React.FC<Props> = (props) => {
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
const handleViewToggle = () => {
if (!filteredOptions) return;
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
else setItemsToRender(filteredOptions.length);
};
return (
<>
<FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={appliedFilters?.includes(label?.id) ? true : false}
onClick={() => handleUpdate(label?.id)}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
};

View file

@ -0,0 +1,57 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { PriorityIcon } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "./helpers";
// constants
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterPriority: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// hooks
const { t } = useTranslation();
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = ISSUE_PRIORITY_FILTERS.filter((p) => p.key.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((priority) => (
<FilterOption
key={priority.key}
isChecked={appliedFilters?.includes(priority.key) ? true : false}
onClick={() => handleUpdate(priority.key)}
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
title={t(priority.titleTranslationKey)}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">{t("common.search.no_matches_found")}</p>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,71 @@
"use client";
import { FC, useCallback } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants";
// components
import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown";
import { FilterSelection } from "@/components/issues/filters/selection";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueFilter } from "@/hooks/store";
// types
import { TIssueQueryFilters } from "@/types/issue";
type IssueFiltersDropdownProps = {
anchor: string;
};
export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();
// hooks
const { getIssueFilters, updateIssueFilters } = useIssueFilter();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const updateRouteParams = useCallback(
(key: keyof TIssueQueryFilters, value: string[]) => {
const state = key === "state" ? value : (issueFilters?.filters?.state ?? []);
const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []);
const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []);
const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels });
router.push(`/issues/${anchor}?${queryParam}`);
},
[anchor, activeLayout, issueFilters, router]
);
const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string) => {
if (!value) return;
const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues);
},
[anchor, issueFilters, updateIssueFilters, updateRouteParams]
);
return (
<div className="z-10 flex h-full w-full flex-col">
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFilters={handleFilters as any}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []}
/>
</FiltersDropdown>
</div>
);
});

View file

@ -0,0 +1,81 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// types
import { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
// components
import { FilterPriority, FilterState } from ".";
type Props = {
filters: IIssueFilterOptions;
handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
layoutDisplayFiltersOptions: TIssueFilterKeys[];
};
export const FilterSelection: React.FC<Props> = observer((props) => {
const { filters, handleFilters, layoutDisplayFiltersOptions } = props;
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions.includes(filter);
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5">
{/* priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFilters("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFilters("state", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* labels */}
{/* {isFilterEnabled("labels") && (
<div className="py-2">
<FilterLabels
appliedFilters={filters.labels ?? null}
handleUpdate={(val) => handleFilters("labels", val)}
labels={labels}
searchQuery={filtersSearchQuery}
/>
</div>
)} */}
</div>
</div>
);
});

View file

@ -0,0 +1,83 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { EIconSize } from "@plane/constants";
import { Loader, StateGroupIcon } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
// hooks
import { useStates } from "@/hooks/store";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterState: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const { sortedStates: states } = useStates();
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()));
const handleViewToggle = () => {
if (!filteredOptions) return;
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
else setItemsToRender(filteredOptions.length);
};
return (
<>
<FilterHeader
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((state) => (
<FilterOption
key={state.id}
isChecked={appliedFilters?.includes(state.id) ? true : false}
onClick={() => handleUpdate(state.id)}
icon={<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.MD} />}
title={state.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,2 @@
export * from "./issue-layouts";
export * from "./navbar";

View file

@ -0,0 +1,17 @@
import Image from "next/image";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
export const SomethingWentWrongError = () => (
<div className="grid min-h-screen w-full place-items-center p-6">
<div className="text-center">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
<div className="grid h-32 w-32 place-items-center">
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Oops! Something went wrong.</h1>
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
</div>
</div>
);

View file

@ -0,0 +1,4 @@
export * from "./kanban/base-kanban-root";
export * from "./list/base-list-root";
export * from "./properties";
export * from "./root";

View file

@ -0,0 +1,33 @@
import { observer } from "mobx-react";
import { TLoader } from "@plane/types";
import { LogoSpinner } from "@/components/common";
interface Props {
children: string | JSX.Element | JSX.Element[];
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
}
export const IssueLayoutHOC = observer((props: Props) => {
const { getIssueLoader, getGroupIssueCount } = props;
const issueCount = getGroupIssueCount(undefined, undefined, false);
if (getIssueLoader() === "init-loader" || issueCount === undefined) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
</div>
);
}
if (getGroupIssueCount(undefined, undefined, false) === 0) {
return <div className="flex w-full h-full items-center justify-center">No work items Found</div>;
}
return <>{props.children}</>;
});

View file

@ -0,0 +1,76 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store";
import { KanBan } from "./default";
type Props = {
anchor: string;
};
export const IssueKanbanLayoutRoot: React.FC<Props> = observer((props: Props) => {
const { anchor } = props;
// store hooks
const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue();
const displayProperties: IIssueDisplayProperties = useMemo(
() => ({
key: true,
state: true,
labels: true,
priority: true,
due_date: true,
}),
[]
);
const fetchMoreIssues = useCallback(
(groupId?: string, subgroupId?: string) => {
if (getIssueLoader(groupId, subgroupId) !== "pagination") {
fetchNextPublicIssues(anchor, groupId, subgroupId);
}
},
[fetchNextPublicIssues]
);
const debouncedFetchMoreIssues = debounce(
(groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId),
300,
{ leading: true, trailing: false }
);
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
return (
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
<div
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 overflow-x-auto overflow-y-hidden`}
ref={scrollableContainerRef}
>
<div className="relative h-full w-max min-w-full bg-custom-background-90">
<div className="h-full w-max">
<KanBan
groupedIssueIds={groupedIssueIds ?? {}}
displayProperties={displayProperties}
subGroupBy={null}
groupBy="state"
showEmptyGroup
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={debouncedFetchMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
/>
</div>
</div>
</div>
</IssueLayoutHOC>
);
});

View file

@ -0,0 +1,45 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane utils
import { cn } from "@plane/utils";
// components
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions";
// hooks
import { usePublish } from "@/hooks/store";
type Props = {
issueId: string;
};
export const BlockReactions = observer((props: Props) => {
const { issueId } = props;
const { anchor } = useParams();
const { canVote, canReact } = usePublish(anchor.toString());
// if the user cannot vote or react then return empty
if (!canVote && !canReact) return <></>;
return (
<div
className={cn(
"flex flex-wrap border-t-[1px] outline-transparent w-full border-t-custom-border-200 bg-custom-background-90 rounded-b"
)}
>
<div className="py-2 px-3 flex flex-wrap items-center gap-2">
{canVote && (
<div
className={cn(`flex items-center gap-2 pr-1`, {
"after:h-6 after:ml-1 after:w-[1px] after:bg-custom-border-200": canReact,
})}
>
<IssueVotes anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
</div>
)}
{canReact && (
<div className="flex flex-wrap items-center gap-2">
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
</div>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,109 @@
"use client";
import { MutableRefObject } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
// plane types
import { IIssueDisplayProperties } from "@plane/types";
// plane ui
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// components
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, usePublish } from "@/hooks/store";
//
import { IIssue } from "@/types/issue";
import { IssueProperties } from "../properties/all-properties";
import { getIssueBlockId } from "../utils";
import { BlockReactions } from "./block-reactions";
interface IssueBlockProps {
issueId: string;
groupId: string;
subGroupId: string;
displayProperties: IIssueDisplayProperties | undefined;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
interface IssueDetailsBlockProps {
issue: IIssue;
displayProperties: IIssueDisplayProperties | undefined;
}
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
const { issue, displayProperties } = props;
const { anchor } = useParams();
// hooks
const { project_details } = usePublish(anchor.toString());
return (
<div className="space-y-2 px-3 py-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="relative">
<div className="line-clamp-1 text-xs text-custom-text-300">
{project_details?.identifier}-{issue.sequence_id}
</div>
</div>
</WithDisplayPropertiesHOC>
<div className="w-full line-clamp-1 text-sm text-custom-text-100 mb-1.5">
<Tooltip tooltipContent={issue.name}>
<span>{issue.name}</span>
</Tooltip>
</div>
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issue}
displayProperties={displayProperties}
/>
</div>
);
});
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const { issueId, groupId, subGroupId, displayProperties } = props;
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board");
// hooks
const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails();
const handleIssuePeekOverview = () => {
setPeekId(issueId);
};
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
const issue = getIssueById(issueId);
if (!issue) return null;
return (
<div className={cn("group/kanban-block relative p-1.5")}>
<div
className={cn(
"relative block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }
)}
>
<Link
id={getIssueBlockId(issueId, groupId, subGroupId)}
className="w-full"
href={`?${queryParam}`}
onClick={handleIssuePeekOverview}
>
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
</Link>
<BlockReactions issueId={issueId} />
</div>
</div>
);
});
KanbanIssueBlock.displayName = "KanbanIssueBlock";

View file

@ -0,0 +1,45 @@
import { MutableRefObject } from "react";
import { observer } from "mobx-react";
//types
import { IIssueDisplayProperties } from "@plane/types";
// components
import { KanbanIssueBlock } from "./block";
interface IssueBlocksListProps {
subGroupId: string;
groupId: string;
issueIds: string[];
displayProperties: IIssueDisplayProperties | undefined;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props;
return (
<>
{issueIds && issueIds.length > 0 ? (
<>
{issueIds.map((issueId) => {
if (!issueId) return null;
let draggableId = issueId;
if (groupId) draggableId = `${draggableId}__${groupId}`;
if (subGroupId) draggableId = `${draggableId}__${subGroupId}`;
return (
<KanbanIssueBlock
key={draggableId}
issueId={issueId}
groupId={groupId}
subGroupId={subGroupId}
displayProperties={displayProperties}
scrollableContainerRef={scrollableContainerRef}
/>
);
})}
</>
) : null}
</>
);
});

View file

@ -0,0 +1,123 @@
import { MutableRefObject } from "react";
import isNil from "lodash/isNil";
import { observer } from "mobx-react";
// types
import {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
//
import { getGroupByColumns } from "../utils";
// components
import { HeaderGroupByCard } from "./headers/group-by-card";
import { KanbanGroup } from "./kanban-group";
export interface IKanBan {
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
displayProperties: IIssueDisplayProperties | undefined;
subGroupBy: TIssueGroupByOptions | undefined;
groupBy: TIssueGroupByOptions | undefined;
subGroupId?: string;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
showEmptyGroup?: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
const {
groupedIssueIds,
displayProperties,
subGroupBy,
groupBy,
subGroupId = "null",
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
scrollableContainerRef,
showEmptyGroup = true,
} = props;
const member = useMember();
const label = useLabel();
const cycle = useCycle();
const modules = useModule();
const state = useStates();
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
if (!groupList) return null;
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
const groupVisibility = {
showGroup: true,
showIssues: true,
};
if (!showEmptyGroup) {
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
}
return groupVisibility;
};
return (
<div className={`relative w-full flex gap-2 px-2 ${subGroupBy ? "h-full" : "h-full"}`}>
{groupList &&
groupList.length > 0 &&
groupList.map((subList: IGroupByColumn) => {
const groupByVisibilityToggle = visibilityGroupBy(subList);
if (groupByVisibilityToggle.showGroup === false) return <></>;
return (
<div
key={subList.id}
className={`group relative flex flex-shrink-0 flex-col ${
groupByVisibilityToggle.showIssues ? `w-[350px]` : ``
} `}
>
{isNil(subGroupBy) && (
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
<HeaderGroupByCard
groupBy={groupBy}
icon={subList.icon as any}
title={subList.name}
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
/>
</div>
)}
{groupByVisibilityToggle.showIssues && (
<KanbanGroup
groupId={subList.id}
groupedIssueIds={groupedIssueIds}
displayProperties={displayProperties}
subGroupBy={subGroupBy}
subGroupId={subGroupId}
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
/>
)}
</div>
);
})}
</div>
);
});

View file

@ -0,0 +1,35 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Circle } from "lucide-react";
// types
import { TIssueGroupByOptions } from "@plane/types";
interface IHeaderGroupByCard {
groupBy: TIssueGroupByOptions | undefined;
icon?: React.ReactNode;
title: string;
count: number;
}
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const { icon, title, count } = props;
return (
<>
<div className={`relative flex flex-shrink-0 gap-2 p-1.5 w-full flex-row items-center`}>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
{icon ? icon : <Circle width={14} strokeWidth={2} />}
</div>
<div className={`relative flex items-center gap-1 w-full flex-row overflow-hidden`}>
<div className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100`}>
{title}
</div>
<div className={`flex-shrink-0 text-sm font-medium text-custom-text-300 pl-2`}>{count || 0}</div>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,35 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
// mobx
interface IHeaderSubGroupByCard {
icon?: React.ReactNode;
title: string;
count: number;
isExpanded: boolean;
toggleExpanded: () => void;
}
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, isExpanded, toggleExpanded } = props;
return (
<div
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5 cursor-pointer`}
onClick={() => toggleExpanded()}
>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
{isExpanded ? <ChevronUp width={14} strokeWidth={2} /> : <ChevronDown width={14} strokeWidth={2} />}
</div>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
{icon ? icon : <Circle width={14} strokeWidth={2} />}
</div>
<div className="flex flex-shrink-0 items-center gap-1 text-sm">
<div className="line-clamp-1 text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
</div>
</div>
);
});

View file

@ -0,0 +1,2 @@
export * from "./block";
export * from "./blocks-list";

View file

@ -0,0 +1,116 @@
"use client";
import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
//types
import {
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
//
import { KanbanIssueBlocksList } from ".";
interface IKanbanGroup {
groupId: string;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
displayProperties: IIssueDisplayProperties | undefined;
subGroupBy: TIssueGroupByOptions | undefined;
subGroupId: string;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
// Loader components
const KanbanIssueBlockLoader = forwardRef<HTMLSpanElement>((props, ref) => (
<span ref={ref} className="block h-28 m-1.5 animate-pulse bg-custom-background-80 rounded" />
));
KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader";
export const KanbanGroup = observer((props: IKanbanGroup) => {
const {
groupId,
subGroupId,
subGroupBy,
displayProperties,
groupedIssueIds,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
scrollableContainerRef,
} = props;
// hooks
const [intersectionElement, setIntersectionElement] = useState<HTMLSpanElement | null>(null);
const columnRef = useRef<HTMLDivElement | null>(null);
const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef;
const loadMoreIssuesInThisGroup = useCallback(() => {
loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId);
}, [loadMoreIssues, groupId, subGroupId]);
const isPaginating = !!getIssueLoader(groupId, subGroupId);
useIntersectionObserver(
containerRef,
isPaginating ? null : intersectionElement,
loadMoreIssuesInThisGroup,
`0% 100% 100% 100%`
);
const isSubGroup = !!subGroupId && subGroupId !== "null";
const issueIds = isSubGroup
? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []
: (groupedIssueIds as TGroupedIssues)?.[groupId] ?? [];
const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0;
const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults;
const loadMore = isPaginating ? (
<KanbanIssueBlockLoader />
) : (
<div
className="w-full p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
onClick={loadMoreIssuesInThisGroup}
>
{" "}
Load More &darr;
</div>
);
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
return (
<div
id={`${groupId}__${subGroupId}`}
className={cn("relative h-full transition-all min-h-[120px]", { "vertical-scrollbar scrollbar-md": !subGroupBy })}
ref={columnRef}
>
<KanbanIssueBlocksList
subGroupId={subGroupId}
groupId={groupId}
issueIds={issueIds || []}
displayProperties={displayProperties}
scrollableContainerRef={scrollableContainerRef}
/>
{shouldLoadMore && (isSubGroup ? <>{loadMore}</> : <KanbanIssueBlockLoader ref={setIntersectionElement} />)}
</div>
);
});

View file

@ -0,0 +1,298 @@
import { MutableRefObject, useState } from "react";
import { observer } from "mobx-react";
// types
import {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TIssueOrderByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
//
import { getGroupByColumns } from "../utils";
import { KanBan } from "./default";
import { HeaderGroupByCard } from "./headers/group-by-card";
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
export interface IKanBanSwimLanes {
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
displayProperties: IIssueDisplayProperties | undefined;
subGroupBy: TIssueGroupByOptions | undefined;
groupBy: TIssueGroupByOptions | undefined;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
showEmptyGroup: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
orderBy: TIssueOrderByOptions | undefined;
}
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
const {
groupedIssueIds,
displayProperties,
subGroupBy,
groupBy,
orderBy,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
showEmptyGroup,
scrollableContainerRef,
} = props;
const member = useMember();
const label = useLabel();
const cycle = useCycle();
const modules = useModule();
const state = useStates();
const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member);
if (!groupByList || !subGroupByList) return null;
return (
<div className="relative">
<div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
<SubGroupSwimlaneHeader
groupBy={groupBy}
subGroupBy={subGroupBy}
groupList={groupByList}
showEmptyGroup={showEmptyGroup}
getGroupIssueCount={getGroupIssueCount}
/>
</div>
{subGroupBy && (
<SubGroupSwimlane
groupList={subGroupByList}
groupedIssueIds={groupedIssueIds}
displayProperties={displayProperties}
groupBy={groupBy}
subGroupBy={subGroupBy}
orderBy={orderBy}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
showEmptyGroup={showEmptyGroup}
scrollableContainerRef={scrollableContainerRef}
/>
)}
</div>
);
});
interface ISubGroupSwimlaneHeader {
subGroupBy: TIssueGroupByOptions | undefined;
groupBy: TIssueGroupByOptions | undefined;
groupList: IGroupByColumn[];
showEmptyGroup: boolean;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
}
const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => {
let subGroupHeaderVisibility = true;
if (showEmptyGroup) subGroupHeaderVisibility = true;
else {
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
else subGroupHeaderVisibility = false;
}
return subGroupHeaderVisibility;
};
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => (
<div className="relative flex h-max min-h-full w-full items-center gap-2">
{groupList &&
groupList.length > 0 &&
groupList.map((group: IGroupByColumn) => {
const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup);
if (subGroupByVisibilityToggle === false) return <></>;
return (
<div key={`${subGroupBy}_${group.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
<HeaderGroupByCard
groupBy={groupBy}
icon={group.icon}
title={group.name}
count={groupCount}
/>
</div>
);
})}
</div>
)
);
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
showEmptyGroup: boolean;
displayProperties: IIssueDisplayProperties | undefined;
orderBy: TIssueOrderByOptions | undefined;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
}
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
const {
groupedIssueIds,
subGroupBy,
groupBy,
groupList,
displayProperties,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
showEmptyGroup,
scrollableContainerRef,
} = props;
return (
<div className="relative h-max min-h-full w-full">
{groupList &&
groupList.length > 0 &&
groupList.map((group: IGroupByColumn) => (
<SubGroup
key={group.id}
groupedIssueIds={groupedIssueIds}
subGroupBy={subGroupBy}
groupBy={groupBy}
group={group}
displayProperties={displayProperties}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
showEmptyGroup={showEmptyGroup}
scrollableContainerRef={scrollableContainerRef}
/>
))}
</div>
);
});
interface ISubGroup {
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
showEmptyGroup: boolean;
displayProperties: IIssueDisplayProperties | undefined;
groupBy: TIssueGroupByOptions | undefined;
subGroupBy: TIssueGroupByOptions | undefined;
group: IGroupByColumn;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
}
const SubGroup: React.FC<ISubGroup> = observer((props) => {
const {
groupedIssueIds,
subGroupBy,
groupBy,
group,
displayProperties,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
showEmptyGroup,
scrollableContainerRef,
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => {
setIsExpanded((prevState) => !prevState);
};
const visibilitySubGroupBy = (
_list: IGroupByColumn,
subGroupCount: number
): { showGroup: boolean; showIssues: boolean } => {
const subGroupVisibility = {
showGroup: true,
showIssues: true,
};
if (showEmptyGroup) subGroupVisibility.showGroup = true;
else {
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
else subGroupVisibility.showGroup = false;
}
return subGroupVisibility;
};
const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0;
const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount);
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
return (
<>
<div className="flex flex-shrink-0 flex-col">
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
<div className="sticky left-0 flex-shrink-0">
<HeaderSubGroupByCard
icon={group.icon as any}
title={group.name || ""}
count={issueCount}
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
/>
</div>
</div>
{subGroupByVisibilityToggle.showIssues && isExpanded && (
<div className="relative">
<KanBan
groupedIssueIds={groupedIssueIds}
displayProperties={displayProperties}
subGroupBy={subGroupBy}
groupBy={groupBy}
subGroupId={group.id}
showEmptyGroup={showEmptyGroup}
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
/>
</div>
)}
</div>
</>
);
});

View file

@ -0,0 +1,63 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// types
import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
// constants
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store";
import { List } from "./default";
type Props = {
anchor: string;
};
export const IssuesListLayoutRoot = observer((props: Props) => {
const { anchor } = props;
// store hooks
const {
groupedIssueIds: storeGroupedIssueIds,
fetchNextPublicIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
} = useIssue();
const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined;
// auth
const displayProperties: IIssueDisplayProperties = useMemo(
() => ({
key: true,
state: true,
labels: true,
priority: true,
due_date: true,
}),
[]
);
const loadMoreIssues = useCallback(
(groupId?: string) => {
fetchNextPublicIssues(anchor, groupId);
},
[fetchNextPublicIssues]
);
return (
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
<div className={`relative size-full bg-custom-background-90`}>
<List
displayProperties={displayProperties}
groupBy={"state"}
groupedIssueIds={groupedIssueIds ?? {}}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
showEmptyGroup
/>
</div>
</IssueLayoutHOC>
);
});

View file

@ -0,0 +1,92 @@
"use client";
import { useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
// plane types
import { IIssueDisplayProperties } from "@plane/types";
// plane ui
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, usePublish } from "@/hooks/store";
//
import { IssueProperties } from "../properties/all-properties";
interface IssueBlockProps {
issueId: string;
groupId: string;
displayProperties: IIssueDisplayProperties | undefined;
}
export const IssueBlock = observer((props: IssueBlockProps) => {
const { anchor } = useParams();
const { issueId, displayProperties } = props;
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board");
// ref
const issueRef = useRef<HTMLDivElement | null>(null);
// hooks
const { project_details } = usePublish(anchor.toString());
const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails();
const handleIssuePeekOverview = () => {
setPeekId(issueId);
};
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
const issue = getIssueById(issueId);
if (!issue) return null;
const projectIdentifier = project_details?.identifier;
return (
<div
ref={issueRef}
className={cn(
"group/list-block min-h-11 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 hover:bg-custom-background-90 p-3 pl-1.5 text-sm transition-colors border-b border-b-custom-border-200",
{
"border-custom-primary-70": getIsIssuePeeked(issue.id),
"last:border-b-transparent": !getIsIssuePeeked(issue.id),
}
)}
>
<div className="flex w-full truncate">
<div className="flex flex-grow items-center gap-0.5 truncate">
<div className="flex items-center gap-1">
{displayProperties && displayProperties?.key && (
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300 px-4">
{projectIdentifier}-{issue.sequence_id}
</div>
)}
</div>
<Link
id={`issue-${issue.id}`}
href={`?${queryParam}`}
onClick={handleIssuePeekOverview}
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
>
<Tooltip tooltipContent={issue.name} position="top-left">
<p className="truncate">{issue.name}</p>
</Tooltip>
</Link>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<IssueProperties
className="relative flex flex-wrap md:flex-grow md:flex-shrink-0 items-center gap-2 whitespace-nowrap"
issue={issue}
displayProperties={displayProperties}
/>
</div>
</div>
);
});

View file

@ -0,0 +1,25 @@
import { FC, MutableRefObject } from "react";
// types
import { IIssueDisplayProperties } from "@plane/types";
import { IssueBlock } from "./block";
interface Props {
issueIds: string[] | undefined;
groupId: string;
displayProperties?: IIssueDisplayProperties;
containerRef: MutableRefObject<HTMLDivElement | null>;
}
export const IssueBlocksList: FC<Props> = (props) => {
const { issueIds = [], groupId, displayProperties } = props;
return (
<div className="relative h-full w-full">
{issueIds &&
issueIds?.length > 0 &&
issueIds.map((issueId: string) => (
<IssueBlock key={issueId} issueId={issueId} displayProperties={displayProperties} groupId={groupId} />
))}
</div>
);
};

View file

@ -0,0 +1,86 @@
import { useRef } from "react";
import { observer } from "mobx-react";
// types
import {
GroupByColumnTypes,
TGroupedIssues,
IIssueDisplayProperties,
TIssueGroupByOptions,
IGroupByColumn,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
//
import { getGroupByColumns } from "../utils";
import { ListGroup } from "./list-group";
export interface IList {
groupedIssueIds: TGroupedIssues;
groupBy: TIssueGroupByOptions | undefined;
displayProperties: IIssueDisplayProperties | undefined;
showEmptyGroup?: boolean;
loadMoreIssues: (groupId?: string) => void;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
}
export const List: React.FC<IList> = observer((props) => {
const {
groupedIssueIds,
groupBy,
displayProperties,
showEmptyGroup,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
} = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const member = useMember();
const label = useLabel();
const cycle = useCycle();
const modules = useModule();
const state = useStates();
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true);
if (!groupList) return null;
return (
<div className="relative size-full flex flex-col">
{groupList && (
<>
<div
ref={containerRef}
className="size-full vertical-scrollbar scrollbar-lg relative overflow-auto vertical-scrollbar-margin-top-md"
>
{groupList.map((group: IGroupByColumn) => (
<ListGroup
key={group.id}
groupIssueIds={groupedIssueIds?.[group.id]}
groupBy={groupBy}
group={group}
displayProperties={displayProperties}
showEmptyGroup={showEmptyGroup}
loadMoreIssues={loadMoreIssues}
getGroupIssueCount={getGroupIssueCount}
getPaginationData={getPaginationData}
getIssueLoader={getIssueLoader}
containerRef={containerRef}
/>
))}
</div>
</>
)}
</div>
);
});

View file

@ -0,0 +1,34 @@
"use client";
import { observer } from "mobx-react";
import { CircleDashed } from "lucide-react";
interface IHeaderGroupByCard {
groupID: string;
icon?: React.ReactNode;
title: string;
count: number;
toggleListGroup: (id: string) => void;
}
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
const { groupID, icon, title, count, toggleListGroup } = props;
return (
<>
<div
className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2 py-1.5"
onClick={() => toggleListGroup(groupID)}
>
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer">
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,2 @@
export * from "./block";
export * from "./blocks-list";

View file

@ -0,0 +1,133 @@
"use client";
import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane types
import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
//
import { IssueBlocksList } from "./blocks-list";
import { HeaderGroupByCard } from "./headers/group-by-card";
interface Props {
groupIssueIds: string[] | undefined;
group: IGroupByColumn;
groupBy: TIssueGroupByOptions | undefined;
displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>;
showEmptyGroup?: boolean;
loadMoreIssues: (groupId?: string) => void;
getGroupIssueCount: (
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
) => number | undefined;
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
}
// List loader component
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} className="flex items-center justify-between h-11 p-3 border-b border-custom-border-200">
<div className="flex items-center gap-3">
<span className="h-5 w-10 bg-custom-background-80 rounded animate-pulse" />
<span className={`h-5 w-52 bg-custom-background-80 rounded animate-pulse`} />
</div>
<div className="flex items-center gap-2">
{[...Array(6)].map((_, index) => (
<Fragment key={index}>
<span key={index} className="h-5 w-5 bg-custom-background-80 rounded animate-pulse" />
</Fragment>
))}
</div>
</div>
));
ListLoaderItemRow.displayName = "ListLoaderItemRow";
export const ListGroup = observer((props: Props) => {
const {
groupIssueIds = [],
group,
groupBy,
displayProperties,
containerRef,
showEmptyGroup,
loadMoreIssues,
getGroupIssueCount,
getPaginationData,
getIssueLoader,
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const groupRef = useRef<HTMLDivElement | null>(null);
// hooks
const { t } = useTranslation();
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(group.id);
useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);
const shouldLoadMore =
nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds
? groupIssueIds.length < groupIssueCount
: !!nextPageResults;
const loadMore = isPaginating ? (
<ListLoaderItemRow />
) : (
<div
className={
"h-11 relative flex items-center gap-3 bg-custom-background-100 border border-transparent border-t-custom-border-200 pl-6 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
}
onClick={() => loadMoreIssues(group.id)}
>
{t("common.load_more")} &darr;
</div>
);
const validateEmptyIssueGroups = (issueCount: number = 0) => {
if (!showEmptyGroup && issueCount <= 0) return false;
return true;
};
const toggleListGroup = () => {
setIsExpanded((prevState) => !prevState);
};
const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy;
return validateEmptyIssueGroups(groupIssueCount) ? (
<div ref={groupRef} className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`)}>
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pl-2 pr-3 py-1">
<HeaderGroupByCard
groupID={group.id}
icon={group.icon}
title={group.name || ""}
count={groupIssueCount}
toggleListGroup={toggleListGroup}
/>
</div>
{shouldExpand && (
<div className="relative">
{groupIssueIds && (
<IssueBlocksList
issueIds={groupIssueIds}
groupId={group.id}
displayProperties={displayProperties}
containerRef={containerRef}
/>
)}
{shouldLoadMore && (groupBy ? <>{loadMore}</> : <ListLoaderItemRow ref={setIntersectionElement} />)}
</div>
)}
</div>
) : null;
});

View file

@ -0,0 +1,184 @@
"use client";
import { observer } from "mobx-react";
import { Layers, Link, Paperclip } from "lucide-react";
// plane types
import { IIssueDisplayProperties } from "@plane/types";
// plane ui
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// components
import {
IssueBlockDate,
IssueBlockLabels,
IssueBlockPriority,
IssueBlockState,
IssueBlockMembers,
IssueBlockModules,
IssueBlockCycle,
} from "@/components/issues";
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
// helpers
import { getDate } from "@/helpers/date-time.helper";
//// hooks
import { IIssue } from "@/types/issue";
export interface IIssueProperties {
issue: IIssue;
displayProperties: IIssueDisplayProperties | undefined;
className: string;
}
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const { issue, displayProperties, className } = props;
if (!displayProperties || !issue.project_id) return null;
const minDate = getDate(issue.start_date);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
return (
<div className={className}>
{/* basic properties */}
{/* state */}
{issue.state_id && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5">
<IssueBlockState stateId={issue.state_id} />
</div>
</WithDisplayPropertiesHOC>
)}
{/* priority */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5">
<IssueBlockPriority priority={issue.priority} />
</div>
</WithDisplayPropertiesHOC>
{/* label */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
<div className="h-5">
<IssueBlockLabels labelIds={issue.label_ids} />
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
{issue?.start_date && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
<div className="h-5">
<IssueBlockDate
due_date={issue?.start_date}
stateId={issue?.state_id ?? undefined}
shouldHighLight={false}
/>
</div>
</WithDisplayPropertiesHOC>
)}
{/* target/due date */}
{issue?.target_date && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
<div className="h-5">
<IssueBlockDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
</div>
</WithDisplayPropertiesHOC>
)}
{/* assignee */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5">
<IssueBlockMembers memberIds={issue.assignee_ids} />
</div>
</WithDisplayPropertiesHOC>
{/* modules */}
{issue.module_ids && issue.module_ids.length > 0 && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5">
<IssueBlockModules moduleIds={issue.module_ids} />
</div>
</WithDisplayPropertiesHOC>
)}
{/* cycles */}
{issue.cycle_id && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5">
<IssueBlockCycle cycleId={issue.cycle_id} />
</div>
</WithDisplayPropertiesHOC>
)}
{/* estimates */}
{/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
<div className="h-5">
<EstimateDropdown
value={issue.estimate_point ?? undefined}
onChange={handleEstimate}
projectId={issue.project_id}
disabled={isReadOnly}
buttonVariant="border-with-text"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
)} */}
{/* extra render properties */}
{/* sub-issues */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="sub_issue_count"
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count}
>
<Tooltip tooltipHeading="Sub-work items" tooltipContent={`${issue.sub_issues_count}`}>
<div
className={cn(
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
{
"hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count,
}
)}
>
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.sub_issues_count}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
{/* attachments */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="attachment_count"
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
>
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.attachment_count}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
{/* link */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="link"
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
>
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.link_count}</div>
</div>
</Tooltip>
</WithDisplayPropertiesHOC>
</div>
);
});

View file

@ -0,0 +1,36 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { ContrastIcon, Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
//hooks
import { useCycle } from "@/hooks/store/use-cycle";
type Props = {
cycleId: string | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => {
const { getCycleById } = useCycle();
const cycle = getCycleById(cycleId);
return (
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? "No Cycle"}>
<div
className={cn(
"flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs duration-300 focus:outline-none",
{ "border-[0.5px] border-custom-border-300": shouldShowBorder }
)}
>
<div className="flex w-full items-center text-xs gap-1.5">
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
</div>
</div>
</Tooltip>
);
});

Some files were not shown because too many files have changed in this diff Show more