feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 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"

12
apps/space/.eslintignore Normal file
View File

@@ -0,0 +1,12 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test

18
apps/space/.eslintrc.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
rules: {
"no-duplicate-imports": "off",
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
disallowTypeAnnotations: false,
},
],
},
};

40
apps/space/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# 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*
.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,7 @@
.next
.vercel
.tubro
out/
dist/
build/
node_modules/

View File

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

19
apps/space/Dockerfile.dev Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN corepack enable pnpm && pnpm add -g turbo
RUN pnpm install
EXPOSE 3002
ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
VOLUME [ "/app/node_modules", "/app/apps/space/node_modules"]
CMD ["pnpm", "dev", "--filter=space"]

103
apps/space/Dockerfile.space Normal file
View File

@@ -0,0 +1,103 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
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/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
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
RUN pnpm turbo run build --filter=space
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static
COPY --from=installer /app/apps/space/public ./apps/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
CMD ["node", "apps/space/server.js"]

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 type { TProjectPublishSettings } from "@plane/types";
const publishService = new SitesProjectPublishService();
type Props = {
params: {
workspaceSlug: string;
projectId: string;
};
searchParams: Record<"board" | "peekId", string | string[] | undefined>;
};
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) {
console.error("Error fetching project publish settings:", error);
notFound();
}
let url = "";
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
if (board) params.append("board", String(board));
if (peekId) params.append("peekId", String(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/propel/button";
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,65 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PoweredBy } from "@/components/common/powered-by";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
import { IssuesNavbarRoot } from "@/components/issues/navbar";
// hooks
import { usePublish, usePublishList } from "@/hooks/store/publish";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
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 (
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
</div>
);
}
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,57 @@
"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.";
// Validate anchor before using in request (only allow alphanumeric, -, _)
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
if (!ANCHOR_REGEX.test(anchor)) {
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
}
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,39 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
// components
import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useLabel } from "@/hooks/store/use-label";
import { useStates } from "@/hooks/store/use-state";
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 type { 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;

47
apps/space/app/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useSearchParams, useRouter } from "next/navigation";
// plane imports
import { isValidNextPath } from "@plane/utils";
// components
import { UserLoggedIn } from "@/components/account/user-logged-in";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { AuthView } from "@/components/views";
// hooks
import { useUser } from "@/hooks/store/use-user";
const HomePage = observer(() => {
const { data: currentUser, isAuthenticated, isInitializing } = useUser();
const searchParams = useSearchParams();
const router = useRouter();
const nextPath = searchParams.get("next_path");
useEffect(() => {
if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) {
router.replace(nextPath);
}
}, [currentUser, isAuthenticated, nextPath, router]);
if (isInitializing)
return (
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
<LogoSpinner />
</div>
);
if (currentUser && isAuthenticated) {
if (nextPath && isValidNextPath(nextPath)) {
return (
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
<LogoSpinner />
</div>
);
}
return <UserLoggedIn />;
}
return <AuthView />;
});
export default HomePage;

View File

@@ -0,0 +1,29 @@
"use client";
import type { ReactNode, FC } from "react";
import { ThemeProvider } from "next-themes";
// 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 (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<StoreProvider>
<TranslationProvider>
<ToastProvider>
<InstanceProvider>{children}</InstanceProvider>
</ToastProvider>
</TranslationProvider>
</StoreProvider>
</ThemeProvider>
);
};

View File

@@ -0,0 +1,65 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PoweredBy } from "@/components/common/powered-by";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { usePublish, usePublishList } from "@/hooks/store/publish";
// 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 ViewsLayout = 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 (
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
</div>
);
}
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 ViewsLayout;

View File

@@ -0,0 +1,37 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// components
import { PoweredBy } from "@/components/common/powered-by";
// hooks
import { usePublish } from "@/hooks/store/publish";
// plane-web
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
type Props = {
params: {
anchor: string;
};
};
const ViewsPage = 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 ViewsPage;

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 type { TMentionComponentProps } from "@plane/editor";
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;

View File

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

View File

@@ -0,0 +1,9 @@
import { PageNotFound } from "@/components/ui/not-found";
import type { PublishStore } from "@/store/publish/publish.store";
type Props = {
peekId: string | undefined;
publishSettings: PublishStore;
};
export const ViewLayoutsRoot = (_props: Props) => <PageNotFound />;

View File

@@ -0,0 +1,8 @@
import type { 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,35 @@
// editor
import type { TExtensions } from "@plane/editor";
export type TEditorFlaggingHookReturnType = {
document: {
disabled: TExtensions[];
flagged: TExtensions[];
};
liteText: {
disabled: TExtensions[];
flagged: TExtensions[];
};
richText: {
disabled: TExtensions[];
flagged: TExtensions[];
};
};
/**
* @description extensions disabled in various editors
*/
export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({
document: {
disabled: [],
flagged: [],
},
liteText: {
disabled: [],
flagged: [],
},
richText: {
disabled: [],
flagged: [],
},
});

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 type { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import type { 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,55 @@
"use client";
import type { FC } from "react";
// helpers
import { EAuthModes } from "@/types/auth";
type TAuthHeader = {
authMode: EAuthModes;
};
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 } = props;
const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => {
if (mode) {
return Titles[mode];
}
return {
header: "Comment or react to work items",
subHeader: "Use plane to add your valuable inputs to features.",
};
};
const { header, subHeader } = getHeaderSubHeader(authMode);
return (
<>
<div className="flex flex-col gap-1">
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{header}</span>
<span className="text-2xl font-semibold text-custom-text-400 leading-7">{subHeader}</span>
</div>
</>
);
};

View File

@@ -0,0 +1,237 @@
"use client";
import type { FC } from "react";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { SitesAuthService } from "@plane/services";
import type { IEmailCheckData } from "@plane/types";
import { OAuthOptions } from "@plane/ui";
// components
// helpers
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
// assets
import GithubLightLogo from "/public/logos/github-black.png";
import GithubDarkLogo from "/public/logos/github-dark.svg";
import GitlabLogo from "/public/logos/gitlab-logo.svg";
import GoogleLogo from "/public/logos/google-logo.svg";
// local imports
import { TermsAndConditions } from "../terms-and-conditions";
import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { AuthEmailForm } from "./email";
import { AuthPasswordForm } from "./password";
import { AuthUniqueCodeForm } from "./unique-code";
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;
const next_path = searchParams.get("next_path");
// 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 { resolvedTheme } = useTheme();
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;
});
};
const content = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const OAuthConfig = [
{
id: "google",
text: `${content} with Google`,
icon: <Image src={GoogleLogo} height={18} width={18} alt="Google Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_google_enabled,
},
{
id: "github",
text: `${content} with GitHub`,
icon: (
<Image
src={resolvedTheme === "dark" ? GithubLightLogo : GithubDarkLogo}
height={18}
width={18}
alt="GitHub Logo"
/>
),
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_github_enabled,
},
{
id: "gitlab",
text: `${content} with GitLab`,
icon: <Image src={GitlabLogo} height={18} width={18} alt="GitLab Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_gitlab_enabled,
},
];
return (
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
<AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />
)}
<AuthHeader authMode={authMode} />
{isOAuthEnabled && <OAuthOptions options={OAuthConfig} compact={authStep === EAuthSteps.PASSWORD} />}
{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);
}}
/>
)}
<TermsAndConditions isSignUp={authMode === EAuthModes.SIGN_UP ? true : false} />
</div>
</div>
);
});

View File

@@ -0,0 +1,104 @@
"use client";
import type { FC, FormEvent } from "react";
import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { CircleAlert, XCircle } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
import type { IEmailCheckData } from "@plane/types";
// ui
import { 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-custom-text-300 font-medium" htmlFor="email">
Email
</label>
<div
className={cn(
`relative flex items-center rounded-md bg-custom-background-100 border`,
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-custom-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-10 w-full placeholder:text-custom-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-10 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 @@
export * from "./auth-root";

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, E_PASSWORD_STRENGTH } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Input, Spinner, PasswordStrengthIndicator } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// 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 && (
<PasswordStrengthIndicator 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();
if (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-custom-text-300" htmlFor="email">
Email
</label>
<div
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-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-10 w-full placeholder:text-custom-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-custom-text-300 font-medium" htmlFor="password">
{mode === EAuthModes.SIGN_IN ? "Password" : "Set a password"}
</label>
<div className="relative flex items-center rounded-md bg-custom-background-100">
<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-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-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-custom-text-300 font-medium" htmlFor="confirm_password">
Confirm password
</label>
<div className="relative flex items-center rounded-md bg-custom-background-100">
<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-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-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,153 @@
"use client";
import React, { useEffect, useState } from "react";
import { CircleCheck, XCircle } from "lucide-react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { 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-custom-text-300" htmlFor="email">
Email
</label>
<div
className={`relative flex items-center rounded-md bg-custom-background-100 border border-custom-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-10 w-full placeholder:text-custom-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-custom-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-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-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-custom-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,28 @@
"use client";
import type { FC } from "react";
import React 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-custom-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,44 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import { PlaneLockup } from "@plane/propel/icons";
// components
import { PoweredBy } from "@/components/common/powered-by";
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
// hooks
import { useUser } from "@/hooks/store/use-user";
// assets
import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = observer(() => {
// store hooks
const { data: user } = useUser();
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">
<PlaneLockup className="h-6 w-auto text-custom-text-100" />
<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,18 @@
"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" ? LogoSpinnerLight : LogoSpinnerDark;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
</div>
);
};

View File

@@ -0,0 +1,31 @@
"use client";
import type { FC } from "react";
import { WEBSITE_URL } from "@plane/constants";
// assets
import { PlaneLogo } from "@plane/propel/icons";
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"
>
<PlaneLogo className="h-3 w-auto text-custom-text-100" />
<div className="text-xs">
Powered by <span className="font-semibold">Plane Publish</span>
</div>
</a>
);
};

View File

@@ -0,0 +1,34 @@
// types
import type { 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 "./root";

View File

@@ -0,0 +1,17 @@
// plane editor
import type { 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,40 @@
import { observer } from "mobx-react";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/use-user";
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,90 @@
import React from "react";
// plane imports
import { LiteTextEditorWithRef } from "@plane/editor";
import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor";
import type { MakeOptional } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
// local imports
import { EditorMentionsRoot } from "./embeds/mentions";
import { IssueCommentToolbar } from "./toolbar";
type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions"
> & {
anchor: string;
isSubmitting?: boolean;
showSubmitButton?: boolean;
workspaceId: string;
} & (
| {
editable: false;
}
| {
editable: true;
uploadFile: TFileHandler["upload"];
}
);
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
const {
anchor,
containerClassName,
disabledExtensions: additionalDisabledExtensions = [],
editable,
isSubmitting = false,
showSubmitButton = true,
workspaceId,
...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;
const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);
return (
<div className="border border-custom-border-200 rounded p-3 space-y-3">
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
flaggedExtensions={liteTextEditorExtensions.flagged}
editable={editable}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile: editable ? props.uploadFile : async () => "",
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
}}
extendedEditorProps={{}}
{...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,69 @@
import React, { forwardRef } from "react";
// plane imports
import { RichTextEditorWithRef } from "@plane/editor";
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
import type { MakeOptional } from "@plane/types";
// helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useMember } from "@/hooks/store/use-member";
// plane web imports
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
// local imports
import { EditorMentionsRoot } from "./embeds/mentions";
type RichTextEditorWrapperProps = MakeOptional<
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions"
> & {
anchor: string;
workspaceId: string;
} & (
| {
editable: false;
}
| {
editable: true;
uploadFile: TFileHandler["upload"];
}
);
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
const {
anchor,
containerClassName,
editable,
workspaceId,
disabledExtensions: additionalDisabledExtensions = [],
...rest
} = props;
const { getMemberById } = useMember();
const { richText: richTextEditorExtensions } = useEditorFlagging(anchor);
return (
<RichTextEditorWithRef
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({
display_name: getMemberById(id)?.member__display_name ?? "",
}),
}}
ref={ref}
disabledExtensions={[...richTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile: editable ? props.uploadFile : async () => "",
workspaceId,
})}
flaggedExtensions={richTextEditorExtensions.flagged}
extendedEditorProps={{}}
{...rest}
containerClassName={containerClassName}
editorClassName="min-h-[100px] py-2 overflow-hidden"
displayConfig={{ fontSize: "large-font" }}
/>
);
});
RichTextEditor.displayName = "RichTextEditor";

View File

@@ -0,0 +1,116 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
// plane imports
import { TOOLBAR_ITEMS } from "@plane/editor";
import type { ToolbarMenuItem, EditorRefApi } from "@plane/editor";
import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip";
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,39 @@
"use client";
import type { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/propel/button";
// 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,74 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// types
import { useTranslation } from "@plane/i18n";
import type { 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 ?? []) as TFilters["priority"]}
/>
)}
{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 type { 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,34 @@
"use client";
import { X } from "lucide-react";
import { PriorityIcon } from "@plane/propel/icons";
import type { TIssuePriorities } from "@plane/propel/icons";
type Props = {
handleRemove: (val: string) => void;
values: TIssuePriorities[];
};
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} 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,103 @@
"use client";
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// hooks
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
// store
import type { TIssueLayout, 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 ?? []);
const params: {
board: TIssueLayout | string;
priority?: string;
states?: string;
labels?: string;
} = {
board: activeLayout || "list",
};
if (priority.length > 0) params.priority = priority.join(",");
if (state.length > 0) params.states = state.join(",");
if (labels.length > 0) params.labels = labels.join(",");
const qs = new URLSearchParams(params).toString();
router.push(`/issues/${anchor}?${qs}`);
},
[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/propel/icons";
// hooks
import { useStates } from "@/hooks/store/use-state";
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 type { Placement } from "@popperjs/core";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/propel/button";
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 @@
export * from "./root";

View File

@@ -0,0 +1,85 @@
"use client";
import React, { useState } from "react";
// plane imports
import { Loader } from "@plane/ui";
// types
import type { IIssueLabel } from "@/types/issue";
// local imports
import { FilterHeader } from "./helpers/filter-header";
import { FilterOption } from "./helpers/filter-option";
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";
// plane imports
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
// local imports
import { FilterHeader } from "./helpers/filter-header";
import { FilterOption } from "./helpers/filter-option";
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,72 @@
"use client";
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
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/use-issue-filter";
// types
import type { 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,82 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// types
import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
// local imports
import { FilterPriority } from "./priority";
import { FilterState } from "./state";
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,85 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import { Loader } from "@plane/ui";
// hooks
import { useStates } from "@/hooks/store/use-state";
// local imports
import { FilterHeader } from "./helpers/filter-header";
import { FilterOption } from "./helpers/filter-option";
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,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 @@
export * from "./root";

View File

@@ -0,0 +1,35 @@
import { observer } from "mobx-react";
// plane imports
import type { TLoader } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
interface Props {
children: string | React.ReactNode | React.ReactNode[];
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-es";
import { observer } from "mobx-react";
// types
import type { IIssueDisplayProperties } from "@plane/types";
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
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);
}
},
[anchor, getIssueLoader, 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,46 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane utils
import { cn } from "@plane/utils";
// components
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
// hooks
import { usePublish } from "@/hooks/store/publish";
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,110 @@
"use client";
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
// plane types
import { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
// 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 { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
//
import type { 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 type { MutableRefObject } from "react";
import { observer } from "mobx-react";
//types
import type { 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,127 @@
import type { MutableRefObject } from "react";
import { isNil } from "lodash-es";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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,36 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Circle } from "lucide-react";
// types
import type { 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,36 @@
import type { FC } from "react";
import React 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,117 @@
"use client";
import type { MutableRefObject } from "react";
import { forwardRef, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
//types
import type {
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
// local imports
import { KanbanIssueBlocksList } from "./blocks-list";
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 type { MutableRefObject } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TIssueOrderByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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 type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
// constants
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
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);
},
[anchor, 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,93 @@
"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 { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
// plane ui
// plane utils
import { cn } from "@plane/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
//
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-start">
<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 type { FC, MutableRefObject } from "react";
// types
import type { 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,90 @@
import { useRef } from "react";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
TGroupedIssues,
IIssueDisplayProperties,
TIssueGroupByOptions,
IGroupByColumn,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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,140 @@
"use client";
import type { MutableRefObject } from "react";
import { Fragment, forwardRef, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane types
import type {
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,181 @@
"use client";
import { observer } from "mobx-react";
import { Link, Paperclip } from "lucide-react";
import { ViewsIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
// helpers
import { getDate } from "@/helpers/date-time.helper";
//// hooks
import type { IIssue } from "@/types/issue";
import { IssueBlockCycle } from "./cycle";
import { IssueBlockDate } from "./due-date";
import { IssueBlockLabels } from "./labels";
import { IssueBlockMembers } from "./member";
import { IssueBlockModules } from "./modules";
import { IssueBlockPriority } from "./priority";
import { IssueBlockState } from "./state";
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,
}
)}
>
<ViewsIcon 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,37 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { CycleIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// 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">
<CycleIcon className="h-3 w-3 flex-shrink-0" />
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
</div>
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,41 @@
"use client";
import { observer } from "mobx-react";
import { CalendarCheck2 } from "lucide-react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
due_date: string | undefined;
stateId: string | undefined;
shouldHighLight?: boolean;
shouldShowBorder?: boolean;
};
export const IssueBlockDate = observer((props: Props) => {
const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props;
const { getStateById } = useStates();
const state = getStateById(stateId);
const formattedDate = renderFormattedDate(due_date);
return (
<Tooltip tooltipHeading="Due Date" tooltipContent={formattedDate}>
<div
className={cn("flex h-full items-center gap-1 rounded px-2.5 py-1 text-xs text-custom-text-100", {
"text-red-500": shouldHighLight && due_date && shouldHighlightIssueDueDate(due_date, state?.group),
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<CalendarCheck2 className="size-3 flex-shrink-0" />
{formattedDate ? formattedDate : "No Date"}
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,72 @@
"use client";
import { observer } from "mobx-react";
import { Tags } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
// hooks
import { useLabel } from "@/hooks/store/use-label";
type Props = {
labelIds: string[];
shouldShowLabel?: boolean;
};
export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => {
const { getLabelsByIds } = useLabel();
const labels = getLabelsByIds(labelIds);
const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels";
if (labels.length <= 0)
return (
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
<div
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs border-[0.5px] border-custom-border-300`}
>
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
{shouldShowLabel && <span>No Labels</span>}
</div>
</Tooltip>
);
return (
<div className="flex h-5 w-full flex-wrap items-center gap-2 overflow-hidden">
{labels.length <= 2 ? (
<>
{labels.map((label) => (
<Tooltip key={label.id} position="top" tooltipHeading="Labels" tooltipContent={label?.name ?? ""}>
<div
key={label?.id}
className={`flex overflow-hidden h-full max-w-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs`}
>
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color ?? "#000000",
}}
/>
<div className="line-clamp-1 inline-block w-auto max-w-[100px] truncate">{label?.name}</div>
</div>
</div>
</Tooltip>
))}
</>
) : (
<div
className={`flex h-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs cursor-not-allowed"
`}
>
<Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelsString}>
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
{`${labels.length} Labels`}
</div>
</Tooltip>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,76 @@
"use client";
import { observer } from "mobx-react";
// icons
import type { LucideIcon } from "lucide-react";
import { Users } from "lucide-react";
// plane ui
import { Avatar, AvatarGroup } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
//
import type { TPublicMember } from "@/types/member";
type Props = {
memberIds: string[];
shouldShowBorder?: boolean;
};
type AvatarProps = {
showTooltip: boolean;
members: TPublicMember[];
icon?: LucideIcon;
};
export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps) => {
const { showTooltip, members, icon: Icon } = props;
if (Array.isArray(members)) {
if (members.length > 1) {
return (
<AvatarGroup size="md" showTooltip={!showTooltip}>
{members.map((member) => {
if (!member) return;
return <Avatar key={member.id} src={member.member__avatar} name={member.member__display_name} />;
})}
</AvatarGroup>
);
} else if (members.length === 1) {
return (
<Avatar
src={members[0].member__avatar}
name={members[0].member__display_name}
size="md"
showTooltip={!showTooltip}
/>
);
}
}
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
});
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
const { getMembersByIds } = useMember();
const members = getMembersByIds(memberIds);
return (
<div className="relative h-full flex flex-wrap items-center gap-1">
<div
className={cn("flex flex-shrink-0 cursor-default items-center rounded-md text-xs", {
"border-[0.5px] border-custom-border-300 px-2.5 py-1": shouldShowBorder && !members?.length,
})}
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<ButtonAvatars members={members} showTooltip={false} />
{!shouldShowBorder && members.length <= 1 && (
<span>{members?.[0]?.member__display_name ?? "No Assignees"}</span>
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,49 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { ModuleIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useModule } from "@/hooks/store/use-module";
type Props = {
moduleIds: string[] | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => {
const { getModulesByIds } = useModule();
const modules = getModulesByIds(moduleIds ?? []);
const modulesString = modules.map((module) => module.name).join(", ");
return (
<div className="relative flex h-full flex-wrap items-center gap-1">
<Tooltip tooltipHeading="Modules" tooltipContent={modulesString}>
{modules.length <= 1 ? (
<div
key={modules?.[0]?.id}
className={cn("flex h-full flex-shrink-0 cursor-default items-center rounded-md px-2.5 py-1 text-xs", {
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<ModuleIcon className="h-3 w-3 flex-shrink-0" />
<div className="text-xs">{modules?.[0]?.name ?? "No Modules"}</div>
</div>
</div>
) : (
<div className="flex h-full flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs">
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="text-xs">{modules.length} Modules</div>
</div>
</div>
)}
</Tooltip>
</div>
);
});

View File

@@ -0,0 +1,68 @@
"use client";
import { SignalHigh } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssuePriorities } from "@plane/types";
// constants
import { cn, getIssuePriorityFilters } from "@plane/utils";
export const IssueBlockPriority = ({
priority,
shouldShowName = false,
}: {
priority: TIssuePriorities | null;
shouldShowName?: boolean;
}) => {
// hooks
const { t } = useTranslation();
const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null;
const priorityClasses = {
urgent: "bg-red-600/10 text-red-600 border-red-600 px-1",
high: "bg-orange-500/20 text-orange-950 border-orange-500",
medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500",
low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100",
none: "hover:bg-custom-background-80 border-custom-border-300",
};
if (priority_detail === null) return <></>;
return (
<Tooltip tooltipHeading="Priority" tooltipContent={t(priority_detail?.titleTranslationKey || "")}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": !shouldShowName,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-600/10 border-red-600": priority === "urgent" && shouldShowName,
}
)}
>
{priority ? (
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": !shouldShowName,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": !shouldShowName && priority === "high",
"translate-x-0.5": !shouldShowName && priority === "medium",
"translate-x-1": !shouldShowName && priority === "low",
// highlight the icon if priority is urgent
})}
/>
) : (
<SignalHigh className="size-3" />
)}
{shouldShowName && <span className="pl-2 text-sm">{t(priority_detail?.titleTranslationKey || "")}</span>}
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
//hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
stateId: string | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => {
const { getStateById } = useStates();
const state = getStateById(stateId);
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
<div
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
<div className="text-xs">{state?.name ?? "State"}</div>
</div>
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,78 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
// store
import type { PublishStore } from "@/store/publish/publish.store";
// local imports
import { SomethingWentWrongError } from "./error";
import { IssueKanbanLayoutRoot } from "./kanban/base-kanban-root";
import { IssuesListLayoutRoot } from "./list/base-list-root";
type Props = {
peekId: string | undefined;
publishSettings: PublishStore;
};
export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
const { peekId, publishSettings } = props;
// store hooks
const { getIssueFilters } = useIssueFilter();
const { fetchPublicIssues } = useIssue();
const issueDetailStore = useIssueDetails();
// derived values
const { anchor } = publishSettings;
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
// derived values
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const { error } = useSWR(
anchor ? `PUBLIC_ISSUES_${anchor}` : null,
anchor
? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 })
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useEffect(() => {
if (peekId) {
issueDetailStore.setPeekId(peekId.toString());
}
}, [peekId, issueDetailStore]);
if (!anchor) return null;
if (error) return <SomethingWentWrongError />;
return (
<div className="relative h-full w-full overflow-hidden">
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
{activeLayout && (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{/* applied filters */}
<IssueAppliedFilters anchor={anchor} />
{activeLayout === "list" && (
<div className="relative h-full w-full overflow-y-auto">
<IssuesListLayoutRoot anchor={anchor} />
</div>
)}
{activeLayout === "kanban" && (
<div className="relative mx-auto h-full w-full p-5">
<IssueKanbanLayoutRoot anchor={anchor} />
</div>
)}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,240 @@
"use client";
import { isNil } from "lodash-es";
// types
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type {
GroupByColumnTypes,
IGroupByColumn,
TCycleGroups,
IIssueDisplayProperties,
TGroupedIssues,
} from "@plane/types";
// ui
import { Avatar } from "@plane/ui";
// components
// constants
// stores
import type { ICycleStore } from "@/store/cycle.store";
import type { IIssueLabelStore } from "@/store/label.store";
import type { IIssueMemberStore } from "@/store/members.store";
import type { IIssueModuleStore } from "@/store/module.store";
import type { IStateStore } from "@/store/state.store";
export const HIGHLIGHT_CLASS = "highlight";
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
export const getGroupByColumns = (
groupBy: GroupByColumnTypes | null,
cycle: ICycleStore,
module: IIssueModuleStore,
label: IIssueLabelStore,
projectState: IStateStore,
member: IIssueMemberStore,
includeNone?: boolean
): IGroupByColumn[] | undefined => {
switch (groupBy) {
case "cycle":
return getCycleColumns(cycle);
case "module":
return getModuleColumns(module);
case "state":
return getStateColumns(projectState);
case "priority":
return getPriorityColumns();
case "labels":
return getLabelsColumns(label) as any;
case "assignees":
return getAssigneeColumns(member) as any;
case "created_by":
return getCreatedByColumns(member) as any;
default:
if (includeNone) return [{ id: `All Issues`, name: `All work items`, payload: {}, icon: undefined }];
}
};
const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => {
const { cycles } = cycleStore;
if (!cycles) return;
const cycleGroups: IGroupByColumn[] = [];
cycles.map((cycle) => {
if (cycle) {
const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
cycleGroups.push({
id: cycle.id,
name: cycle.name,
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
payload: { cycle_id: cycle.id },
});
}
});
cycleGroups.push({
id: "None",
name: "None",
icon: <CycleIcon className="h-3.5 w-3.5" />,
payload: { cycle_id: null },
});
return cycleGroups;
};
const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => {
const { modules } = moduleStore;
if (!modules) return;
const moduleGroups: IGroupByColumn[] = [];
modules.map((moduleInfo) => {
if (moduleInfo)
moduleGroups.push({
id: moduleInfo.id,
name: moduleInfo.name,
icon: <ModuleIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [moduleInfo.id] },
});
}) as any;
moduleGroups.push({
id: "None",
name: "None",
icon: <ModuleIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [] },
});
return moduleGroups as any;
};
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
const { sortedStates } = projectState;
if (!sortedStates) return;
return sortedStates.map((state) => ({
id: state.id,
name: state.name,
icon: (
<div className="h-3.5 w-3.5 rounded-full">
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.MD} />
</div>
),
payload: { state_id: state.id },
})) as any;
};
const getPriorityColumns = () => {
const priorities = ISSUE_PRIORITIES;
return priorities.map((priority) => ({
id: priority.key,
name: priority.title,
icon: <PriorityIcon priority={priority?.key} />,
payload: { priority: priority.key },
}));
};
const getLabelsColumns = (label: IIssueLabelStore) => {
const { labels: storeLabels } = label;
if (!storeLabels) return;
const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }];
return labels.map((label) => ({
id: label.id,
name: label.name,
icon: (
<div className="h-[12px] w-[12px] rounded-full" style={{ backgroundColor: label.color ? label.color : "#666" }} />
),
payload: label?.id === "None" ? {} : { label_ids: [label.id] },
}));
};
const getAssigneeColumns = (member: IIssueMemberStore) => {
const { members } = member;
if (!members) return;
const assigneeColumns: any = members.map((member) => ({
id: member.id,
name: member?.member__display_name || "",
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
payload: { assignee_ids: [member.id] },
}));
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
return assigneeColumns;
};
const getCreatedByColumns = (member: IIssueMemberStore) => {
const { members } = member;
if (!members) return;
return members.map((member) => ({
id: member.id,
name: member?.member__display_name || "",
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
payload: {},
}));
};
export const getDisplayPropertiesCount = (
displayProperties: IIssueDisplayProperties,
ignoreFields?: (keyof IIssueDisplayProperties)[]
) => {
const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[];
let count = 0;
for (const propertyKey of propertyKeys) {
if (ignoreFields && ignoreFields.includes(propertyKey)) continue;
if (displayProperties[propertyKey]) count++;
}
return count;
};
export const getIssueBlockId = (
issueId: string | undefined,
groupId: string | undefined,
subGroupId?: string | undefined
) => `issue_${issueId}_${groupId}_${subGroupId}`;
/**
* returns empty Array if groupId is None
* @param groupId
* @returns
*/
export const getGroupId = (groupId: string) => {
if (groupId === "None") return [];
return [groupId];
};
/**
* method that removes Null or undefined Keys from object
* @param obj
* @returns
*/
export const removeNillKeys = <T,>(obj: T) =>
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
/**
* This Method returns if the the grouped values are subGrouped
* @param groupedIssueIds
* @returns
*/
export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => {
if (!groupedIssueIds || Array.isArray(groupedIssueIds)) {
return false;
}
if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) {
return false;
}
return true;
};

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import type { IIssueDisplayProperties } from "@plane/types";
interface IWithDisplayPropertiesHOC {
displayProperties: IIssueDisplayProperties;
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
children: ReactNode;
}
export const WithDisplayPropertiesHOC = observer(
({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
let shouldDisplayPropertyFromFilters = false;
if (Array.isArray(displayPropertyKey))
shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]);
else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey];
const renderProperty =
shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true);
if (!renderProperty) return null;
return <>{children}</>;
}
);

View File

@@ -0,0 +1,127 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
// components
import { IssueFiltersDropdown } from "@/components/issues/filters";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
import useIsInIframe from "@/hooks/use-is-in-iframe";
// store
import type { PublishStore } from "@/store/publish/publish.store";
// types
import type { TIssueLayout } from "@/types/issue";
// local imports
import { IssuesLayoutSelection } from "./layout-selection";
import { NavbarTheme } from "./theme";
import { UserAvatar } from "./user-avatar";
export type NavbarControlsProps = {
publishSettings: PublishStore;
};
export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
// props
const { publishSettings } = props;
// router
const router = useRouter();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const labels = searchParams.get("labels") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const peekId = searchParams.get("peekId") || undefined;
// hooks
const { getIssueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter();
const { setPeekId } = useIssueDetails();
// derived values
const { anchor, view_props, workspace_detail } = publishSettings;
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const isInIframe = useIsInIframe();
useEffect(() => {
if (anchor && workspace_detail) {
const viewsAcceptable: string[] = [];
let currentBoard: TIssueLayout | null = null;
if (view_props?.list) viewsAcceptable.push("list");
if (view_props?.kanban) viewsAcceptable.push("kanban");
if (view_props?.calendar) viewsAcceptable.push("calendar");
if (view_props?.gantt) viewsAcceptable.push("gantt");
if (view_props?.spreadsheet) viewsAcceptable.push("spreadsheet");
if (board) {
if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout;
else {
if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout;
}
} else {
if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout;
}
if (currentBoard) {
if (activeLayout === undefined || activeLayout !== currentBoard) {
const { query, queryParam } = queryParamGenerator({ board: currentBoard, peekId, priority, state, labels });
const params: any = {
display_filters: { layout: (query?.board as string[])[0] },
filters: {
priority: query?.priority ?? undefined,
state: query?.state ?? undefined,
labels: query?.labels ?? undefined,
},
};
if (!isIssueFiltersUpdated(anchor, params)) {
initIssueFilters(anchor, params);
router.push(`/issues/${anchor}?${queryParam}`);
}
}
}
}
}, [
anchor,
board,
labels,
state,
priority,
peekId,
activeLayout,
router,
initIssueFilters,
setPeekId,
isIssueFiltersUpdated,
view_props,
workspace_detail,
]);
if (!anchor) return null;
return (
<>
{/* issue views */}
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
<IssuesLayoutSelection anchor={anchor} />
</div>
{/* issue filters */}
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
<IssueFiltersDropdown anchor={anchor} />
</div>
{/* theming */}
<div className="relative flex-shrink-0">
<NavbarTheme />
</div>
{!isInIframe && <UserAvatar />}
</>
);
});

View File

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

View File

@@ -0,0 +1,14 @@
import type { LucideProps } from "lucide-react";
import { List, Kanban } from "lucide-react";
import type { TIssueLayout } from "@plane/constants";
export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => {
switch (layout) {
case "list":
return <List {...props} />;
case "kanban":
return <Kanban {...props} />;
default:
return null;
}
};

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
// ui
import { SITES_ISSUE_LAYOUTS } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
// mobx
import type { TIssueLayout } from "@/types/issue";
import { IssueLayoutIcon } from "./layout-icon";
type Props = {
anchor: string;
};
export const IssuesLayoutSelection: FC<Props> = observer((props) => {
const { anchor } = props;
// hooks
const { t } = useTranslation();
// router
const router = useRouter();
const searchParams = useSearchParams();
// query params
const labels = searchParams.get("labels");
const state = searchParams.get("state");
const priority = searchParams.get("priority");
const peekId = searchParams.get("peekId");
// hooks
const { layoutOptions, getIssueFilters, updateIssueFilters } = useIssueFilter();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const handleCurrentBoardView = (boardView: TIssueLayout) => {
updateIssueFilters(anchor, "display_filters", "layout", boardView);
const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels });
router.push(`/issues/${anchor}?${queryParam}`);
};
return (
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{SITES_ISSUE_LAYOUTS.map((layout) => {
if (!layoutOptions[layout.key]) return;
return (
<Tooltip key={layout.key} tooltipContent={t(layout.titleTranslationKey)}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
activeLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => handleCurrentBoardView(layout.key)}
>
<IssueLayoutIcon
layout={layout.key}
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
/>
</button>
</Tooltip>
);
})}
</div>
);
});

View File

@@ -0,0 +1,45 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { ProjectIcon } from "@plane/propel/icons";
// components
import { ProjectLogo } from "@/components/common/project-logo";
// store
import type { PublishStore } from "@/store/publish/publish.store";
// local imports
import { NavbarControls } from "./controls";
type Props = {
publishSettings: PublishStore;
};
export const IssuesNavbarRoot: FC<Props> = observer((props) => {
const { publishSettings } = props;
// hooks
const { project_details } = publishSettings;
return (
<div className="relative flex justify-between w-full gap-4 px-5">
{/* project detail */}
<div className="flex flex-shrink-0 items-center gap-2">
{project_details ? (
<span className="h-7 w-7 flex-shrink-0 grid place-items-center">
<ProjectLogo logo={project_details.logo_props} className="text-lg" />
</span>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<ProjectIcon className="h-4 w-4" />
</span>
)}
<div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium">
{project_details?.name || `...`}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<NavbarControls publishSettings={publishSettings} />
</div>
</div>
);
});

View File

@@ -0,0 +1,33 @@
"use client";
// next theme
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// mobx react lite
export const NavbarTheme = observer(() => {
const [appTheme, setAppTheme] = useState("light");
const { setTheme, theme } = useTheme();
const handleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
useEffect(() => {
if (!theme) return;
setAppTheme(theme);
}, [theme]);
return (
<button
type="button"
onClick={handleTheme}
className="relative grid h-7 w-7 place-items-center rounded bg-custom-background-100 text-custom-text-100 hover:bg-custom-background-80"
>
<span className="material-symbols-rounded text-sm">{appTheme === "light" ? "dark_mode" : "light_mode"}</span>
</button>
);
});

View File

@@ -0,0 +1,128 @@
"use client";
import type { FC } from "react";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { usePopper } from "react-popper";
import { LogOut } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useUser } from "@/hooks/store/use-user";
const authService = new AuthService();
export const UserAvatar: FC = observer(() => {
const pathName = usePathname();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const labels = searchParams.get("labels") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const peekId = searchParams.get("peekId") || undefined;
// hooks
const { data: currentUser, signOut } = useUser();
// states
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-end",
modifiers: [
{
name: "offset",
options: {
offset: [0, 40],
},
},
],
});
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
return (
<div className="relative mr-2">
{currentUser?.id ? (
<div>
<Popover as="div">
<Popover.Button as={Fragment}>
<button
ref={setReferenceElement}
className="flex items-center gap-2 rounded border border-custom-border-200 p-2"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url)}
shape="square"
size="sm"
showTooltip={false}
/>
<h6 className="text-xs font-medium">
{currentUser?.display_name ||
`${currentUser?.first_name} ${currentUser?.first_name}` ||
currentUser?.email ||
"User"}
</h6>
</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 p-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{csrfToken && (
<form method="POST" action={`${API_BASE_URL}/auth/spaces/sign-out/`} onSubmit={signOut}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="next_path" value={`${pathName}?${queryParam}`} />
<button
type="submit"
className="flex items-center gap-2 rounded p-2 whitespace-nowrap hover:bg-custom-background-80 text-sm min-w-36 cursor-pointer"
>
<LogOut size={12} className="flex-shrink-0 text-red-500" />
<div>Sign out</div>
</button>
</form>
)}
</div>
</Popover.Panel>
</Transition>
</Popover>
</div>
) : (
<div className="flex-shrink-0">
<Link href={`/?next_path=${pathName}?${queryParam}`}>
<Button variant="outline-primary">Sign in</Button>
</Link>
</div>
)}
</div>
);
});

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