diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml new file mode 100644 index 000000000..3d42f2ecd --- /dev/null +++ b/.github/workflows/build-aio-base.yml @@ -0,0 +1,91 @@ +name: Build AIO Base Image + +on: + workflow_dispatch: + +env: + TARGET_BRANCH: ${{ github.ref_name }} + +jobs: + base_build_setup: + name: Build Preparation + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_base: ${{ steps.changed_files.outputs.base_any_changed }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + base: + - aio/Dockerfile.base + + base_build_push: + if: ${{ needs.base_build_setup.outputs.build_base == 'true' || github.event_name == 'workflow_dispatch' || needs.base_build_setup.outputs.gh_branch_name == 'master' }} + runs-on: ubuntu-latest + needs: [base_build_setup] + env: + BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set Docker Tag + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-aio-base:latest + else + TAG=${{ env.BASE_IMG_TAG }} + fi + echo "BASE_IMG_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./aio + file: ./aio/Dockerfile.base + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.BASE_IMG_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 13c7ca221..0ccccda5f 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -14,7 +14,7 @@ env: jobs: branch_build_setup: - name: Build-Push Web/Space/API/Proxy Docker Image + name: Build Setup runs-on: ubuntu-latest outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} @@ -85,7 +85,7 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -95,9 +95,9 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} + TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest + TAG=makeplane/plane-frontend:latest else TAG=${{ env.FRONTEND_TAG }} fi @@ -137,7 +137,7 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} + ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -147,9 +147,9 @@ jobs: - name: Set Admin Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }} + TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest + TAG=makeplane/plane-admin:latest else TAG=${{ env.ADMIN_TAG }} fi @@ -189,7 +189,7 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} + SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -199,9 +199,9 @@ jobs: - name: Set Space Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} + TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest + TAG=makeplane/plane-space:latest else TAG=${{ env.SPACE_TAG }} fi @@ -241,7 +241,7 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -251,9 +251,9 @@ jobs: - name: Set Backend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} + TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest + TAG=makeplane/plane-backend:latest else TAG=${{ env.BACKEND_TAG }} fi @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} + PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -303,9 +303,9 @@ jobs: - name: Set Proxy Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} + TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest + TAG=makeplane/plane-proxy:latest else TAG=${{ env.PROXY_TAG }} fi diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 5b94b215a..2e6f9c642 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -3,10 +3,11 @@ name: Build and Lint on Pull Request on: workflow_dispatch: pull_request: - types: ["opened", "synchronize"] + types: ["opened", "synchronize", "ready_for_review"] jobs: get-changed-files: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest outputs: apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index c195f8423..24c4fb995 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -29,7 +29,7 @@ jobs: else echo "MATCH=false" >> $GITHUB_OUTPUT fi - Auto_Merge: + Create_PR: if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} needs: [Check_Branch] runs-on: ubuntu-latest diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/repo-sync.yml index 9ac4771ef..665a7dbe2 100644 --- a/.github/workflows/repo-sync.yml +++ b/.github/workflows/repo-sync.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - preview + - develop env: SOURCE_BRANCH_NAME: ${{ github.ref_name }} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ec01b2a55..000000000 --- a/Dockerfile +++ /dev/null @@ -1,124 +0,0 @@ -FROM node:18-alpine AS builder -RUN apk add --no-cache libc6-compat -# Set working directory -WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER - -RUN yarn global add turbo -RUN apk add tree -COPY . . - -RUN turbo prune --scope=app --scope=plane-deploy --docker -CMD tree -I node_modules/ - -# Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer - -RUN apk add --no-cache libc6-compat -WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -# First install the dependencies (as they change less often) -COPY .gitignore .gitignore -COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install - -# # Build the project -COPY --from=builder /app/out/full/ . -COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ - -RUN chmod +x /usr/local/bin/replace-env-vars.sh - -RUN yarn turbo run build - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} - -FROM python:3.11.1-alpine3.17 AS backend - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - -WORKDIR /code - -RUN apk --no-cache add \ - "libpq~=15" \ - "libxslt~=1.1" \ - "nodejs-current~=19" \ - "xmlsec~=1.2" \ - "nginx" \ - "nodejs" \ - "npm" \ - "supervisor" - -COPY apiserver/requirements.txt ./ -COPY apiserver/requirements ./requirements -RUN apk add --no-cache libffi-dev -RUN apk add --no-cache --virtual .build-deps \ - "bash~=5.2" \ - "g++~=12.2" \ - "gcc~=12.2" \ - "cargo~=1.64" \ - "git~=2" \ - "make~=4.3" \ - "postgresql13-dev~=13" \ - "libc-dev" \ - "linux-headers" \ - && \ - pip install -r requirements.txt --compile --no-cache-dir \ - && \ - apk del .build-deps - -# Add in Django deps and generate Django's static files -COPY apiserver/manage.py manage.py -COPY apiserver/plane plane/ -COPY apiserver/templates templates/ - -RUN apk --no-cache add "bash~=5.2" -COPY apiserver/bin ./bin/ - -RUN chmod +x ./bin/* -RUN chmod -R 777 /code - -# Expose container port and run entry point script - -WORKDIR /app - -COPY --from=installer /app/apps/app/next.config.js . -COPY --from=installer /app/apps/app/package.json . -COPY --from=installer /app/apps/space/next.config.js . -COPY --from=installer /app/apps/space/package.json . - -COPY --from=installer /app/apps/app/.next/standalone ./ - -COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static - -COPY --from=installer /app/apps/space/.next/standalone ./ -COPY --from=installer /app/apps/space/.next ./apps/space/.next - -ENV NEXT_TELEMETRY_DISABLED 1 - -# RUN rm /etc/nginx/conf.d/default.conf -####################################################################### -COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf -####################################################################### - -COPY nginx/supervisor.conf /code/supervisor.conf - -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -COPY replace-env-vars.sh /usr/local/bin/ -COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh -RUN chmod +x /usr/local/bin/start.sh - -EXPOSE 80 - -CMD ["supervisord","-c","/code/supervisor.conf"] diff --git a/README.md b/README.md index 38ead5f99..e465f3cb1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. -If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose). +If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview). | Installation methods | Docs link | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index cec5c0748..510566e80 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 75c76e7a5..e9359a68b 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { FC, useState } from "react"; import isEmpty from "lodash/isEmpty"; import Link from "next/link"; diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx index fd2e7c73c..42dd15e4d 100644 --- a/admin/app/authentication/google/form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC, useState } from "react"; import isEmpty from "lodash/isEmpty"; import Link from "next/link"; diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d1e6fb0ba..c44b74b49 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -7,12 +7,12 @@ import { useTheme } from "next-themes"; import useSWR from "swr"; import { Mails, KeyRound } from "lucide-react"; import { TInstanceConfigurationKeys } from "@plane/types"; -import { Loader, setPromiseToast } from "@plane/ui"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; // hooks // helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { cn, resolveGeneralTheme } from "@/helpers/common.helper"; import { useInstance } from "@/hooks/store"; // images import githubLightModeImage from "@/public/logos/github-black.png"; @@ -45,6 +45,8 @@ const InstanceAuthenticationPage = observer(() => { const [isSubmitting, setIsSubmitting] = useState(false); // theme const { resolvedTheme } = useTheme(); + // derived values + const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { setIsSubmitting(true); @@ -129,7 +131,34 @@ const InstanceAuthenticationPage = observer(() => {
{formattedConfig ? (
-
Authentication modes
+
Sign-up configuration
+
+
+
+
+ Allow anyone to sign up without invite +
+
+ Toggling this off will disable self sign ups. +
+
+
+
+
+ { + Boolean(parseInt(enableSignUpConfig)) === true + ? updateConfig("ENABLE_SIGNUP", "0") + : updateConfig("ENABLE_SIGNUP", "1"); + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Authentication modes
{authenticationMethodsCard.map((method) => ( {children}; - -export default EmailLayout; +export default function EmailLayout({ children }: EmailLayoutProps) { + return {children}; +} diff --git a/admin/app/image/form.tsx b/admin/app/image/form.tsx index a6fe2945b..61d2875ed 100644 --- a/admin/app/image/form.tsx +++ b/admin/app/image/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { useForm } from "react-hook-form"; import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx index 18e9343b5..62a58391b 100644 --- a/admin/app/image/layout.tsx +++ b/admin/app/image/layout.tsx @@ -10,6 +10,6 @@ export const metadata: Metadata = { title: "Images Settings - God Mode", }; -const ImageLayout = ({ children }: ImageLayoutProps) => {children}; - -export default ImageLayout; +export default function ImageLayout({ children }: ImageLayoutProps) { + return {children}; +} diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index e79d0bac8..9fdce4017 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -16,7 +16,7 @@ import { UserProvider } from "@/lib/user-provider"; // styles import "./globals.css"; -function RootLayout({ children }: { children: ReactNode }) { +export default function RootLayout({ children }: { children: ReactNode }) { // themes const { resolvedTheme } = useTheme(); @@ -44,5 +44,3 @@ function RootLayout({ children }: { children: ReactNode }) { ); } - -export default RootLayout; diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 56ccbcd84..d2b3cc492 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -5,9 +5,11 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; +// ui import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; +// helpers +import { WEB_BASE_URL, cn } from "@/helpers/common.helper"; // hooks -import { WEB_BASE_URL } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; @@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => { return (
diff --git a/admin/components/admin-sidebar/root.tsx b/admin/components/admin-sidebar/root.tsx index ff94bf228..6d7a10c35 100644 --- a/admin/components/admin-sidebar/root.tsx +++ b/admin/components/admin-sidebar/root.tsx @@ -41,10 +41,10 @@ export const InstanceSidebar: FC = observer(() => {
diff --git a/admin/components/common/breadcrumb-link.tsx b/admin/components/common/breadcrumb-link.tsx index dfa437231..d5a00ccaa 100644 --- a/admin/components/common/breadcrumb-link.tsx +++ b/admin/components/common/breadcrumb-link.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { Tooltip } from "@plane/ui"; diff --git a/admin/components/common/confirm-discard-modal.tsx b/admin/components/common/confirm-discard-modal.tsx index 64e4d7a08..d0ca21fc2 100644 --- a/admin/components/common/confirm-discard-modal.tsx +++ b/admin/components/common/confirm-discard-modal.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import Link from "next/link"; // headless ui @@ -43,33 +45,22 @@ export const ConfirmDiscardModal: React.FC = (props) => {
- + You have unsaved changes

- Changes you made will be lost if you go back. Do you - wish to go back? + Changes you made will be lost if you go back. Do you wish to go back?

- - + Go back
diff --git a/admin/components/common/empty-state.tsx b/admin/components/common/empty-state.tsx index fbbe0bc0f..57489ccc6 100644 --- a/admin/components/common/empty-state.tsx +++ b/admin/components/common/empty-state.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import Image from "next/image"; import { Button } from "@plane/ui"; diff --git a/admin/components/common/toast.tsx b/admin/components/common/toast.tsx index fe4983db6..455e67930 100644 --- a/admin/components/common/toast.tsx +++ b/admin/components/common/toast.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useTheme } from "next-themes"; // ui import { Toast as ToastComponent } from "@plane/ui"; diff --git a/admin/next.config.js b/admin/next.config.js index 07f6664af..2109cec69 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -1,4 +1,5 @@ /** @type {import('next').NextConfig} */ + const nextConfig = { trailingSlash: true, reactStrictMode: false, diff --git a/admin/package.json b/admin/package.json index 1e1bc372e..ff7178066 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.20.0", + "version": "0.21.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -14,7 +14,6 @@ "@headlessui/react": "^1.7.19", "@plane/types": "*", "@plane/ui": "*", - "@plane/constants": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", diff --git a/aio/Dockerfile b/aio/Dockerfile new file mode 100644 index 000000000..94d61b866 --- /dev/null +++ b/aio/Dockerfile @@ -0,0 +1,149 @@ +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER + +RUN yarn global add turbo +COPY . . + +RUN turbo prune --scope=web --scope=space --scope=admin --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM node:18-alpine AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# # Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + +RUN yarn turbo run build + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +# FROM makeplane/plane-aio-base AS runner +FROM makeplane/plane-aio-base:develop AS runner + +WORKDIR /app + +SHELL [ "/bin/bash", "-c" ] + +# PYTHON APPLICATION SETUP + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + +COPY apiserver/requirements.txt ./api/ +COPY apiserver/requirements ./api/requirements + +RUN python3.12 -m venv /app/venv && \ + source /app/venv/bin/activate && \ + /app/venv/bin/pip install --upgrade pip && \ + /app/venv/bin/pip install -r ./api/requirements.txt --compile --no-cache-dir + +# Add in Django deps and generate Django's static files +COPY apiserver/manage.py ./api/manage.py +COPY apiserver/plane ./api/plane/ +COPY apiserver/templates ./api/templates/ +COPY package.json ./api/package.json + +COPY apiserver/bin ./api/bin/ + +RUN chmod +x ./api/bin/* +RUN chmod -R 777 ./api/ + +# NEXTJS BUILDS + +COPY --from=installer /app/web/next.config.js ./web/ +COPY --from=installer /app/web/package.json ./web/ +COPY --from=installer /app/web/.next/standalone ./web +COPY --from=installer /app/web/.next/static ./web/web/.next/static +COPY --from=installer /app/web/public ./web/web/public + +COPY --from=installer /app/space/next.config.js ./space/ +COPY --from=installer /app/space/package.json ./space/ +COPY --from=installer /app/space/.next/standalone ./space +COPY --from=installer /app/space/.next/static ./space/space/.next/static +COPY --from=installer /app/space/public ./space/space/public + +COPY --from=installer /app/admin/next.config.js ./admin/ +COPY --from=installer /app/admin/package.json ./admin/ +COPY --from=installer /app/admin/.next/standalone ./admin +COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static +COPY --from=installer /app/admin/public ./admin/admin/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 + +COPY aio/supervisord.conf /app/supervisord.conf + +COPY aio/aio.sh /app/aio.sh +RUN chmod +x /app/aio.sh + +COPY aio/pg-setup.sh /app/pg-setup.sh +RUN chmod +x /app/pg-setup.sh + +COPY deploy/selfhost/variables.env /app/plane.env + +# NGINX Conf Copy +COPY ./aio/nginx.conf.aio /etc/nginx/nginx.conf.template +COPY ./nginx/env.sh /app/nginx-start.sh +RUN chmod +x /app/nginx-start.sh + +RUN ./pg-setup.sh + +VOLUME [ "/app/data/minio/uploads", "/var/lib/postgresql/data" ] + +CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile.base b/aio/Dockerfile.base new file mode 100644 index 000000000..092deb797 --- /dev/null +++ b/aio/Dockerfile.base @@ -0,0 +1,92 @@ +FROM --platform=$BUILDPLATFORM tonistiigi/binfmt as binfmt + +FROM debian:12-slim + +# Set environment variables to non-interactive for apt +ENV DEBIAN_FRONTEND=noninteractive + +SHELL [ "/bin/bash", "-c" ] + +# Update the package list and install prerequisites +RUN apt-get update && \ + apt-get install -y \ + gnupg2 curl ca-certificates lsb-release software-properties-common \ + build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ + libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ + tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu + +# Install Redis 7.2 +RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \ + curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \ + apt-get update && \ + apt-get install -y redis-server + +# Install PostgreSQL 15 +ENV POSTGRES_VERSION 15 +RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \ + mkdir -p /var/lib/postgresql/data && \ + chown -R postgres:postgres /var/lib/postgresql + +# Install MinIO +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \ + else \ + echo "Unsupported architecture: $TARGETARCH"; exit 1; \ + fi && \ + chmod +x /usr/local/bin/minio + + +# Install Node.js 18 +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ + apt-get install -y nodejs + +# Install Python 3.12 from source +RUN cd /usr/src && \ + wget https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz && \ + tar xzf Python-3.12.0.tgz && \ + cd Python-3.12.0 && \ + ./configure --enable-optimizations && \ + make altinstall && \ + rm -f /usr/src/Python-3.12.0.tgz + +RUN python3.12 -m pip install --upgrade pip + +RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ + echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc + +# Clean up +RUN apt-get clean && \ + rm -rf /var/lib/apt/lists/* /usr/src/Python-3.12.0 + +WORKDIR /app + +RUN mkdir -p /app/{data,logs} && \ + mkdir -p /app/data/{redis,pg,minio,nginx} && \ + mkdir -p /app/logs/{access,error} && \ + mkdir -p /etc/supervisor/conf.d + +# Create Supervisor configuration file +COPY supervisord.base /app/supervisord.conf + +RUN apt-get update && \ + apt-get install -y sudo lsof net-tools libpq-dev procps gettext && \ + apt-get clean + +RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data +COPY postgresql.conf /etc/postgresql/postgresql.conf + +RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ + echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc + +# Expose ports for Redis, PostgreSQL, and MinIO +EXPOSE 6379 5432 9000 80 + +# Start Supervisor +CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/aio.sh b/aio/aio.sh new file mode 100644 index 000000000..53adbf42b --- /dev/null +++ b/aio/aio.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + + +if [ "$1" = 'api' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-api.sh +elif [ "$1" = 'worker' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-worker.sh +elif [ "$1" = 'beat' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-beat.sh +elif [ "$1" = 'migrator' ]; then + source /app/venv/bin/activate + cd /app/api + exec ./bin/docker-entrypoint-migrator.sh +elif [ "$1" = 'web' ]; then + node /app/web/web/server.js +elif [ "$1" = 'space' ]; then + node /app/space/space/server.js +elif [ "$1" = 'admin' ]; then + node /app/admin/admin/server.js +else + echo "Command not found" + exit 1 +fi \ No newline at end of file diff --git a/aio/nginx.conf.aio b/aio/nginx.conf.aio new file mode 100644 index 000000000..1a1f3c0b8 --- /dev/null +++ b/aio/nginx.conf.aio @@ -0,0 +1,73 @@ +events { +} + +http { + sendfile on; + + server { + listen 80; + root /www/data/; + access_log /var/log/nginx/access.log; + + client_max_body_size ${FILE_SIZE_LIMIT}; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Permissions-Policy "interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Forwarded-Proto "${dollar}scheme"; + add_header X-Forwarded-Host "${dollar}host"; + add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for"; + add_header X-Real-IP "${dollar}remote_addr"; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3001/; + } + + location /spaces/ { + rewrite ^/spaces/?$ /spaces/login break; + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3002/spaces/; + } + + + location /god-mode/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:3003/god-mode/; + } + + location /api/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:8000/api/; + } + + location /auth/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:8000/auth/; + } + + location /${BUCKET_NAME}/ { + proxy_http_version 1.1; + proxy_set_header Upgrade ${dollar}http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host ${dollar}http_host; + proxy_pass http://localhost:9000/uploads/; + } + } +} diff --git a/aio/pg-setup.sh b/aio/pg-setup.sh new file mode 100644 index 000000000..6f6ea88e6 --- /dev/null +++ b/aio/pg-setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + + +# Variables +set -o allexport +source plane.env set +set +o allexport + +export PGHOST=localhost + +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \ +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \ +sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop diff --git a/aio/postgresql.conf b/aio/postgresql.conf new file mode 100644 index 000000000..8c6223fc4 --- /dev/null +++ b/aio/postgresql.conf @@ -0,0 +1,12 @@ +# PostgreSQL configuration file + +# Allow connections from any IP address +listen_addresses = '*' + +# Set the maximum number of connections +max_connections = 100 + +# Set the shared buffers size +shared_buffers = 128MB + +# Other custom configurations can be added here diff --git a/aio/supervisord.base b/aio/supervisord.base new file mode 100644 index 000000000..fe6a76e41 --- /dev/null +++ b/aio/supervisord.base @@ -0,0 +1,37 @@ +[supervisord] +user=root +nodaemon=true +stderr_logfile=/app/logs/error/supervisor.err.log +stdout_logfile=/app/logs/access/supervisor.out.log + +[program:redis] +directory=/app/data/redis +command=redis-server +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/redis.err.log +stdout_logfile=/app/logs/access/redis.out.log + +[program:postgresql] +user=postgres +command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/15/main/postgresql.conf +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/postgresql.err.log +stdout_logfile=/app/logs/access/postgresql.out.log + +[program:minio] +directory=/app/data/minio +command=minio server /app/data/minio +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/minio.err.log +stdout_logfile=/app/logs/access/minio.out.log + +[program:nginx] +directory=/app/data/nginx +command=/usr/sbin/nginx -g 'daemon off;' +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/nginx.err.log +stdout_logfile=/app/logs/access/nginx.out.log diff --git a/aio/supervisord.conf b/aio/supervisord.conf new file mode 100644 index 000000000..46ef1b4fa --- /dev/null +++ b/aio/supervisord.conf @@ -0,0 +1,115 @@ +[supervisord] +user=root +nodaemon=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:redis] +directory=/app/data/redis +command=redis-server +autostart=true +autorestart=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:postgresql] +user=postgres +command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data --config-file=/etc/postgresql/postgresql.conf +autostart=true +autorestart=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:minio] +directory=/app/data/minio +command=minio server /app/data/minio +autostart=true +autorestart=true +priority=1 +stdout_logfile=/app/logs/access/minio.log +stderr_logfile=/app/logs/error/minio.err.log + +[program:nginx] +command=/app/nginx-start.sh +autostart=true +autorestart=true +priority=1 +stdout_logfile=/app/logs/access/nginx.log +stderr_logfile=/app/logs/error/nginx.err.log + + +[program:web] +command=/app/aio.sh web +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3001,HOSTNAME=0.0.0.0 + +[program:space] +command=/app/aio.sh space +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3002,HOSTNAME=0.0.0.0 + +[program:admin] +command=/app/aio.sh admin +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3003,HOSTNAME=0.0.0.0 + +[program:migrator] +command=/app/aio.sh migrator +autostart=true +autorestart=false +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:api] +command=/app/aio.sh api +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:worker] +command=/app/aio.sh worker +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:beat] +command=/app/aio.sh beat +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + diff --git a/apiserver/package.json b/apiserver/package.json index 317e82033..ecaf1194a 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.20.0" + "version": "0.21.0" } diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 6e1e5e057..d1e1917a4 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -784,6 +784,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): def post(self, request, slug, project_id, cycle_id): new_cycle_id = request.data.get("new_cycle_id", False) + plot_type = request.GET.get("plot_type", "issues") if not new_cycle_id: return Response( @@ -865,6 +866,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): queryset=old_cycle.first(), slug=slug, project_id=project_id, + plot_type=plot_type, cycle_id=cycle_id, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index a62278b19..ce0501dd2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -182,7 +182,6 @@ class IssueAPIEndpoint(BaseAPIView): issue_queryset = ( self.get_queryset() .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 019ab704e..408e14fed 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -22,7 +22,7 @@ from plane.db.models import ( IssueProperty, Module, Project, - ProjectDeployBoard, + DeployBoard, ProjectMember, State, Workspace, @@ -99,7 +99,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) .annotate( is_deployed=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index bdcdf6c0d..d8364f931 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -30,7 +30,7 @@ from .project import ( ProjectIdentifierSerializer, ProjectLiteSerializer, ProjectMemberLiteSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ProjectMemberAdminSerializer, ProjectPublicMemberSerializer, ProjectMemberRoleSerializer, diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 1a9ce52d1..97fd47960 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -66,6 +66,7 @@ class CycleSerializer(BaseSerializer): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index d28f38c75..8cb083ca5 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,19 +2,11 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.app.serializers import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, -) from rest_framework import serializers class EstimateSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = Estimate @@ -48,10 +40,6 @@ class EstimatePointSerializer(BaseSerializer): class EstimateReadSerializer(BaseSerializer): points = EstimatePointSerializer(read_only=True, many=True) - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = Estimate diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 6a0c4c94f..bb759784e 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer): started_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) + total_estimate_points = serializers.IntegerField(read_only=True) + completed_estimate_points = serializers.IntegerField(read_only=True) class Meta: model = Module @@ -199,7 +201,10 @@ class ModuleSerializer(DynamicBaseSerializer): "sort_order", "external_source", "external_id", + "logo_props", # computed fields + "total_estimate_points", + "completed_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -217,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer): class ModuleDetailSerializer(ModuleSerializer): link_module = ModuleLinkSerializer(read_only=True, many=True) sub_issues = serializers.IntegerField(read_only=True) + backlog_estimate_points = serializers.IntegerField(read_only=True) + unstarted_estimate_points = serializers.IntegerField(read_only=True) + started_estimate_points = serializers.IntegerField(read_only=True) + cancelled_estimate_points = serializers.IntegerField(read_only=True) class Meta(ModuleSerializer.Meta): - fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"] class ModuleUserPropertiesSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 41f46c6e4..853d854b3 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -8,6 +8,8 @@ from plane.db.models import ( PageLog, PageLabel, Label, + ProjectPage, + Project, ) @@ -18,6 +20,7 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) + project = serializers.UUIDField(read_only=True) class Meta: model = Page @@ -33,16 +36,16 @@ class PageSerializer(BaseSerializer): "is_locked", "archived_at", "workspace", - "project", "created_at", "updated_at", "created_by", "updated_by", "view_props", + "logo_props", + "project", ] read_only_fields = [ "workspace", - "project", "owned_by", ] @@ -56,11 +59,23 @@ class PageSerializer(BaseSerializer): project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] description_html = self.context["description_html"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + page = Page.objects.create( **validated_data, description_html=description_html, - project_id=project_id, owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, ) if labels is not None: @@ -69,7 +84,6 @@ class PageSerializer(BaseSerializer): PageLabel( label=label, page=page, - project_id=project_id, workspace_id=page.workspace_id, created_by_id=page.created_by_id, updated_by_id=page.updated_by_id, @@ -89,7 +103,6 @@ class PageSerializer(BaseSerializer): PageLabel( label=label, page=instance, - project_id=instance.project_id, workspace_id=instance.workspace_id, created_by_id=instance.created_by_id, updated_by_id=instance.updated_by_id, @@ -119,7 +132,6 @@ class SubPageSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", ] @@ -140,6 +152,5 @@ class PageLogSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 96d92f340..1bbc580c1 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -13,7 +13,7 @@ from plane.db.models import ( ProjectMember, ProjectMemberInvite, ProjectIdentifier, - ProjectDeployBoard, + DeployBoard, ProjectPublicMember, ) @@ -114,7 +114,7 @@ class ProjectListSerializer(DynamicBaseSerializer): is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) + anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() def get_members(self, obj): @@ -148,7 +148,7 @@ class ProjectDetailSerializer(BaseSerializer): is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) + anchor = serializers.CharField(read_only=True) class Meta: model = Project @@ -206,14 +206,14 @@ class ProjectMemberLiteSerializer(BaseSerializer): read_only_fields = fields -class ProjectDeployBoardSerializer(BaseSerializer): +class DeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" ) class Meta: - model = ProjectDeployBoard + model = DeployBoard fields = "__all__" read_only_fields = [ "workspace", diff --git a/apiserver/plane/app/urls/estimate.py b/apiserver/plane/app/urls/estimate.py index d8571ff0c..7db94aa46 100644 --- a/apiserver/plane/app/urls/estimate.py +++ b/apiserver/plane/app/urls/estimate.py @@ -4,6 +4,7 @@ from django.urls import path from plane.app.views import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, + EstimatePointEndpoint, ) @@ -34,4 +35,23 @@ urlpatterns = [ ), name="bulk-create-estimate-points", ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointEndpoint.as_view( + { + "post": "create", + } + ), + name="estimate-points", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointEndpoint.as_view( + { + "patch": "partial_update", + "delete": "destroy", + } + ), + name="estimate-points", + ), ] diff --git a/apiserver/plane/app/urls/external.py b/apiserver/plane/app/urls/external.py index 8db87a249..744c646ca 100644 --- a/apiserver/plane/app/urls/external.py +++ b/apiserver/plane/app/urls/external.py @@ -2,7 +2,7 @@ from django.urls import path from plane.app.views import UnsplashEndpoint -from plane.app.views import GPTIntegrationEndpoint +from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpoint urlpatterns = [ @@ -16,4 +16,9 @@ urlpatterns = [ GPTIntegrationEndpoint.as_view(), name="importer", ), + path( + "workspaces//ai-assistant/", + WorkspaceGPTIntegrationEndpoint.as_view(), + name="importer", + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 76c63edfb..82d2fe8be 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -20,6 +20,8 @@ from plane.app.views import ( IssueViewSet, LabelViewSet, IssueDescriptionViewSet, + BulkIssueOperationsEndpoint, + BulkArchiveIssuesEndpoint, ) urlpatterns = [ @@ -92,6 +94,11 @@ urlpatterns = [ BulkDeleteIssuesEndpoint.as_view(), name="project-issues-bulk", ), + path( + "workspaces//projects//bulk-archive-issues/", + BulkArchiveIssuesEndpoint.as_view(), + name="bulk-archive-issues", + ), ## path( "workspaces//projects//issues//sub-issues/", @@ -309,4 +316,9 @@ urlpatterns = [ ), name="project-issue-draft", ), + path( + "workspaces//projects//bulk-operation-issues/", + BulkIssueOperationsEndpoint.as_view(), + name="bulk-operations-issues", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 7ea636df8..0807c7616 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -2,6 +2,7 @@ from django.urls import path from plane.app.views import ( ProjectViewSet, + DeployBoardViewSet, ProjectInvitationsViewset, ProjectMemberViewSet, ProjectMemberUserEndpoint, @@ -12,7 +13,6 @@ from plane.app.views import ( ProjectFavoritesViewSet, UserProjectInvitationsViewset, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, ) @@ -157,7 +157,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "list", "post": "create", @@ -167,7 +167,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "retrieve", "patch": "partial_update", diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index f1dfc8864..e849410b3 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -4,7 +4,7 @@ from .project.base import ( ProjectUserViewsEndpoint, ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, + DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) @@ -114,9 +114,7 @@ from .issue.activity import ( IssueActivityEndpoint, ) -from .issue.archive import ( - IssueArchiveViewSet, -) +from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, @@ -155,6 +153,8 @@ from .issue.subscriber import ( ) +from .issue.bulk_operations import BulkIssueOperationsEndpoint + from .module.base import ( ModuleViewSet, ModuleLinkViewSet, @@ -187,10 +187,12 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .external.base import ( GPTIntegrationEndpoint, UnsplashEndpoint, + WorkspaceGPTIntegrationEndpoint, ) from .estimate.base import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, + EstimatePointEndpoint, ) from .inbox.base import ( diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 256d3cae5..3d27641e3 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -33,7 +33,7 @@ class AnalyticsEndpoint(BaseAPIView): "state__group", "labels__id", "assignees__id", - "estimate_point", + "estimate_point__value", "issue_cycle__cycle_id", "issue_module__module_id", "priority", @@ -381,9 +381,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ) open_estimate_sum = open_issues_queryset.aggregate( - sum=Sum("estimate_point") + sum=Sum("point") )["sum"] - total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[ + total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[ "sum" ] diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 8f21f5fe1..45488b64e 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -1,4 +1,6 @@ # Python imports +import traceback + import zoneinfo from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): response = super().handle_exception(exc) return response except Exception as e: - print(e) if settings.DEBUG else print("Server Error") + ( + print(e, traceback.format_exc()) + if settings.DEBUG + else print("Server Error") + ) if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 5e1241b08..f99c2ec97 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -177,6 +177,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk=None): + plot_type = request.GET.get("plot_type", "issues") if pk is None: queryset = ( self.get_queryset() @@ -375,6 +376,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): queryset=queryset, slug=slug, project_id=project_id, + plot_type=plot_type, cycle_id=pk, ) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index e0b28ac7b..8c3f98ac6 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -17,8 +17,11 @@ from django.db.models import ( UUIDField, Value, When, + Subquery, + Sum, + IntegerField, ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -43,6 +46,7 @@ from plane.db.models import ( Issue, Label, User, + Project, ) from plane.utils.analytics_plot import burndown_plot @@ -73,6 +77,89 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + backlog_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + unstarted_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + started_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + cancelled_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("cancelled_estimate_point")[:1] + ) + completed_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="completed", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + completed_estimate_points=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("completed_estimate_points")[:1] + ) + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + total_estimate_points=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("total_estimate_points")[:1] + ) return self.filter_queryset( super() .get_queryset() @@ -197,6 +284,42 @@ class CycleViewSet(BaseViewSet): Value([], output_field=ArrayField(UUIDField())), ) ) + .annotate( + backlog_estimate_points=Coalesce( + Subquery(backlog_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + unstarted_estimate_points=Coalesce( + Subquery(unstarted_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + started_estimate_points=Coalesce( + Subquery(started_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + cancelled_estimate_points=Coalesce( + Subquery(cancelled_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + completed_estimate_points=Coalesce( + Subquery(completed_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + total_estimate_points=Coalesce( + Subquery(total_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) .order_by("-is_favorite", "name") .distinct() ) @@ -231,7 +354,14 @@ class CycleViewSet(BaseViewSet): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields + "backlog_estimate_points", + "unstarted_estimate_points", + "started_estimate_points", + "cancelled_estimate_points", + "completed_estimate_points", + "total_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -243,8 +373,108 @@ class CycleViewSet(BaseViewSet): "status", "created_by", ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() if data: + data[0]["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data[0]["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=data[0]["id"], + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -258,7 +488,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -297,8 +530,11 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -334,6 +570,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset.first(), slug=slug, project_id=project_id, + plot_type="issues", cycle_id=data[0]["id"], ) ) @@ -356,7 +593,10 @@ class CycleViewSet(BaseViewSet): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields + "completed_estimate_points", + "total_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -403,6 +643,7 @@ class CycleViewSet(BaseViewSet): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "cancelled_issues", @@ -496,6 +737,7 @@ class CycleViewSet(BaseViewSet): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", @@ -556,6 +798,7 @@ class CycleViewSet(BaseViewSet): "external_id", "progress_snapshot", "sub_issues", + "logo_props", # meta fields "is_favorite", "total_issues", @@ -572,12 +815,107 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() - if data is None: - return Response( - {"error": "Cycle does not exist"}, - status=status.HTTP_400_BAD_REQUEST, + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") ) + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data["start_date"] and data["end_date"]: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=pk, + ) + ) + # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -600,7 +938,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -640,7 +981,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -677,6 +1021,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset, slug=slug, project_id=project_id, + plot_type="issues", cycle_id=pk, ) @@ -869,13 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) ) - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle.first(), - slug=slug, - project_id=project_id, - cycle_id=cycle_id, - ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + if estimate_type: + assignee_estimate_data = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) + if item["assignee_id"] + else None + ), + "avatar": item["avatar"], + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] # Get the assignee distribution assignee_distribution = ( @@ -891,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -945,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -971,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): .order_by("label_name") ) - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), - "avatar": item["avatar"], - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - # Label distribution serilization label_distribution_data = [ { @@ -1000,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): for item in label_distribution ] + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + current_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=cycle_id ).first() @@ -1016,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignees": assignee_distribution_data, "completion_chart": completion_chart, }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), } current_cycle.save(update_fields=["progress_snapshot"]) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index fdc998f6d..1932ae169 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -2,43 +2,50 @@ import json # Django imports -from django.db.models import ( - Func, - F, - Q, - OuterRef, - Value, - UUIDField, -) from django.core import serializers +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ( + ProjectEntityPermission, +) # Module imports from .. import BaseViewSet from plane.app.serializers import ( - IssueSerializer, CycleIssueSerializer, ) -from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Cycle, CycleIssue, Issue, - IssueLink, IssueAttachment, + IssueLink, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, ) -from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug, project_id, cycle_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - order_by = request.GET.get("order_by", "created_at") + order_by_param = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") - queryset = ( + issue_queryset = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .filter(project_id=project_id) .filter(workspace__slug=slug) @@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet): "issue_module__module", "issue_cycle__cycle", ) - .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( @@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .order_by(order_by) ) - if self.fields: - issues = IssueSerializer( - queryset, many=True, fields=fields if fields else None - ).data - else: - issues = queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + filters = issue_filters(request.query_params, "GET") - return Response(issues, status=status.HTTP_200_OK) + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = issue_queryset.filter(**filters) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 9558348d9..76a3563de 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -1,52 +1,53 @@ # Django imports -from django.db.models import ( - Q, - Case, - When, - Value, - CharField, - Count, - F, - Exists, - OuterRef, - Subquery, - JSONField, - Func, - Prefetch, - IntegerField, -) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + IntegerField, + JSONField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, + When, +) from django.db.models.functions import Coalesce from django.utils import timezone +from rest_framework import status # Third Party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.serializers import ( + DashboardSerializer, + IssueActivitySerializer, + IssueSerializer, + WidgetSerializer, +) +from plane.db.models import ( + Dashboard, + DashboardWidget, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueRelation, + Project, + ProjectMember, + User, + Widget, +) +from plane.utils.issue_filters import issue_filters # Module imports from .. import BaseAPIView -from plane.db.models import ( - Issue, - IssueActivity, - ProjectMember, - Widget, - DashboardWidget, - Dashboard, - Project, - IssueLink, - IssueAttachment, - IssueRelation, - User, -) -from plane.app.serializers import ( - IssueActivitySerializer, - IssueSerializer, - DashboardSerializer, - WidgetSerializer, -) -from plane.utils.issue_filters import issue_filters def dashboard_overview_stats(self, request, slug): diff --git a/apiserver/plane/app/views/estimate/base.py b/apiserver/plane/app/views/estimate/base.py index 7ac3035a9..2bd9e3dfe 100644 --- a/apiserver/plane/app/views/estimate/base.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -1,3 +1,6 @@ +import random +import string + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -5,7 +8,7 @@ from rest_framework import status # Module imports from ..base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission -from plane.db.models import Project, Estimate, EstimatePoint +from plane.db.models import Project, Estimate, EstimatePoint, Issue from plane.app.serializers import ( EstimateSerializer, EstimatePointSerializer, @@ -13,6 +16,12 @@ from plane.app.serializers import ( ) from plane.utils.cache import invalidate_cache + +def generate_random_name(length=10): + letters = string.ascii_lowercase + return "".join(random.choice(letters) for i in range(length)) + + class ProjectEstimatePointEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, @@ -49,13 +58,17 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer = EstimateReadSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def create(self, request, slug, project_id): - if not request.data.get("estimate", False): - return Response( - {"error": "Estimate is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) + estimate = request.data.get('estimate') + estimate_name = estimate.get("name", generate_random_name()) + estimate_type = estimate.get("type", 'categories') + last_used = estimate.get("last_used", False) + estimate = Estimate.objects.create( + name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type + ) estimate_points = request.data.get("estimate_points", []) @@ -67,14 +80,6 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - estimate_serializer = EstimateSerializer( - data=request.data.get("estimate") - ) - if not estimate_serializer.is_valid(): - return Response( - estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - estimate = estimate_serializer.save(project_id=project_id) estimate_points = EstimatePoint.objects.bulk_create( [ EstimatePoint( @@ -93,17 +98,8 @@ class BulkEstimatePointEndpoint(BaseViewSet): ignore_conflicts=True, ) - estimate_point_serializer = EstimatePointSerializer( - estimate_points, many=True - ) - - return Response( - { - "estimate": estimate_serializer.data, - "estimate_points": estimate_point_serializer.data, - }, - status=status.HTTP_200_OK, - ) + serializer = EstimateReadSerializer(estimate) + return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, estimate_id): estimate = Estimate.objects.get( @@ -115,13 +111,10 @@ class BulkEstimatePointEndpoint(BaseViewSet): status=status.HTTP_200_OK, ) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def partial_update(self, request, slug, project_id, estimate_id): - if not request.data.get("estimate", False): - return Response( - {"error": "Estimate is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) if not len(request.data.get("estimate_points", [])): return Response( @@ -131,15 +124,10 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate = Estimate.objects.get(pk=estimate_id) - estimate_serializer = EstimateSerializer( - estimate, data=request.data.get("estimate"), partial=True - ) - if not estimate_serializer.is_valid(): - return Response( - estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - - estimate = estimate_serializer.save() + if request.data.get("estimate"): + estimate.name = request.data.get("estimate").get("name", estimate.name) + estimate.type = request.data.get("estimate").get("type", estimate.type) + estimate.save() estimate_points_data = request.data.get("estimate_points", []) @@ -165,29 +153,113 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_point.value = estimate_point_data[0].get( "value", estimate_point.value ) + estimate_point.key = estimate_point_data[0].get( + "key", estimate_point.key + ) updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( updated_estimate_points, - ["value"], + ["key", "value"], batch_size=10, ) - estimate_point_serializer = EstimatePointSerializer( - estimate_points, many=True - ) + estimate_serializer = EstimateReadSerializer(estimate) return Response( - { - "estimate": estimate_serializer.data, - "estimate_points": estimate_point_serializer.data, - }, + estimate_serializer.data, status=status.HTTP_200_OK, ) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def destroy(self, request, slug, project_id, estimate_id): estimate = Estimate.objects.get( pk=estimate_id, workspace__slug=slug, project_id=project_id ) estimate.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class EstimatePointEndpoint(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + def create(self, request, slug, project_id, estimate_id): + # TODO: add a key validation if the same key already exists + if not request.data.get("key") or not request.data.get("value"): + return Response( + {"error": "Key and value are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + key = request.data.get("key", 0) + value = request.data.get("value", "") + estimate_point = EstimatePoint.objects.create( + estimate_id=estimate_id, + project_id=project_id, + key=key, + value=value, + ) + serializer = EstimatePointSerializer(estimate_point).data + return Response(serializer, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id): + # TODO: add a key validation if the same key already exists + estimate_point = EstimatePoint.objects.get( + pk=estimate_point_id, + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer( + estimate_point, data=request.data, partial=True + ) + if not serializer.is_valid(): + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy( + self, request, slug, project_id, estimate_id, estimate_point_id + ): + new_estimate_id = request.GET.get("new_estimate_id", None) + estimate_points = EstimatePoint.objects.filter( + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + # update all the issues with the new estimate + if new_estimate_id: + _ = Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + estimate_id=estimate_point_id, + ).update(estimate_id=new_estimate_id) + + # delete the estimate point + old_estimate_point = EstimatePoint.objects.filter( + pk=estimate_point_id + ).first() + + # rearrange the estimate points + updated_estimate_points = [] + for estimate_point in estimate_points: + if estimate_point.key > old_estimate_point.key: + estimate_point.key -= 1 + updated_estimate_points.append(estimate_point) + + EstimatePoint.objects.bulk_update( + updated_estimate_points, + ["key"], + batch_size=10, + ) + + old_estimate_point.delete() + + return Response( + EstimatePointSerializer(updated_estimate_points, many=True).data, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 698d9eb99..dba61d728 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -1,14 +1,14 @@ # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.serializers import ExporterHistorySerializer +from plane.bgtasks.export_task import issue_export_task +from plane.db.models import ExporterHistory, Project, Workspace # Module imports from .. import BaseAPIView -from plane.app.permissions import WorkSpaceAdminPermission -from plane.bgtasks.export_task import issue_export_task -from plane.db.models import Project, ExporterHistory, Workspace - -from plane.app.serializers import ExporterHistorySerializer class ExportIssuesEndpoint(BaseAPIView): @@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=exporter_history, on_results=lambda exporter_history: ExporterHistorySerializer( diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 2d5d2c7aa..d9a66b850 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -11,7 +11,7 @@ from rest_framework import status # Module imports from ..base import BaseAPIView -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission from plane.db.models import Workspace, Project from plane.app.serializers import ( ProjectLiteSerializer, @@ -83,6 +83,64 @@ class GPTIntegrationEndpoint(BaseAPIView): ) +class WorkspaceGPTIntegrationEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def post(self, request, slug): + OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( + [ + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", None), + }, + { + "key": "GPT_ENGINE", + "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + }, + ] + ) + + # Get the configuration value + # Check the keys + if not OPENAI_API_KEY or not GPT_ENGINE: + return Response( + {"error": "OpenAI API key and engine is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + prompt = request.data.get("prompt", False) + task = request.data.get("task", False) + + if not task: + return Response( + {"error": "Task is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + final_text = task + "\n" + prompt + + client = OpenAI( + api_key=OPENAI_API_KEY, + ) + + response = client.chat.completions.create( + model=GPT_ENGINE, + messages=[{"role": "user", "content": final_text}], + ) + + text = response.choices[0].message.content.strip() + text_html = text.replace("\n", "
") + return Response( + { + "response": text, + "response_html": text_html, + }, + status=status.HTTP_200_OK, + ) + + class UnsplashEndpoint(BaseAPIView): def get(self, request): (UNSPLASH_ACCESS_KEY,) = get_configuration_value( diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index cc3a343d2..584edd8f9 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -2,52 +2,54 @@ import json # Django imports -from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - UUIDField, -) from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + F, + Func, + OuterRef, + Q, + Prefetch, + Exists, +) +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response -# Module imports -from .. import BaseViewSet -from plane.app.serializers import ( - IssueSerializer, - IssueFlatSerializer, - IssueDetailSerializer, -) from plane.app.permissions import ( ProjectEntityPermission, ) +from plane.app.serializers import ( + IssueFlatSerializer, + IssueSerializer, + IssueDetailSerializer +) +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Issue, - IssueLink, IssueAttachment, + IssueLink, IssueSubscriber, IssueReaction, ) -from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports +from .. import BaseViewSet, BaseAPIView + class IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ) @method_decorator(gzip_page) @@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - issue_queryset = ( issue_queryset if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) ) - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - return Response(issues, status=status.HTTP_200_OK) + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) def retrieve(self, request, slug, project_id, pk=None): issue = ( @@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet): issue.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkArchiveIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ).select_related("state") + bulk_archive_issues = [] + for issue in issues: + if issue.state.group not in ["completed", "cancelled"]: + return Response( + { + "error_code": 4091, + "error_message": "INVALID_ARCHIVE_STATE_GROUP" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + { + "archived_at": str(timezone.now().date()), + "automation": False, + } + ), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = timezone.now().date() + bulk_archive_issues.append(issue) + Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) + + return Response( + {"archived_at": str(timezone.now().date())}, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 8a9e2fe42..bb09dc074 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -7,21 +7,16 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce -from django.http import StreamingHttpResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -29,6 +24,8 @@ from django.views.decorators.gzip import gzip_page # Third Party imports from rest_framework import status from rest_framework.response import Response + +# Module imports from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -49,11 +46,19 @@ from plane.db.models import ( IssueSubscriber, Project, ) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter - -# Module imports +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) from .. import BaseAPIView, BaseViewSet +from plane.utils.user_timezone_converter import user_timezone_converter class IssueListEndpoint(BaseAPIView): @@ -105,110 +110,28 @@ class IssueListEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = queryset.filter(**filters) + # Issue queryset + issue_queryset, _ = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) if self.fields or self.expand: issues = IssueSerializer( @@ -304,33 +227,6 @@ class IssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) @@ -340,116 +236,104 @@ class IssueViewSet(BaseViewSet): issue_queryset = self.get_queryset().filter(**filters) # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand or fields else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -481,8 +365,13 @@ class IssueViewSet(BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) issue = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) + issue_queryset_grouper( + queryset=self.get_queryset().filter( + pk=serializer.data["id"] + ), + group_by=None, + sub_group_by=None, + ) .values( "id", "name", @@ -523,6 +412,33 @@ class IssueViewSet(BaseViewSet): issue = ( self.get_queryset() .filter(pk=pk) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .prefetch_related( Prefetch( "issue_reactions", diff --git a/apiserver/plane/app/views/issue/bulk_operations.py b/apiserver/plane/app/views/issue/bulk_operations.py new file mode 100644 index 000000000..ea6637826 --- /dev/null +++ b/apiserver/plane/app/views/issue/bulk_operations.py @@ -0,0 +1,288 @@ +# Python imports +import json +from datetime import datetime + +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseAPIView +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.db.models import ( + Project, + Issue, + IssueLabel, + IssueAssignee, +) +from plane.bgtasks.issue_activites_task import issue_activity + + +class BulkIssueOperationsEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all the issues + issues = ( + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .select_related("state") + .prefetch_related("labels", "assignees") + ) + # Current epoch + epoch = int(timezone.now().timestamp()) + + # Project details + project = Project.objects.get(workspace__slug=slug, pk=project_id) + workspace_id = project.workspace_id + + # Initialize arrays + bulk_update_issues = [] + bulk_issue_activities = [] + bulk_update_issue_labels = [] + bulk_update_issue_assignees = [] + + properties = request.data.get("properties", {}) + + if properties.get("start_date", False) and properties.get("target_date", False): + if ( + datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date() + > datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date() + ): + return Response( + { + "error_code": 4100, + "error_message": "INVALID_ISSUE_DATES", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + for issue in issues: + + # Priority + if properties.get("priority", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"priority": properties.get("priority")} + ), + "current_instance": json.dumps( + {"priority": (issue.priority)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.priority = properties.get("priority") + + # State + if properties.get("state_id", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"state": properties.get("state")} + ), + "current_instance": json.dumps( + {"state": str(issue.state_id)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.state_id = properties.get("state_id") + + # Start date + if properties.get("start_date", False): + if ( + issue.target_date + and not properties.get("target_date", False) + and issue.target_date + <= datetime.strptime( + properties.get("start_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4101, + "error_message": "INVALID_ISSUE_START_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"start_date": properties.get("start_date")} + ), + "current_instance": json.dumps( + {"start_date": str(issue.start_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.start_date = properties.get("start_date") + + # Target date + if properties.get("target_date", False): + if ( + issue.start_date + and not properties.get("start_date", False) + and issue.start_date + >= datetime.strptime( + properties.get("target_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4102, + "error_message": "INVALID_ISSUE_TARGET_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"target_date": properties.get("target_date")} + ), + "current_instance": json.dumps( + {"target_date": str(issue.target_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.target_date = properties.get("target_date") + + bulk_update_issues.append(issue) + + # Labels + if properties.get("label_ids", []): + for label_id in properties.get("label_ids", []): + bulk_update_issue_labels.append( + IssueLabel( + issue=issue, + label_id=label_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"label_ids": properties.get("label_ids", [])} + ), + "current_instance": json.dumps( + { + "label_ids": [ + str(label.id) + for label in issue.labels.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Assignees + if properties.get("assignee_ids", []): + for assignee_id in properties.get( + "assignee_ids", issue.assignees + ): + bulk_update_issue_assignees.append( + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + { + "assignee_ids": properties.get( + "assignee_ids", [] + ) + } + ), + "current_instance": json.dumps( + { + "assignee_ids": [ + str(assignee.id) + for assignee in issue.assignees.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Bulk update all the objects + Issue.objects.bulk_update( + bulk_update_issues, + [ + "priority", + "start_date", + "target_date", + "state", + ], + batch_size=100, + ) + + # Create new labels + IssueLabel.objects.bulk_create( + bulk_update_issue_labels, + ignore_conflicts=True, + batch_size=100, + ) + + # Create new assignees + IssueAssignee.objects.bulk_create( + bulk_update_issue_assignees, + ignore_conflicts=True, + batch_size=100, + ) + # update the issue activity + [ + issue_activity.delay(**activity) + for activity in bulk_issue_activities + ] + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 610c3c468..6944f40f7 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -6,18 +6,14 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( IssueCreateSerializer, @@ -44,10 +41,17 @@ from plane.db.models import ( IssueSubscriber, Project, ) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter - -# Module imports +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) from .. import BaseViewSet @@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) + issue = ( - self.get_queryset().filter(pk=serializer.data["id"]).first() - ) - return Response( - IssueSerializer(issue).data, status=status.HTTP_201_CREATED + issue_queryset_grouper( + queryset=self.get_queryset().filter( + pk=serializer.data["id"] + ), + group_by=None, + sub_group_by=None, + ) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + .first() ) + return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): @@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet): issue = ( self.get_queryset() .filter(pk=pk) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .prefetch_related( Prefetch( "issue_reactions", diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 2cac5f366..5bc91ff1c 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -12,8 +12,9 @@ from django.db.models import ( Subquery, UUIDField, Value, + Sum ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.utils import timezone # Third party imports @@ -25,7 +26,7 @@ from plane.app.permissions import ( from plane.app.serializers import ( ModuleDetailSerializer, ) -from plane.db.models import Issue, Module, ModuleLink, UserFavorite +from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot from plane.utils.user_timezone_converter import user_timezone_converter @@ -217,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .values("count") ) ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignee"] = assignee_distribution + data["estimate_distribution"]["label"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) assignee_distribution = ( Issue.objects.filter( issue_module__module_id=pk, @@ -309,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -317,12 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): } # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, + plot_type="issues", module_id=pk, ) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index f98e0fbc2..e1a323880 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -16,8 +16,9 @@ from django.db.models import ( Subquery, UUIDField, Value, + Sum, ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone @@ -128,6 +129,90 @@ class ModuleViewSet(BaseViewSet): .annotate(cnt=Count("pk")) .values("cnt") ) + completed_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + completed_estimate_points=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("completed_estimate_points")[:1] + ) + + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + total_estimate_points=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("total_estimate_points")[:1] + ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + backlog_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + unstarted_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + started_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + cancelled_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("cancelled_estimate_point")[:1] + ) return ( super() .get_queryset() @@ -182,6 +267,42 @@ class ModuleViewSet(BaseViewSet): Value(0, output_field=IntegerField()), ) ) + .annotate( + backlog_estimate_points=Coalesce( + Subquery(backlog_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + unstarted_estimate_points=Coalesce( + Subquery(unstarted_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + started_estimate_points=Coalesce( + Subquery(started_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + cancelled_estimate_points=Coalesce( + Subquery(cancelled_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + completed_estimate_points=Coalesce( + Subquery(completed_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + total_estimate_points=Coalesce( + Subquery(total_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) .annotate( member_ids=Coalesce( ArrayAgg( @@ -225,6 +346,7 @@ class ModuleViewSet(BaseViewSet): "sort_order", "external_source", "external_id", + "logo_props", # computed fields "is_favorite", "cancelled_issues", @@ -232,6 +354,8 @@ class ModuleViewSet(BaseViewSet): "total_issues", "started_issues", "unstarted_issues", + "completed_estimate_points", + "total_estimate_points", "backlog_issues", "created_at", "updated_at", @@ -281,7 +405,10 @@ class ModuleViewSet(BaseViewSet): "sort_order", "external_source", "external_id", + "logo_props", # computed fields + "completed_estimate_points", + "total_estimate_points", "total_issues", "is_favorite", "cancelled_issues", @@ -315,6 +442,116 @@ class ModuleViewSet(BaseViewSet): ) ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"]["labels"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_module__module_id=pk, @@ -340,7 +577,7 @@ class ModuleViewSet(BaseViewSet): archived_at__isnull=True, is_draft=False, ), - ) + ), ) .annotate( completed_issues=Count( @@ -407,20 +644,17 @@ class ModuleViewSet(BaseViewSet): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - - # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, + plot_type="issues", module_id=pk, ) @@ -465,7 +699,10 @@ class ModuleViewSet(BaseViewSet): "sort_order", "external_source", "external_id", + "logo_props", # computed fields + "completed_estimate_points", + "total_estimate_points", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 879ab7e47..53665b943 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -1,37 +1,50 @@ # Python imports import json +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) + # Django Imports from django.utils import timezone -from django.db.models import F, OuterRef, Func, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import Value, UUIDField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.app.serializers import ( + ModuleIssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + ModuleIssue, + Project, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - ModuleIssueSerializer, - IssueSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - ModuleIssue, - Project, - Issue, - IssueLink, - IssueAttachment, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id, module_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) - if self.fields or self.expand: - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + order_by_param = request.GET.get("order_by", "created_at") - return Response(issues, status=status.HTTP_200_OK) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index 8dae618db..5af5d0a9a 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -1,26 +1,27 @@ # Django imports -from django.db.models import Q, OuterRef, Exists +from django.db.models import Exists, OuterRef, Q from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response -from plane.utils.paginator import BasePaginator -# Module imports -from ..base import BaseViewSet, BaseAPIView -from plane.db.models import ( - Notification, - IssueAssignee, - IssueSubscriber, - Issue, - WorkspaceMember, - UserNotificationPreference, -) from plane.app.serializers import ( NotificationSerializer, UserNotificationPreferenceSerializer, ) +from plane.db.models import ( + Issue, + IssueAssignee, + IssueSubscriber, + Notification, + UserNotificationPreference, + WorkspaceMember, +) +from plane.utils.paginator import BasePaginator + +# Module imports +from ..base import BaseAPIView, BaseViewSet class NotificationViewSet(BaseViewSet, BasePaginator): @@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(notifications), on_results=lambda notifications: NotificationSerializer( diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index c7f53b9fe..dbe85f2bc 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -6,7 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef, Q, Subquery from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.http import StreamingHttpResponse @@ -15,6 +15,7 @@ from django.http import StreamingHttpResponse from rest_framework import status from rest_framework.response import Response + from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( PageLogSerializer, @@ -27,6 +28,7 @@ from plane.db.models import ( PageLog, UserFavorite, ProjectMember, + ProjectPage, ) # Module imports @@ -66,28 +68,31 @@ class PageViewSet(BaseViewSet): user=self.request.user, entity_type="page", entity_identifier=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") + ).values_list("project_id", flat=True)[:1] return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) - .select_related("project") + .prefetch_related("projects") .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") .order_by("-is_favorite", "-created_at") + .annotate(project=Subquery(project_subquery)) + .filter(project=self.kwargs.get("project_id")) .distinct() ) @@ -115,7 +120,9 @@ class PageViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, + workspace__slug=slug, + projects__id=project_id, ) if page.is_locked: @@ -127,7 +134,9 @@ class PageViewSet(BaseViewSet): parent = request.data.get("parent", None) if parent: _ = Page.objects.get( - pk=parent, workspace__slug=slug, project_id=project_id + pk=parent, + workspace__slug=slug, + projects__id=project_id, ) # Only update access if the page owner is the requesting user @@ -187,7 +196,7 @@ class PageViewSet(BaseViewSet): def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = True @@ -196,7 +205,7 @@ class PageViewSet(BaseViewSet): def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = False @@ -211,7 +220,7 @@ class PageViewSet(BaseViewSet): def archive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can archive the page @@ -238,7 +247,7 @@ class PageViewSet(BaseViewSet): def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can un archive the page @@ -267,7 +276,7 @@ class PageViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner and admin can delete the page @@ -380,7 +389,6 @@ class SubPagesEndpoint(BaseAPIView): pages = ( PageLog.objects.filter( page_id=page_id, - project_id=project_id, workspace__slug=slug, entity_name__in=["forward_link", "back_link"], ) @@ -399,7 +407,7 @@ class PagesDescriptionViewSet(BaseViewSet): def retrieve(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) binary_data = page.description_binary @@ -419,7 +427,7 @@ class PagesDescriptionViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) base64_data = request.data.get("description_binary") diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 39db11871..78a9b9547 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -1,26 +1,25 @@ # Python imports import boto3 +from django.conf import settings +from django.utils import timezone import json # Django imports from django.db import IntegrityError from django.db.models import ( - Prefetch, - Q, Exists, - OuterRef, F, Func, + OuterRef, + Prefetch, + Q, Subquery, ) -from django.conf import settings -from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import status -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.permissions import AllowAny # Module imports @@ -28,27 +27,26 @@ from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ) from plane.app.permissions import ( ProjectBasePermission, ProjectMemberPermission, ) - from plane.db.models import ( - Project, - ProjectMember, - Workspace, - State, UserFavorite, - ProjectIdentifier, - Module, Cycle, Inbox, - ProjectDeployBoard, + DeployBoard, IssueProperty, Issue, + Module, + Project, + ProjectIdentifier, + ProjectMember, + State, + Workspace, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity @@ -137,12 +135,11 @@ class ProjectViewSet(BaseViewSet): ).values("role") ) .annotate( - is_deployed=Exists( - ProjectDeployBoard.objects.filter( - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - ) + anchor=DeployBoard.objects.filter( + entity_name="project", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ).values("anchor") ) .annotate(sort_order=Subquery(sort_order)) .prefetch_related( @@ -169,6 +166,7 @@ class ProjectViewSet(BaseViewSet): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(projects), on_results=lambda projects: ProjectListSerializer( @@ -639,29 +637,28 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): return Response(files, status=status.HTTP_200_OK) -class ProjectDeployBoardViewSet(BaseViewSet): +class DeployBoardViewSet(BaseViewSet): permission_classes = [ ProjectMemberPermission, ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard + serializer_class = DeployBoardSerializer + model = DeployBoard - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .select_related("project") - ) + def list(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.filter( + entity_name="project", + entity_identifier=project_id, + workspace__slug=slug, + ).first() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): - comments = request.data.get("comments", False) - reactions = request.data.get("reactions", False) + comments = request.data.get("is_comments_enabled", False) + reactions = request.data.get("is_reactions_enabled", False) inbox = request.data.get("inbox", None) - votes = request.data.get("votes", False) + votes = request.data.get("is_votes_enabled", False) views = request.data.get( "views", { @@ -673,17 +670,18 @@ class ProjectDeployBoardViewSet(BaseViewSet): }, ) - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( - anchor=f"{slug}/{project_id}", + project_deploy_board, _ = DeployBoard.objects.get_or_create( + entity_name="project", + entity_identifier=project_id, project_id=project_id, ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views + project_deploy_board.view_props = views + project_deploy_board.is_votes_enabled = votes + project_deploy_board.is_comments_enabled = comments + project_deploy_board.is_reactions_enabled = reactions project_deploy_board.save() - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 93bab2de3..15b05e83f 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -147,9 +147,9 @@ class GlobalSearchEndpoint(BaseAPIView): pages = Page.objects.filter( q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, workspace__slug=slug, ) @@ -249,7 +249,7 @@ class IssueSearchEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, - project__archived_at__isnull=True + project__archived_at__isnull=True, ) if workspace_search == "false": diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 9a9cdde43..5d757ef57 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -1,5 +1,5 @@ # Python imports -# import uuid +import uuid # Django imports from django.db.models import Case, Count, IntegerField, Q, When @@ -183,8 +183,8 @@ class UserEndpoint(BaseViewSet): profile.save() # Reset password - # user.is_password_autoset = True - # user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.set_password(uuid.uuid4().hex) # Deactivate the user user.is_active = False @@ -250,6 +250,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): ).select_related("actor", "workspace", "issue", "project") return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 72c27d20a..d4bf258a5 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,47 +1,56 @@ # Django imports -from django.db.models import ( - Q, - OuterRef, - Func, - F, - Case, - Value, - CharField, - When, - Exists, - Max, -) -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + UUIDField, + Value, +) from django.db.models.functions import Coalesce +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from rest_framework import status # Third party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.permissions import ( + ProjectEntityPermission, + WorkspaceEntityPermission, +) +from plane.app.serializers import ( + IssueViewSerializer, +) +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueView, + Workspace, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueViewSerializer, - IssueSerializer, -) -from plane.app.permissions import ( - WorkspaceEntityPermission, - ProjectEntityPermission, -) + from plane.db.models import ( - Workspace, - IssueView, - Issue, UserFavorite, - IssueLink, - IssueAttachment, ) -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter + class GlobalViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer @@ -143,17 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -162,103 +160,107 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(cycle_id=F("issue_cycle__cycle_id")) ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - if self.fields: - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields - ).data + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=None, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 94a22a1a7..addb8c5ac 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -1,61 +1,66 @@ # Python imports from datetime import date + from dateutil.relativedelta import relativedelta # Django imports -from django.utils import timezone from django.db.models import ( - OuterRef, - Func, - F, - Q, - Count, Case, - Value, - CharField, - When, - Max, + Count, + F, + Func, IntegerField, - UUIDField, + OuterRef, + Q, + Value, + When, ) -from django.db.models.functions import ExtractWeek, Cast from django.db.models.fields import DateField -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce +from django.db.models.functions import Cast, ExtractWeek +from django.utils import timezone # Third party modules from rest_framework import status from rest_framework.response import Response -# Module imports -from plane.app.serializers import ( - WorkSpaceSerializer, - ProjectMemberSerializer, - IssueActivitySerializer, - IssueSerializer, - WorkspaceUserPropertiesSerializer, -) -from plane.app.views.base import BaseAPIView -from plane.db.models import ( - User, - Workspace, - ProjectMember, - IssueActivity, - Issue, - IssueLink, - IssueAttachment, - IssueSubscriber, - Project, - WorkspaceMember, - CycleIssue, - WorkspaceUserProperties, -) from plane.app.permissions import ( WorkspaceEntityPermission, WorkspaceViewerPermission, ) + +# Module imports +from plane.app.serializers import ( + IssueActivitySerializer, + ProjectMemberSerializer, + WorkSpaceSerializer, + WorkspaceUserPropertiesSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + CycleIssue, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueSubscriber, + Project, + ProjectMember, + User, + Workspace, + WorkspaceMember, + WorkspaceUserProperties, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): @@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ] def get(self, request, slug, user_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) .order_by("created_at") ).distinct() - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - return Response(issues, status=status.HTTP_200_OK) + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) class WorkspaceUserPropertiesEndpoint(BaseAPIView): @@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): queryset = queryset.filter(project__in=projects) return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 7b899e63c..5876e934f 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -4,6 +4,8 @@ import uuid # Django imports from django.utils import timezone +from django.core.validators import validate_email +from django.core.exceptions import ValidationError # Third party imports from zxcvbn import zxcvbn @@ -46,68 +48,71 @@ class Adapter: def authenticate(self): raise NotImplementedError - def complete_login_or_signup(self): - email = self.user_data.get("email") - user = User.objects.filter(email=email).first() - # Check if sign up case or login - is_signup = bool(user) - if not user: - # New user - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "1"), - }, - ] - ) - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], - error_message="SIGNUP_DISABLED", - payload={"email": email}, - ) - user = User(email=email, username=uuid.uuid4().hex) - - if self.user_data.get("user").get("is_password_autoset"): - user.set_password(uuid.uuid4().hex) - user.is_password_autoset = True - user.is_email_verified = True - else: - # Validate password - results = zxcvbn(self.code) - if results["score"] < 3: - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD" - ], - error_message="INVALID_PASSWORD", - payload={"email": email}, - ) - - user.set_password(self.code) - user.is_password_autoset = False - - avatar = self.user_data.get("user", {}).get("avatar", "") - first_name = self.user_data.get("user", {}).get("first_name", "") - last_name = self.user_data.get("user", {}).get("last_name", "") - user.avatar = avatar if avatar else "" - user.first_name = first_name if first_name else "" - user.last_name = last_name if last_name else "" - user.save() - Profile.objects.create(user=user) - - if not user.is_active: + def sanitize_email(self, email): + # Check if email is present + if not email: raise AuthenticationException( - AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], - error_message="USER_ACCOUNT_DEACTIVATED", + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, ) + # Sanitize email + email = str(email).lower().strip() + + # validate email + try: + validate_email(email) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, + ) + # Return email + return email + + def validate_password(self, email): + """Validate password strength""" + results = zxcvbn(self.code) + if results["score"] < 3: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + payload={"email": email}, + ) + return + + def __check_signup(self, email): + """Check if sign up is enabled or not and raise exception if not enabled""" + + # Get configuration value + (ENABLE_SIGNUP,) = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "1"), + }, + ] + ) + + # Check if sign up is disabled and invite is present or not + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + # Raise exception + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, + ) + + return True + + def save_user_data(self, user): # Update user details user.last_login_medium = self.provider user.last_active = timezone.now() @@ -116,7 +121,63 @@ class Adapter: user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() user.save() + return user + def complete_login_or_signup(self): + # Get email + email = self.user_data.get("email") + + # Sanitize email + email = self.sanitize_email(email) + + # Check if the user is present + user = User.objects.filter(email=email).first() + # Check if sign up case or login + is_signup = bool(user) + # If user is not present, create a new user + if not user: + # New user + self.__check_signup(email) + + # Initialize user + user = User(email=email, username=uuid.uuid4().hex) + + # Check if password is autoset + if self.user_data.get("user").get("is_password_autoset"): + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + + # Validate password + else: + # Validate password + self.validate_password(email) + # Set password + user.set_password(self.code) + user.is_password_autoset = False + + # Set user details + avatar = self.user_data.get("user", {}).get("avatar", "") + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.avatar = avatar if avatar else "" + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + user.save() + + # Create profile + Profile.objects.create(user=user) + + if not user.is_active: + raise AuthenticationException( + AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], + error_message="USER_ACCOUNT_DEACTIVATED", + ) + + # Save user data + user = self.save_user_data(user=user) + + # Call callback if present if self.callback: self.callback( user, @@ -124,7 +185,9 @@ class Adapter: self.request, ) + # Create or update account if token data is present if self.token_data: self.create_update_account(user=user) + # Return user return user diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 7b12db945..55ff10988 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -58,6 +58,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_DEACTIVATED": 5190, # Rate limit "RATE_LIMIT_EXCEEDED": 5900, + # Unknown + "AUTHENTICATION_FAILED": 5999, } diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index 60c2ea0c6..b1a92e79e 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -8,6 +8,10 @@ from django.utils import timezone from plane.db.models import Account from .base import Adapter +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) class OauthAdapter(Adapter): @@ -50,20 +54,42 @@ class OauthAdapter(Adapter): return self.complete_login_or_signup() def get_user_token(self, data, headers=None): - headers = headers or {} - response = requests.post( - self.get_token_url(), data=data, headers=headers - ) - response.raise_for_status() - return response.json() + try: + headers = headers or {} + response = requests.post( + self.get_token_url(), data=data, headers=headers + ) + response.raise_for_status() + return response.json() + except requests.RequestException: + code = ( + "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "google" + else "GITHUB_OAUTH_PROVIDER_ERROR" + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[code], + error_message=str(code), + ) def get_user_response(self): - headers = { - "Authorization": f"Bearer {self.token_data.get('access_token')}" - } - response = requests.get(self.get_user_info_url(), headers=headers) - response.raise_for_status() - return response.json() + try: + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}" + } + response = requests.get(self.get_user_info_url(), headers=headers) + response.raise_for_status() + return response.json() + except requests.RequestException: + if self.provider == "google": + code = "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "github": + code = "GITHUB_OAUTH_PROVIDER_ERROR" + + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[code], + error_message=str(code), + ) def set_user_data(self, data): self.user_data = data diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index 21309ea9c..418dd2a06 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -48,7 +48,7 @@ class MagicCodeProvider(CredentialAdapter): raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], error_message="SMTP_NOT_CONFIGURED", - payload={"email": str(self.key)}, + payload={"email": str(key)}, ) if ENABLE_MAGIC_LINK_LOGIN == "0": @@ -57,7 +57,7 @@ class MagicCodeProvider(CredentialAdapter): "MAGIC_LINK_LOGIN_DISABLED" ], error_message="MAGIC_LINK_LOGIN_DISABLED", - payload={"email": str(self.key)}, + payload={"email": str(key)}, ) super().__init__( diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index 798863d8f..edd99b1ba 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -105,14 +105,26 @@ class GitHubOAuthProvider(OauthAdapter): ) def __get_email(self, headers): - # Github does not provide email in user response - emails_url = "https://api.github.com/user/emails" - emails_response = requests.get(emails_url, headers=headers).json() - email = next( - (email["email"] for email in emails_response if email["primary"]), - None, - ) - return email + try: + # Github does not provide email in user response + emails_url = "https://api.github.com/user/emails" + emails_response = requests.get(emails_url, headers=headers).json() + email = next( + ( + email["email"] + for email in emails_response + if email["primary"] + ), + None, + ) + return email + except requests.RequestException: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITHUB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) def set_user_data(self): user_info_response = self.get_user_response() diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py index 8910ec637..3b6f231ed 100644 --- a/apiserver/plane/authentication/utils/workspace_project_join.py +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -4,6 +4,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, ) +from plane.utils.cache import invalidate_cache_directly def process_workspace_project_invitations(user): @@ -26,6 +27,16 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) + [ + invalidate_cache_directly( + path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", + url_params=False, + user=False, + multiple=True, + ) + for workspace_member_invite in workspace_member_invites + ] + # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter( email=user.email, accepted=True diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py index e76cdac22..83ba513d7 100644 --- a/apiserver/plane/bgtasks/dummy_data_task.py +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -278,7 +278,6 @@ def create_page_labels(workspace, project, user_id, pages_count): PageLabel( page_id=page, label_id=label, - project=project, workspace=workspace, ) ) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index c99836c83..cfb6853a7 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -69,26 +69,34 @@ def create_zip_file(files): def upload_to_s3(zip_file, workspace_id, token_id, slug): - file_name = ( - f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" - ) + file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" expires_in = 7 * 24 * 60 * 60 if settings.USE_MINIO: - s3 = boto3.client( + upload_s3 = boto3.client( "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), ) - s3.upload_fileobj( + upload_s3.upload_fileobj( zip_file, settings.AWS_STORAGE_BUCKET_NAME, file_name, ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, ) - presigned_url = s3.generate_presigned_url( + + # Generate presigned url for the uploaded file with different base + presign_s3 = boto3.client( + "s3", + endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + presigned_url = presign_s3.generate_presigned_url( "get_object", Params={ "Bucket": settings.AWS_STORAGE_BUCKET_NAME, @@ -96,19 +104,27 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): }, ExpiresIn=expires_in, ) - # Create the new url with updated domain and protocol - presigned_url = presigned_url.replace( - f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/", - f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/", - ) else: - s3 = boto3.client( - "s3", - region_name=settings.AWS_REGION, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - ) + + # If endpoint url is present, use it + if settings.AWS_S3_ENDPOINT_URL: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + else: + s3 = boto3.client( + "s3", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + # Upload the file to S3 s3.upload_fileobj( zip_file, settings.AWS_STORAGE_BUCKET_NAME, @@ -116,6 +132,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, ) + # Generate presigned url for the uploaded file presigned_url = s3.generate_presigned_url( "get_object", Params={ @@ -127,6 +144,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): exporter_instance = ExporterHistory.objects.get(token=token_id) + # Update the exporter instance with the presigned url if presigned_url: exporter_instance.url = presigned_url exporter_instance.status = "completed" diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 007b3e48c..67cda14af 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -28,6 +28,7 @@ from plane.db.models import ( Project, State, User, + EstimatePoint, ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception @@ -448,21 +449,37 @@ def track_estimate_points( if current_instance.get("estimate_point") != requested_data.get( "estimate_point" ): + old_estimate = ( + EstimatePoint.objects.filter( + pk=current_instance.get("estimate_point") + ).first() + if current_instance.get("estimate_point") is not None + else None + ) + new_estimate = ( + EstimatePoint.objects.filter( + pk=requested_data.get("estimate_point") + ).first() + if requested_data.get("estimate_point") is not None + else None + ) issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=( + old_identifier=( current_instance.get("estimate_point") if current_instance.get("estimate_point") is not None - else "" + else None ), - new_value=( + new_identifier=( requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None - else "" + else None ), + old_value=old_estimate.value if old_estimate else None, + new_value=new_estimate.value if new_estimate else None, field="estimate_point", project_id=project_id, workspace_id=workspace_id, diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py index eceb3693e..e3cf81a6e 100644 --- a/apiserver/plane/bgtasks/page_transaction_task.py +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id): entity_identifier=mention["entity_identifier"], entity_name=mention["entity_name"], workspace_id=page.workspace_id, - project_id=page.project_id, created_at=timezone.now(), updated_at=timezone.now(), ) diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index b60c49da1..84ef237ef 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -5,6 +5,7 @@ import logging from celery import shared_task # Django imports +# Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags diff --git a/apiserver/plane/db/management/commands/activate_user.py b/apiserver/plane/db/management/commands/activate_user.py new file mode 100644 index 000000000..29123b4e5 --- /dev/null +++ b/apiserver/plane/db/management/commands/activate_user.py @@ -0,0 +1,34 @@ +# Django imports +from django.core.management import BaseCommand, CommandError + +# Module imports +from plane.db.models import User + + +class Command(BaseCommand): + help = "Make the user with the given email active" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("email", type=str, help="user email") + + def handle(self, *args, **options): + # get the user email from console + email = options.get("email", False) + + # raise error if email is not present + if not email: + raise CommandError("Error: Email is required") + + # filter the user + user = User.objects.filter(email=email).first() + + # Raise error if the user is not present + if not user: + raise CommandError(f"Error: User with {email} does not exists") + + # Activate the user + user.is_active = True + user.save() + + self.stdout.write(self.style.SUCCESS("User activated succesfully")) diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apiserver/plane/db/migrations/0067_issue_estimate.py new file mode 100644 index 000000000..b341f9864 --- /dev/null +++ b/apiserver/plane/db/migrations/0067_issue_estimate.py @@ -0,0 +1,260 @@ +# # Generated by Django 4.2.7 on 2024-05-24 09:47 +# Python imports +import uuid +from uuid import uuid4 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import plane.db.models.deploy_board + + +def issue_estimate_point(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + Project = apps.get_model("db", "Project") + EstimatePoint = apps.get_model("db", "EstimatePoint") + IssueActivity = apps.get_model("db", "IssueActivity") + updated_estimate_point = [] + updated_issue_activity = [] + + # loop through all the projects + for project in Project.objects.filter(estimate__isnull=False): + estimate_points = EstimatePoint.objects.filter( + estimate=project.estimate, project=project + ) + + for issue_activity in IssueActivity.objects.filter( + field="estimate_point", project=project + ): + if issue_activity.new_value: + new_identifier = estimate_points.filter( + key=issue_activity.new_value + ).first().id + issue_activity.new_identifier = new_identifier + new_value = estimate_points.filter( + key=issue_activity.new_value + ).first().value + issue_activity.new_value = new_value + + if issue_activity.old_value: + old_identifier = estimate_points.filter( + key=issue_activity.old_value + ).first().id + issue_activity.old_identifier = old_identifier + old_value = estimate_points.filter( + key=issue_activity.old_value + ).first().value + issue_activity.old_value = old_value + updated_issue_activity.append(issue_activity) + + for issue in Issue.objects.filter( + point__isnull=False, project=project + ): + # get the estimate id for the corresponding estimate point in the issue + estimate = estimate_points.filter(key=issue.point).first() + issue.estimate_point = estimate + updated_estimate_point.append(issue) + + Issue.objects.bulk_update( + updated_estimate_point, ["estimate_point"], batch_size=1000 + ) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value", "new_identifier", "old_identifier"], + batch_size=1000, + ) + + +def last_used_estimate(apps, schema_editor): + Project = apps.get_model("db", "Project") + Estimate = apps.get_model("db", "Estimate") + + # Get all estimate ids used in projects + estimate_ids = Project.objects.filter(estimate__isnull=False).values_list( + "estimate", flat=True + ) + + # Update all matching estimates + Estimate.objects.filter(id__in=estimate_ids).update(last_used=True) + + +def populate_deploy_board(apps, schema_editor): + DeployBoard = apps.get_model("db", "DeployBoard") + ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard") + + DeployBoard.objects.bulk_create( + [ + DeployBoard( + entity_identifier=deploy_board.project_id, + project_id=deploy_board.project_id, + entity_name="project", + anchor=uuid4().hex, + is_comments_enabled=deploy_board.comments, + is_reactions_enabled=deploy_board.reactions, + inbox=deploy_board.inbox, + is_votes_enabled=deploy_board.votes, + view_props=deploy_board.views, + workspace_id=deploy_board.workspace_id, + created_at=deploy_board.created_at, + updated_at=deploy_board.updated_at, + created_by_id=deploy_board.created_by_id, + updated_by_id=deploy_board.updated_by_id, + ) + for deploy_board in ProjectDeployBoard.objects.all() + ], + batch_size=100, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0066_account_id_token_cycle_logo_props_module_logo_props"), + ] + + operations = [ + migrations.CreateModel( + name="DeployBoard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ], + max_length=30, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.deploy_board.get_anchor, + max_length=255, + unique=True, + ), + ), + ("is_comments_enabled", models.BooleanField(default=False)), + ("is_reactions_enabled", models.BooleanField(default=False)), + ("is_votes_enabled", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="board_inbox", + to="db.inbox", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Deploy Board", + "verbose_name_plural": "Deploy Boards", + "db_table": "deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("entity_name", "entity_identifier")}, + }, + ), + migrations.AddField( + model_name="estimate", + name="last_used", + field=models.BooleanField(default=False), + ), + # Rename the existing field + migrations.RenameField( + model_name="issue", + old_name="estimate_point", + new_name="point", + ), + # Add a new field with the original name as a foreign key + migrations.AddField( + model_name="issue", + name="estimate_point", + field=models.ForeignKey( + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_estimates", + to="db.EstimatePoint", + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="estimate", + name="type", + field=models.CharField(default="categories", max_length=255), + ), + migrations.AlterField( + model_name="estimatepoint", + name="value", + field=models.CharField(max_length=255), + ), + migrations.RunPython(issue_estimate_point), + migrations.RunPython(last_used_estimate), + migrations.RunPython(populate_deploy_board), + ] diff --git a/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py new file mode 100644 index 000000000..50475c2a8 --- /dev/null +++ b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py @@ -0,0 +1,257 @@ +# Generated by Django 4.2.11 on 2024-06-07 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def migrate_pages(apps, schema_editor): + ProjectPage = apps.get_model("db", "ProjectPage") + Page = apps.get_model("db", "Page") + ProjectPage.objects.bulk_create( + [ + ProjectPage( + workspace_id=page.get("workspace_id"), + project_id=page.get("project_id"), + page_id=page.get("id"), + created_by_id=page.get("created_by_id"), + updated_by_id=page.get("updated_by_id"), + ) + for page in Page.objects.values( + "workspace_id", + "project_id", + "id", + "created_by_id", + "updated_by_id", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0067_issue_estimate"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="is_global", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ProjectPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Page", + "verbose_name_plural": "Project Pages", + "db_table": "project_pages", + "ordering": ("-created_at",), + "unique_together": {("project", "page")}, + }, + ), + migrations.CreateModel( + name="TeamPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.page", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.team", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Team Page", + "verbose_name_plural": "Team Pages", + "db_table": "team_pages", + "ordering": ("-created_at",), + "unique_together": {("team", "page")}, + }, + ), + migrations.AddField( + model_name="page", + name="projects", + field=models.ManyToManyField( + related_name="pages", through="db.ProjectPage", to="db.project" + ), + ), + migrations.AddField( + model_name="page", + name="teams", + field=models.ManyToManyField( + related_name="pages", through="db.TeamPage", to="db.team" + ), + ), + migrations.RunPython(migrate_pages), + migrations.RemoveField( + model_name="page", + name="project", + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to="db.workspace", + ), + ), + migrations.RemoveField( + model_name="pagelabel", + name="project", + ), + migrations.RemoveField( + model_name="pagelog", + name="project", + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_label", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelog", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_log", + to="db.workspace", + ), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b11ce7aa3..a1c2b5ecf 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -4,6 +4,7 @@ from .asset import FileAsset from .base import BaseModel from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget +from .deploy_board import DeployBoard from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer @@ -49,17 +50,17 @@ from .notification import ( Notification, UserNotificationPreference, ) -from .page import Page, PageFavorite, PageLabel, PageLog +from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage from .project import ( Project, ProjectBaseModel, - ProjectDeployBoard, ProjectFavorite, ProjectIdentifier, ProjectMember, ProjectMemberInvite, ProjectPublicMember, ) +from .deploy_board import DeployBoard from .session import Session from .social_connection import SocialLoginConnection from .state import State diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 7dd2f2c91..86e5ceef8 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -12,6 +12,7 @@ from .base import BaseModel def get_upload_path(instance, filename): + filename = filename[:50] if instance.workspace_id is not None: return f"{instance.workspace.id}/{uuid4().hex}-{filename}" return f"user-{uuid4().hex}-{filename}" diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py new file mode 100644 index 000000000..41ffbc7c1 --- /dev/null +++ b/apiserver/plane/db/models/deploy_board.py @@ -0,0 +1,53 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +def get_anchor(): + return uuid4().hex + + +class DeployBoard(WorkspaceBaseModel): + TYPE_CHOICES = ( + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ) + + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + ) + anchor = models.CharField( + max_length=255, default=get_anchor, unique=True, db_index=True + ) + is_comments_enabled = models.BooleanField(default=False) + is_reactions_enabled = models.BooleanField(default=False) + inbox = models.ForeignKey( + "db.Inbox", + related_name="board_inbox", + on_delete=models.SET_NULL, + null=True, + ) + is_votes_enabled = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the deploy board""" + return f"{self.entity_identifier} <{self.entity_name}>" + + class Meta: + unique_together = ["entity_name", "entity_identifier"] + verbose_name = "Deploy Board" + verbose_name_plural = "Deploy Boards" + db_table = "deploy_boards" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index 6ff1186c3..0713d774f 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel): description = models.TextField( verbose_name="Estimate Description", blank=True ) - type = models.CharField(max_length=255, default="Categories") + type = models.CharField(max_length=255, default="categories") + last_used = models.BooleanField(default=False) def __str__(self): """Return name of the estimate""" @@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel): default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) description = models.TextField(blank=True) - value = models.CharField(max_length=20) + value = models.CharField(max_length=255) def __str__(self): """Return name of the estimate""" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 527597ddc..2b07bd77b 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -119,11 +119,18 @@ class Issue(ProjectBaseModel): blank=True, related_name="state_issue", ) - estimate_point = models.IntegerField( + point = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True, ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="issue_estimates", + null=True, + blank=True, + ) name = models.CharField(max_length=255, verbose_name="Issue Name") description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index e079dcbe5..9a8b3078d 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -9,13 +9,17 @@ from django.db import models from plane.utils.html_processor import strip_tags from .project import ProjectBaseModel +from .base import BaseModel def get_view_props(): return {"full_width": False} -class Page(ProjectBaseModel): +class Page(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="pages" + ) name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) @@ -44,6 +48,13 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) + is_global = models.BooleanField(default=False) + projects = models.ManyToManyField( + "db.Project", related_name="pages", through="db.ProjectPage" + ) + teams = models.ManyToManyField( + "db.Team", related_name="pages", through="db.TeamPage" + ) class Meta: verbose_name = "Page" @@ -56,7 +67,7 @@ class Page(ProjectBaseModel): return f"{self.owned_by.email} <{self.name}>" -class PageLog(ProjectBaseModel): +class PageLog(BaseModel): TYPE_CHOICES = ( ("to_do", "To Do"), ("issue", "issue"), @@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel): choices=TYPE_CHOICES, verbose_name="Transaction Type", ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" + ) class Meta: unique_together = ["page", "transaction"] @@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel): return f"{self.user.email} <{self.page.name}>" -class PageLabel(ProjectBaseModel): +class PageLabel(BaseModel): label = models.ForeignKey( "db.Label", on_delete=models.CASCADE, related_name="page_labels" ) page = models.ForeignKey( "db.Page", on_delete=models.CASCADE, related_name="page_labels" ) + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_page_label", + ) class Meta: verbose_name = "Page Label" @@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel): def __str__(self): return f"{self.page.name} {self.label.name}" + + +class ProjectPage(BaseModel): + project = models.ForeignKey( + "db.Project", on_delete=models.CASCADE, related_name="project_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="project_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="project_pages" + ) + + class Meta: + unique_together = ["project", "page"] + verbose_name = "Project Page" + verbose_name_plural = "Project Pages" + db_table = "project_pages" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.page.name}" + + +class TeamPage(BaseModel): + team = models.ForeignKey( + "db.Team", on_delete=models.CASCADE, related_name="team_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="team_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="team_pages" + ) + + class Meta: + unique_together = ["team", "page"] + verbose_name = "Team Page" + verbose_name_plural = "Team Pages" + db_table = "team_pages" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 49fca1323..ba8dbf580 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -260,6 +260,8 @@ def get_default_views(): } +# DEPRECATED TODO: +# used to get the old anchors for the project deploy boards class ProjectDeployBoard(ProjectBaseModel): anchor = models.CharField( max_length=255, default=get_anchor, unique=True, db_index=True diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 92e82d012..3b905e64d 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -13,12 +13,9 @@ class InstanceSerializer(BaseSerializer): model = Instance exclude = [ "license_key", - "api_key", - "version", ] read_only_fields = [ "id", - "instance_id", "email", "last_checked_at", "is_setup_done", diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 5a6eadc2e..1d4d5fb29 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -2,7 +2,7 @@ import os # Django imports -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError # Module imports from plane.license.models import InstanceConfiguration @@ -15,6 +15,12 @@ class Command(BaseCommand): from plane.license.utils.encryption import encrypt_data from plane.license.utils.instance_value import get_configuration_value + mandatory_keys = ["SECRET_KEY"] + + for item in mandatory_keys: + if not os.environ.get(item): + raise CommandError(f"{item} env variable is required.") + config_keys = [ # Authentication Settings { diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index b5cc8a60d..42676bb72 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -49,8 +49,8 @@ class Command(BaseCommand): instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), license_key=None, - api_key=secrets.token_hex(8), - version=payload.get("version"), + current_version=payload.get("version"), + latest_version=payload.get("version"), last_checked_at=timezone.now(), user_count=payload.get("user_count", 0), ) diff --git a/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py b/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py new file mode 100644 index 000000000..3cdea7902 --- /dev/null +++ b/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 4.2.11 on 2024-05-31 10:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("license", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="instance", + name="instance_id", + field=models.CharField(max_length=255, unique=True), + ), + migrations.RenameField( + model_name="instance", + old_name="version", + new_name="current_version", + ), + migrations.RemoveField( + model_name="instance", + name="api_key", + ), + migrations.AddField( + model_name="instance", + name="domain", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=10, null=True), + ), + migrations.AddField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=50), + ), + migrations.CreateModel( + name="ChangeLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=100)), + ("description", models.TextField(blank=True)), + ("version", models.CharField(max_length=100)), + ("tags", models.JSONField(default=list)), + ("release_date", models.DateTimeField(null=True)), + ("is_release_candidate", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Change Log", + "verbose_name_plural": "Change Logs", + "db_table": "changelogs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py new file mode 100644 index 000000000..8d7b9a402 --- /dev/null +++ b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2024-06-05 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0002_rename_version_instance_current_version_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="changelog", + name="title", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="changelog", + name="version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="current_version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="namespace", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=255), + ), + ] diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index b8957e44f..0c0581c8b 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -1,3 +1,6 @@ +# Python imports +from enum import Enum + # Django imports from django.db import models from django.conf import settings @@ -8,17 +11,25 @@ from plane.db.models import BaseModel ROLE_CHOICES = ((20, "Admin"),) +class ProductTypes(Enum): + PLANE_CE = "plane-ce" + + class Instance(BaseModel): - # General informations + # General information instance_name = models.CharField(max_length=255) whitelist_emails = models.TextField(blank=True, null=True) - instance_id = models.CharField(max_length=25, unique=True) + instance_id = models.CharField(max_length=255, unique=True) license_key = models.CharField(max_length=256, null=True, blank=True) - api_key = models.CharField(max_length=16) - version = models.CharField(max_length=10) - # Instnace specifics + current_version = models.CharField(max_length=255) + latest_version = models.CharField(max_length=255, null=True, blank=True) + product = models.CharField( + max_length=255, default=ProductTypes.PLANE_CE.value + ) + domain = models.TextField(blank=True) + # Instance specifics last_checked_at = models.DateTimeField() - namespace = models.CharField(max_length=50, blank=True, null=True) + namespace = models.CharField(max_length=255, blank=True, null=True) # telemetry and support is_telemetry_enabled = models.BooleanField(default=True) is_support_required = models.BooleanField(default=True) @@ -70,3 +81,20 @@ class InstanceConfiguration(BaseModel): verbose_name_plural = "Instance Configurations" db_table = "instance_configurations" ordering = ("-created_at",) + + +class ChangeLog(BaseModel): + """Change Log model to store the release changelogs made in the application.""" + + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + version = models.CharField(max_length=255) + tags = models.JSONField(default=list) + release_date = models.DateTimeField(null=True) + is_release_candidate = models.BooleanField(default=False) + + class Meta: + verbose_name = "Change Log" + verbose_name_plural = "Change Logs" + db_table = "changelogs" + ordering = ("-created_at",) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 853478c75..40128f9ad 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -225,6 +225,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" # Storage Settings +# Use Minio settings +USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 + STORAGES = { "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", @@ -243,7 +246,7 @@ AWS_S3_FILE_OVERWRITE = False AWS_S3_ENDPOINT_URL = os.environ.get( "AWS_S3_ENDPOINT_URL", None ) or os.environ.get("MINIO_ENDPOINT_URL", None) -if AWS_S3_ENDPOINT_URL: +if AWS_S3_ENDPOINT_URL and USE_MINIO: parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" @@ -307,8 +310,6 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) -# Use Minio settings -USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 # Posthog settings POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) @@ -350,4 +351,4 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" # Base URLs ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) -APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL") +APP_BASE_URL = os.environ.get("APP_BASE_URL") diff --git a/apiserver/plane/space/urls/inbox.py b/apiserver/plane/space/urls/inbox.py index 60de040e2..20ebb3437 100644 --- a/apiserver/plane/space/urls/inbox.py +++ b/apiserver/plane/space/urls/inbox.py @@ -10,7 +10,7 @@ from plane.space.views import ( urlpatterns = [ path( - "workspaces//project-boards//inboxes//inbox-issues/", + "anchor//inboxes//inbox-issues/", InboxIssuePublicViewSet.as_view( { "get": "list", @@ -20,7 +20,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//project-boards//inboxes//inbox-issues//", + "anchor//inboxes//inbox-issues//", InboxIssuePublicViewSet.as_view( { "get": "retrieve", @@ -31,7 +31,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//project-boards//issues//votes/", + "anchor//issues//votes/", IssueVotePublicViewSet.as_view( { "get": "list", diff --git a/apiserver/plane/space/urls/issue.py b/apiserver/plane/space/urls/issue.py index 099eace5d..61c19ba01 100644 --- a/apiserver/plane/space/urls/issue.py +++ b/apiserver/plane/space/urls/issue.py @@ -10,12 +10,12 @@ from plane.space.views import ( urlpatterns = [ path( - "workspaces//project-boards//issues//", + "anchor//issues//", IssueRetrievePublicEndpoint.as_view(), name="workspace-project-boards", ), path( - "workspaces//project-boards//issues//comments/", + "anchor//issues//comments/", IssueCommentPublicViewSet.as_view( { "get": "list", @@ -25,7 +25,7 @@ urlpatterns = [ name="issue-comments-project-board", ), path( - "workspaces//project-boards//issues//comments//", + "anchor//issues//comments//", IssueCommentPublicViewSet.as_view( { "get": "retrieve", @@ -36,7 +36,7 @@ urlpatterns = [ name="issue-comments-project-board", ), path( - "workspaces//project-boards//issues//reactions/", + "anchor//issues//reactions/", IssueReactionPublicViewSet.as_view( { "get": "list", @@ -46,7 +46,7 @@ urlpatterns = [ name="issue-reactions-project-board", ), path( - "workspaces//project-boards//issues//reactions//", + "anchor//issues//reactions//", IssueReactionPublicViewSet.as_view( { "delete": "destroy", @@ -55,7 +55,7 @@ urlpatterns = [ name="issue-reactions-project-board", ), path( - "workspaces//project-boards//comments//reactions/", + "anchor//comments//reactions/", CommentReactionPublicViewSet.as_view( { "get": "list", @@ -65,7 +65,7 @@ urlpatterns = [ name="comment-reactions-project-board", ), path( - "workspaces//project-boards//comments//reactions//", + "anchor//comments//reactions//", CommentReactionPublicViewSet.as_view( { "delete": "destroy", diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py index dc97b43a7..3294b01f6 100644 --- a/apiserver/plane/space/urls/project.py +++ b/apiserver/plane/space/urls/project.py @@ -4,17 +4,23 @@ from django.urls import path from plane.space.views import ( ProjectDeployBoardPublicSettingsEndpoint, ProjectIssuesPublicEndpoint, + WorkspaceProjectAnchorEndpoint, ) urlpatterns = [ path( - "workspaces//project-boards//settings/", + "anchor//settings/", ProjectDeployBoardPublicSettingsEndpoint.as_view(), name="project-deploy-board-settings", ), path( - "workspaces//project-boards//issues/", + "anchor//issues/", ProjectIssuesPublicEndpoint.as_view(), name="project-deploy-board", ), + path( + "workspaces//projects//anchor/", + WorkspaceProjectAnchorEndpoint.as_view(), + name="project-deploy-board", + ), ] diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index 5130e04d5..eced7d1b4 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -1,6 +1,7 @@ from .project import ( ProjectDeployBoardPublicSettingsEndpoint, WorkspaceProjectDeployBoardEndpoint, + WorkspaceProjectAnchorEndpoint, ) from .issue import ( diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 9f681c160..b89c77672 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -18,7 +18,7 @@ from plane.db.models import ( State, IssueLink, IssueAttachment, - ProjectDeployBoard, + DeployBoard, ) from plane.app.serializers import ( IssueSerializer, @@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ] def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -58,9 +58,9 @@ class InboxIssuePublicViewSet(BaseViewSet): ) return InboxIssue.objects.none() - def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def list(self, request, anchor, inbox_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -72,8 +72,8 @@ class InboxIssuePublicViewSet(BaseViewSet): issues = ( Issue.objects.filter( issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) .filter(**filters) .annotate(bridge_id=F("issue_inbox__id")) @@ -117,9 +117,9 @@ class InboxIssuePublicViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, inbox_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -151,7 +151,7 @@ class InboxIssuePublicViewSet(BaseViewSet): name="Triage", group="backlog", description="Default state for managing all Inbox Issues", - project_id=project_id, + project_id=project_deploy_board.project_id, color="#ff7700", ) @@ -163,7 +163,7 @@ class InboxIssuePublicViewSet(BaseViewSet): "description_html", "

" ), priority=request.data.get("issue", {}).get("priority", "low"), - project_id=project_id, + project_id=project_deploy_board.project_id, state=state, ) @@ -173,14 +173,14 @@ class InboxIssuePublicViewSet(BaseViewSet): requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue.id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) # create an inbox issue InboxIssue.objects.create( inbox_id=inbox_id, - project_id=project_id, + project_id=project_deploy_board.project_id, issue=issue, source=request.data.get("source", "in-app"), ) @@ -188,9 +188,9 @@ class InboxIssuePublicViewSet(BaseViewSet): serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def partial_update(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -200,8 +200,8 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) # Get the project member @@ -216,8 +216,8 @@ class InboxIssuePublicViewSet(BaseViewSet): issue = Issue.objects.get( pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) # viewers and guests since only viewers and guests issue_data = { @@ -242,7 +242,7 @@ class InboxIssuePublicViewSet(BaseViewSet): requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(issue.id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, @@ -255,9 +255,9 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def retrieve(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -267,21 +267,21 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) issue = Issue.objects.get( pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -291,8 +291,8 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 8c4d6e150..a736ed8ec 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -1,56 +1,51 @@ # Python imports import json +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch + # Django imports from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - IntegerField, -) -from django.core.serializers.json import DjangoJSONEncoder - -# Third Party imports -from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated -# Module imports -from .base import BaseViewSet, BaseAPIView -from plane.app.serializers import ( - IssueCommentSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssuePublicSerializer, -) +# Third Party imports +from rest_framework.response import Response +from plane.app.serializers import ( + CommentReactionSerializer, + IssueCommentSerializer, + IssuePublicSerializer, + IssueReactionSerializer, + IssueVoteSerializer, +) from plane.db.models import ( Issue, IssueComment, - Label, IssueLink, IssueAttachment, - State, ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, + DeployBoard, IssueVote, ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports +from .base import BaseAPIView, BaseViewSet class IssueCommentPublicViewSet(BaseViewSet): @@ -76,15 +71,15 @@ class IssueCommentPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + anchor=self.kwargs.get("anchor"), + entity_name="project", ) - if project_deploy_board.comments: + if project_deploy_board.is_comments_enabled: return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + .filter(workspace_id=project_deploy_board.workspace_id) .filter(issue_id=self.kwargs.get("issue_id")) .filter(access="EXTERNAL") .select_related("project") @@ -93,8 +88,8 @@ class IssueCommentPublicViewSet(BaseViewSet): .annotate( is_member=Exists( ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, member_id=self.request.user.id, is_active=True, ) @@ -103,15 +98,15 @@ class IssueCommentPublicViewSet(BaseViewSet): .distinct() ).order_by("created_at") return IssueComment.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueComment.objects.none() - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, @@ -120,7 +115,7 @@ class IssueCommentPublicViewSet(BaseViewSet): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, + project_id=project_deploy_board.project_id, issue_id=issue_id, actor=request.user, access="EXTERNAL", @@ -132,37 +127,35 @@ class IssueCommentPublicViewSet(BaseViewSet): ), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def partial_update(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def partial_update(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) - comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, actor=request.user - ) + comment = IssueComment.objects.get(pk=pk, actor=request.user) serializer = IssueCommentSerializer( comment, data=request.data, partial=True ) @@ -173,7 +166,7 @@ class IssueCommentPublicViewSet(BaseViewSet): requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, @@ -183,20 +176,18 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, - project_id=project_id, actor=request.user, ) issue_activity.delay( @@ -204,7 +195,7 @@ class IssueCommentPublicViewSet(BaseViewSet): requested_data=json.dumps({"comment_id": str(pk)}), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, @@ -221,11 +212,11 @@ class IssueReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) - if project_deploy_board.reactions: + if project_deploy_board.is_reactions_enabled: return ( super() .get_queryset() @@ -236,15 +227,15 @@ class IssueReactionPublicViewSet(BaseViewSet): .distinct() ) return IssueReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueReaction.objects.none() - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this project board"}, status=status.HTTP_400_BAD_REQUEST, @@ -253,16 +244,18 @@ class IssueReactionPublicViewSet(BaseViewSet): serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user + project_id=project_deploy_board.project_id, + issue_id=issue_id, + actor=request.user, ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_activity.delay( @@ -272,25 +265,25 @@ class IssueReactionPublicViewSet(BaseViewSet): ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, issue_id, reaction_code): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this project board"}, status=status.HTTP_400_BAD_REQUEST, ) issue_reaction = IssueReaction.objects.get( - workspace__slug=slug, + workspace_id=project_deploy_board.workspace_id, issue_id=issue_id, reaction=reaction_code, actor=request.user, @@ -300,7 +293,7 @@ class IssueReactionPublicViewSet(BaseViewSet): requested_data=None, actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "reaction": str(reaction_code), @@ -319,30 +312,29 @@ class CommentReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + anchor=self.kwargs.get("anchor"), entity_name="project" ) - if project_deploy_board.reactions: + if project_deploy_board.is_reactions_enabled: return ( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) .filter(comment_id=self.kwargs.get("comment_id")) .order_by("-created_at") .distinct() ) return CommentReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return CommentReaction.objects.none() - def create(self, request, slug, project_id, comment_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, comment_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this board"}, status=status.HTTP_400_BAD_REQUEST, @@ -351,18 +343,18 @@ class CommentReactionPublicViewSet(BaseViewSet): serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, + project_id=project_deploy_board.project_id, comment_id=comment_id, actor=request.user, ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_activity.delay( @@ -379,19 +371,19 @@ class CommentReactionPublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, comment_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, comment_id, reaction_code): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this board"}, status=status.HTTP_400_BAD_REQUEST, ) comment_reaction = CommentReaction.objects.get( - project_id=project_id, - workspace__slug=slug, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, comment_id=comment_id, reaction=reaction_code, actor=request.user, @@ -401,7 +393,7 @@ class CommentReactionPublicViewSet(BaseViewSet): requested_data=None, actor_id=str(self.request.user.id), issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "reaction": str(reaction_code), @@ -421,36 +413,39 @@ class IssueVotePublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + workspace__slug=self.kwargs.get("anchor"), + entity_name="project", ) - if project_deploy_board.votes: + if project_deploy_board.is_votes_enabled: return ( super() .get_queryset() .filter(issue_id=self.kwargs.get("issue_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) ) return IssueVote.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueVote.objects.none() - def create(self, request, slug, project_id, issue_id): + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, - project_id=project_id, + project_id=project_deploy_board.project_id, issue_id=issue_id, ) # Add the user for workspace tracking if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_vote.vote = request.data.get("vote", 1) @@ -462,26 +457,29 @@ class IssueVotePublicViewSet(BaseViewSet): ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) - def destroy(self, request, slug, project_id, issue_id): + def destroy(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue_vote = IssueVote.objects.get( - workspace__slug=slug, - project_id=project_id, issue_id=issue_id, actor_id=request.user.id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, ) issue_activity.delay( type="issue_vote.activity.deleted", requested_data=None, actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "vote": str(issue_vote.vote), @@ -499,9 +497,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id, issue_id): + def get(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=issue_id + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + pk=issue_id, ) serializer = IssuePublicSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) @@ -512,27 +515,23 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id): - if not ProjectDeployBoard.objects.filter( - workspace__slug=slug, project_id=project_id + def get(self, request, anchor): + if not DeployBoard.objects.filter( + anchor=anchor, entity_name="project" ).exists(): return Response( {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND, ) + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) + + project_id = project_deploy_board.entity_identifier + slug = project_deploy_board.workspace.slug filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -544,8 +543,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) + .filter(project_id=project_deploy_board.project_id) + .filter(workspace_id=project_deploy_board.workspace_id) .select_related("project", "workspace", "state", "parent") .prefetch_related("assignees", "labels") .prefetch_related( @@ -562,7 +561,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -577,112 +575,118 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).distinct() + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssuePublicSerializer(issue_queryset, many=True).data - - state_group_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - - states = ( - State.objects.filter( - ~Q(name="Triage"), - workspace__slug=slug, - project_id=project_id, - ) - .annotate( - custom_order=Case( - *[ - When(group=value, then=Value(index)) - for index, value in enumerate(state_group_order) - ], - default=Value(len(state_group_order)), - output_field=IntegerField(), + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), ) - .values("name", "group", "color", "id") - .order_by("custom_order", "sequence") - ) - - labels = Label.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("id", "name", "color", "parent") - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - issues = group_results(issues, group_by) - - return Response( - { - "issues": issues, - "states": states, - "labels": labels, - }, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 10a3c3879..76f1600ee 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView -from plane.app.serializers import ProjectDeployBoardSerializer +from plane.app.serializers import DeployBoardSerializer from plane.db.models import ( Project, - ProjectDeployBoard, + DeployBoard, ) @@ -23,11 +23,11 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def get(self, request, anchor): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) @@ -36,13 +36,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug): + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list projects = ( - Project.objects.filter(workspace__slug=slug) + Project.objects.filter(workspace=deploy_board.workspace) .annotate( is_public=Exists( - ProjectDeployBoard.objects.filter( - workspace__slug=slug, project_id=OuterRef("pk") + DeployBoard.objects.filter( + anchor=anchor, + project_id=OuterRef("pk"), + entity_name="project", ) ) ) @@ -58,3 +61,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): ) return Response(projects, status=status.HTTP_200_OK) + + +class WorkspaceProjectAnchorEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index cd57690c6..59ddbd933 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -4,18 +4,28 @@ from itertools import groupby # Django import from django.db import models -from django.db.models import Case, CharField, Count, F, Sum, Value, When +from django.db.models import ( + Case, + CharField, + Count, + F, + Sum, + Value, + When, + IntegerField, +) from django.db.models.functions import ( Coalesce, Concat, ExtractMonth, ExtractYear, TruncDate, + Cast, ) from django.utils import timezone # Module imports -from plane.db.models import Issue +from plane.db.models import Issue, Project def annotate_with_monthly_dimension(queryset, field_name, attribute): @@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): # Estimate else: - queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by( - x_axis - ) + queryset = queryset.annotate( + estimate=Sum(Cast("estimate_point__value", IntegerField())) + ).order_by(x_axis) queryset = ( queryset.annotate(segment=F(segment)) if segment else queryset ) @@ -110,9 +120,33 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): return sort_data(grouped_data, temp_axis) -def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): +def burndown_plot( + queryset, + slug, + project_id, + plot_type, + cycle_id=None, + module_id=None, +): # Total Issues in Cycle or Module total_issues = queryset.total_issues + # check whether the estimate is a point or not + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + if estimate_type and plot_type == "points": + issue_estimates = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + estimate_point__isnull=False, + ).values_list("estimate_point__value", flat=True) + + issue_estimates = [int(value) for value in issue_estimates] + total_estimate_points = sum(issue_estimates) if cycle_id: if queryset.end_date and queryset.start_date: @@ -128,18 +162,32 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): chart_data = {str(date): 0 for date in date_range} - completed_issues_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_cycle__cycle_id=cycle_id, + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + estimate_point__isnull=False, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") + ) + else: + completed_issues_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") ) - .annotate(date=TruncDate("completed_at")) - .values("date") - .annotate(total_completed=Count("id")) - .values("date", "total_completed") - .order_by("date") - ) if module_id: # Get all dates between the two dates @@ -152,31 +200,59 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): chart_data = {str(date): 0 for date in date_range} - completed_issues_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_module__module_id=module_id, + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + estimate_point__isnull=False, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") + ) + else: + completed_issues_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") ) - .annotate(date=TruncDate("completed_at")) - .values("date") - .annotate(total_completed=Count("id")) - .values("date", "total_completed") - .order_by("date") - ) for date in date_range: - cumulative_pending_issues = total_issues - total_completed = 0 - total_completed = sum( - item["total_completed"] - for item in completed_issues_distribution - if item["date"] is not None and item["date"] <= date - ) - cumulative_pending_issues -= total_completed - if date > timezone.now().date(): - chart_data[str(date)] = None + if plot_type == "points": + cumulative_pending_issues = total_estimate_points + total_completed = 0 + total_completed = sum( + int(item["estimate_point__value"]) + for item in completed_issues_estimate_point_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues else: - chart_data[str(date)] = cumulative_pending_issues + cumulative_pending_issues = total_issues + total_completed = 0 + total_completed = sum( + item["total_completed"] + for item in completed_issues_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues return chart_data diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 071051129..bda942899 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -66,7 +66,7 @@ def invalidate_cache_directly( custom_path = path if path is not None else request.get_full_path() auth_header = ( None - if request.user.is_anonymous + if request and request.user.is_anonymous else str(request.user.id) if user else None ) key = generate_cache_key(custom_path, auth_header) diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index 0938f054b..e1d4ea26f 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -1,16 +1,23 @@ # Python imports import logging +import traceback + +# Django imports +from django.conf import settings # Third party imports from sentry_sdk import capture_exception def log_exception(e): - print(e) # Log the error logger = logging.getLogger("plane") logger.error(e) + if settings.DEBUG: + # Print the traceback if in debug mode + traceback.print_exc(e) + # Capture in sentry if configured capture_exception(e) return diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index edc7adc15..dd7d3e87a 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -1,240 +1,204 @@ -def resolve_keys(group_keys, value): - """resolve keys to a key which will be used for - grouping +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value +from django.db.models.functions import Coalesce - Args: - group_keys (string): key which will be used for grouping - value (obj): data value - - Returns: - string: the key which will be used for - """ - keys = group_keys.split(".") - for key in keys: - value = value.get(key, None) - return value +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, +) -def group_results(results_data, group_by, sub_group_by=False): - """group results data into certain group_by +def issue_queryset_grouper(queryset, group_by, sub_group_by): - Args: - results_data (obj): complete results data - group_by (key): string + FIELD_MAPPER = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } - Returns: - obj: grouped results - """ - if sub_group_by: - main_responsive_dict = dict() + annotations_map = { + "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), + "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "module_ids": ( + "issue_module__module_id", + ~Q(issue_module__module_id__isnull=True), + ), + } + default_annotations = { + key: Coalesce( + ArrayAgg( + field, + distinct=True, + filter=condition, + ), + Value([], output_field=ArrayField(UUIDField())), + ) + for key, (field, condition) in annotations_map.items() + if FIELD_MAPPER.get(key) != group_by + or FIELD_MAPPER.get(key) != sub_group_by + } - if sub_group_by == "priority": - main_responsive_dict = { - "urgent": {}, - "high": {}, - "medium": {}, - "low": {}, - "none": {}, - } + return queryset.annotate(**default_annotations) - for value in results_data: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) - if isinstance(main_group_attribute, list) and not isinstance( - group_attribute, list - ): - if len(main_group_attribute): - for attrib in main_group_attribute: - if str(attrib) not in main_responsive_dict: - main_responsive_dict[str(attrib)] = {} - if ( - str(group_attribute) - in main_responsive_dict[str(attrib)] - ): - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(attrib)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if str(group_attribute) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) +def issue_on_results(issues, group_by, sub_group_by): - elif isinstance(group_attribute, list) and not isinstance( - main_group_attribute, list - ): - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(None) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } - elif isinstance(group_attribute, list) and isinstance( - main_group_attribute, list - ): - if len(main_group_attribute): - for main_attrib in main_group_attribute: - if str(main_attrib) not in main_responsive_dict: - main_responsive_dict[str(main_attrib)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(None) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(attrib) - ] = [] - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - if str(None) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_responsive_dict[str(None)][str(None)] = [] - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) + original_list = ["assignee_ids", "label_ids", "module_ids"] - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} + required_fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + "state__group", + ] - if ( - str(group_attribute) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) - return main_responsive_dict + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) - else: - response_dict = {} + required_fields.extend(original_list) + return issues.values(*required_fields) - if group_by == "priority": - response_dict = { - "urgent": [], - "high": [], - "medium": [], - "low": [], - "none": [], - } - for value in results_data: - group_attribute = resolve_keys(group_by, value) - if isinstance(group_attribute, list): - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in response_dict: - response_dict[str(attrib)].append(value) - else: - response_dict[str(attrib)] = [] - response_dict[str(attrib)].append(value) - else: - if str(None) in response_dict: - response_dict[str(None)].append(value) - else: - response_dict[str(None)] = [] - response_dict[str(None)].append(value) - else: - if str(group_attribute) in response_dict: - response_dict[str(group_attribute)].append(value) - else: - response_dict[str(group_attribute)] = [] - response_dict[str(group_attribute)].append(value) +def issue_group_values(field, slug, project_id=None, filters=dict): + if field == "state_id": + queryset = State.objects.filter( + ~Q(name="Triage"), + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "labels__id": + queryset = Label.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "assignees__id": + if project_id: + return ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).values_list("member_id", flat=True) + else: + return list( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True + ).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": + queryset = Module.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "cycle_id": + queryset = Cycle.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + return list(queryset) + if field == "priority": + return [ + "low", + "medium", + "high", + "urgent", + "none", + ] + if field == "state__group": + return [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + if field == "target_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("target_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "start_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("start_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) - return response_dict + if field == "created_by": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("created_by", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + return [] diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 531ef93ec..d68850856 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,6 +1,7 @@ import re import uuid from datetime import timedelta + from django.utils import timezone # The date from pattern @@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries): """ for query in queries: date_query = query.split(";") - if len(date_query) >= 2: - match = pattern.match(date_query[0]) - if match: - if len(date_query) == 3: - digit, term = date_query[0].split("_") - string_date_filter( - filter=filter, - duration=int(digit), - subsequent=date_query[1], - term=term, - date_filter=date_term, - offset=date_query[2], - ) - else: - if "after" in date_query: - filter[f"{date_term}__gte"] = date_query[0] + if date_query: + if len(date_query) >= 2: + match = pattern.match(date_query[0]) + if match: + if len(date_query) == 3: + digit, term = date_query[0].split("_") + string_date_filter( + filter=filter, + duration=int(digit), + subsequent=date_query[1], + term=term, + date_filter=date_term, + offset=date_query[2], + ) else: - filter[f"{date_term}__lte"] = date_query[0] + if "after" in date_query: + filter[f"{date_term}__gte"] = date_query[0] + else: + filter[f"{date_term}__lte"] = date_query[0] + else: + filter[f"{date_term}__contains"] = date_query[0] def filter_state(params, filter, method, prefix=""): diff --git a/apiserver/plane/utils/order_queryset.py b/apiserver/plane/utils/order_queryset.py new file mode 100644 index 000000000..aafa954dc --- /dev/null +++ b/apiserver/plane/utils/order_queryset.py @@ -0,0 +1,84 @@ +from django.db.models import ( + Case, + CharField, + Min, + Value, + When, +) + +# Custom ordering for priority and state +PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"] +STATE_ORDER = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", +] + + +def order_issue_queryset(issue_queryset, order_by_param="-created_at"): + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(PRIORITY_ORDER) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + order_by_param = ( + "-priority_order" + if order_by_param.startswith("-") + else "priority_order" + ) + # State Ordering + elif order_by_param in [ + "state__group", + "-state__group", + ]: + state_order = ( + STATE_ORDER + if order_by_param in ["state__name", "state__group"] + else STATE_ORDER[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + order_by_param = ( + "-state_order" if order_by_param.startswith("-") else "state_order" + ) + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "assignees__first_name", + "issue_module__module__name", + "-labels__name", + "-assignees__first_name", + "-issue_module__module__name", + ]: + issue_queryset = issue_queryset.annotate( + min_values=Min( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + order_by_param = ( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + order_by_param = order_by_param + return issue_queryset, order_by_param diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 8cc853370..8bea4746a 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -1,33 +1,49 @@ -from rest_framework.response import Response -from rest_framework.exceptions import ParseError -from collections.abc import Sequence +# Python imports import math +from collections import defaultdict +from collections.abc import Sequence + +# Django imports +from django.db.models import Count, F, Window +from django.db.models.functions import RowNumber + +# Third party imports +from rest_framework.exceptions import ParseError +from rest_framework.response import Response + +# Module imports class Cursor: + # The cursor value def __init__(self, value, offset=0, is_prev=False, has_results=None): self.value = value self.offset = int(offset) self.is_prev = bool(is_prev) self.has_results = has_results + # Return the cursor value in string format def __str__(self): return f"{self.value}:{self.offset}:{int(self.is_prev)}" + # Return the cursor value def __eq__(self, other): return all( getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results") ) + # Return the representation of the cursor def __repr__(self): return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" + # Return if the cursor is true def __bool__(self): return bool(self.has_results) @classmethod def from_string(cls, value): + """Return the cursor value from string format""" try: bits = value.split(":") if len(bits) != 3: @@ -50,15 +66,19 @@ class CursorResult(Sequence): self.max_hits = max_hits def __len__(self): + # Return the length of the results return len(self.results) def __iter__(self): + # Return the iterator of the results return iter(self.results) def __getitem__(self, key): + # Return the results based on the key return self.results[key] def __repr__(self): + # Return the representation of the results return f"<{type(self).__name__}: results={len(self.results)}>" @@ -85,11 +105,14 @@ class OffsetPaginator: max_offset=None, on_results=None, ): + # Key tuple and remove `-` if descending order by self.key = ( order_by if order_by is None or isinstance(order_by, (list, tuple, set)) - else (order_by,) + else (order_by[1::] if order_by.startswith("-") else order_by,) ) + # Set desc to true when `-` exists in the order by + self.desc = True if order_by and order_by.startswith("-") else False self.queryset = queryset self.max_limit = max_limit self.max_offset = max_offset @@ -101,11 +124,101 @@ class OffsetPaginator: if cursor is None: cursor = Cursor(0, 0, 0) + # Get the min from limit and max limit limit = min(limit, self.max_limit) + # queryset queryset = self.queryset if self.key: - queryset = queryset.order_by(*self.key) + queryset = queryset.order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ) + # The current page + page = cursor.offset + # The offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + results = queryset[offset:stop] + + if cursor.value != limit: + results = results[-(limit + 1) :] + + # Adjust cursors based on the results for pagination + next_cursor = Cursor(limit, page + 1, False, results.count() > limit) + # If the page is greater than 0, then set the previous cursor + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + # Process the results + results = results[:limit] + + # Process the results + if self.on_results: + results = self.on_results(results) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + max_hits = math.ceil(count / limit) + + # Return the cursor results + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def process_results(self, results): + raise NotImplementedError + + +class GroupedOffsetPaginator(OffsetPaginator): + + # Field mappers + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "modules__id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + group_by_fields, + count_filter, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + self.count_filter = count_filter + + def get_result(self, limit=50, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset page = cursor.offset offset = cursor.offset * cursor.value @@ -116,20 +229,73 @@ class OffsetPaginator: if offset < 0: raise BadPaginationError("Pagination offset cannot be negative") - results = list(queryset[offset:stop]) - if cursor.value != limit: - results = results[-(limit + 1) :] + # Compute the results + results = {} + # Create window for all the groups + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[F(self.group_by_field_name)], + order_by=( + ( + F(*self.key).desc( + nulls_last=True + ) # order by desc if desc is set + if self.desc + else F(*self.key).asc( + nulls_last=True + ) # Order by asc if set + ), + F("created_at").desc(), + ), + ) + ) + # Filter the results by row number + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) - next_cursor = Cursor(limit, page + 1, False, len(results) > limit) - prev_cursor = Cursor(limit, page - 1, True, page > 0) - - results = list(results[:limit]) - if self.on_results: - results = self.on_results(results) + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + # Count the queryset count = queryset.count() - max_hits = math.ceil(count / limit) + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 return CursorResult( results=results, next=next_cursor, @@ -138,6 +304,386 @@ class OffsetPaginator: max_hits=max_hits, ) + def __get_total_queryset(self): + # Get total queryset + return ( + self.queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by() + ) + + def __get_total_dict(self): + # Convert the total into dictionary of keys as group name and value as the total + total_group_dict = {} + for group in self.__get_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + + return total_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict = self.__get_total_dict() + return { + str(field): { + "results": [], + "total_results": total_group_dict.get(str(field), 0), + } + for field in self.group_by_fields + } + + def __result_already_added(self, result, group): + # Check if the result is already added then add it + for existing_issue in group: + if existing_issue["id"] == result["id"]: + return True + return False + + def __query_multi_grouper(self, results): + # Grouping for m2m values + total_group_dict = self.__get_total_dict() + + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + # Preparing a dict to group result by group ID + grouped_by_field_name = defaultdict(list) + + # Iterate over results to fill the above dictionaries + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Adding group_ids key to each issue and grouping by group_name + for result in results: + result_id = result["id"] + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + # If a result belongs to multiple groups, add it to each group + for group_id in group_ids: + if not self.__result_already_added( + result, grouped_by_field_name[group_id] + ): + grouped_by_field_name[group_id].append(result) + + # Convert grouped_by_field_name back to a list for each group + processed_results = { + str(group_id): { + "results": issues, + "total_results": total_group_dict.get(str(group_id)), + } + for group_id, issues in grouped_by_field_name.items() + } + + return processed_results + + def __query_grouper(self, results): + # Grouping for single values + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + if group_value in processed_results: + processed_results[str(group_value)]["results"].append(result) + return processed_results + + def process_results(self, results): + # Process results + if results: + if self.group_by_field_name in self.FIELD_MAPPER: + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + + +class SubGroupedOffsetPaginator(OffsetPaginator): + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "modules__id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + sub_group_by_field_name, + group_by_fields, + sub_group_by_fields, + count_filter, + *args, + **kwargs, + ): + super().__init__(queryset, *args, **kwargs) + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + self.sub_group_by_field_name = sub_group_by_field_name + self.sub_group_by_fields = sub_group_by_fields + self.count_filter = count_filter + + def get_result(self, limit=30, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + + # Create windows for group and sub group field name + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[ + F(self.group_by_field_name), + F(self.sub_group_by_field_name), + ], + order_by=( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ), + ) + ) + + # Filter the results + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_group_total_queryset(self): + # Get group totals + return ( + self.queryset.order_by(self.group_by_field_name) + .values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .distinct() + ) + + def __get_subgroup_total_queryset(self): + # Get subgroup totals + return ( + self.queryset.values( + self.group_by_field_name, self.sub_group_by_field_name + ) + .annotate( + count=Count("id", filter=self.count_filter, distinct=True) + ) + .order_by() + .values( + self.group_by_field_name, self.sub_group_by_field_name, "count" + ) + ) + + def __get_total_dict(self): + # Use the above to convert to dictionary of 2D objects + total_group_dict = {} + total_sub_group_dict = {} + for group in self.__get_group_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + + # Sub group total values + for item in self.__get_subgroup_total_queryset(): + group = str(item[self.group_by_field_name]) + subgroup = str(item[self.sub_group_by_field_name]) + count = item["count"] + + if group not in total_sub_group_dict: + total_sub_group_dict[str(group)] = {} + + if subgroup not in total_sub_group_dict[group]: + total_sub_group_dict[str(group)][str(subgroup)] = {} + + total_sub_group_dict[group][subgroup] = count + + return total_group_dict, total_sub_group_dict + + def __get_field_dict(self): + total_group_dict, total_sub_group_dict = self.__get_total_dict() + + return { + str(group): { + "results": { + str(sub_group): { + "results": [], + "total_results": total_sub_group_dict.get( + str(group) + ).get(str(sub_group), 0), + } + for sub_group in total_sub_group_dict.get(str(group), []) + }, + "total_results": total_group_dict.get(str(group), 0), + } + for group in self.group_by_fields + } + + def __query_multi_grouper(self, results): + # Multi grouper + processed_results = self.__get_field_dict() + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + result_sub_group_mapping = defaultdict(set) + + # Iterate over results to fill the above dictionaries + if self.group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Use the same calculation for the sub group + if self.sub_group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + sub_group_id = result[self.sub_group_by_field_name] + result_sub_group_mapping[str(result_id)].add(str(sub_group_id)) + + # Iterate over results + for result in results: + # Get the group value + group_value = str(result.get(self.group_by_field_name)) + # Get the sub group value + sub_group_value = str(result.get(self.sub_group_by_field_name)) + if ( + group_value in processed_results + and sub_group_value + in processed_results[str(group_value)]["results"] + ): + if self.group_by_field_name in self.FIELD_MAPPER: + # for multi grouper + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + if self.sub_group_by_field_name in self.FIELD_MAPPER: + sub_group_ids = list(result_group_mapping[str(result_id)]) + # for multi groups + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in sub_group_ids else sub_group_ids + ) + + processed_results[str(group_value)]["results"][ + str(sub_group_value) + ]["results"].append(result) + + return processed_results + + def __query_grouper(self, results): + # Single grouper + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + sub_group_value = str(result.get(self.sub_group_by_field_name)) + processed_results[group_value]["results"][sub_group_value][ + "results" + ].append(result) + + return processed_results + + def process_results(self, results): + if results: + if ( + self.group_by_field_name in self.FIELD_MAPPER + or self.sub_group_by_field_name in self.FIELD_MAPPER + ): + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + class BasePaginator: """BasePaginator class can be inherited by any View to return a paginated view""" @@ -171,6 +717,11 @@ class BasePaginator: cursor_cls=Cursor, extra_stats=None, controller=None, + group_by_field_name=None, + group_by_fields=None, + sub_group_by_field_name=None, + sub_group_by_fields=None, + count_filter=None, **paginator_kwargs, ): """Paginate the request""" @@ -178,15 +729,27 @@ class BasePaginator: # Convert the cursor value to integer and float from string input_cursor = None - if request.GET.get(self.cursor_name): - try: - input_cursor = cursor_cls.from_string( - request.GET.get(self.cursor_name) - ) - except ValueError: - raise ParseError(detail="Invalid cursor parameter.") + try: + input_cursor = cursor_cls.from_string( + request.GET.get(self.cursor_name, f"{per_page}:0:0"), + ) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") if not paginator: + if group_by_field_name: + paginator_kwargs["group_by_field_name"] = group_by_field_name + paginator_kwargs["group_by_fields"] = group_by_fields + paginator_kwargs["count_filter"] = count_filter + + if sub_group_by_field_name: + paginator_kwargs["sub_group_by_field_name"] = ( + sub_group_by_field_name + ) + paginator_kwargs["sub_group_by_fields"] = ( + sub_group_by_fields + ) + paginator = paginator_cls(**paginator_kwargs) try: @@ -196,12 +759,14 @@ class BasePaginator: except BadPaginationError: raise ParseError(detail="Error in parsing") - # Serialize result according to the on_result function if on_results: results = on_results(cursor_result.results) else: results = cursor_result.results + if group_by_field_name: + results = paginator.process_results(results=results) + # Add Manipulation functions to the response if controller is not None: results = controller(results) @@ -211,6 +776,9 @@ class BasePaginator: # Return the response response = Response( { + "grouped_by": group_by_field_name, + "sub_grouped_by": sub_group_by_field_name, + "total_count": (cursor_result.hits), "next_cursor": str(cursor_result.next), "prev_cursor": str(cursor_result.prev), "next_page_results": cursor_result.next.has_results, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index a6bd2ab50..028451874 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -60,4 +60,5 @@ zxcvbn==4.4.28 # timezone pytz==2024.1 # jwt -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 + diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index c75e9cfee..10f64fd1c 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -128,7 +128,7 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: ${PULL_POLICY:-always} - restart: no + restart: "no" command: ./bin/docker-entrypoint-migrator.sh volumes: - logs_migrator:/code/plane/logs diff --git a/nginx/supervisor.conf b/nginx/supervisor.conf deleted file mode 100644 index 7e1ed70e9..000000000 --- a/nginx/supervisor.conf +++ /dev/null @@ -1,32 +0,0 @@ -[supervisord] ## This is the main process for the Supervisor -nodaemon=true - -[program:node] -command=sh /usr/local/bin/start.sh -autostart=true -autorestart=true -stderr_logfile=/var/log/node.err.log -stdout_logfile=/var/log/node.out.log - -[program:python] -directory=/code -command=sh bin/docker-entrypoint-api.sh -autostart=true -autorestart=true -stderr_logfile=/var/log/python.err.log -stdout_logfile=/var/log/python.out.log - -[program:nginx] -command=nginx -g "daemon off;" -autostart=true -autorestart=true -stderr_logfile=/var/log/nginx.err.log -stdout_logfile=/var/log/nginx.out.log - -[program:worker] -directory=/code -command=sh bin/worker -autostart=true -autorestart=true -stderr_logfile=/var/log/worker.err.log -stdout_logfile=/var/log/worker.out.log \ No newline at end of file diff --git a/package.json b/package.json index 813f9bcd1..c0f92bdc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.20.0", + "version": "0.21.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -34,10 +34,11 @@ "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", - "turbo": "^1.13.2" + "turbo": "^2.0.3" }, "resolutions": { "@types/react": "18.2.48" }, - "packageManager": "yarn@1.22.19" + "packageManager": "yarn@1.22.22", + "name": "plane" } diff --git a/packages/constants/package.json b/packages/constants/package.json deleted file mode 100644 index ac97dcb11..000000000 --- a/packages/constants/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@plane/constants", - "version": "0.20.0", - "private": true, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*" - } -} diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts deleted file mode 100644 index c12b63d63..000000000 --- a/packages/constants/src/auth.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { ReactNode } from "react"; -import Link from "next/link"; - -export enum EPageTypes { - PUBLIC = "PUBLIC", - NON_AUTHENTICATED = "NON_AUTHENTICATED", - SET_PASSWORD = "SET_PASSWORD", - ONBOARDING = "ONBOARDING", - AUTHENTICATED = "AUTHENTICATED", -} - -export enum EAuthModes { - SIGN_IN = "SIGN_IN", - SIGN_UP = "SIGN_UP", -} - -export enum EAuthSteps { - EMAIL = "EMAIL", - PASSWORD = "PASSWORD", - UNIQUE_CODE = "UNIQUE_CODE", -} - -// TODO: remove this -export enum EErrorAlertType { - BANNER_ALERT = "BANNER_ALERT", - INLINE_FIRST_NAME = "INLINE_FIRST_NAME", - INLINE_EMAIL = "INLINE_EMAIL", - INLINE_PASSWORD = "INLINE_PASSWORD", - INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", -} - -export enum EAuthErrorCodes { - // Global - INSTANCE_NOT_CONFIGURED = "5000", - INVALID_EMAIL = "5005", - EMAIL_REQUIRED = "5010", - SIGNUP_DISABLED = "5015", - MAGIC_LINK_LOGIN_DISABLED = "5017", - PASSWORD_LOGIN_DISABLED = "5019", - SMTP_NOT_CONFIGURED = "5025", - // Password strength - INVALID_PASSWORD = "5020", - // Sign Up - USER_ACCOUNT_DEACTIVATED = "5019", - USER_ALREADY_EXIST = "5030", - AUTHENTICATION_FAILED_SIGN_UP = "5035", - REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", - INVALID_EMAIL_SIGN_UP = "5045", - INVALID_EMAIL_MAGIC_SIGN_UP = "5050", - MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", - // Sign In - USER_DOES_NOT_EXIST = "5060", - AUTHENTICATION_FAILED_SIGN_IN = "5065", - REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", - INVALID_EMAIL_SIGN_IN = "5075", - INVALID_EMAIL_MAGIC_SIGN_IN = "5080", - MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", - // Both Sign in and Sign up for magic - INVALID_MAGIC_CODE = "5090", - EXPIRED_MAGIC_CODE = "5095", - EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100", - // Oauth - GOOGLE_NOT_CONFIGURED = "5105", - GITHUB_NOT_CONFIGURED = "5110", - GOOGLE_OAUTH_PROVIDER_ERROR = "5115", - GITHUB_OAUTH_PROVIDER_ERROR = "5120", - // Reset Password - INVALID_PASSWORD_TOKEN = "5125", - EXPIRED_PASSWORD_TOKEN = "5130", - // Change password - INCORRECT_OLD_PASSWORD = "5135", - MISSING_PASSWORD= "5138", - INVALID_NEW_PASSWORD = "5140", - // set passowrd - PASSWORD_ALREADY_SET = "5145", - // Admin - ADMIN_ALREADY_EXIST = "5150", - REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", - INVALID_ADMIN_EMAIL = "5160", - INVALID_ADMIN_PASSWORD = "5165", - REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", - ADMIN_AUTHENTICATION_FAILED = "5175", - ADMIN_USER_ALREADY_EXIST = "5180", - ADMIN_USER_DOES_NOT_EXIST = "5185", -} - -export type TAuthErrorInfo = { - type: EErrorAlertType; - code: EAuthErrorCodes; - title: string; - message: ReactNode; -}; - -const errorCodeMessages: { - [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; -} = { - // global - [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: { - title: `Instance not configured`, - message: () => `Instance not configured. Please contact your administrator.`, - }, - [EAuthErrorCodes.SIGNUP_DISABLED]: { - title: `Sign up disabled`, - message: () => `Sign up disabled. Please contact your administrator.`, - }, - [EAuthErrorCodes.INVALID_PASSWORD]: { - title: `Invalid password`, - message: () => `Invalid password. Please try again.`, - }, - [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { - title: `SMTP not configured`, - message: () => `SMTP not configured. Please contact your administrator.`, - }, - - // email check in both sign up and sign in - [EAuthErrorCodes.INVALID_EMAIL]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { - title: `Email required`, - message: () => `Email required. Please try again.`, - }, - - // sign up - [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { - title: `User already exists`, - message: (email = undefined) => ( -
- Your account is already registered.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { - title: `Email and code required`, - message: () => `Email and code required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - - // sign in - [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { - title: `User account deactivated`, - message: () =>
Your account is deactivated. Contact support@plane.so.
, - }, - [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { - title: `User does not exist`, - message: (email = undefined) => ( -
- No account found.  - - Create one - -  to get started. -
- ), - }, - [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { - title: `Email and code required`, - message: () => `Email and code required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - - // Both Sign in and Sign up - [EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: { - title: `Authentication failed`, - message: () => `Invalid magic code. Please try again.`, - }, - [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: { - title: `Expired magic code`, - message: () => `Expired magic code. Please try again.`, - }, - [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED]: { - title: `Expired magic code`, - message: () => `Expired magic code. Please try again.`, - }, - - // Oauth - [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { - title: `Google not configured`, - message: () => `Google not configured. Please contact your administrator.`, - }, - [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { - title: `GitHub not configured`, - message: () => `GitHub not configured. Please contact your administrator.`, - }, - [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { - title: `Google OAuth provider error`, - message: () => `Google OAuth provider error. Please try again.`, - }, - [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { - title: `GitHub OAuth provider error`, - message: () => `GitHub OAuth provider error. Please try again.`, - }, - - // Reset Password - [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { - title: `Invalid password token`, - message: () => `Invalid password token. Please try again.`, - }, - [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { - title: `Expired password token`, - message: () => `Expired password token. Please try again.`, - }, - - // Change password - - [EAuthenticationErrorCodes.MISSING_PASSWORD]: { - title: `Password required`, - message: () => `Password required. Please try again.`, - }, - [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { - title: `Incorrect old password`, - message: () => `Incorrect old password. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { - title: `Invalid new password`, - message: () => `Invalid new password. Please try again.`, - }, - - // set password - [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { - title: `Password already set`, - message: () => `Password already set. Please try again.`, - }, - - // admin - [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { - title: `Admin already exists`, - message: () => `Admin already exists. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { - title: `Email, password and first name required`, - message: () => `Email, password and first name required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { - title: `Invalid admin email`, - message: () => `Invalid admin email. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { - title: `Invalid admin password`, - message: () => `Invalid admin password. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { - title: `Admin user already exists`, - message: () => ( -
- Admin user already exists.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { - title: `Admin user does not exist`, - message: () => ( -
- Admin user does not exist.  - - Sign In - -  now. -
- ), - }, -}; - -export const authErrorHandler = ( - errorCode: EAuthenticationErrorCodes, - email?: string | undefined -): TAuthErrorInfo | undefined => { - const bannerAlertErrorCodes = [ - EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, - EAuthenticationErrorCodes.INVALID_EMAIL, - EAuthenticationErrorCodes.EMAIL_REQUIRED, - EAuthenticationErrorCodes.SIGNUP_DISABLED, - EAuthenticationErrorCodes.INVALID_PASSWORD, - EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, - EAuthenticationErrorCodes.USER_ALREADY_EXIST, - EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, - EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, - EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, - EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, - EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, - EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, - EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, - EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.INVALID_MAGIC_CODE, - EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE, - EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED, - EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, - EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, - EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, - EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, - EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, - EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, - EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, - EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, - EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, - EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, - EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, - EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, - EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, - EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, - EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, - ]; - - if (bannerAlertErrorCodes.includes(errorCode)) - return { - type: EErrorAlertType.BANNER_ALERT, - code: errorCode, - title: errorCodeMessages[errorCode]?.title || "Error", - message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", - }; - - return undefined; -}; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts deleted file mode 100644 index 97ccf7649..000000000 --- a/packages/constants/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./auth"; diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index d01b25bb1..79f6d6687 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.20.0", + "version": "0.21.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 2d2e1662a..563cb5122 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -112,7 +112,7 @@ export const useEditor = ({ if (value === null || value === undefined) return; if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { try { - editor.commands.setContent(value); + editor.commands.setContent(value, false, { preserveWhitespace: "full" }); const currentSavedSelection = savedSelectionRef.current; if (currentSavedSelection) { const docLength = editor.state.doc.content.size; @@ -147,7 +147,7 @@ export const useEditor = ({ const item = getEditorMenuItem(itemName); if (item) { if (item.key === "image") { - item.command(savedSelection); + item.command(savedSelectionRef.current); } else { item.command(); } @@ -186,6 +186,7 @@ export const useEditor = ({ if (!editorRef.current) return; scrollSummary(editorRef.current, marking); }, + isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editorRef.current || editorRef.current.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 1f058ddd7..743278366 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -9,7 +9,7 @@ export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell // utils export * from "src/lib/utils"; export * from "src/ui/extensions/table/table"; -export { startImageUpload } from "src/ui/plugins/upload-image"; +export { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; // components export { EditorContainer } from "src/ui/components/editor-container"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index b82b1f354..6e05ff13d 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -1,8 +1,9 @@ import { Editor, Range } from "@tiptap/core"; -import { startImageUpload } from "src/ui/plugins/upload-image"; +import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; import { findTableAncestor } from "src/lib/utils"; import { Selection } from "@tiptap/pm/state"; import { UploadImage } from "src/types/upload-image"; +import { replaceCodeWithText } from "src/ui/extensions/code/utils/replace-code-block-with-text"; export const setText = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).clearNodes().run(); @@ -54,69 +55,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleUnderline().run(); }; -const replaceCodeBlockWithContent = (editor: Editor) => { - try { - const { schema } = editor.state; - const { paragraph } = schema.nodes; - let replaced = false; - - const replaceCodeBlock = (from: number, to: number, textContent: string) => { - const docSize = editor.state.doc.content.size; - - if (from < 0 || to > docSize || from > to) { - console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize); - return; - } - - // split the textContent by new lines to handle each line as a separate paragraph - const lines = textContent.split(/\r?\n/); - - const tr = editor.state.tr; - - // Calculate the position for inserting the first paragraph - let insertPos = from; - - // Remove the code block first - tr.delete(from, to); - - // For each line, create a paragraph node and insert it - lines.forEach((line) => { - const paragraphNode = paragraph.create({}, schema.text(line)); - tr.insert(insertPos, paragraphNode); - // Update insertPos for the next insertion - insertPos += paragraphNode.nodeSize; - }); - - // Dispatch the transaction - editor.view.dispatch(tr); - replaced = true; - }; - - editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => { - if (node.type === schema.nodes.codeBlock) { - const startPos = pos; - const endPos = pos + node.nodeSize; - const textContent = node.textContent; - if (textContent.length === 0) { - editor.chain().focus().toggleCodeBlock().run(); - } - replaceCodeBlock(startPos, endPos, textContent); - return false; - } - }); - - if (!replaced) { - console.log("No code block to replace."); - } - } catch (error) { - console.error("An error occurred while replacing code block content:", error); - } -}; - export const toggleCodeBlock = (editor: Editor, range?: Range) => { try { + // if it's a code block, replace it with the code with paragraphs if (editor.isActive("codeBlock")) { - replaceCodeBlockWithContent(editor); + replaceCodeWithText(editor); return; } @@ -124,11 +67,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { const text = editor.state.doc.textBetween(from, to, "\n"); const isMultiline = text.includes("\n"); + // if the selection is not a range i.e. empty, then simply convert it into a code block if (editor.state.selection.empty) { editor.chain().focus().toggleCodeBlock().run(); } else if (isMultiline) { + // if the selection is multiline, then also replace the text content with + // a code block editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run(); } else { + // if the selection is single line, then simply convert it into inline + // code editor.chain().focus().toggleCode().run(); } } catch (error) { @@ -194,7 +142,7 @@ export const insertImageCommand = ( if (range) editor.chain().focus().deleteRange(range).run(); const input = document.createElement("input"); input.type = "file"; - input.accept = "image/*"; + input.accept = ".jpeg, .jpg, .png, .webp, .svg"; input.onchange = async () => { if (input.files?.length) { const file = input.files[0]; diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 00312cb00..f76b6b1bf 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -110,6 +110,11 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover { } } +/* the p tag just after the ul tag */ +ul[data-type="taskList"] + p { + margin-top: 0.4rem !important; +} + ul[data-type="taskList"] li > label input[type="checkbox"] { position: relative; -webkit-appearance: none; @@ -152,6 +157,10 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { } } +ul[data-type="taskList"] li > div > p { + margin-top: 10px; +} + ul[data-type="taskList"] li[data-checked="true"] > div > p { color: rgb(var(--color-text-400)); text-decoration: line-through; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts index 4eed815d6..b15ae943d 100644 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -15,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; + isEditorReadyToDiscard: () => boolean; } diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx index bc629160a..60a12364e 100644 --- a/packages/editor/core/src/ui/extensions/code-inline/index.tsx +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create({ return { HTMLAttributes: { class: - "rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm", + "rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200", spellcheck: "false", }, }; diff --git a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx index 21fc36b39..57c665231 100644 --- a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx +++ b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; import { common, createLowlight } from "lowlight"; diff --git a/packages/editor/core/src/ui/extensions/code/utils/replace-code-block-with-text.ts b/packages/editor/core/src/ui/extensions/code/utils/replace-code-block-with-text.ts new file mode 100644 index 000000000..daf2c5f05 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/utils/replace-code-block-with-text.ts @@ -0,0 +1,124 @@ +import { Editor, findParentNode } from "@tiptap/core"; + +type ReplaceCodeBlockParams = { + editor: Editor; + from: number; + to: number; + textContent: string; + cursorPosInsideCodeblock: number; +}; + +export function replaceCodeWithText(editor: Editor): void { + try { + const { from, to } = editor.state.selection; + const cursorPosInsideCodeblock = from; + let replaced = false; + + editor.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type === editor.state.schema.nodes.codeBlock) { + const startPos = pos; + const endPos = pos + node.nodeSize; + const textContent = node.textContent; + + if (textContent.length === 0) { + editor.chain().focus().toggleCodeBlock().run(); + } else { + transformCodeBlockToParagraphs({ + editor, + from: startPos, + to: endPos, + textContent, + cursorPosInsideCodeblock, + }); + } + + replaced = true; + return false; + } + }); + + if (!replaced) { + console.log("No code block to replace."); + } + } catch (error) { + console.error("An error occurred while replacing code block content:", error); + } +} + +function transformCodeBlockToParagraphs({ + editor, + from, + to, + textContent, + cursorPosInsideCodeblock, +}: ReplaceCodeBlockParams): void { + const { schema } = editor.state; + const { paragraph } = schema.nodes; + const docSize = editor.state.doc.content.size; + + if (from < 0 || to > docSize || from > to) { + console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize); + return; + } + + // Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n) + const lines = textContent.split(/\r?\n/); + const tr = editor.state.tr; + let insertPos = from; + + // Remove the code block first + tr.delete(from, to); + + // For each line, create a paragraph node and insert it + lines.forEach((line) => { + // if the line is empty, create a paragraph node with no content + const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line)); + tr.insert(insertPos, paragraphNode); + insertPos += paragraphNode.nodeSize; + }); + + // Now persist the focus to the converted paragraph + const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos; + + if (parentNodeOffset === undefined) throw new Error("Invalid code block offset"); + + const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset); + const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1); + + editor.view.dispatch(tr); + editor.chain().focus(cursorPosOutsideCodeblock).run(); +} + +/** + * Calculates the line number where the cursor is located inside the code block. + * Assumes the indexing of the content inside the code block is like ProseMirror's indexing. + * + * @param {string} textContent - The content of the code block. + * @param {number} cursorPosition - The absolute cursor position in the document. + * @param {number} codeBlockNodePos - The starting position of the code block node in the document. + * @returns {number} The 1-based line number where the cursor is located. + */ +function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number { + // Split the text content into lines, handling both Unix and Windows newlines + const lines = textContent.split(/\r?\n/); + const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos; + + let startPosition = 0; + let lineNumber = 0; + + for (let i = 0; i < lines.length; i++) { + // Calculate the end position of the current line + const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character + + // Check if the cursor position is within the current line + if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) { + lineNumber = i + 1; // Line numbers are 1-based + break; + } + + // Update the start position for the next line + startPosition = endPosition; + } + + return lineNumber; +} diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts index 330ebbc12..d6ddb8ce0 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts @@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => { // Traverse up the document structure from the adjusted position for (let d = resolvedPos.depth; d > 0; d--) { const node = resolvedPos.node(d); - if (node.type.name === "bulletList" || node.type.name === "orderedList") { + if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") { // Increment depth for each list ancestor found depth++; } @@ -146,6 +146,8 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s if (!isAtStartOfNode(editor.state)) { return false; } + + // is the paragraph node inside of the current list item (maybe with a hard break) const isParaSibling = isCurrentParagraphASibling(editor.state); const isCurrentListItemSublist = prevListIsHigher(name, editor.state); const listItemPos = findListItemPos(name, editor.state); @@ -306,7 +308,10 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => { const currentParagraphNode = $from.parent; // Get the current node where the selection is. // Ensure we're in a paragraph and the parent is a list item. - if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") { + if ( + currentParagraphNode.type.name === "paragraph" && + (listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem") + ) { let paragraphNodesCount = 0; listItemNode.forEach((child) => { if (child.type.name === "paragraph") { @@ -327,16 +332,19 @@ export function isCursorInSubList(editor: Editor) { // Check if the current node is a list item const listItem = editor.schema.nodes.listItem; + const taskItem = editor.schema.nodes.taskItem; // Traverse up the document tree from the current position for (let depth = $from.depth; depth > 0; depth--) { const node = $from.node(depth); - if (node.type === listItem) { + if (node.type === listItem || node.type === taskItem) { // If the parent of the list item is also a list, it's a sub-list const parent = $from.node(depth - 1); if ( parent && - (parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList) + (parent.type === editor.schema.nodes.bulletList || + parent.type === editor.schema.nodes.orderedList || + parent.type === editor.schema.nodes.taskList) ) { return true; } diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts index db1264f57..f2b6dd999 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return handled; } catch (e) { - console.log("error in handling Backspac:", e); + console.log("Error in handling Delete:", e); return false; } }, @@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return handled; } catch (e) { - console.log("error in handling Backspac:", e); + console.log("Error in handling Backspace:", e); return false; } }, diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/core/src/ui/extensions/drop.tsx index ed206bc42..4bf4e2625 100644 --- a/packages/editor/core/src/ui/extensions/drop.tsx +++ b/packages/editor/core/src/ui/extensions/drop.tsx @@ -1,7 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { UploadImage } from "src/types/upload-image"; -import { startImageUpload } from "../plugins/upload-image"; +import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; export const DropHandlerExtension = (uploadFile: UploadImage) => Extension.create({ diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index b85100fe5..7ea12fb11 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -1,25 +1,16 @@ -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { UploadImagesPlugin } from "src/ui/plugins/upload-image"; +import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image"; import ImageExt from "@tiptap/extension-image"; -import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; +import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image"; import { DeleteImage } from "src/types/delete-image"; import { RestoreImage } from "src/types/restore-image"; import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; +import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image"; +import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; +import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node"; -interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -const deleteKey = new PluginKey("delete-image"); -const IMAGE_NODE_TYPE = "image"; - -export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => void) => - ImageExt.extend({ +export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => + ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertLineBelowImageAction, @@ -29,77 +20,8 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma addProseMirrorPlugins() { return [ UploadImagesPlugin(this.editor, cancelUploadImage), - new Plugin({ - key: deleteKey, - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - // transaction could be a selection - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - // iterate through all the nodes in the old state - oldState.doc.descendants((oldNode, oldPos) => { - // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; - - // Check if the node has been deleted or replaced - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - this.storage.images.set(src, true); - await onNodeDeleted(src, deleteImage); - }); - }); - - return null; - }, - }), - new Plugin({ - key: new PluginKey("imageRestoration"), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const oldImageSources = new Set(); - oldState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - oldImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const addedImages: ImageNode[] = []; - - newState.doc.descendants((node, pos) => { - if (node.type.name !== IMAGE_NODE_TYPE) return; - if (pos < 0 || pos > newState.doc.content.size) return; - if (oldImageSources.has(node.attrs.src)) return; - addedImages.push(node as ImageNode); - }); - - addedImages.forEach(async (image) => { - const wasDeleted = this.storage.images.get(image.attrs.src); - if (wasDeleted === undefined) { - this.storage.images.set(image.attrs.src, false); - } else if (wasDeleted === true) { - await onNodeRestored(image.attrs.src, restoreFile); - } - }); - }); - return null; - }, - }), + TrackImageDeletionPlugin(this.editor, deleteImage), + TrackImageRestorationPlugin(this.editor, restoreImage), ]; }, @@ -113,7 +35,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma imageSources.forEach(async (src) => { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreFile(assetUrlWithWorkspaceId); + await restoreImage(assetUrlWithWorkspaceId); } catch (error) { console.error("Error restoring image: ", error); } @@ -123,7 +45,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma // storage to keep track of image states Map addStorage() { return { - images: new Map(), + deletedImageSet: new Map(), uploadInProgress: false, }; }, diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 425ad89b0..2507aca36 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -141,8 +141,11 @@ export const CoreEditorExtensions = ({ placeholder: ({ editor, node }) => { if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + if (editor.storage.image.uploadInProgress) return ""; + const shouldHidePlaceholder = editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + if (shouldHidePlaceholder) return ""; if (placeholder) { diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/core/src/ui/extensions/keymap.tsx index 2e0bdd1fe..43b4e3435 100644 --- a/packages/editor/core/src/ui/extensions/keymap.tsx +++ b/packages/editor/core/src/ui/extensions/keymap.tsx @@ -15,9 +15,7 @@ declare module "@tiptap/core" { } } -function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { - if (!tr.isGeneric) return false; - +function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) { // Find all ranges where we might want to join. const ranges: Array = []; for (let i = 0; i < tr.mapping.maps.length; i++) { @@ -28,7 +26,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { // Figure out which joinable points exist inside those ranges, // by checking all node boundaries in their parent nodes. - const joinable = []; + const joinable: number[] = []; for (let i = 0; i < ranges.length; i += 2) { const from = ranges[i], to = ranges[i + 1]; @@ -40,7 +38,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { if (!after) break; if (index && joinable.indexOf(pos) == -1) { const before = parent.child(index - 1); - if (before.type == after.type && before.type === nodeType) joinable.push(pos); + if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos); } pos += after.nodeSize; } @@ -88,25 +86,15 @@ export const CustomKeymap = Extension.create({ // Create a new transaction. const newTr = newState.tr; - let joined = false; - for (const transaction of transactions) { - const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]); - joined = anotherJoin || joined; - } - if (joined) { - return newTr; - } - }, - }), - new Plugin({ - key: new PluginKey("unordered-list-merging"), - appendTransaction(transactions, oldState, newState) { - // Create a new transaction. - const newTr = newState.tr; + const joinableNodes = [ + newState.schema.nodes["orderedList"], + newState.schema.nodes["taskList"], + newState.schema.nodes["bulletList"], + ]; let joined = false; for (const transaction of transactions) { - const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]); + const anotherJoin = autoJoin(transaction, newTr, joinableNodes); joined = anotherJoin || joined; } if (joined) { diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index b9ac11d13..dc67d26ae 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Editor } from "@tiptap/react"; import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { cn } from "src/lib/utils"; diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index 0a1f1b5e0..939528785 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -2,12 +2,13 @@ // @ts-nocheck import { NodeViewWrapper } from "@tiptap/react"; import { cn } from "src/lib/utils"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import { IMentionHighlight } from "src/types/mention-suggestion"; import { useEffect, useState } from "react"; // eslint-disable-next-line import/no-anonymous-default-export export const MentionNodeView = (props) => { + // TODO: move it to web app const router = useRouter(); const [highlightsState, setHighlightsState] = useState(); diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.ts similarity index 97% rename from packages/editor/core/src/ui/menus/menu-items/index.tsx rename to packages/editor/core/src/ui/menus/menu-items/index.ts index 46b1ed92a..ab2ad8ed4 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.ts @@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag ]; } -export type EditorMenuItemNames = ReturnType extends (infer U)[] - ? U extends { key: infer N } - ? N - : never - : never; +export type EditorMenuItemNames = + ReturnType extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never; diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx deleted file mode 100644 index 03b4dbd10..000000000 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { DeleteImage } from "src/types/delete-image"; -import { RestoreImage } from "src/types/restore-image"; - -const deleteKey = new PluginKey("delete-image"); -const IMAGE_NODE_TYPE = "image"; - -interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => - new Plugin({ - key: deleteKey, - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - oldState.doc.descendants((oldNode, oldPos) => { - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; - if (oldPos < 0 || oldPos > newState.doc.content.size) return; - if (!newState.doc.resolve(oldPos).parent) return; - - const newNode = newState.doc.nodeAt(oldPos); - - // Check if the node has been deleted or replaced - if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) { - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - await onNodeDeleted(src, deleteImage); - }); - }); - - return null; - }, - }); - -export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await deleteImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error deleting image: ", error); - } -} - -export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error restoring image: ", error); - } -} diff --git a/packages/editor/core/src/ui/plugins/image/constants.ts b/packages/editor/core/src/ui/plugins/image/constants.ts new file mode 100644 index 000000000..72fae6710 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/constants.ts @@ -0,0 +1,7 @@ +import { PluginKey } from "@tiptap/pm/state"; + +export const uploadKey = new PluginKey("upload-image"); +export const deleteKey = new PluginKey("delete-image"); +export const restoreKey = new PluginKey("restore-image"); + +export const IMAGE_NODE_TYPE = "image"; diff --git a/packages/editor/core/src/ui/plugins/image/delete-image.ts b/packages/editor/core/src/ui/plugins/image/delete-image.ts new file mode 100644 index 000000000..645dda99e --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/delete-image.ts @@ -0,0 +1,54 @@ +import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { DeleteImage } from "src/types/delete-image"; +import { Editor } from "@tiptap/core"; + +import { type ImageNode } from "src/ui/plugins/image/types/image-node"; +import { deleteKey, IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; + +export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + // transaction could be a selection + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((oldNode) => { + // if the node is not an image, then return as no point in checking + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + + // Check if the node has been deleted or replaced + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + editor.storage.image.deletedImageSet.set(src, true); + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }); + +async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await deleteImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error deleting image: ", error); + } +} diff --git a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts new file mode 100644 index 000000000..eb7021819 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts @@ -0,0 +1,141 @@ +import { type UploadImage } from "src/types/upload-image"; + +// utilities +import { v4 as uuidv4 } from "uuid"; + +// types +import { isFileValid } from "src/ui/plugins/image/utils/validate-file"; +import { Editor } from "@tiptap/core"; +import { EditorView } from "@tiptap/pm/view"; +import { uploadKey } from "./constants"; +import { removePlaceholder, findPlaceholder } from "./utils/placeholder"; + +export async function startImageUpload( + editor: Editor, + file: File, + view: EditorView, + pos: number | null, + uploadFile: UploadImage +) { + editor.storage.image.uploadInProgress = true; + + if (!isFileValid(file)) { + editor.storage.image.uploadInProgress = false; + return; + } + + const id = uuidv4(); + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + // Handle FileReader errors + reader.onerror = (error) => { + console.error("FileReader error: ", error); + removePlaceholder(editor, view, id); + return; + }; + + try { + const fileNameTrimmed = trimFileName(file.name); + const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); + + const resolvedPos = view.state.doc.resolve(pos ?? 0); + const nodeBefore = resolvedPos.nodeBefore; + + // if the image is at the start of the line i.e. when nodeBefore is null + if (nodeBefore === null) { + if (pos) { + // so that the image is not inserted at the next line, else incase the + // image is inserted at any line where there's some content, the + // position is kept as it is to be inserted at the next line + pos -= 1; + } + } + + view.focus(); + + const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile); + + if (src == null) { + throw new Error("Resolved image URL is undefined."); + } + + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) { + editor.storage.image.uploadInProgress = false; + return; + } + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + + if (pos < 0 || pos > view.state.doc.content.size) { + throw new Error("Invalid position to insert the image node."); + } + + // insert the image node at the position of the placeholder and remove the placeholder + const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } }); + + view.dispatch(transaction); + + editor.storage.image.uploadInProgress = false; + } catch (error) { + console.error("Error in uploading and inserting image: ", error); + removePlaceholder(editor, view, id); + } +} + +async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise { + try { + const imageUrl = await uploadFile(file); + + if (imageUrl == null) { + throw new Error("Image URL is undefined."); + } + + await new Promise((resolve, reject) => { + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(); + }; + image.onerror = (error) => { + console.error("Error in loading image: ", error); + reject(error); + }; + }); + + return imageUrl; + } catch (error) { + console.error("Error in uploading image: ", error); + // throw error to remove the placeholder + throw error; + } +} + +function trimFileName(fileName: string, maxLength = 100) { + if (fileName.length > maxLength) { + const extension = fileName.split(".").pop(); + const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1)); + const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot + return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`; + } + + return fileName; +} diff --git a/packages/editor/core/src/ui/plugins/image/restore-image.ts b/packages/editor/core/src/ui/plugins/image/restore-image.ts new file mode 100644 index 000000000..61a7a7a34 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/restore-image.ts @@ -0,0 +1,57 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { RestoreImage } from "src/types/restore-image"; + +import { restoreKey, IMAGE_NODE_TYPE } from "./constants"; +import { type ImageNode } from "./types/image-node"; + +export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => + new Plugin({ + key: restoreKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const oldImageSources = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + oldImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const addedImages: ImageNode[] = []; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== IMAGE_NODE_TYPE) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldImageSources.has(node.attrs.src)) return; + addedImages.push(node as ImageNode); + }); + + addedImages.forEach(async (image) => { + const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src); + if (wasDeleted === undefined) { + editor.storage.image.deletedImageSet.set(image.attrs.src, false); + } else if (wasDeleted === true) { + try { + await onNodeRestored(image.attrs.src, restoreImage); + editor.storage.image.deletedImageSet.set(image.attrs.src, false); + } catch (error) { + console.error("Error restoring image: ", error); + } + } + }); + }); + return null; + }, + }); + +async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + throw error; + } +} diff --git a/packages/editor/core/src/ui/plugins/image/types/image-node.ts b/packages/editor/core/src/ui/plugins/image/types/image-node.ts new file mode 100644 index 000000000..67afc8315 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/types/image-node.ts @@ -0,0 +1,13 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +export type ImageExtensionStorage = { + deletedImageSet: Map; + uploadInProgress: boolean; +}; diff --git a/packages/editor/core/src/ui/plugins/image/upload-image.ts b/packages/editor/core/src/ui/plugins/image/upload-image.ts new file mode 100644 index 000000000..554e37de2 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/upload-image.ts @@ -0,0 +1,91 @@ +import { Editor } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; + +// utils +import { removePlaceholder } from "src/ui/plugins/image/utils/placeholder"; + +// constants +import { uploadKey } from "src/ui/plugins/image/constants"; + +export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { + let currentView: EditorView | null = null; + + const createPlaceholder = (src: string): HTMLElement => { + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); + image.src = src; + placeholder.appendChild(image); + + return placeholder; + }; + + const createCancelButton = (id: string): HTMLButtonElement => { + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.style.position = "absolute"; + cancelButton.style.right = "3px"; + cancelButton.style.top = "3px"; + cancelButton.setAttribute("class", "opacity-90 rounded-lg"); + + cancelButton.onclick = () => { + if (currentView) { + cancelUploadImage?.(); + removePlaceholder(editor, currentView, id); + } + }; + + // Create an SVG element from the SVG string + const svgString = ``; + const parser = new DOMParser(); + const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; + + cancelButton.appendChild(svgElement); + + return cancelButton; + }; + + return new Plugin({ + key: uploadKey, + view(editorView) { + currentView = editorView; + return { + destroy() { + currentView = null; + }, + }; + }, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + const action = tr.getMeta(uploadKey); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = createPlaceholder(src); + const cancelButton = createCancelButton(id); + + placeholder.appendChild(cancelButton); + + const deco = Decoration.widget(pos, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +}; diff --git a/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts b/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts new file mode 100644 index 000000000..9636da4a7 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts @@ -0,0 +1,16 @@ +import { Editor } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; +import { DecorationSet, EditorView } from "@tiptap/pm/view"; +import { uploadKey } from "src/ui/plugins/image/constants"; + +export function findPlaceholder(state: EditorState, id: string): number | null { + const decos = uploadKey.getState(state) as DecorationSet; + const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id); + return found.length ? found[0].from : null; +} + +export function removePlaceholder(editor: Editor, view: EditorView, id: string) { + const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } }); + view.dispatch(removePlaceholderTr); + editor.storage.image.uploadInProgress = false; +} diff --git a/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts b/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts new file mode 100644 index 000000000..a7952a0e1 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts @@ -0,0 +1,19 @@ +export function isFileValid(file: File): boolean { + if (!file) { + alert("No file selected. Please select a file to upload."); + return false; + } + + const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"]; + if (!allowedTypes.includes(file.type)) { + alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file."); + return false; + } + + if (file.size > 5 * 1024 * 1024) { + alert("File size too large. Please select a file smaller than 5MB."); + return false; + } + + return true; +} diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx deleted file mode 100644 index 7a370da4e..000000000 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; -import { UploadImage } from "src/types/upload-image"; - -const uploadKey = new PluginKey("upload-image"); - -export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { - let currentView: EditorView | null = null; - return new Plugin({ - key: uploadKey, - view(editorView) { - currentView = editorView; - return { - destroy() { - currentView = null; - }, - }; - }, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - const action = tr.getMeta(uploadKey); - if (action && action.add) { - const { id, pos, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); - image.src = src; - placeholder.appendChild(image); - - // Create cancel button - const cancelButton = document.createElement("button"); - cancelButton.type = "button"; - cancelButton.style.position = "absolute"; - cancelButton.style.right = "3px"; - cancelButton.style.top = "3px"; - cancelButton.setAttribute("class", "opacity-90 rounded-lg"); - - cancelButton.onclick = () => { - if (currentView) { - cancelUploadImage?.(); - removePlaceholder(editor, currentView, id); - } - }; - - // Create an SVG element from the SVG string - const svgString = ``; - const parser = new DOMParser(); - const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; - - cancelButton.appendChild(svgElement); - placeholder.appendChild(cancelButton); - const deco = Decoration.widget(pos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action && action.remove) { - set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, - }); -}; - -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state); - const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id); - return found.length ? found[0].from : null; -} - -const removePlaceholder = (editor: Editor, view: EditorView, id: {}) => { - const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { - remove: { id }, - }); - view.dispatch(removePlaceholderTr); - editor.storage.image.uploadInProgress = false; -}; - -export async function startImageUpload( - editor: Editor, - file: File, - view: EditorView, - pos: number, - uploadFile: UploadImage -) { - editor.storage.image.uploadInProgress = true; - - if (!file) { - alert("No file selected. Please select a file to upload."); - editor.storage.image.uploadInProgress = false; - return; - } - - if (!file.type.includes("image/")) { - alert("Invalid file type. Please select an image file."); - editor.storage.image.uploadInProgress = false; - return; - } - - if (file.size > 5 * 1024 * 1024) { - alert("File size too large. Please select a file smaller than 5MB."); - editor.storage.image.uploadInProgress = false; - return; - } - - const id = {}; - - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); - - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); - view.dispatch(tr); - }; - - // Handle FileReader errors - reader.onerror = (error) => { - console.error("FileReader error: ", error); - removePlaceholder(editor, view, id); - return; - }; - - // setIsSubmitting?.("submitting"); - - try { - const src = await UploadImageHandler(file, uploadFile); - const { schema } = view.state; - pos = findPlaceholder(view.state, id); - - if (pos == null) { - editor.storage.image.uploadInProgress = false; - return; - } - const imageSrc = typeof src === "object" ? reader.result : src; - - const node = schema.nodes.image.create({ src: imageSrc }); - const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } }); - - view.dispatch(transaction); - if (view.hasFocus()) view.focus(); - editor.storage.image.uploadInProgress = false; - } catch (error) { - removePlaceholder(editor, view, id); - } -} - -const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise => { - try { - return new Promise(async (resolve, reject) => { - try { - const imageUrl = await uploadFile(file); - - const image = new Image(); - image.src = imageUrl; - image.onload = () => { - resolve(imageUrl); - }; - } catch (error) { - if (error instanceof Error) { - console.log(error.message); - } - reject(error); - } - }); - } catch (error) { - return Promise.reject(error); - } -}; diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index d3bfbd6aa..b565bfedf 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.20.0", + "version": "0.21.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index e586bfd80..da6cd250c 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -1,3 +1,5 @@ +"use client"; + import { cn } from "@plane/editor-core"; import { Editor } from "@tiptap/core"; import tippy from "tippy.js"; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx index d3b6fd04f..bc0ca00fa 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx @@ -1,3 +1,5 @@ +"use client"; + // @ts-nocheck import { Button } from "@plane/ui"; import { NodeViewWrapper } from "@tiptap/react"; diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index e59bac106..7a5d20933 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.20.0", + "version": "0.21.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index c1b1ef9c0..7844700bf 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -69,7 +69,6 @@ const Command = Extension.create({ return true; }, - allowSpaces: true, }, }; }, diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index a2136cb8a..8e03e82ac 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.20.0", + "version": "0.21.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 698c0b923..c0b3d28cc 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.20.0", + "version": "0.21.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx new file mode 100644 index 000000000..70037f046 --- /dev/null +++ b/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx @@ -0,0 +1,25 @@ +import { Extension } from "@tiptap/core"; + +export const EnterKeyExtension = (onEnterKeyPress?: () => void) => + Extension.create({ + name: "enterKey", + + addKeyboardShortcuts(this) { + return { + Enter: () => { + if (onEnterKeyPress) { + onEnterKeyPress(); + } + return true; + }, + "Shift-Enter": ({ editor }) => + editor.commands.first(({ commands }) => [ + () => commands.newlineInCode(), + () => commands.splitListItem("listItem"), + () => commands.createParagraphNear(), + () => commands.liftEmptyBlock(), + () => commands.splitBlock(), + ]), + }; + }, + }); diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index 406fb677f..4face2cb7 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,13 +1,22 @@ import { UploadImage } from "@plane/editor-core"; import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; +import { EnterKeyExtension } from "./enter-key-extension"; type TArguments = { uploadFile: UploadImage; dragDropEnabled?: boolean; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + onEnterKeyPress?: () => void; }; -export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle }: TArguments) => [ +export const RichTextEditorExtensions = ({ + uploadFile, + dragDropEnabled, + setHideDragHandle, + onEnterKeyPress, +}: TArguments) => [ SlashCommand(uploadFile), dragDropEnabled === true && DragAndDrop(setHideDragHandle), + // TODO; add the extension conditionally for forms that don't require it + // EnterKeyExtension(onEnterKeyPress), ]; diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 649057c4d..ee790842b 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -32,6 +32,7 @@ export type IRichTextEditor = { }; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; + onEnterKeyPress?: (e?: any) => void; }; const RichTextEditor = (props: IRichTextEditor) => { @@ -48,6 +49,7 @@ const RichTextEditor = (props: IRichTextEditor) => { placeholder, tabIndex, mentionHandler, + onEnterKeyPress, } = props; const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -69,6 +71,7 @@ const RichTextEditor = (props: IRichTextEditor) => { uploadFile: fileHandler.upload, dragDropEnabled, setHideDragHandle: setHideDragHandleFunction, + onEnterKeyPress, }), mentionHandler, placeholder, diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 39d657d93..99a101941 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -1,7 +1,6 @@ module.exports = { extends: [ "next", - "turbo", "prettier", "plugin:@typescript-eslint/recommended", ], @@ -17,6 +16,7 @@ module.exports = { }, }, rules: { + "no-useless-escape": "off", "prefer-const": "error", "no-irregular-whitespace": "error", "no-trailing-spaces": "error", @@ -37,6 +37,7 @@ module.exports = { { selector: ["function", "variable"], format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"], + leadingUnderscore: "allow", }, ], }, diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b6bc7b2c1..fb34284c3 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.20.0", + "version": "0.21.0", "main": "index.js", "license": "MIT", "devDependencies": {}, diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 550308e57..187188029 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.20.0", + "version": "0.21.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 42e176043..7cece3a9b 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -8,6 +8,10 @@ module.exports = { content: { relative: true, files: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./core/**/*.{js,ts,jsx,tsx}", + "./ce/**/*.{js,ts,jsx,tsx}", + "./ee/**/*.{js,ts,jsx,tsx}", "./components/**/*.tsx", "./constants/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.tsx", diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index a181b0289..a2f084dbc 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.20.0", + "version": "0.21.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 487505f7d..918e9e77d 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.20.0", + "version": "0.21.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index 35da4b723..2fb7ad51a 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -54,7 +54,7 @@ export type TXAxisValues = | "state__group" | "labels__id" | "assignees__id" - | "estimate_point" + | "estimate_point__value" | "issue_cycle__cycle_id" | "issue_module__module_id" | "priority" diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index d347ecef1..6a8c725a8 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -9,3 +9,15 @@ export type TPaginationInfo = { per_page?: number; total_results: number; }; + +export type TLogoProps = { + in_use: "emoji" | "icon"; + emoji?: { + value?: string; + url?: string; + }; + icon?: { + name?: string; + color?: string; + }; +}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index e93d6e444..b0528ccc1 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -2,32 +2,81 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types"; export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; -export interface ICycle { - backlog_issues: number; - cancelled_issues: number; +export type TCycleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TCycleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; +}; + +export type TCycleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TCycleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TCycleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TCycleDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleDistributionBase)[]; +}; + +export type TCycleEstimateDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleEstimateDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[]; +}; + +export type TProgressSnapshot = { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TCycleDistribution; + estimate_distribution?: TCycleEstimateDistribution; +}; + +export interface ICycle extends TProgressSnapshot { + progress_snapshot: TProgressSnapshot | undefined; + created_at?: string; created_by?: string; description: string; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; end_date: string | null; id: string; is_favorite?: boolean; name: string; owned_by_id: string; - progress_snapshot: TProgressSnapshot; project_id: string; status?: TCycleGroups; sort_order: number; start_date: string | null; - started_issues: number; sub_issues?: number; - total_issues: number; - unstarted_issues: number; updated_at?: string; updated_by?: string; archived_at: string | null; @@ -38,47 +87,6 @@ export interface ICycle { workspace_id: string; } -export type TProgressSnapshot = { - backlog_issues: number; - cancelled_issues: number; - completed_estimates: number | null; - completed_issues: number; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - started_estimates: number | null; - started_issues: number; - total_estimates: number | null; - total_issues: number; - unstarted_issues: number; -}; - -export type TAssigneesDistribution = { - assignee_id: string | null; - avatar: string | null; - completed_issues: number; - first_name: string | null; - last_name: string | null; - display_name: string | null; - pending_issues: number; - total_issues: number; -}; - -export type TCompletionChartDistribution = { - [key: string]: number | null; -}; - -export type TLabelsDistribution = { - color: string | null; - completed_issues: number; - label_id: string | null; - label_name: string | null; - pending_issues: number; - total_issues: number; -}; - export interface CycleIssueResponse { id: string; issue_detail: TIssue; @@ -102,3 +110,5 @@ export type CycleDateCheckData = { end_date: string; cycle_id?: string; }; + +export type TCyclePlotType = "burndown" | "points"; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index a4d098506..cc8575374 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -24,3 +24,16 @@ export enum EIssueCommentAccessSpecifier { EXTERNAL = "EXTERNAL", INTERNAL = "INTERNAL", } + +// estimates +export enum EEstimateSystem { + POINTS = "points", + CATEGORIES = "categories", + TIME = "time", +} + +export enum EEstimateUpdateStages { + CREATE = "create", + EDIT = "edit", + SWITCH = "switch", +} diff --git a/packages/types/src/estimate.d.ts b/packages/types/src/estimate.d.ts index 96b584ce1..9bad7e260 100644 --- a/packages/types/src/estimate.d.ts +++ b/packages/types/src/estimate.d.ts @@ -1,40 +1,77 @@ -export interface IEstimate { - created_at: Date; - created_by: string; - description: string; - id: string; - name: string; - project: string; - project_detail: IProject; - updated_at: Date; - updated_by: string; - points: IEstimatePoint[]; - workspace: string; - workspace_detail: IWorkspace; -} +import { EEstimateSystem, EEstimateUpdateStages } from "./enums"; export interface IEstimatePoint { - created_at: string; - created_by: string; - description: string; - estimate: string; - id: string; - key: number; - project: string; - updated_at: string; - updated_by: string; - value: string; - workspace: string; + id: string | undefined; + key: number | undefined; + value: string | undefined; + description: string | undefined; + workspace: string | undefined; + project: string | undefined; + estimate: string | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; + created_by: string | undefined; + updated_by: string | undefined; +} + +export type TEstimateSystemKeys = + | EEstimateSystem.POINTS + | EEstimateSystem.CATEGORIES + | EEstimateSystem.TIME; + +export interface IEstimate { + id: string | undefined; + name: string | undefined; + description: string | undefined; + type: TEstimateSystemKeys | undefined; // categories, points, time + points: IEstimatePoint[] | undefined; + workspace: string | undefined; + project: string | undefined; + last_used: boolean | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; + created_by: string | undefined; + updated_by: string | undefined; } export interface IEstimateFormData { - estimate: { - name: string; - description: string; + estimate?: { + name?: string; + type?: string; + last_used?: boolean; }; estimate_points: { - id?: string; + id?: string | undefined; key: number; value: string; }[]; } + +export type TEstimatePointsObject = { + id?: string | undefined; + key: number; + value: string; +}; + +export type TTemplateValues = { + title: string; + values: TEstimatePointsObject[]; + hide?: boolean; +}; + +export type TEstimateSystem = { + name: string; + templates: Record; + is_available: boolean; + is_ee: boolean; +}; + +export type TEstimateSystems = { + [K in TEstimateSystemKeys]: TEstimateSystem; +}; + +// update estimates +export type TEstimateUpdateStageKeys = + | EEstimateUpdateStages.CREATE + | EEstimateUpdateStages.EDIT + | EEstimateUpdateStages.SWITCH; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b8dd2d3c1..25c2b255b 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -15,7 +15,6 @@ export * from "./importer"; export * from "./inbox"; export * from "./analytics"; export * from "./api_token"; -export * from "./app"; export * from "./auth"; export * from "./calendar"; export * from "./instance"; @@ -28,3 +27,4 @@ export * from "./webhook"; export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; +export * from "./publish"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index d51978c06..53804dec3 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -19,8 +19,8 @@ export interface IInstance { whitelist_emails: string | undefined; instance_id: string | undefined; license_key: string | undefined; - api_key: string | undefined; - version: string | undefined; + current_version: string | undefined; + latest_version: string | undefined; last_checked_at: string | undefined; namespace: string | undefined; is_telemetry_enabled: boolean; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index ae210d3b1..1ad8530cd 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -1,3 +1,6 @@ +import { StateGroup } from "components/states"; +import { TIssuePriorities } from "../issues"; + // issues export * from "./issue"; export * from "./issue_reaction"; @@ -7,16 +10,30 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; -export type TLoader = "init-loader" | "mutation" | undefined; +export type TLoader = "init-loader" | "mutation" | "pagination" | undefined; export type TGroupedIssues = { [group_id: string]: string[]; }; export type TSubGroupedIssues = { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; + [sub_grouped_id: string]: TGroupedIssues; }; -export type TUnGroupedIssues = string[]; +export type TIssues = TGroupedIssues | TSubGroupedIssues; + +export type TPaginationData = { + nextCursor: string; + prevCursor: string; + nextPageResults: boolean; +}; + +export type TIssuePaginationData = { + [group_id: string]: TPaginationData; +}; + +export type TGroupedIssueCount = { + [group_id: string]: number; +}; + +export type TUnGroupedIssues = string[]; \ No newline at end of file diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index e648a447b..26e838b78 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -15,17 +15,17 @@ export type TIssue = TIssueDescription & { name: string; sort_order: number; - state_id: string; - priority: TIssuePriorities; + state_id: string | null; + priority: TIssuePriorities | null; label_ids: string[]; assignee_ids: string[]; - estimate_point: number | null; + estimate_point: string | null; sub_issues_count: number; attachment_count: number; link_count: number; - project_id: string; + project_id: string | null; parent_id: string | null; cycle_id: string | null; module_ids: string[] | null; @@ -41,9 +41,14 @@ export type TIssue = TIssueDescription & { updated_by: string; is_draft: boolean; +}; + +export type TIssue = TBaseIssue & { + description_html?: string; is_subscribed?: boolean; parent?: partial; + issue_reactions?: TIssueReaction[]; issue_attachment?: TIssueAttachment[]; issue_link?: TIssueLink[]; @@ -55,3 +60,47 @@ export type TIssue = TIssueDescription & { export type TIssueMap = { [issue_id: string]: TIssue; }; + +type TIssueResponseResults = + | TBaseIssue[] + | { + [key: string]: { + results: + | TBaseIssue[] + | { + [key: string]: { + results: TBaseIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +} + +export type TBulkIssueProperties = Pick< + TIssue, + | "state_id" + | "priority" + | "label_ids" + | "assignee_ids" + | "start_date" + | "target_date" +>; + +export type TBulkOperationsPayload = { + issue_ids: string[]; + properties: Partial; +}; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 0019781ba..6a5a09231 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -1,11 +1,4 @@ -import type { - TIssue, - IIssueFilterOptions, - ILinkDetails, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, -} from "@plane/types"; +import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types"; export type TModuleStatus = | "backlog" @@ -15,42 +8,88 @@ export type TModuleStatus = | "completed" | "cancelled"; -export interface IModule { - backlog_issues: number; - cancelled_issues: number; +export type TModuleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TModuleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; - created_at: string; - created_by?: string; +}; + +export type TModuleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TModuleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TModuleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TModuleDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleDistributionBase)[]; +}; + +export type TModuleEstimateDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleEstimateDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleEstimateDistributionBase)[]; +}; + +export interface IModule { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TModuleDistribution; + estimate_distribution?: TModuleEstimateDistribution; + + id: string; + name: string; description: string; description_text: any; description_html: any; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - id: string; - lead_id: string | null; - link_module?: ILinkDetails[]; - member_ids: string[]; - is_favorite: boolean; - name: string; + workspace_id: string; project_id: string; - sort_order: number; + lead_id: string | null; + member_ids: string[]; + link_module?: ILinkDetails[]; sub_issues?: number; - start_date: string | null; - started_issues: number; - status?: TModuleStatus; - target_date: string | null; - total_issues: number; - unstarted_issues: number; - updated_at: string; - updated_by?: string; - archived_at: string | null; + is_favorite: boolean; + sort_order: number; view_props: { filters: IIssueFilterOptions; }; - workspace_id: string; + status?: TModuleStatus; + archived_at: string | null; + start_date: string | null; + target_date: string | null; + created_at: string; + updated_at: string; + created_by?: string; + updated_by?: string; } export interface ModuleIssueResponse { @@ -76,3 +115,5 @@ export type ModuleLink = { export type SelectModuleType = | (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; + +export type TModulePlotType = "burndown" | "points"; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 571b75765..d739b2309 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -57,7 +57,7 @@ export interface INotificationIssueLite { state_group: string; } -export type NotificationType = "created" | "assigned" | "watching" | null; +export type NotificationType = "created" | "assigned" | "watching" | "all"; export interface INotificationParams { snoozed?: boolean; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 4871ddc06..1c94dfc06 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,3 +1,4 @@ +import { TLogoProps } from "./common"; import { EPageAccess } from "./enums"; export type TPage = { @@ -17,6 +18,7 @@ export type TPage = { updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; + logo_props: TLogoProps | undefined; }; // page filters diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 459d9f0e2..59ccf73b6 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -6,21 +6,10 @@ import type { IUserMemberLite, IWorkspace, IWorkspaceLite, + TLogoProps, TStateGroups, } from ".."; -export type TProjectLogoProps = { - in_use: "emoji" | "icon"; - emoji?: { - value?: string; - url?: string; - }; - icon?: { - name?: string; - color?: string; - }; -}; - export interface IProject { archive_in: number; archived_at: string | null; @@ -43,10 +32,10 @@ export interface IProject { estimate: string | null; id: string; identifier: string; - is_deployed: boolean; + anchor: string | null; is_favorite: boolean; is_member: boolean; - logo_props: TProjectLogoProps; + logo_props: TLogoProps; member_role: EUserProjectRoles | null; members: IProjectMemberLite[]; name: string; diff --git a/packages/types/src/publish.d.ts b/packages/types/src/publish.d.ts new file mode 100644 index 000000000..883ef8dd6 --- /dev/null +++ b/packages/types/src/publish.d.ts @@ -0,0 +1,41 @@ +import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types"; + +export type TPublishEntityType = "project"; + +export type TProjectPublishLayouts = + | "calendar" + | "gantt" + | "kanban" + | "list" + | "spreadsheet"; + +export type TPublishViewProps = { + calendar?: boolean; + gantt?: boolean; + kanban?: boolean; + list?: boolean; + spreadsheet?: boolean; +}; + +export type TProjectDetails = IProjectLite & + Pick; + +export type TPublishSettings = { + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TViewProps | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; +}; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index c191cac89..f167bef48 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -128,6 +128,7 @@ export interface IUserActivityResponse { prev_page_results: boolean; results: IIssueActivity[]; total_pages: number; + total_results: number; } export type UserAuth = { @@ -185,6 +186,8 @@ export interface IUserEmailNotificationSettings { issue_completed: boolean; } +export type TProfileViews = "assigned" | "created" | "subscribed"; + // export interface ICurrentUser { // id: readonly string; // avatar: string; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index c2c98def3..82302dda1 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,3 +1,5 @@ +import { EIssueLayoutTypes } from "constants/issue"; + export type TIssueLayouts = | "list" | "kanban" @@ -13,9 +15,9 @@ export type TIssueGroupByOptions = | "state_detail.group" | "project" | "assignees" - | "mentions" | "cycle" | "module" + | "target_date" | null; export type TIssueOrderByOptions = @@ -32,10 +34,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" - | "modules__name" - | "-modules__name" - | "cycle__name" - | "-cycle__name" + | "issue_module__module__name" + | "-issue_module__module__name" + | "issue_cycle__cycle__name" + | "-issue_cycle__cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -72,7 +74,9 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups"; + | "show_empty_groups" + | "cursor" + | "per_page"; export type TCalendarLayouts = "month" | "week"; @@ -82,9 +86,9 @@ export interface IIssueFilterOptions { created_by?: string[] | null; labels?: string[] | null; priority?: string[] | null; - project?: string[] | null; cycle?: string[] | null; module?: string[] | null; + project?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -99,7 +103,7 @@ export interface IIssueDisplayFilterOptions { }; group_by?: TIssueGroupByOptions; sub_group_by?: TIssueGroupByOptions; - layout?: TIssueLayouts; + layout?: EIssueLayoutTypes; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; @@ -191,3 +195,11 @@ export interface IWorkspaceGlobalViewProps { display_filters: IWorkspaceIssueDisplayFilterOptions | undefined; display_properties: IIssueDisplayProperties; } + +export interface IssuePaginationOptions { + canGroup: boolean; + perPageCount: number; + before?: string; + after?: string; + groupedBy?: TIssueGroupByOptions; +} diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index f9f7ee385..9415f7488 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -1,3 +1,4 @@ +import { TLogoProps } from "./common"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, @@ -21,4 +22,5 @@ export interface IProjectView { query_data: IIssueFilterOptions; project: string; workspace: string; + logo_props: TLogoProps | undefined; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 62c335839..49a1e4443 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.20.0", + "version": "0.21.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -20,16 +20,20 @@ "postcss": "postcss styles/globals.css -o styles/output.css --watch" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.10", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", - "@headlessui/react": "^1.7.17", + "@headlessui/react": "^1.7.3", "@popperjs/core": "^2.11.8", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", + "lodash": "^4.17.21", + "lucide-react": "^0.379.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", "react-popper": "^2.3.0", - "sonner": "^1.4.2", + "sonner": "^1.4.41", "tailwind-merge": "^2.0.0" }, "devDependencies": { diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index 61426e44b..83f3157cc 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -2,22 +2,23 @@ import * as React from "react"; export type TControlLink = React.AnchorHTMLAttributes & { href: string; - onClick: () => void; + onClick: (event: React.MouseEvent) => void; children: React.ReactNode; target?: string; disabled?: boolean; className?: string; + draggable?: boolean; }; export const ControlLink = React.forwardRef((props, ref) => { - const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props; + const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; const handleOnClick = (event: React.MouseEvent) => { const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; if (!clickCondition) { event.preventDefault(); - onClick(); + onClick(event); } }; @@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef((pr if (disabled) return <>{children}; return ( - + {children} ); diff --git a/packages/ui/src/drag-handle.tsx b/packages/ui/src/drag-handle.tsx index 0496f86de..89037a5ca 100644 --- a/packages/ui/src/drag-handle.tsx +++ b/packages/ui/src/drag-handle.tsx @@ -1,14 +1,15 @@ -import React from "react"; -import { forwardRef } from "react"; +import React, { forwardRef } from "react"; import { MoreVertical } from "lucide-react"; +// helpers +import { cn } from "../helpers"; interface IDragHandle { - isDragging: boolean; + className?: string; disabled?: boolean; } export const DragHandle = forwardRef((props, ref) => { - const { isDragging, disabled = false } = props; + const { className, disabled = false } = props; if (disabled) { return
; @@ -17,9 +18,10 @@ export const DragHandle = forwardRef((pro return ( + + ); +}; diff --git a/packages/ui/src/dropdown/common/index.ts b/packages/ui/src/dropdown/common/index.ts new file mode 100644 index 000000000..f9a6d7388 --- /dev/null +++ b/packages/ui/src/dropdown/common/index.ts @@ -0,0 +1,4 @@ +export * from "./input-search"; +export * from "./button"; +export * from "./options"; +export * from "./loader"; diff --git a/packages/ui/src/dropdown/common/input-search.tsx b/packages/ui/src/dropdown/common/input-search.tsx new file mode 100644 index 000000000..10fc258e1 --- /dev/null +++ b/packages/ui/src/dropdown/common/input-search.tsx @@ -0,0 +1,58 @@ +import React, { FC, useEffect, useRef } from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Search } from "lucide-react"; +// helpers +import { cn } from "../../../helpers"; + +interface IInputSearch { + isOpen: boolean; + query: string; + updateQuery: (query: string) => void; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + inputClassName?: string; + inputPlaceholder?: string; +} + +export const InputSearch: FC = (props) => { + const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props; + + const inputRef = useRef(null); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + updateQuery(""); + } + }; + + useEffect(() => { + if (isOpen) { + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + return ( +
+ {inputIcon ? <>{inputIcon} :
+ ); +}; diff --git a/packages/ui/src/dropdown/common/loader.tsx b/packages/ui/src/dropdown/common/loader.tsx new file mode 100644 index 000000000..0ec1f053b --- /dev/null +++ b/packages/ui/src/dropdown/common/loader.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const DropdownOptionsLoader = () => ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+); diff --git a/packages/ui/src/dropdown/common/options.tsx b/packages/ui/src/dropdown/common/options.tsx new file mode 100644 index 000000000..f17a431a1 --- /dev/null +++ b/packages/ui/src/dropdown/common/options.tsx @@ -0,0 +1,88 @@ +import React from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Check } from "lucide-react"; +// components +import { DropdownOptionsLoader, InputSearch } from "."; +// helpers +import { cn } from "../../../helpers"; +// types +import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown"; + +export const DropdownOptions: React.FC = (props) => { + const { + isOpen, + query, + setQuery, + inputIcon, + inputPlaceholder, + inputClassName, + inputContainerClassName, + disableSearch, + keyExtractor, + options, + value, + renderItem, + loader, + } = props; + return ( + <> + {!disableSearch && ( + setQuery(query)} + inputIcon={inputIcon} + inputPlaceholder={inputPlaceholder} + inputClassName={inputClassName} + inputContainerClassName={inputContainerClassName} + /> + )} +
+ <> + {options ? ( + options.length > 0 ? ( + options?.map((option) => ( + + cn( + "flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", + { + "bg-custom-background-80": active, + "text-custom-text-100": selected, + "text-custom-text-200": !selected, + }, + option.className && option.className({ active, selected }) + ) + } + > + {({ selected }) => ( + <> + {renderItem ? ( + <>{renderItem({ value: option.data[option.value], selected })} + ) : ( + <> + {value} + {selected && } + + )} + + )} + + )) + ) : ( +

No matching results

+ ) + ) : loader ? ( + <> {loader} + ) : ( + + )} + +
+ + ); +}; diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts new file mode 100644 index 000000000..1f109add6 --- /dev/null +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -0,0 +1,94 @@ +import { Placement } from "@popperjs/core"; + +export interface IDropdown { + // root props + onOpen?: () => void; + onClose?: () => void; + containerClassName?: (isOpen: boolean) => string; + tabIndex?: number; + placement?: Placement; + disabled?: boolean; + + // button props + buttonContent?: (isOpen: boolean) => React.ReactNode; + buttonContainerClassName?: string; + buttonClassName?: string; + + // input props + disableSearch?: boolean; + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + + // options props + keyExtractor: (option: TDropdownOption) => string; + optionsContainerClassName?: string; + queryArray: string[]; + sortByKey: string; + firstItem?: (optionValue: string) => boolean; + renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; + loader?: React.ReactNode; + disableSorting?: boolean; +} + +export interface TDropdownOption { + data: any; + value: string; + className?: ({ active, selected }: { active: boolean; selected: boolean }) => string; +} + +export interface IMultiSelectDropdown extends IDropdown { + value: string[]; + onChange: (value: string[]) => void; + options: TDropdownOption[] | undefined; +} + +export interface ISingleSelectDropdown extends IDropdown { + value: string; + onChange: (value: string) => void; + options: TDropdownOption[] | undefined; +} + +export interface IDropdownButton { + isOpen: boolean; + buttonContent?: (isOpen: boolean) => React.ReactNode; + buttonClassName?: string; + buttonContainerClassName?: string; + handleOnClick: (e: React.MouseEvent) => void; + setReferenceElement: (element: HTMLButtonElement | null) => void; + disabled?: boolean; +} + +export interface IMultiSelectDropdownButton extends IDropdownButton { + value: string[]; +} + +export interface ISingleSelectDropdownButton extends IDropdownButton { + value: string; +} + +export interface IDropdownOptions { + isOpen: boolean; + query: string; + setQuery: (query: string) => void; + + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + disableSearch?: boolean; + + keyExtractor: (option: TDropdownOption) => string; + renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; + options: TDropdownOption[] | undefined; + loader?: React.ReactNode; +} + +export interface IMultiSelectDropdownOptions extends IDropdownOptions { + value: string[]; +} + +export interface ISingleSelectDropdownOptions extends IDropdownOptions { + value: string; +} diff --git a/packages/ui/src/dropdown/index.ts b/packages/ui/src/dropdown/index.ts new file mode 100644 index 000000000..a15df9567 --- /dev/null +++ b/packages/ui/src/dropdown/index.ts @@ -0,0 +1,3 @@ +export * from "./common"; +export * from "./multi-select"; +export * from "./single-select"; diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx new file mode 100644 index 000000000..08bc58638 --- /dev/null +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -0,0 +1,167 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { IMultiSelectDropdown } from "./dropdown"; + +export const MultiSelectDropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }); + + if (disableSorting) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx new file mode 100644 index 000000000..045296873 --- /dev/null +++ b/packages/ui/src/dropdown/single-select.tsx @@ -0,0 +1,166 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { ISingleSelectDropdown } from "./dropdown"; + +export const Dropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }); + + if (disableSorting) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/emoji/emoji-icon-helper.tsx b/packages/ui/src/emoji/emoji-icon-helper.tsx new file mode 100644 index 000000000..533f025d1 --- /dev/null +++ b/packages/ui/src/emoji/emoji-icon-helper.tsx @@ -0,0 +1,100 @@ +import { Placement } from "@popperjs/core"; +import { EmojiClickData, Theme } from "emoji-picker-react"; + +export enum EmojiIconPickerTypes { + EMOJI = "emoji", + ICON = "icon", +} + +export const TABS_LIST = [ + { + key: EmojiIconPickerTypes.EMOJI, + title: "Emojis", + }, + { + key: EmojiIconPickerTypes.ICON, + title: "Icons", + }, +]; + +export type TChangeHandlerProps = + | { + type: EmojiIconPickerTypes.EMOJI; + value: EmojiClickData; + } + | { + type: EmojiIconPickerTypes.ICON; + value: { + name: string; + color: string; + }; + }; + +export type TCustomEmojiPicker = { + isOpen: boolean; + handleToggle: (value: boolean) => void; + buttonClassName?: string; + className?: string; + closeOnSelect?: boolean; + defaultIconColor?: string; + defaultOpen?: EmojiIconPickerTypes; + disabled?: boolean; + dropdownClassName?: string; + label: React.ReactNode; + onChange: (value: TChangeHandlerProps) => void; + placement?: Placement; + searchPlaceholder?: string; + theme?: Theme; + iconType?: "material" | "lucide"; +}; + +export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b55c", "#f2be02", "#e57a00", "#f38e82"]; + +export type TIconsListProps = { + defaultColor: string; + onChange: (val: { name: string; color: string }) => void; +}; + +/** + * Adjusts the given hex color to ensure it has enough contrast. + * @param {string} hex - The hex color code input by the user. + * @returns {string} - The adjusted hex color code. + */ +export const adjustColorForContrast = (hex: string): string => { + // Ensure hex color is valid + if (!/^#([0-9A-F]{3}){1,2}$/i.test(hex)) { + throw new Error("Invalid hex color code"); + } + + // Convert hex to RGB + let r = 0, + g = 0, + b = 0; + if (hex.length === 4) { + r = parseInt(hex[1] + hex[1], 16); + g = parseInt(hex[2] + hex[2], 16); + b = parseInt(hex[3] + hex[3], 16); + } else if (hex.length === 7) { + r = parseInt(hex[1] + hex[2], 16); + g = parseInt(hex[3] + hex[4], 16); + b = parseInt(hex[5] + hex[6], 16); + } + + // Calculate luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // If the color is too light, darken it + if (luminance > 0.5) { + r = Math.max(0, r - 50); + g = Math.max(0, g - 50); + b = Math.max(0, b - 50); + } + + // Convert RGB back to hex + const toHex = (value: number): string => { + const hex = value.toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +}; diff --git a/packages/ui/src/emoji/emoji-icon-picker-new.tsx b/packages/ui/src/emoji/emoji-icon-picker-new.tsx new file mode 100644 index 000000000..557b39658 --- /dev/null +++ b/packages/ui/src/emoji/emoji-icon-picker-new.tsx @@ -0,0 +1,135 @@ +import React, { useRef, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover, Tab } from "@headlessui/react"; +import EmojiPicker from "emoji-picker-react"; +// helpers +import { cn } from "../../helpers"; +// hooks +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +import { LucideIconsList } from "./lucide-icons-list"; +// helpers +import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper"; + +export const EmojiIconPicker: React.FC = (props) => { + const { + isOpen, + handleToggle, + buttonClassName, + className, + closeOnSelect = true, + defaultIconColor = "#6d7b8a", + defaultOpen = EmojiIconPickerTypes.EMOJI, + disabled = false, + dropdownClassName, + label, + onChange, + placement = "bottom-start", + searchPlaceholder = "Search", + theme, + } = props; + // refs + const containerRef = useRef(null); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement, + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 20, + }, + }, + ], + }); + + // close dropdown on outside click + useOutsideClickDetector(containerRef, () => handleToggle(false)); + + return ( + + <> + + + + {isOpen && ( + +
+ tab.key === defaultOpen)} + > + + {TABS_LIST.map((tab) => ( + + cn("py-1 text-sm rounded border border-custom-border-200", { + "bg-custom-background-80": selected, + "hover:bg-custom-background-90 focus:bg-custom-background-90": !selected, + }) + } + > + {tab.title} + + ))} + + + + { + onChange({ + type: EmojiIconPickerTypes.EMOJI, + value: val, + }); + if (closeOnSelect) close(); + }} + height="20rem" + width="100%" + theme={theme} + searchPlaceholder={searchPlaceholder} + previewConfig={{ + showPreview: false, + }} + /> + + + { + onChange({ + type: EmojiIconPickerTypes.ICON, + value: val, + }); + if (closeOnSelect) close(); + }} + /> + + + +
+
+ )} + +
+ ); +}; diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx index 5bfcdbe17..c531dd168 100644 --- a/packages/ui/src/emoji/emoji-icon-picker.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -1,63 +1,23 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { usePopper } from "react-popper"; -import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react"; +import EmojiPicker from "emoji-picker-react"; import { Popover, Tab } from "@headlessui/react"; -import { Placement } from "@popperjs/core"; // components import { IconsList } from "./icons-list"; // helpers import { cn } from "../../helpers"; - -export enum EmojiIconPickerTypes { - EMOJI = "emoji", - ICON = "icon", -} - -type TChangeHandlerProps = - | { - type: EmojiIconPickerTypes.EMOJI; - value: EmojiClickData; - } - | { - type: EmojiIconPickerTypes.ICON; - value: { - name: string; - color: string; - }; - }; - -export type TCustomEmojiPicker = { - buttonClassName?: string; - className?: string; - closeOnSelect?: boolean; - defaultIconColor?: string; - defaultOpen?: EmojiIconPickerTypes; - disabled?: boolean; - dropdownClassName?: string; - label: React.ReactNode; - onChange: (value: TChangeHandlerProps) => void; - placement?: Placement; - searchPlaceholder?: string; - theme?: Theme; -}; - -const TABS_LIST = [ - { - key: EmojiIconPickerTypes.EMOJI, - title: "Emojis", - }, - { - key: EmojiIconPickerTypes.ICON, - title: "Icons", - }, -]; +// hooks +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper"; export const CustomEmojiIconPicker: React.FC = (props) => { const { + isOpen, + handleToggle, buttonClassName, className, closeOnSelect = true, - defaultIconColor = "#5f5f5f", + defaultIconColor = "#6d7b8a", defaultOpen = EmojiIconPickerTypes.EMOJI, disabled = false, dropdownClassName, @@ -68,6 +28,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { theme, } = props; // refs + const containerRef = useRef(null); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); // popper-js @@ -83,21 +44,25 @@ export const CustomEmojiIconPicker: React.FC = (props) => { ], }); + // close dropdown on outside click + useOutsideClickDetector(containerRef, () => handleToggle(false)); + return ( - {({ close }) => ( - <> - - - - + <> + + + + {isOpen && ( +
= (props) => { )} > tab.key === defaultOpen)} @@ -162,8 +128,8 @@ export const CustomEmojiIconPicker: React.FC = (props) => {
- - )} + )} +
); }; diff --git a/packages/ui/src/emoji/icons-list.tsx b/packages/ui/src/emoji/icons-list.tsx index f55da881b..0352e1ec8 100644 --- a/packages/ui/src/emoji/icons-list.tsx +++ b/packages/ui/src/emoji/icons-list.tsx @@ -3,15 +3,11 @@ import React, { useEffect, useState } from "react"; import { Input } from "../form-fields"; // helpers import { cn } from "../../helpers"; -// constants +import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper"; +// icons import { MATERIAL_ICONS_LIST } from "./icons"; - -type TIconsListProps = { - defaultColor: string; - onChange: (val: { name: string; color: string }) => void; -}; - -const DEFAULT_COLORS = ["#ff6b00", "#8cc1ff", "#fcbe1d", "#18904f", "#adf672", "#05c3ff", "#5f5f5f"]; +import { InfoIcon } from "../icons"; +import { Search } from "lucide-react"; export const IconsList: React.FC = (props) => { const { defaultColor, onChange } = props; @@ -19,6 +15,8 @@ export const IconsList: React.FC = (props) => { const [activeColor, setActiveColor] = useState(defaultColor); const [showHexInput, setShowHexInput] = useState(false); const [hexValue, setHexValue] = useState(""); + const [isInputFocused, setIsInputFocused] = useState(false); + const [query, setQuery] = useState(""); useEffect(() => { if (DEFAULT_COLORS.includes(defaultColor.toLowerCase())) setShowHexInput(false); @@ -28,11 +26,28 @@ export const IconsList: React.FC = (props) => { } }, [defaultColor]); + const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase())); + return ( <> -
+
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + + setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " + /> +
+
+
{showHexInput ? ( -
+
= (props) => { onChange={(e) => { const value = e.target.value; setHexValue(value); - if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(`#${value}`); + if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); }} className="flex-grow pl-0 text-xs text-custom-text-200" mode="true-transparent" @@ -59,7 +74,7 @@ export const IconsList: React.FC = (props) => {
-
- {MATERIAL_ICONS_LIST.map((icon) => ( +
+ +

Colors will be adjusted to ensure sufficient contrast.

+
+
+ {filteredArray.map((icon) => ( diff --git a/packages/ui/src/emoji/icons.ts b/packages/ui/src/emoji/icons.ts index 72aacf18b..3d650e244 100644 --- a/packages/ui/src/emoji/icons.ts +++ b/packages/ui/src/emoji/icons.ts @@ -1,3 +1,156 @@ +import { + Activity, + Airplay, + AlertCircle, + AlertOctagon, + AlertTriangle, + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Anchor, + Aperture, + Archive, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + AtSign, + Award, + BarChart, + BarChart2, + Battery, + BatteryCharging, + Bell, + BellOff, + Book, + Bookmark, + BookOpen, + Box, + Briefcase, + Calendar, + Camera, + CameraOff, + Cast, + Check, + CheckCircle, + CheckSquare, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Clipboard, + Clock, + Cloud, + CloudDrizzle, + CloudLightning, + CloudOff, + CloudRain, + CloudSnow, + Code, + Codepen, + Codesandbox, + Coffee, + Columns, + Command, + Compass, + Copy, + CornerDownLeft, + CornerDownRight, + CornerLeftDown, + CornerLeftUp, + CornerRightDown, + CornerRightUp, + CornerUpLeft, + CornerUpRight, + Cpu, + CreditCard, + Crop, + Crosshair, + Database, + Delete, + Disc, + Divide, + DivideCircle, + DivideSquare, + DollarSign, + Download, + DownloadCloud, + Dribbble, + Droplet, + Edit, + Edit2, + Edit3, + ExternalLink, + Eye, + EyeOff, + Facebook, + FastForward, + Feather, + Figma, + File, + FileMinus, + FilePlus, + FileText, + Film, + Filter, + Flag, + Folder, + FolderMinus, + FolderPlus, + Framer, + Frown, + Gift, + GitBranch, + GitCommit, + GitMerge, + GitPullRequest, + Github, + Gitlab, + Globe, + Grid, + HardDrive, + Hash, + Headphones, + Heart, + HelpCircle, + Hexagon, + Home, + Image, + Inbox, + Info, + Instagram, + Italic, + Key, + Layers, + Layout, + LifeBuoy, + Link, + Link2, + Linkedin, + List, + Loader, + Lock, + LogIn, + LogOut, + Mail, + Map, + MapPin, + Maximize, + Maximize2, + Meh, + Menu, + MessageCircle, + MessageSquare, + Mic, + MicOff, + Minimize, + Minimize2, + Minus, + MinusCircle, + MinusSquare, +} from "lucide-react"; + export const MATERIAL_ICONS_LIST = [ { name: "search", @@ -603,3 +756,156 @@ export const MATERIAL_ICONS_LIST = [ name: "skull", }, ]; + +export const LUCIDE_ICONS_LIST = [ + { name: "Activity", element: Activity }, + { name: "Airplay", element: Airplay }, + { name: "AlertCircle", element: AlertCircle }, + { name: "AlertOctagon", element: AlertOctagon }, + { name: "AlertTriangle", element: AlertTriangle }, + { name: "AlignCenter", element: AlignCenter }, + { name: "AlignJustify", element: AlignJustify }, + { name: "AlignLeft", element: AlignLeft }, + { name: "AlignRight", element: AlignRight }, + { name: "Anchor", element: Anchor }, + { name: "Aperture", element: Aperture }, + { name: "Archive", element: Archive }, + { name: "ArrowDown", element: ArrowDown }, + { name: "ArrowLeft", element: ArrowLeft }, + { name: "ArrowRight", element: ArrowRight }, + { name: "ArrowUp", element: ArrowUp }, + { name: "AtSign", element: AtSign }, + { name: "Award", element: Award }, + { name: "BarChart", element: BarChart }, + { name: "BarChart2", element: BarChart2 }, + { name: "Battery", element: Battery }, + { name: "BatteryCharging", element: BatteryCharging }, + { name: "Bell", element: Bell }, + { name: "BellOff", element: BellOff }, + { name: "Book", element: Book }, + { name: "Bookmark", element: Bookmark }, + { name: "BookOpen", element: BookOpen }, + { name: "Box", element: Box }, + { name: "Briefcase", element: Briefcase }, + { name: "Calendar", element: Calendar }, + { name: "Camera", element: Camera }, + { name: "CameraOff", element: CameraOff }, + { name: "Cast", element: Cast }, + { name: "Check", element: Check }, + { name: "CheckCircle", element: CheckCircle }, + { name: "CheckSquare", element: CheckSquare }, + { name: "ChevronDown", element: ChevronDown }, + { name: "ChevronLeft", element: ChevronLeft }, + { name: "ChevronRight", element: ChevronRight }, + { name: "ChevronUp", element: ChevronUp }, + { name: "Clipboard", element: Clipboard }, + { name: "Clock", element: Clock }, + { name: "Cloud", element: Cloud }, + { name: "CloudDrizzle", element: CloudDrizzle }, + { name: "CloudLightning", element: CloudLightning }, + { name: "CloudOff", element: CloudOff }, + { name: "CloudRain", element: CloudRain }, + { name: "CloudSnow", element: CloudSnow }, + { name: "Code", element: Code }, + { name: "Codepen", element: Codepen }, + { name: "Codesandbox", element: Codesandbox }, + { name: "Coffee", element: Coffee }, + { name: "Columns", element: Columns }, + { name: "Command", element: Command }, + { name: "Compass", element: Compass }, + { name: "Copy", element: Copy }, + { name: "CornerDownLeft", element: CornerDownLeft }, + { name: "CornerDownRight", element: CornerDownRight }, + { name: "CornerLeftDown", element: CornerLeftDown }, + { name: "CornerLeftUp", element: CornerLeftUp }, + { name: "CornerRightDown", element: CornerRightDown }, + { name: "CornerRightUp", element: CornerRightUp }, + { name: "CornerUpLeft", element: CornerUpLeft }, + { name: "CornerUpRight", element: CornerUpRight }, + { name: "Cpu", element: Cpu }, + { name: "CreditCard", element: CreditCard }, + { name: "Crop", element: Crop }, + { name: "Crosshair", element: Crosshair }, + { name: "Database", element: Database }, + { name: "Delete", element: Delete }, + { name: "Disc", element: Disc }, + { name: "Divide", element: Divide }, + { name: "DivideCircle", element: DivideCircle }, + { name: "DivideSquare", element: DivideSquare }, + { name: "DollarSign", element: DollarSign }, + { name: "Download", element: Download }, + { name: "DownloadCloud", element: DownloadCloud }, + { name: "Dribbble", element: Dribbble }, + { name: "Droplet", element: Droplet }, + { name: "Edit", element: Edit }, + { name: "Edit2", element: Edit2 }, + { name: "Edit3", element: Edit3 }, + { name: "ExternalLink", element: ExternalLink }, + { name: "Eye", element: Eye }, + { name: "EyeOff", element: EyeOff }, + { name: "Facebook", element: Facebook }, + { name: "FastForward", element: FastForward }, + { name: "Feather", element: Feather }, + { name: "Figma", element: Figma }, + { name: "File", element: File }, + { name: "FileMinus", element: FileMinus }, + { name: "FilePlus", element: FilePlus }, + { name: "FileText", element: FileText }, + { name: "Film", element: Film }, + { name: "Filter", element: Filter }, + { name: "Flag", element: Flag }, + { name: "Folder", element: Folder }, + { name: "FolderMinus", element: FolderMinus }, + { name: "FolderPlus", element: FolderPlus }, + { name: "Framer", element: Framer }, + { name: "Frown", element: Frown }, + { name: "Gift", element: Gift }, + { name: "GitBranch", element: GitBranch }, + { name: "GitCommit", element: GitCommit }, + { name: "GitMerge", element: GitMerge }, + { name: "GitPullRequest", element: GitPullRequest }, + { name: "Github", element: Github }, + { name: "Gitlab", element: Gitlab }, + { name: "Globe", element: Globe }, + { name: "Grid", element: Grid }, + { name: "HardDrive", element: HardDrive }, + { name: "Hash", element: Hash }, + { name: "Headphones", element: Headphones }, + { name: "Heart", element: Heart }, + { name: "HelpCircle", element: HelpCircle }, + { name: "Hexagon", element: Hexagon }, + { name: "Home", element: Home }, + { name: "Image", element: Image }, + { name: "Inbox", element: Inbox }, + { name: "Info", element: Info }, + { name: "Instagram", element: Instagram }, + { name: "Italic", element: Italic }, + { name: "Key", element: Key }, + { name: "Layers", element: Layers }, + { name: "Layout", element: Layout }, + { name: "LifeBuoy", element: LifeBuoy }, + { name: "Link", element: Link }, + { name: "Link2", element: Link2 }, + { name: "Linkedin", element: Linkedin }, + { name: "List", element: List }, + { name: "Loader", element: Loader }, + { name: "Lock", element: Lock }, + { name: "LogIn", element: LogIn }, + { name: "LogOut", element: LogOut }, + { name: "Mail", element: Mail }, + { name: "Map", element: Map }, + { name: "MapPin", element: MapPin }, + { name: "Maximize", element: Maximize }, + { name: "Maximize2", element: Maximize2 }, + { name: "Meh", element: Meh }, + { name: "Menu", element: Menu }, + { name: "MessageCircle", element: MessageCircle }, + { name: "MessageSquare", element: MessageSquare }, + { name: "Mic", element: Mic }, + { name: "MicOff", element: MicOff }, + { name: "Minimize", element: Minimize }, + { name: "Minimize2", element: Minimize2 }, + { name: "Minus", element: Minus }, + { name: "MinusCircle", element: MinusCircle }, + { name: "MinusSquare", element: MinusSquare }, +]; diff --git a/packages/ui/src/emoji/index.ts b/packages/ui/src/emoji/index.ts index 973454139..128b80292 100644 --- a/packages/ui/src/emoji/index.ts +++ b/packages/ui/src/emoji/index.ts @@ -1 +1,4 @@ +export * from "./emoji-icon-picker-new"; export * from "./emoji-icon-picker"; +export * from "./emoji-icon-helper"; +export * from "./icons"; diff --git a/packages/ui/src/emoji/lucide-icons-list.tsx b/packages/ui/src/emoji/lucide-icons-list.tsx new file mode 100644 index 000000000..799f0919d --- /dev/null +++ b/packages/ui/src/emoji/lucide-icons-list.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +// components +import { Input } from "../form-fields"; +// helpers +import { cn } from "../../helpers"; +import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper"; +// icons +import { InfoIcon } from "../icons"; +// constants +import { LUCIDE_ICONS_LIST } from "./icons"; +import { Search } from "lucide-react"; + +export const LucideIconsList: React.FC = (props) => { + const { defaultColor, onChange } = props; + // states + const [activeColor, setActiveColor] = useState(defaultColor); + const [showHexInput, setShowHexInput] = useState(false); + const [hexValue, setHexValue] = useState(""); + const [isInputFocused, setIsInputFocused] = useState(false); + const [query, setQuery] = useState(""); + + useEffect(() => { + if (DEFAULT_COLORS.includes(defaultColor.toLowerCase())) setShowHexInput(false); + else { + setHexValue(defaultColor.slice(1, 7)); + setShowHexInput(true); + } + }, [defaultColor]); + + const filteredArray = LUCIDE_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase())); + + return ( + <> +
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + + setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " + /> +
+
+
+ {showHexInput ? ( +
+ + HEX + # + { + const value = e.target.value; + setHexValue(value); + if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); + }} + className="flex-grow pl-0 text-xs text-custom-text-200" + mode="true-transparent" + autoFocus + /> +
+ ) : ( + DEFAULT_COLORS.map((curCol) => ( + + )) + )} + +
+
+ +

Colors will be adjusted to ensure sufficient contrast.

+
+
+ {filteredArray.map((icon) => ( + + ))} +
+ + ); +}; diff --git a/packages/ui/src/form-fields/checkbox.tsx b/packages/ui/src/form-fields/checkbox.tsx index 887bc6074..3c45cf4f5 100644 --- a/packages/ui/src/form-fields/checkbox.tsx +++ b/packages/ui/src/form-fields/checkbox.tsx @@ -3,15 +3,26 @@ import * as React from "react"; import { cn } from "../../helpers"; export interface CheckboxProps extends React.InputHTMLAttributes { - intermediate?: boolean; - className?: string; + containerClassName?: string; + iconClassName?: string; + indeterminate?: boolean; } const Checkbox = React.forwardRef((props, ref) => { - const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props; + const { + id, + name, + checked, + indeterminate = false, + disabled, + containerClassName, + iconClassName, + className, + ...rest + } = props; return ( -
+
((props, ref) name={name} checked={checked} className={cn( - "appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50", + "appearance-none shrink-0 size-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50 cursor-pointer", { "border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled, - "cursor-pointer border-custom-border-300 hover:border-custom-border-400 bg-white": !disabled, - "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200": - !disabled && (checked || intermediate), - } + "border-custom-border-300 hover:border-custom-border-400 bg-transparent": !disabled, + "border-custom-primary-40 hover:border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200": + !disabled && (checked || indeterminate), + }, + className )} disabled={disabled} {...rest} /> void, + onEscKeyDown: () => void, + stopPropagation?: boolean + ): (event: React.KeyboardEvent) => void; +}; + +export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = useCallback( + (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }, + [stopPropagation] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + stopEventPropagation(event); + onEnterKeyDown(); + } else if (event.key === "Escape") { + stopEventPropagation(event); + onEscKeyDown(); + } else if (event.key === "Tab") onEscKeyDown(); + }, + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] + ); + + return handleKeyDown; +}; diff --git a/packages/ui/src/hooks/use-page-title.tsx b/packages/ui/src/hooks/use-page-title.tsx new file mode 100644 index 000000000..79fb6857e --- /dev/null +++ b/packages/ui/src/hooks/use-page-title.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +interface IUseHeadParams { + title?: string; +} + +export const useHead = ({ title }: IUseHeadParams) => { + useEffect(() => { + if (title) { + document.title = title ?? "Plane | Simple, extensible, open-source project management tool."; + } + }, [title]); +}; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index dbed6ba89..c51375282 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -19,4 +19,4 @@ export * from "./priority-icon"; export * from "./related-icon"; export * from "./side-panel-icon"; export * from "./transfer-icon"; -export * from "./user-group-icon"; +export * from "./info-icon"; diff --git a/packages/ui/src/icons/info-icon.tsx b/packages/ui/src/icons/info-icon.tsx new file mode 100644 index 000000000..5dbc7f756 --- /dev/null +++ b/packages/ui/src/icons/info-icon.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const InfoIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + + +); diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 031b769f1..ffa74a374 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -7,7 +7,7 @@ type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; interface IPriorityIcon { className?: string; containerClassName?: string; - priority: TIssuePriorities; + priority: TIssuePriorities | undefined | null; size?: number; withContainer?: boolean; } @@ -31,7 +31,7 @@ export const PriorityIcon: React.FC = (props) => { low: SignalLow, none: Ban, }; - const Icon = icons[priority]; + const Icon = icons[priority ?? "none"]; if (!Icon) return null; @@ -41,7 +41,7 @@ export const PriorityIcon: React.FC = (props) => {
diff --git a/packages/ui/src/icons/user-group-icon.tsx b/packages/ui/src/icons/user-group-icon.tsx deleted file mode 100644 index 7cad96d23..000000000 --- a/packages/ui/src/icons/user-group-icon.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; - -import { ISvgIcons } from "./type"; - -export const UserGroupIcon: React.FC = ({ className = "text-current", ...rest }) => ( - - - - - - -); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dae012381..e0bb66c33 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,6 +4,7 @@ export * from "./badge"; export * from "./button"; export * from "./emoji"; export * from "./dropdowns"; +export * from "./dropdown"; export * from "./form-fields"; export * from "./icons"; export * from "./progress"; @@ -13,4 +14,7 @@ export * from "./loader"; export * from "./control-link"; export * from "./toast"; export * from "./drag-handle"; -export * from "./drop-indicator"; \ No newline at end of file +export * from "./typography"; +export * from "./drop-indicator"; +export * from "./sortable"; +export * from "./hooks"; diff --git a/packages/ui/src/sortable/draggable.tsx b/packages/ui/src/sortable/draggable.tsx new file mode 100644 index 000000000..7fded837e --- /dev/null +++ b/packages/ui/src/sortable/draggable.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { isEqual } from "lodash"; +import { cn } from "../../helpers"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { DropIndicator } from "../drop-indicator"; + +type Props = { + children: React.ReactNode; + data: any; //@todo make this generic + className?: string; +}; +const Draggable = ({ children, data, className }: Props) => { + const ref = useRef(null); + const [dragging, setDragging] = useState(false); // NEW + const [isDraggedOver, setIsDraggedOver] = useState(false); + + const [closestEdge, setClosestEdge] = useState(null); + useEffect(() => { + const el = ref.current; + + if (el) { + combine( + draggable({ + element: el, + onDragStart: () => setDragging(true), // NEW + onDrop: () => setDragging(false), // NEW + getInitialData: () => data, + }), + dropTargetForElements({ + element: el, + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data)); + }, + onDragLeave: () => setIsDraggedOver(false), + onDrop: () => { + setIsDraggedOver(false); + }, + canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__, + getData: ({ input, element }) => + attachClosestEdge(data, { + input, + element, + allowedEdges: ["top", "bottom"], + }), + }) + ); + } + }, [data]); + + return ( +
+ {} + {children} + {} +
+ ); +}; + +export { Draggable }; diff --git a/packages/ui/src/sortable/index.ts b/packages/ui/src/sortable/index.ts new file mode 100644 index 000000000..9dde5a404 --- /dev/null +++ b/packages/ui/src/sortable/index.ts @@ -0,0 +1,2 @@ +export * from "./sortable"; +export * from "./draggable"; diff --git a/packages/ui/src/sortable/sortable.stories.tsx b/packages/ui/src/sortable/sortable.stories.tsx new file mode 100644 index 000000000..b701af95d --- /dev/null +++ b/packages/ui/src/sortable/sortable.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { Sortable } from "./sortable"; + +const meta: Meta = { + title: "Sortable", + component: Sortable, +}; + +export default meta; +type Story = StoryObj; + +const data = [ + { id: "1", name: "John Doe" }, + { id: "2", name: "Satish" }, + { id: "3", name: "Alice" }, + { id: "4", name: "Bob" }, + { id: "5", name: "Charlie" }, +]; +export const Default: Story = { + args: { + data, + render: (item: any) => ( + // +
{item.name}
+ //
+ ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange: (data) => console.log(data.map(({ id }: any) => id)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keyExtractor: (item: any) => item.id, + }, +}; diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx new file mode 100644 index 000000000..b495d535e --- /dev/null +++ b/packages/ui/src/sortable/sortable.tsx @@ -0,0 +1,79 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { Draggable } from "./draggable"; + +type Props = { + data: T[]; + render: (item: T, index: number) => React.ReactNode; + onChange: (data: T[]) => void; + keyExtractor: (item: T, index: number) => string; + containerClassName?: string; + id?: string; +}; + +const moveItem = ( + data: T[], + source: T, + destination: T & Record, + keyExtractor: (item: T, index: number) => string +) => { + const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0)); + if (sourceIndex === -1) return data; + + const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0)); + + if (destinationIndex === -1) return data; + + const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)"); + const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol + const newData = [...data]; + const [movedItem] = newData.splice(sourceIndex, 1); + + let adjustedDestinationIndex = destinationIndex; + if (position === "bottom") { + adjustedDestinationIndex++; + } + + // Prevent moving item out of bounds + if (adjustedDestinationIndex > newData.length) { + adjustedDestinationIndex = newData.length; + } + + newData.splice(adjustedDestinationIndex, 0, movedItem); + + return newData; +}; + +export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName, id }: Props) => { + useEffect(() => { + const unsubscribe = monitorForElements({ + onDrop({ source, location }) { + const destination = location?.current?.dropTargets[0]; + if (!destination) return; + onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor)); + }, + }); + + // Clean up the subscription on unmount + return () => { + if (unsubscribe) unsubscribe(); + }; + }, [data, keyExtractor, onChange]); + + const enhancedData = useMemo(() => { + const uuid = id ? id : Math.random().toString(36).substring(7); + return data.map((item) => ({ ...item, __uuid__: uuid })); + }, [data, id]); + + return ( + <> + {enhancedData.map((item, index) => ( + + {render(item, index)} + + ))} + + ); +}; + +export default Sortable; diff --git a/packages/ui/src/typography/index.tsx b/packages/ui/src/typography/index.tsx new file mode 100644 index 000000000..0b1b7ffe1 --- /dev/null +++ b/packages/ui/src/typography/index.tsx @@ -0,0 +1 @@ +export * from "./sub-heading"; diff --git a/packages/ui/src/typography/sub-heading.tsx b/packages/ui/src/typography/sub-heading.tsx new file mode 100644 index 000000000..9e7075583 --- /dev/null +++ b/packages/ui/src/typography/sub-heading.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { cn } from "../../helpers"; + +type Props = { + children: React.ReactNode; + className?: string; + noMargin?: boolean; +}; +const SubHeading = ({ children, className, noMargin }: Props) => ( +

+ {children} +

+); + +export { SubHeading }; diff --git a/space/.gitignore b/space/.gitignore index a2a963ee7..a64f113f1 100644 --- a/space/.gitignore +++ b/space/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # env .env + +# Sentry Config File +.env.sentry-build-plugin diff --git a/space/app/[workspaceSlug]/[projectId]/page.ts b/space/app/[workspaceSlug]/[projectId]/page.ts new file mode 100644 index 000000000..4f18e8bd5 --- /dev/null +++ b/space/app/[workspaceSlug]/[projectId]/page.ts @@ -0,0 +1,42 @@ +import { notFound, redirect } from "next/navigation"; +// types +import { TPublishSettings } from "@plane/types"; +// services +import PublishService from "@/services/publish.service"; + +const publishService = new PublishService(); + +type Props = { + params: { + workspaceSlug: string; + projectId: string; + }; + searchParams: any; +}; + +export default async function IssuesPage(props: Props) { + const { params, searchParams } = props; + // query params + const { workspaceSlug, projectId } = params; + const { board, peekId } = searchParams; + + let response: TPublishSettings | undefined = undefined; + try { + response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId); + } catch (error) { + // redirect to 404 page on error + notFound(); + } + + let url = ""; + if (response?.entity_name === "project") { + url = `/issues/${response?.anchor}`; + const params = new URLSearchParams(); + if (board) params.append("board", board); + if (peekId) params.append("peekId", peekId); + if (params.toString()) url += `?${params.toString()}`; + redirect(url); + } else { + notFound(); + } +} diff --git a/space/app/[workspace_slug]/[project_id]/page.tsx b/space/app/[workspace_slug]/[project_id]/page.tsx deleted file mode 100644 index 0d08ae7eb..000000000 --- a/space/app/[workspace_slug]/[project_id]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -// components -import { ProjectDetailsView } from "@/components/views"; - -export default function WorkspaceProjectPage({ params }: { params: { workspace_slug: any; project_id: any } }) { - const { workspace_slug, project_id } = params; - - const searchParams = useSearchParams(); - const peekId = searchParams.get("peekId") || undefined; - - if (!workspace_slug || !project_id) return <>; - - return ; -} diff --git a/space/app/error.tsx b/space/app/error.tsx index 2d6f22e90..e47a1af1d 100644 --- a/space/app/error.tsx +++ b/space/app/error.tsx @@ -1,38 +1,47 @@ "use client"; -import Image from "next/image"; -import { useTheme } from "next-themes"; +// ui import { Button } from "@plane/ui"; -// assets -import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; -import InstanceFailureImage from "@/public/instance/instance-failure.svg"; - -export default function InstanceError() { - const { resolvedTheme } = useTheme(); - - const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; +const ErrorPage = () => { const handleRetry = () => { window.location.reload(); }; return ( -
-
-
- Plane instance failure image -

Unable to fetch instance details.

-

- We were unable to fetch the details of the instance.
- Fret not, it might just be a connectivity issue. +

+
+
+

Exception Detected!

+

+ We{"'"}re Sorry! An exception has been detected, and our engineering team has been notified. We apologize + for any inconvenience this may have caused. Please reach out to our engineering team at{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + {" "} + server for further assistance.

-
- + {/* */}
); -} +}; + +export default ErrorPage; diff --git a/space/app/[workspace_slug]/[project_id]/layout.tsx b/space/app/issues/[anchor]/layout.tsx similarity index 54% rename from space/app/[workspace_slug]/[project_id]/layout.tsx rename to space/app/issues/[anchor]/layout.tsx index b1e134ea6..91291e481 100644 --- a/space/app/[workspace_slug]/[project_id]/layout.tsx +++ b/space/app/issues/[anchor]/layout.tsx @@ -1,25 +1,39 @@ +"use client"; + +import { observer } from "mobx-react-lite"; import Image from "next/image"; -import { notFound } from "next/navigation"; +import useSWR from "swr"; // components -import IssueNavbar from "@/components/issues/navbar"; +import { LogoSpinner } from "@/components/common"; +import { IssuesNavbarRoot } from "@/components/issues"; +// hooks +import { usePublish, usePublishList } from "@/hooks/store"; // assets -import planeLogo from "public/plane-logo.svg"; +import planeLogo from "@/public/plane-logo.svg"; -export default async function ProjectLayout({ - children, - params, -}: { +type Props = { children: React.ReactNode; - params: { workspace_slug: string; project_id: string }; -}) { - const { workspace_slug, project_id } = params; + params: { + anchor: string; + }; +}; - if (!workspace_slug || !project_id) notFound(); +const IssuesLayout = observer((props: Props) => { + const { children, params } = props; + // params + const { anchor } = params; + // store hooks + const { fetchPublishSettings } = usePublishList(); + const publishSettings = usePublish(anchor); + // fetch publish settings + useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null); + + if (!publishSettings) return ; return (
- +
{children}
); -} +}); + +export default IssuesLayout; diff --git a/space/app/issues/[anchor]/page.tsx b/space/app/issues/[anchor]/page.tsx new file mode 100644 index 000000000..b3c9353e6 --- /dev/null +++ b/space/app/issues/[anchor]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +import { useSearchParams } from "next/navigation"; +// components +import { IssuesLayoutsRoot } from "@/components/issues"; +// hooks +import { usePublish } from "@/hooks/store"; + +type Props = { + params: { + anchor: string; + }; +}; + +const IssuesPage = observer((props: Props) => { + const { params } = props; + const { anchor } = params; + // params + const searchParams = useSearchParams(); + const peekId = searchParams.get("peekId") || undefined; + + const publishSettings = usePublish(anchor); + + if (!publishSettings) return null; + + return ; +}); + +export default IssuesPage; diff --git a/space/app/not-found.tsx b/space/app/not-found.tsx index cae576319..c5320b2dc 100644 --- a/space/app/not-found.tsx +++ b/space/app/not-found.tsx @@ -4,20 +4,18 @@ import Image from "next/image"; // assets import UserLoggedInImage from "public/user-logged-in.svg"; -export default function NotFound() { - return ( -
-
-
-
-
- User already logged in -
-
-

Not Found

-

Please enter the appropriate project URL to view the issue board.

+const NotFound = () => ( +
+
+
+
+ User already logged in
+

Not Found

+

Please enter the appropriate project URL to view the issue board.

- ); -} +
+); + +export default NotFound; diff --git a/space/components/account/auth-forms/email.tsx b/space/components/account/auth-forms/email.tsx index 946e51916..86e3507b8 100644 --- a/space/components/account/auth-forms/email.tsx +++ b/space/components/account/auth-forms/email.tsx @@ -66,9 +66,10 @@ export const AuthEmailForm: FC = observer((props) => { autoFocus /> {email.length > 0 && ( -
- setEmail("")} /> -
+ setEmail("")} + /> )}
{emailError?.email && !isFocused && ( diff --git a/space/components/account/auth-forms/password.tsx b/space/components/account/auth-forms/password.tsx index 361bd4fc3..5a2f0664e 100644 --- a/space/components/account/auth-forms/password.tsx +++ b/space/components/account/auth-forms/password.tsx @@ -117,9 +117,10 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { disabled /> {passwordFormData.email.length > 0 && ( -
- -
+ )}
diff --git a/space/components/account/auth-forms/unique-code.tsx b/space/components/account/auth-forms/unique-code.tsx index e3c82bce6..10c7b4f00 100644 --- a/space/components/account/auth-forms/unique-code.tsx +++ b/space/components/account/auth-forms/unique-code.tsx @@ -101,9 +101,10 @@ export const AuthUniqueCodeForm: React.FC = (props) => { disabled /> {uniqueCodeFormData.email.length > 0 && ( -
- -
+ )}
diff --git a/space/components/account/user-logged-in.tsx b/space/components/account/user-logged-in.tsx index 33be330fa..5975d73b6 100644 --- a/space/components/account/user-logged-in.tsx +++ b/space/components/account/user-logged-in.tsx @@ -1,36 +1,44 @@ "use client"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; +import { useTheme } from "next-themes"; // components -import { UserAvatar } from "@/components/issues/navbar/user-avatar"; +import { UserAvatar } from "@/components/issues"; // hooks import { useUser } from "@/hooks/store"; // assets -import PlaneLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; import UserLoggedInImage from "@/public/user-logged-in.svg"; -export const UserLoggedIn = () => { +export const UserLoggedIn = observer(() => { + // store hooks const { data: user } = useUser(); + // next-themes + const { resolvedTheme } = useTheme(); + + const logo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo; if (!user) return null; return ( -
+
-
- User already logged in +
+ Plane logo
-
+
-
-
+
+
User already logged in
-

Logged in Successfully!

+

Logged in successfully!

You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board.

@@ -38,4 +46,4 @@ export const UserLoggedIn = () => {
); -}; +}); diff --git a/space/components/common/index.ts b/space/components/common/index.ts index c4ea97f3c..1949c069b 100644 --- a/space/components/common/index.ts +++ b/space/components/common/index.ts @@ -1,3 +1,2 @@ -export * from "./latest-feature-block"; export * from "./project-logo"; export * from "./logo-spinner"; diff --git a/space/components/common/latest-feature-block.tsx b/space/components/common/latest-feature-block.tsx deleted file mode 100644 index c1b5db954..000000000 --- a/space/components/common/latest-feature-block.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -// icons -import { Lightbulb } from "lucide-react"; -// images -import latestFeatures from "public/onboarding/onboarding-pages.svg"; - -export const LatestFeatureBlock = () => { - const { resolvedTheme } = useTheme(); - - return ( - <> -
- -

- Pages gets a facelift! Write anything and use Galileo to help you start.{" "} - - Learn more - -

-
-
-
- Plane Issues -
-
- - ); -}; diff --git a/space/components/common/project-logo.tsx b/space/components/common/project-logo.tsx index 9b69e9616..dfb3a4b80 100644 --- a/space/components/common/project-logo.tsx +++ b/space/components/common/project-logo.tsx @@ -1,11 +1,11 @@ -// helpers -import { TProjectLogoProps } from "@plane/types"; -import { cn } from "@/helpers/common.helper"; // types +import { TLogoProps } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; type Props = { className?: string; - logo: TProjectLogoProps; + logo: TLogoProps; }; export const ProjectLogo: React.FC = (props) => { diff --git a/space/components/editor/toolbar.tsx b/space/components/editor/toolbar.tsx index 19bbdab9a..0140a46b6 100644 --- a/space/components/editor/toolbar.tsx +++ b/space/components/editor/toolbar.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useEffect, useState, useCallback } from "react"; // editor import { EditorMenuItemNames, EditorRefApi } from "@plane/lite-text-editor"; diff --git a/space/components/instance/index.ts b/space/components/instance/index.ts index 6568894f0..be80bc669 100644 --- a/space/components/instance/index.ts +++ b/space/components/instance/index.ts @@ -1,2 +1 @@ -export * from "./not-ready-view"; export * from "./instance-failure-view"; diff --git a/space/components/instance/not-ready-view.tsx b/space/components/instance/not-ready-view.tsx deleted file mode 100644 index be46a9473..000000000 --- a/space/components/instance/not-ready-view.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { FC } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -// ui -import { Button } from "@plane/ui"; -// helper -import { GOD_MODE_URL, SPACE_BASE_PATH } from "@/helpers/common.helper"; -// images -import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png"; -import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; -import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; - -export const InstanceNotReady: FC = () => { - const { resolvedTheme } = useTheme(); - const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; - - const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; - - return ( -
- ); -}; diff --git a/space/components/issues/board-views/block-downvotes.tsx b/space/components/issues/board-views/block-downvotes.tsx deleted file mode 100644 index 4326a8823..000000000 --- a/space/components/issues/board-views/block-downvotes.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -export const IssueBlockDownVotes = ({ number }: { number: number }) => ( -
- - arrow_upward_alt - - {number} -
-); diff --git a/space/components/issues/board-views/block-due-date.tsx b/space/components/issues/board-views/block-due-date.tsx deleted file mode 100644 index ecf229562..000000000 --- a/space/components/issues/board-views/block-due-date.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -// helpers -import { renderFullDate } from "@/helpers/date-time.helper"; - -export const dueDateIconDetails = ( - date: string, - stateGroup: string -): { - iconName: string; - className: string; -} => { - let iconName = "calendar_today"; - let className = ""; - - if (!date || ["completed", "cancelled"].includes(stateGroup)) { - iconName = "calendar_today"; - className = ""; - } else { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const targetDate = new Date(date); - targetDate.setHours(0, 0, 0, 0); - - const timeDifference = targetDate.getTime() - today.getTime(); - - if (timeDifference < 0) { - iconName = "event_busy"; - className = "text-red-500"; - } else if (timeDifference === 0) { - iconName = "today"; - className = "text-red-500"; - } else if (timeDifference === 24 * 60 * 60 * 1000) { - iconName = "event"; - className = "text-yellow-500"; - } else { - iconName = "calendar_today"; - className = ""; - } - } - - return { - iconName, - className, - }; -}; - -export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => { - const iconDetails = dueDateIconDetails(due_date, group); - - return ( -
- - {iconDetails.iconName} - - {renderFullDate(due_date)} -
- ); -}; diff --git a/space/components/issues/board-views/block-labels.tsx b/space/components/issues/board-views/block-labels.tsx deleted file mode 100644 index 05f6a039f..000000000 --- a/space/components/issues/board-views/block-labels.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -export const IssueBlockLabels = ({ labels }: any) => ( -
- {labels && - labels.length > 0 && - labels.map((_label: any) => ( -
-
-
-
{_label?.name}
-
-
- ))} -
-); diff --git a/space/components/issues/board-views/block-state.tsx b/space/components/issues/board-views/block-state.tsx deleted file mode 100644 index 39b10ceb0..000000000 --- a/space/components/issues/board-views/block-state.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// ui -import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; - -export const IssueBlockState = ({ state }: any) => { - const stateGroup = issueGroupFilter(state.group); - - if (stateGroup === null) return <>; - return ( -
-
- -
{state?.name}
-
-
- ); -}; diff --git a/space/components/issues/board-views/block-upvotes.tsx b/space/components/issues/board-views/block-upvotes.tsx deleted file mode 100644 index 3927acac4..000000000 --- a/space/components/issues/board-views/block-upvotes.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -export const IssueBlockUpVotes = ({ number }: { number: number }) => ( -
- arrow_upward_alt - {number} -
-); diff --git a/space/components/issues/board-views/calendar/index.tsx b/space/components/issues/board-views/calendar/index.tsx deleted file mode 100644 index 0edeca96c..000000000 --- a/space/components/issues/board-views/calendar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueCalendarView = () =>
; diff --git a/space/components/issues/board-views/gantt/index.tsx b/space/components/issues/board-views/gantt/index.tsx deleted file mode 100644 index 5da924b2c..000000000 --- a/space/components/issues/board-views/gantt/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueGanttView = () =>
; diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx deleted file mode 100644 index e34222dd4..000000000 --- a/space/components/issues/board-views/kanban/block.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter, useSearchParams } from "next/navigation"; -// components -import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; -import { IssueBlockPriority } from "@/components/issues/board-views/block-priority"; -import { IssueBlockState } from "@/components/issues/board-views/block-state"; -// helpers -import { queryParamGenerator } from "@/helpers/query-param-generator"; -// hooks -import { useIssueDetails, useProject } from "@/hooks/store"; -// interfaces -import { IIssue } from "@/types/issue"; - -type IssueKanBanBlockProps = { - issue: IIssue; - workspaceSlug: string; - projectId: string; - params: any; -}; - -export const IssueKanBanBlock: FC = observer((props) => { - const router = useRouter(); - const searchParams = useSearchParams(); - // query params - const board = searchParams.get("board") || undefined; - const state = searchParams.get("state") || undefined; - const priority = searchParams.get("priority") || undefined; - const labels = searchParams.get("labels") || undefined; - // props - const { workspaceSlug, projectId, issue } = props; - // hooks - const { project } = useProject(); - const { setPeekId } = useIssueDetails(); - - const handleBlockClick = () => { - setPeekId(issue.id); - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); - }; - - return ( -
- {/* id */} -
- {project?.identifier}-{issue?.sequence_id} -
- - {/* name */} -
- {issue.name} -
- -
- {/* priority */} - {issue?.priority && ( -
- -
- )} - {/* state */} - {issue?.state_detail && ( -
- -
- )} - {/* due date */} - {issue?.target_date && ( -
- -
- )} -
-
- ); -}); diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx deleted file mode 100644 index baf5612b3..000000000 --- a/space/components/issues/board-views/kanban/header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// ui -import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; -// mobx hook -// import { useIssue } from "@/hooks/store"; -// interfaces -import { IIssueState } from "@/types/issue"; - -export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { - // const { getCountOfIssuesByState } = useIssue(); - const stateGroup = issueGroupFilter(state.group); - - if (stateGroup === null) return <>; - - return ( -
-
- -
-
{state?.name}
- {/* {getCountOfIssuesByState(state.id)} */} -
- ); -}); diff --git a/space/components/issues/board-views/kanban/index.tsx b/space/components/issues/board-views/kanban/index.tsx deleted file mode 100644 index e2e4e9900..000000000 --- a/space/components/issues/board-views/kanban/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { IssueKanBanBlock } from "@/components/issues/board-views/kanban/block"; -import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header"; -// ui -import { Icon } from "@/components/ui"; -// mobx hook -import { useIssue } from "@/hooks/store"; -// interfaces -import { IIssueState, IIssue } from "@/types/issue"; - -type IssueKanbanViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const IssueKanbanView: FC = observer((props) => { - const { workspaceSlug, projectId } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( -
- {states && - states.length > 0 && - states.map((_state: IIssueState) => ( -
-
- -
-
- {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? ( -
- {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - - ))} -
- ) : ( -
- - No issues in this state -
- )} -
-
- ))} -
- ); -}); diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx deleted file mode 100644 index 2a2b958be..000000000 --- a/space/components/issues/board-views/list/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { IssueListBlock } from "@/components/issues/board-views/list/block"; -import { IssueListHeader } from "@/components/issues/board-views/list/header"; -// mobx hook -import { useIssue } from "@/hooks/store"; -// types -import { IIssueState, IIssue } from "@/types/issue"; - -type IssueListViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const IssueListView: FC = observer((props) => { - const { workspaceSlug, projectId } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( - <> - {states && - states.length > 0 && - states.map((_state: IIssueState) => ( -
- - {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? ( -
- {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - - ))} -
- ) : ( -
No issues.
- )} -
- ))} - - ); -}); diff --git a/space/components/issues/board-views/spreadsheet/index.tsx b/space/components/issues/board-views/spreadsheet/index.tsx deleted file mode 100644 index 45ebf2792..000000000 --- a/space/components/issues/board-views/spreadsheet/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueSpreadsheetView = () =>
; diff --git a/space/components/issues/filters/applied-filters/filters-list.tsx b/space/components/issues/filters/applied-filters/filters-list.tsx index 83d651f5d..87089c500 100644 --- a/space/components/issues/filters/applied-filters/filters-list.tsx +++ b/space/components/issues/filters/applied-filters/filters-list.tsx @@ -1,10 +1,10 @@ "use client"; -// icons import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; // types -import { IIssueLabel, IIssueState, TFilters } from "@/types/issue"; +import { IStateLite } from "@plane/types"; +import { IIssueLabel, TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; import { AppliedStateFilters } from "./state"; @@ -14,7 +14,7 @@ type Props = { handleRemoveAllFilters: () => void; handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; labels?: IIssueLabel[] | undefined; - states?: IIssueState[] | undefined; + states?: IStateLite[] | undefined; }; export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); diff --git a/space/components/issues/filters/applied-filters/root.tsx b/space/components/issues/filters/applied-filters/root.tsx index 9dd1eb013..9b6625d75 100644 --- a/space/components/issues/filters/applied-filters/root.tsx +++ b/space/components/issues/filters/applied-filters/root.tsx @@ -12,18 +12,18 @@ import { TIssueQueryFilters } from "@/types/issue"; import { AppliedFiltersList } from "./filters-list"; type TIssueAppliedFilters = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueAppliedFilters: FC = observer((props) => { + const { anchor } = props; + // router const router = useRouter(); - // props - const { workspaceSlug, projectId } = props; - // hooks - const { issueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); + // store hooks + const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); const { states, labels } = useIssue(); - + // derived values + const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; const userFilters = issueFilters?.filters || {}; @@ -46,30 +46,26 @@ export const IssueAppliedFilters: FC = observer((props) => if (labels.length > 0) params = { ...params, labels: labels.join(",") }; params = new URLSearchParams(params).toString(); - router.push(`/${workspaceSlug}/${projectId}?${params}`); + router.push(`/issues/${anchor}?${params}`); }, - [workspaceSlug, projectId, activeLayout, issueFilters, router] + [activeLayout, anchor, issueFilters, router] ); const handleFilters = useCallback( (key: keyof TIssueQueryFilters, value: string | null) => { - if (!projectId) return; - let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; if (value === null) newValues = []; else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); - updateIssueFilters(projectId, "filters", key, newValues); + updateIssueFilters(anchor, "filters", key, newValues); updateRouteParams(key, newValues); }, - [projectId, issueFilters, updateIssueFilters, updateRouteParams] + [anchor, issueFilters, updateIssueFilters, updateRouteParams] ); const handleRemoveAllFilters = () => { - if (!projectId) return; - - initIssueFilters(projectId, { + initIssueFilters(anchor, { display_filters: { layout: activeLayout || "list" }, filters: { state: [], @@ -78,13 +74,13 @@ export const IssueAppliedFilters: FC = observer((props) => }, }); - router.push(`/${workspaceSlug}/${projectId}?${`board=${activeLayout || "list"}`}`); + router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); }; if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
void; - states: IIssueState[]; + states: IStateLite[]; values: string[]; }; diff --git a/space/components/issues/filters/root.tsx b/space/components/issues/filters/root.tsx index de972ea8a..dba13f9fb 100644 --- a/space/components/issues/filters/root.tsx +++ b/space/components/issues/filters/root.tsx @@ -17,17 +17,18 @@ import { useIssue, useIssueFilter } from "@/hooks/store"; import { TIssueQueryFilters } from "@/types/issue"; type IssueFiltersDropdownProps = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueFiltersDropdown: FC = observer((props) => { + const { anchor } = props; + // router const router = useRouter(); - const { workspaceSlug, projectId } = props; // hooks - const { issueFilters, updateIssueFilters } = useIssueFilter(); + const { getIssueFilters, updateIssueFilters } = useIssueFilter(); const { states, labels } = useIssue(); - + // derived values + const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; const updateRouteParams = useCallback( @@ -37,24 +38,24 @@ export const IssueFiltersDropdown: FC = observer((pro const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); + router.push(`/issues/${anchor}?${queryParam}`); }, - [workspaceSlug, projectId, activeLayout, issueFilters, router] + [anchor, activeLayout, issueFilters, router] ); const handleFilters = useCallback( (key: keyof TIssueQueryFilters, value: string) => { - if (!projectId || !value) return; + if (!value) return; const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); - updateIssueFilters(projectId, "filters", key, newValues); + updateIssueFilters(anchor, "filters", key, newValues); updateRouteParams(key, newValues); }, - [projectId, issueFilters, updateIssueFilters, updateRouteParams] + [anchor, issueFilters, updateIssueFilters, updateRouteParams] ); return ( diff --git a/space/components/issues/filters/selection.tsx b/space/components/issues/filters/selection.tsx index a1180b0ee..926fbf5b0 100644 --- a/space/components/issues/filters/selection.tsx +++ b/space/components/issues/filters/selection.tsx @@ -4,7 +4,8 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; // types -import { IIssueState, IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; +import { IStateLite } from "@plane/types"; +import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; // components import { FilterPriority, FilterState } from "./"; @@ -13,7 +14,7 @@ type Props = { handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; layoutDisplayFiltersOptions: TIssueFilterKeys[]; labels?: IIssueLabel[] | undefined; - states?: IIssueState[] | undefined; + states?: IStateLite[] | undefined; }; export const FilterSelection: React.FC = observer((props) => { diff --git a/space/components/issues/filters/state.tsx b/space/components/issues/filters/state.tsx index 24b6bb5c8..f61237eef 100644 --- a/space/components/issues/filters/state.tsx +++ b/space/components/issues/filters/state.tsx @@ -1,17 +1,18 @@ "use client"; import React, { useState } from "react"; +// types +import { IStateLite } from "@plane/types"; +// ui import { Loader, StateGroupIcon } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers"; -// types -import { IIssueState } from "@/types/issue"; type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; searchQuery: string; - states: IIssueState[] | undefined; + states: IStateLite[] | undefined; }; export const FilterState: React.FC = (props) => { diff --git a/space/components/issues/index.ts b/space/components/issues/index.ts new file mode 100644 index 000000000..6aee62097 --- /dev/null +++ b/space/components/issues/index.ts @@ -0,0 +1,2 @@ +export * from "./issue-layouts"; +export * from "./navbar"; diff --git a/space/components/issues/issue-layouts/index.ts b/space/components/issues/issue-layouts/index.ts new file mode 100644 index 000000000..5ab6813cd --- /dev/null +++ b/space/components/issues/issue-layouts/index.ts @@ -0,0 +1,4 @@ +export * from "./kanban"; +export * from "./list"; +export * from "./properties"; +export * from "./root"; diff --git a/space/components/issues/issue-layouts/kanban/block.tsx b/space/components/issues/issue-layouts/kanban/block.tsx new file mode 100644 index 000000000..ac03823b4 --- /dev/null +++ b/space/components/issues/issue-layouts/kanban/block.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +// components +import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails, usePublish } from "@/hooks/store"; +// interfaces +import { IIssue } from "@/types/issue"; + +type Props = { + anchor: string; + issue: IIssue; + params: any; +}; + +export const IssueKanBanBlock: FC = observer((props) => { + const { anchor, issue } = props; + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board"); + const state = searchParams.get("state"); + const priority = searchParams.get("priority"); + const labels = searchParams.get("labels"); + // store hooks + const { project_details } = usePublish(anchor); + const { setPeekId } = useIssueDetails(); + + const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); + + const handleBlockClick = () => { + setPeekId(issue.id); + }; + + return ( + + {/* id */} +
+ {project_details?.identifier}-{issue?.sequence_id} +
+ + {/* name */} +
+ {issue.name} +
+ +
+ {/* priority */} + {issue?.priority && ( +
+ +
+ )} + {/* state */} + {issue?.state_detail && ( +
+ +
+ )} + {/* due date */} + {issue?.target_date && ( +
+ +
+ )} +
+ + ); +}); diff --git a/space/components/issues/issue-layouts/kanban/header.tsx b/space/components/issues/issue-layouts/kanban/header.tsx new file mode 100644 index 000000000..ee5433d68 --- /dev/null +++ b/space/components/issues/issue-layouts/kanban/header.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { observer } from "mobx-react-lite"; +// types +import { IStateLite } from "@plane/types"; +// ui +import { StateGroupIcon } from "@plane/ui"; + +type Props = { + state: IStateLite; +}; + +export const IssueKanBanHeader: React.FC = observer((props) => { + const { state } = props; + + return ( +
+
+ +
+
{state?.name}
+ {/* {getCountOfIssuesByState(state.id)} */} +
+ ); +}); diff --git a/space/components/issues/issue-layouts/kanban/index.ts b/space/components/issues/issue-layouts/kanban/index.ts new file mode 100644 index 000000000..62874fbda --- /dev/null +++ b/space/components/issues/issue-layouts/kanban/index.ts @@ -0,0 +1,3 @@ +export * from "./block"; +export * from "./header"; +export * from "./root"; diff --git a/space/components/issues/issue-layouts/kanban/root.tsx b/space/components/issues/issue-layouts/kanban/root.tsx new file mode 100644 index 000000000..e0a5593e9 --- /dev/null +++ b/space/components/issues/issue-layouts/kanban/root.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues"; +// ui +import { Icon } from "@/components/ui"; +// mobx hook +import { useIssue } from "@/hooks/store"; + +type Props = { + anchor: string; +}; + +export const IssueKanbanLayoutRoot: FC = observer((props) => { + const { anchor } = props; + // store hooks + const { states, getFilteredIssuesByState } = useIssue(); + + return ( +
+ {states?.map((state) => { + const issues = getFilteredIssuesByState(state.id); + + return ( +
+
+ +
+
+ {issues && issues.length > 0 ? ( +
+ {issues.map((issue) => ( + + ))} +
+ ) : ( +
+ + No issues in this state +
+ )} +
+
+ ); + })} +
+ ); +}); diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/issue-layouts/list/block.tsx similarity index 64% rename from space/components/issues/board-views/list/block.tsx rename to space/components/issues/issue-layouts/list/block.tsx index 6b6231fcf..8c241753d 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/issue-layouts/list/block.tsx @@ -1,56 +1,52 @@ "use client"; import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; // components -import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; -import { IssueBlockLabels } from "@/components/issues/board-views/block-labels"; -import { IssueBlockPriority } from "@/components/issues/board-views/block-priority"; -import { IssueBlockState } from "@/components/issues/board-views/block-state"; +import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hook -import { useIssueDetails, useProject } from "@/hooks/store"; -// interfaces +import { useIssueDetails, usePublish } from "@/hooks/store"; +// types import { IIssue } from "@/types/issue"; -// store type IssueListBlockProps = { + anchor: string; issue: IIssue; - workspaceSlug: string; - projectId: string; }; -export const IssueListBlock: FC = observer((props) => { - const { workspaceSlug, projectId, issue } = props; - const searchParams = useSearchParams(); +export const IssueListLayoutBlock: FC = observer((props) => { + const { anchor, issue } = props; // query params + const searchParams = useSearchParams(); const board = searchParams.get("board") || undefined; const state = searchParams.get("state") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - // store - const { project } = useProject(); + // store hooks const { setPeekId } = useIssueDetails(); - // router - const router = useRouter(); + const { project_details } = usePublish(anchor); + const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); const handleBlockClick = () => { setPeekId(issue.id); - - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); }; return ( -
+
{/* id */}
- {project?.identifier}-{issue?.sequence_id} + {project_details?.identifier}-{issue?.sequence_id}
{/* name */} -
+
{issue.name}
@@ -84,6 +80,6 @@ export const IssueListBlock: FC = observer((props) => {
)}
-
+ ); }); diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/issue-layouts/list/header.tsx similarity index 54% rename from space/components/issues/board-views/list/header.tsx rename to space/components/issues/issue-layouts/list/header.tsx index 2f8f6c018..a038050a9 100644 --- a/space/components/issues/board-views/list/header.tsx +++ b/space/components/issues/issue-layouts/list/header.tsx @@ -1,20 +1,18 @@ "use client"; + +import React from "react"; import { observer } from "mobx-react-lite"; +// types +import { IStateLite } from "@plane/types"; // ui import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; -// mobx hook -// import { useIssue } from "@/hooks/store"; -// types -import { IIssueState } from "@/types/issue"; -export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { - // const { getCountOfIssuesByState } = useIssue(); - const stateGroup = issueGroupFilter(state.group); - // const count = getCountOfIssuesByState(state.id); +type Props = { + state: IStateLite; +}; - if (stateGroup === null) return <>; +export const IssueListLayoutHeader: React.FC = observer((props) => { + const { state } = props; return (
diff --git a/space/components/issues/issue-layouts/list/index.ts b/space/components/issues/issue-layouts/list/index.ts new file mode 100644 index 000000000..62874fbda --- /dev/null +++ b/space/components/issues/issue-layouts/list/index.ts @@ -0,0 +1,3 @@ +export * from "./block"; +export * from "./header"; +export * from "./root"; diff --git a/space/components/issues/issue-layouts/list/root.tsx b/space/components/issues/issue-layouts/list/root.tsx new file mode 100644 index 000000000..02cd25b40 --- /dev/null +++ b/space/components/issues/issue-layouts/list/root.tsx @@ -0,0 +1,40 @@ +"use client"; +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues"; +// mobx hook +import { useIssue } from "@/hooks/store"; + +type Props = { + anchor: string; +}; + +export const IssuesListLayoutRoot: FC = observer((props) => { + const { anchor } = props; + // store hooks + const { states, getFilteredIssuesByState } = useIssue(); + + return ( + <> + {states?.map((state) => { + const issues = getFilteredIssuesByState(state.id); + + return ( +
+ + {issues && issues.length > 0 ? ( +
+ {issues.map((issue) => ( + + ))} +
+ ) : ( +
No issues.
+ )} +
+ ); + })} + + ); +}); diff --git a/space/components/issues/issue-layouts/properties/due-date.tsx b/space/components/issues/issue-layouts/properties/due-date.tsx new file mode 100644 index 000000000..3b73973e7 --- /dev/null +++ b/space/components/issues/issue-layouts/properties/due-date.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { CalendarCheck2 } from "lucide-react"; +// types +import { TStateGroups } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; + +type Props = { + due_date: string; + group: TStateGroups; +}; + +export const IssueBlockDueDate = (props: Props) => { + const { due_date, group } = props; + + return ( +
+ + {renderFormattedDate(due_date)} +
+ ); +}; diff --git a/space/components/issues/issue-layouts/properties/index.ts b/space/components/issues/issue-layouts/properties/index.ts new file mode 100644 index 000000000..de78f9966 --- /dev/null +++ b/space/components/issues/issue-layouts/properties/index.ts @@ -0,0 +1,4 @@ +export * from "./due-date"; +export * from "./labels"; +export * from "./priority"; +export * from "./state"; diff --git a/space/components/issues/issue-layouts/properties/labels.tsx b/space/components/issues/issue-layouts/properties/labels.tsx new file mode 100644 index 000000000..75c32c4a0 --- /dev/null +++ b/space/components/issues/issue-layouts/properties/labels.tsx @@ -0,0 +1,17 @@ +"use client"; + +export const IssueBlockLabels = ({ labels }: any) => ( +
+ {labels?.map((_label: any) => ( +
+
+
+
{_label?.name}
+
+
+ ))} +
+); diff --git a/space/components/issues/board-views/block-priority.tsx b/space/components/issues/issue-layouts/properties/priority.tsx similarity index 85% rename from space/components/issues/board-views/block-priority.tsx rename to space/components/issues/issue-layouts/properties/priority.tsx index 3110930ec..b91d56bb8 100644 --- a/space/components/issues/board-views/block-priority.tsx +++ b/space/components/issues/issue-layouts/properties/priority.tsx @@ -1,11 +1,11 @@ "use client"; // types -import { issuePriorityFilter } from "@/constants/issue"; -import { TIssueFilterPriority } from "@/types/issue"; +import { TIssuePriorities } from "@plane/types"; // constants +import { issuePriorityFilter } from "@/constants/issue"; -export const IssueBlockPriority = ({ priority }: { priority: TIssueFilterPriority | null }) => { +export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorities | null }) => { const priority_detail = priority != null ? issuePriorityFilter(priority) : null; if (priority_detail === null) return <>; diff --git a/space/components/issues/issue-layouts/properties/state.tsx b/space/components/issues/issue-layouts/properties/state.tsx new file mode 100644 index 000000000..31e851c64 --- /dev/null +++ b/space/components/issues/issue-layouts/properties/state.tsx @@ -0,0 +1,13 @@ +"use client"; + +// ui +import { StateGroupIcon } from "@plane/ui"; + +export const IssueBlockState = ({ state }: any) => ( +
+
+ +
{state?.name}
+
+
+); diff --git a/space/components/views/project-details.tsx b/space/components/issues/issue-layouts/root.tsx similarity index 52% rename from space/components/views/project-details.tsx rename to space/components/issues/issue-layouts/root.tsx index 462c656f0..e53986c85 100644 --- a/space/components/views/project-details.tsx +++ b/space/components/issues/issue-layouts/root.tsx @@ -6,69 +6,55 @@ import Image from "next/image"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; // components -import { IssueCalendarView } from "@/components/issues/board-views/calendar"; -import { IssueGanttView } from "@/components/issues/board-views/gantt"; -import { IssueKanbanView } from "@/components/issues/board-views/kanban"; -import { IssueListView } from "@/components/issues/board-views/list"; -import { IssueSpreadsheetView } from "@/components/issues/board-views/spreadsheet"; +import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues"; import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root"; import { IssuePeekOverview } from "@/components/issues/peek-overview"; -// mobx store -import { useIssue, useUser, useIssueDetails, useIssueFilter, useProject } from "@/hooks/store"; +// hooks +import { useIssue, useIssueDetails, useIssueFilter } from "@/hooks/store"; +// store +import { PublishStore } from "@/store/publish/publish.store"; // assets import SomethingWentWrongImage from "public/something-went-wrong.svg"; -type ProjectDetailsViewProps = { - workspaceSlug: string; - projectId: string; +type Props = { peekId: string | undefined; + publishSettings: PublishStore; }; -export const ProjectDetailsView: FC = observer((props) => { - // router - const searchParams = useSearchParams(); +export const IssuesLayoutsRoot: FC = observer((props) => { + const { peekId, publishSettings } = props; // query params + const searchParams = useSearchParams(); const states = searchParams.get("states") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - - const { workspaceSlug, projectId, peekId } = props; - // hooks - const { fetchProjectSettings } = useProject(); - const { issueFilters } = useIssueFilter(); + // store hooks + const { getIssueFilters } = useIssueFilter(); const { loader, issues, error, fetchPublicIssues } = useIssue(); const issueDetailStore = useIssueDetails(); - const { data: currentUser, fetchCurrentUser } = useUser(); + // derived values + const { anchor } = publishSettings; + const issueFilters = anchor ? getIssueFilters(anchor) : undefined; useSWR( - workspaceSlug && projectId ? "WORKSPACE_PROJECT_SETTINGS" : null, - workspaceSlug && projectId ? () => fetchProjectSettings(workspaceSlug, projectId) : null - ); - useSWR( - (workspaceSlug && projectId) || states || priority || labels ? "WORKSPACE_PROJECT_PUBLIC_ISSUES" : null, - (workspaceSlug && projectId) || states || priority || labels - ? () => fetchPublicIssues(workspaceSlug, projectId, { states, priority, labels }) - : null - ); - useSWR( - workspaceSlug && projectId && !currentUser ? "WORKSPACE_PROJECT_CURRENT_USER" : null, - workspaceSlug && projectId && !currentUser ? () => fetchCurrentUser() : null + anchor ? `PUBLIC_ISSUES_${anchor}` : null, + anchor ? () => fetchPublicIssues(anchor, { states, priority, labels }) : null ); useEffect(() => { - if (peekId && workspaceSlug && projectId) { + if (peekId) { issueDetailStore.setPeekId(peekId.toString()); } - }, [peekId, issueDetailStore, projectId, workspaceSlug]); + }, [peekId, issueDetailStore]); // derived values const activeLayout = issueFilters?.display_filters?.layout || undefined; + if (!anchor) return null; + return (
- {workspaceSlug && projectId && peekId && ( - - )} + {peekId && } {loader && !issues ? (
Loading...
@@ -90,21 +76,18 @@ export const ProjectDetailsView: FC = observer((props) activeLayout && (
{/* applied filters */} - + {activeLayout === "list" && (
- +
)} {activeLayout === "kanban" && (
- +
)} - {activeLayout === "calendar" && } - {activeLayout === "spreadsheet" && } - {activeLayout === "gantt" && }
) )} diff --git a/space/components/issues/navbar/controls.tsx b/space/components/issues/navbar/controls.tsx index 20c0ca408..25f2edfb0 100644 --- a/space/components/issues/navbar/controls.tsx +++ b/space/components/issues/navbar/controls.tsx @@ -4,26 +4,25 @@ import { useEffect, FC } from "react"; import { observer } from "mobx-react-lite"; import { useRouter, useSearchParams } from "next/navigation"; // components +import { IssuesLayoutSelection, NavbarTheme, UserAvatar } from "@/components/issues"; import { IssueFiltersDropdown } from "@/components/issues/filters"; -import { NavbarIssueBoardView } from "@/components/issues/navbar/issue-board-view"; -import { NavbarTheme } from "@/components/issues/navbar/theme"; -import { UserAvatar } from "@/components/issues/navbar/user-avatar"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks -import { useProject, useIssueFilter, useIssueDetails } from "@/hooks/store"; +import { useIssueFilter, useIssueDetails } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; +// store +import { PublishStore } from "@/store/publish/publish.store"; // types import { TIssueLayout } from "@/types/issue"; export type NavbarControlsProps = { - workspaceSlug: string; - projectId: string; + publishSettings: PublishStore; }; export const NavbarControls: FC = observer((props) => { // props - const { workspaceSlug, projectId } = props; + const { publishSettings } = props; // router const router = useRouter(); const searchParams = useSearchParams(); @@ -34,24 +33,25 @@ export const NavbarControls: FC = observer((props) => { const priority = searchParams.get("priority") || undefined; const peekId = searchParams.get("peekId") || undefined; // hooks - const { issueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter(); - const { settings } = useProject(); + 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 (workspaceSlug && projectId && settings) { + if (anchor && workspace_detail) { const viewsAcceptable: string[] = []; let currentBoard: TIssueLayout | null = null; - if (settings?.views?.list) viewsAcceptable.push("list"); - if (settings?.views?.kanban) viewsAcceptable.push("kanban"); - if (settings?.views?.calendar) viewsAcceptable.push("calendar"); - if (settings?.views?.gantt) viewsAcceptable.push("gantt"); - if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); + 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; @@ -74,39 +74,41 @@ export const NavbarControls: FC = observer((props) => { }, }; - if (!isIssueFiltersUpdated(params)) { - initIssueFilters(projectId, params); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); + if (!isIssueFiltersUpdated(anchor, params)) { + initIssueFilters(anchor, params); + router.push(`/issues/${anchor}?${queryParam}`); } } } } }, [ - workspaceSlug, - projectId, + anchor, board, labels, state, priority, peekId, - settings, activeLayout, router, initIssueFilters, setPeekId, isIssueFiltersUpdated, + view_props, + workspace_detail, ]); + if (!anchor) return null; + return ( <> {/* issue views */}
- +
{/* issue filters */}
- +
{/* theming */} diff --git a/space/components/issues/navbar/index.ts b/space/components/issues/navbar/index.ts new file mode 100644 index 000000000..e1bb02d91 --- /dev/null +++ b/space/components/issues/navbar/index.ts @@ -0,0 +1,5 @@ +export * from "./controls"; +export * from "./layout-selection"; +export * from "./root"; +export * from "./theme"; +export * from "./user-avatar"; diff --git a/space/components/issues/navbar/issue-board-view.tsx b/space/components/issues/navbar/issue-board-view.tsx deleted file mode 100644 index 711229961..000000000 --- a/space/components/issues/navbar/issue-board-view.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter, useSearchParams } from "next/navigation"; -// constants -import { issueLayoutViews } from "@/constants/issue"; -// helpers -import { queryParamGenerator } from "@/helpers/query-param-generator"; -// hooks -import { useIssueFilter } from "@/hooks/store"; -// mobx -import { TIssueLayout } from "@/types/issue"; - -type NavbarIssueBoardViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const NavbarIssueBoardView: FC = observer((props) => { - const router = useRouter(); - const searchParams = useSearchParams(); - // query params - const labels = searchParams.get("labels") || undefined; - const state = searchParams.get("state") || undefined; - const priority = searchParams.get("priority") || undefined; - const peekId = searchParams.get("peekId") || undefined; - // props - const { workspaceSlug, projectId } = props; - // hooks - const { layoutOptions, issueFilters, updateIssueFilters } = useIssueFilter(); - - // derived values - const activeLayout = issueFilters?.display_filters?.layout || undefined; - - const handleCurrentBoardView = (boardView: TIssueLayout) => { - updateIssueFilters(projectId, "display_filters", "layout", boardView); - const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); - }; - - return ( - <> - {issueLayoutViews && - Object.keys(issueLayoutViews).map((key: string) => { - const layoutKey = key as TIssueLayout; - if (layoutOptions[layoutKey]) { - return ( -
handleCurrentBoardView(layoutKey)} - title={layoutKey} - > - - {issueLayoutViews[layoutKey]?.icon} - -
- ); - } - })} - - ); -}); diff --git a/space/components/issues/navbar/layout-selection.tsx b/space/components/issues/navbar/layout-selection.tsx new file mode 100644 index 000000000..1989710b5 --- /dev/null +++ b/space/components/issues/navbar/layout-selection.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter, useSearchParams } from "next/navigation"; +// ui +import { Tooltip } from "@plane/ui"; +// constants +import { ISSUE_LAYOUTS } from "@/constants/issue"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueFilter } from "@/hooks/store"; +// mobx +import { TIssueLayout } from "@/types/issue"; + +type Props = { + anchor: string; +}; + +export const IssuesLayoutSelection: FC = observer((props) => { + const { anchor } = props; + // 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 ( +
+ {ISSUE_LAYOUTS.map((layout) => { + if (!layoutOptions[layout.key]) return; + + return ( + + + + ); + })} +
+ ); +}); diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/root.tsx similarity index 58% rename from space/components/issues/navbar/index.tsx rename to space/components/issues/navbar/root.tsx index f5d60b8b0..1d1a294d9 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/root.tsx @@ -4,41 +4,40 @@ import { observer } from "mobx-react-lite"; import { Briefcase } from "lucide-react"; // components import { ProjectLogo } from "@/components/common"; -import { NavbarControls } from "@/components/issues/navbar/controls"; -// hooks -import { useProject } from "@/hooks/store"; +import { NavbarControls } from "@/components/issues"; +// store +import { PublishStore } from "@/store/publish/publish.store"; -type IssueNavbarProps = { - workspaceSlug: string; - projectId: string; +type Props = { + publishSettings: PublishStore; }; -const IssueNavbar: FC = observer((props) => { - const { workspaceSlug, projectId } = props; +export const IssuesNavbarRoot: FC = observer((props) => { + const { publishSettings } = props; // hooks - const { project } = useProject(); + const { project_details } = publishSettings; return (
{/* project detail */}
- {project ? ( + {project_details ? ( - + ) : ( )} -
{project?.name || `...`}
+
+ {project_details?.name || `...`} +
- +
); }); - -export default IssueNavbar; diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx index a1647c9c5..dd328d38d 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/components/issues/peek-overview/comment/add-comment.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; import { useForm, Controller } from "react-hook-form"; @@ -8,7 +10,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; // editor components import { LiteTextEditor } from "@/components/editor/lite-text-editor"; // hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; // types import { Comment } from "@/types/issue"; @@ -17,22 +19,18 @@ const defaultValues: Partial = { }; type Props = { + anchor: string; disabled?: boolean; - workspaceSlug: string; - projectId: string; }; export const AddComment: React.FC = observer((props) => { - // const { disabled = false } = props; - const { workspaceSlug, projectId } = props; + const { anchor } = props; // refs const editorRef = useRef(null); // store hooks - const { workspace } = useProject(); const { peekId: issueId, addIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); - // derived values - const workspaceId = workspace?.id; + const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); // form info const { handleSubmit, @@ -43,9 +41,9 @@ export const AddComment: React.FC = observer((props) => { } = useForm({ defaultValues }); const onSubmit = async (formData: Comment) => { - if (!workspaceSlug || !projectId || !issueId || isSubmitting || !formData.comment_html) return; + if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; - await addIssueComment(workspaceSlug, projectId, issueId, formData) + await addIssueComment(anchor, issueId, formData) .then(() => { reset(defaultValues); editorRef.current?.clearEditor(); @@ -71,8 +69,8 @@ export const AddComment: React.FC = observer((props) => { onEnterKeyPress={(e) => { if (currentUser) handleSubmit(onSubmit)(e); }} - workspaceId={workspaceId as string} - workspaceSlug={workspaceSlug} + workspaceId={workspaceID?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} initialValue={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx index 3ede0333b..31e5f7324 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -10,25 +10,23 @@ import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; // hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; // types import { Comment } from "@/types/issue"; type Props = { - workspaceSlug: string; + anchor: string; comment: Comment; }; export const CommentCard: React.FC = observer((props) => { - const { comment, workspaceSlug } = props; + const { anchor, comment } = props; // store hooks - const { workspace } = useProject(); const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); + const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); const isInIframe = useIsInIframe(); - // derived values - const workspaceId = workspace?.id; // states const [isEditing, setIsEditing] = useState(false); @@ -45,13 +43,13 @@ export const CommentCard: React.FC = observer((props) => { }); const handleDelete = () => { - if (!workspaceSlug || !peekId) return; - deleteIssueComment(workspaceSlug, comment.project, peekId, comment.id); + if (!anchor || !peekId) return; + deleteIssueComment(anchor, peekId, comment.id); }; const handleCommentUpdate = async (formData: Comment) => { - if (!workspaceSlug || !peekId) return; - updateIssueComment(workspaceSlug, comment.project, peekId, comment.id, formData); + if (!anchor || !peekId) return; + updateIssueComment(anchor, peekId, comment.id, formData); setIsEditing(false); editorRef.current?.setEditorValue(formData.comment_html); showEditorRef.current?.setEditorValue(formData.comment_html); @@ -103,8 +101,8 @@ export const CommentCard: React.FC = observer((props) => { name="comment_html" render={({ field: { onChange, value } }) => ( = observer((props) => {
- +
diff --git a/space/components/issues/peek-overview/comment/comment-reactions.tsx b/space/components/issues/peek-overview/comment/comment-reactions.tsx index ed915eff4..3c769ce12 100644 --- a/space/components/issues/peek-overview/comment/comment-reactions.tsx +++ b/space/components/issues/peek-overview/comment/comment-reactions.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react-lite"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -13,12 +15,12 @@ import { useIssueDetails, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; type Props = { + anchor: string; commentId: string; - projectId: string; - workspaceSlug: string; }; export const CommentReactions: React.FC = observer((props) => { + const { anchor, commentId } = props; const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -28,7 +30,6 @@ export const CommentReactions: React.FC = observer((props) => { const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - const { commentId, projectId, workspaceSlug } = props; // hooks const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); const { data: user } = useUser(); @@ -40,13 +41,13 @@ export const CommentReactions: React.FC = observer((props) => { const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !peekId) return; - addCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); + if (!anchor || !peekId) return; + addCommentReaction(anchor, peekId, commentId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !peekId) return; - removeCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); + if (!anchor || !peekId) return; + removeCommentReaction(anchor, peekId, commentId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { diff --git a/space/components/issues/peek-overview/full-screen-peek-view.tsx b/space/components/issues/peek-overview/full-screen-peek-view.tsx index f5918de43..e10c7bbbf 100644 --- a/space/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/space/components/issues/peek-overview/full-screen-peek-view.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react-lite"; // components import { Loader } from "@plane/ui"; @@ -11,14 +13,13 @@ import { import { IIssue } from "@/types/issue"; type Props = { + anchor: string; handleClose: () => void; issueDetails: IIssue | undefined; - workspaceSlug: string; - projectId: string; }; export const FullScreenPeekView: React.FC = observer((props) => { - const { handleClose, issueDetails, workspaceSlug, projectId } = props; + const { anchor, handleClose, issueDetails } = props; return (
@@ -30,17 +31,13 @@ export const FullScreenPeekView: React.FC = observer((props) => {
{/* issue title and description */}
- +
{/* divider */}
{/* issue activity/comments */}
- +
) : ( diff --git a/space/components/issues/peek-overview/header.tsx b/space/components/issues/peek-overview/header.tsx index 0e9b93ab9..b11cace82 100644 --- a/space/components/issues/peek-overview/header.tsx +++ b/space/components/issues/peek-overview/header.tsx @@ -1,10 +1,11 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react-lite"; -import { MoveRight } from "lucide-react"; +import { Link2, MoveRight } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; // ui -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { Icon } from "@/components/ui"; +import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks @@ -18,21 +19,21 @@ type Props = { issueDetails: IIssue | undefined; }; -const peekModes: { +const PEEK_MODES: { key: IPeekMode; - icon: string; + icon: any; label: string; }[] = [ - { key: "side", icon: "side_navigation", label: "Side Peek" }, + { key: "side", icon: SidePanelIcon, label: "Side Peek" }, { key: "modal", - icon: "dialogs", - label: "Modal Peek", + icon: CenterPanelIcon, + label: "Modal", }, { key: "full", - icon: "nearby", - label: "Full Screen Peek", + icon: FullScreenPanelIcon, + label: "Full Screen", }, ]; @@ -47,20 +48,22 @@ export const PeekOverviewHeader: React.FC = observer((props) => { copyTextToClipboard(urlToCopy).then(() => { setToast({ - type: TOAST_TYPE.INFO, + type: TOAST_TYPE.SUCCESS, title: "Link copied!", - message: "Issue link copied to clipboard", + message: "Issue link copied to clipboard.", }); }); }; + const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon; + return ( <>
{peekMode === "side" && ( - )} = observer((props) => { onChange={(val) => setPeekMode(val)} className="relative flex-shrink-0 text-left" > - - m.key === peekMode)?.icon ?? ""} className="text-[1rem]" /> + + = observer((props) => { >
- {peekModes.map((mode) => ( + {PEEK_MODES.map((mode) => ( = observer((props) => {
{isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && (
-
)} diff --git a/space/components/issues/peek-overview/issue-activity.tsx b/space/components/issues/peek-overview/issue-activity.tsx index ec73bda7b..f2c1b48cd 100644 --- a/space/components/issues/peek-overview/issue-activity.tsx +++ b/space/components/issues/peek-overview/issue-activity.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; @@ -7,61 +9,58 @@ import { Button } from "@plane/ui"; import { CommentCard, AddComment } from "@/components/issues/peek-overview"; import { Icon } from "@/components/ui"; // hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; // types import { IIssue } from "@/types/issue"; type Props = { + anchor: string; issueDetails: IIssue; - workspaceSlug: string; - projectId: string; }; export const PeekOverviewIssueActivity: React.FC = observer((props) => { - const { workspaceSlug, projectId } = props; + const { anchor } = props; // router const pathname = usePathname(); - // store - const { canComment } = useProject(); + // store hooks const { details, peekId } = useIssueDetails(); const { data: currentUser } = useUser(); - const isInIframe = useIsInIframe(); - + const { canComment } = usePublish(anchor); + // derived values const comments = details[peekId || ""]?.comments || []; + const isInIframe = useIsInIframe(); return (

Comments

- {workspaceSlug && ( -
-
- {comments.map((comment: any) => ( - - ))} -
- {!isInIframe && - (currentUser ? ( - <> - {canComment && ( -
- -
- )} - - ) : ( -
-

- - Sign in to add your comment -

- - - -
- ))} +
+
+ {comments.map((comment) => ( + + ))}
- )} + {!isInIframe && + (currentUser ? ( + <> + {canComment && ( +
+ +
+ )} + + ) : ( +
+

+ + Sign in to add your comment +

+ + + +
+ ))} +
); }); diff --git a/space/components/issues/peek-overview/issue-details.tsx b/space/components/issues/peek-overview/issue-details.tsx index 5fe73f67a..97a659554 100644 --- a/space/components/issues/peek-overview/issue-details.tsx +++ b/space/components/issues/peek-overview/issue-details.tsx @@ -5,26 +5,33 @@ import { IssueReactions } from "@/components/issues/peek-overview"; import { IIssue } from "@/types/issue"; type Props = { + anchor: string; issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => ( -
-
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
-

{issueDetails.name}

- {issueDetails.description_html !== "" && issueDetails.description_html !== "

" && ( -

" - : issueDetails.description_html - } - /> - )} - -
-); +export const PeekOverviewIssueDetails: React.FC = (props) => { + const { anchor, issueDetails } = props; + + const description = issueDetails.description_html; + + return ( +
+
+ {issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id} +
+

{issueDetails.name}

+ {description !== "" && description !== "

" && ( +

" + : description + } + /> + )} + +
+ ); +}; diff --git a/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/components/issues/peek-overview/issue-emoji-reactions.tsx index 4a0e61554..e25b10397 100644 --- a/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +"use client"; + import { observer } from "mobx-react-lite"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; // lib @@ -11,11 +12,12 @@ import { queryParamGenerator } from "@/helpers/query-param-generator"; import { useIssueDetails, useUser } from "@/hooks/store"; type IssueEmojiReactionsProps = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueEmojiReactions: React.FC = observer((props) => { + const { anchor } = props; + // router const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -25,11 +27,9 @@ export const IssueEmojiReactions: React.FC = observer( const state = searchParams.get("state") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - - const { workspaceSlug, projectId } = props; - // store + // store hooks const issueDetailsStore = useIssueDetails(); - const { data: user, fetchCurrentUser } = useUser(); + const { data: user } = useUser(); const issueId = issueDetailsStore.peekId; const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; @@ -38,13 +38,13 @@ export const IssueEmojiReactions: React.FC = observer( const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - issueDetailsStore.addIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); + if (!issueId) return; + issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - issueDetailsStore.removeIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); + if (!issueId) return; + issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { @@ -53,11 +53,6 @@ export const IssueEmojiReactions: React.FC = observer( else handleAddReaction(reactionHex); }; - useEffect(() => { - if (user) return; - fetchCurrentUser(); - }, [user, fetchCurrentUser]); - // derived values const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); diff --git a/space/components/issues/peek-overview/issue-properties.tsx b/space/components/issues/peek-overview/issue-properties.tsx index 08d22b312..8b81f8c5e 100644 --- a/space/components/issues/peek-overview/issue-properties.tsx +++ b/space/components/issues/peek-overview/issue-properties.tsx @@ -1,16 +1,19 @@ +"use client"; + +import { CalendarCheck2, Signal } from "lucide-react"; // ui -import { StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; -// icons +import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { Icon } from "@/components/ui"; // constants -import { issueGroupFilter, issuePriorityFilter } from "@/constants/issue"; +import { issuePriorityFilter } from "@/constants/issue"; // helpers -import { renderFullDate } from "@/helpers/date-time.helper"; +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; // types import { IIssue, IPeekMode } from "@/types/issue"; -// components -import { dueDateIconDetails } from "../board-views/block-due-date"; type Props = { issueDetails: IIssue; @@ -19,12 +22,9 @@ type Props = { export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mode }) => { const state = issueDetails.state_detail; - const stateGroup = issueGroupFilter(state.group); const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; - const dueDateIcon = dueDateIconDetails(issueDetails.target_date, state.group); - const handleCopyLink = () => { const urlToCopy = window.location.href; @@ -51,28 +51,22 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod
)} -
-
-
- - State +
+
+
+ + State
-
- {stateGroup && ( -
-
- - {addSpaceIfCamelCase(state?.name ?? "")} -
-
- )} +
+ + {addSpaceIfCamelCase(state?.name ?? "")}
-
-
- - Priority +
+
+ + Priority
= ({ issueDetails, mod
-
-
- - Due date + +
+
+ + Due date
{issueDetails.target_date ? ( -
- - {dueDateIcon.iconName} - - {renderFullDate(issueDetails.target_date)} +
+ + {renderFormattedDate(issueDetails.target_date)}
) : ( Empty diff --git a/space/components/issues/peek-overview/issue-reaction.tsx b/space/components/issues/peek-overview/issue-reaction.tsx index 87210f377..c3b580abc 100644 --- a/space/components/issues/peek-overview/issue-reaction.tsx +++ b/space/components/issues/peek-overview/issue-reaction.tsx @@ -1,33 +1,31 @@ -import { useParams } from "next/navigation"; +import { observer } from "mobx-react-lite"; import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview"; -import { useProject } from "@/hooks/store"; +// hooks +import { usePublish } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; -// type IssueReactionsProps = { -// workspaceSlug: string; -// projectId: string; -// }; +type Props = { + anchor: string; +}; -export const IssueReactions: React.FC = () => { - const { workspace_slug: workspaceSlug, project_id: projectId } = useParams(); - - const { canVote, canReact } = useProject(); +export const IssueReactions: React.FC = observer((props) => { + const { anchor } = props; + // store hooks + const { canVote, canReact } = usePublish(anchor); const isInIframe = useIsInIframe(); return (
{canVote && ( - <> -
- -
- +
+ +
)} {!isInIframe && canReact && (
- +
)}
); -}; +}); diff --git a/space/components/issues/peek-overview/issue-vote-reactions.tsx b/space/components/issues/peek-overview/issue-vote-reactions.tsx index 1e565e862..6b24e5a9f 100644 --- a/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/components/issues/peek-overview/issue-vote-reactions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { observer } from "mobx-react-lite"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Tooltip } from "@plane/ui"; @@ -12,11 +12,14 @@ import { useIssueDetails, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; type TIssueVotes = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueVotes: React.FC = observer((props) => { + const { anchor } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // router const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -26,13 +29,9 @@ export const IssueVotes: React.FC = observer((props) => { const state = searchParams.get("state") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - - const { workspaceSlug, projectId } = props; - // states - const [isSubmitting, setIsSubmitting] = useState(false); - + // store hooks const issueDetailsStore = useIssueDetails(); - const { data: user, fetchCurrentUser } = useUser(); + const { data: user } = useUser(); const isInIframe = useIsInIframe(); @@ -47,28 +46,22 @@ export const IssueVotes: React.FC = observer((props) => { const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id); const handleVote = async (e: any, voteValue: 1 | -1) => { - if (!workspaceSlug || !projectId || !issueId) return; + if (!issueId) return; setIsSubmitting(true); const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); - if (actionPerformed) - await issueDetailsStore.removeIssueVote(workspaceSlug.toString(), projectId.toString(), issueId); - else - await issueDetailsStore.addIssueVote(workspaceSlug.toString(), projectId.toString(), issueId, { + if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); + else { + await issueDetailsStore.addIssueVote(anchor, issueId, { vote: voteValue, }); + } setIsSubmitting(false); }; - useEffect(() => { - if (user) return; - - fetchCurrentUser(); - }, [user, fetchCurrentUser]); - const VOTES_LIMIT = 1000; // derived values diff --git a/space/components/issues/peek-overview/layout.tsx b/space/components/issues/peek-overview/layout.tsx index 453cc59f3..d1fe6f7aa 100644 --- a/space/components/issues/peek-overview/layout.tsx +++ b/space/components/issues/peek-overview/layout.tsx @@ -10,13 +10,12 @@ import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overv import { useIssue, useIssueDetails } from "@/hooks/store"; type TIssuePeekOverview = { - workspaceSlug: string; - projectId: string; + anchor: string; peekId: string; }; export const IssuePeekOverview: FC = observer((props) => { - const { workspaceSlug, projectId, peekId } = props; + const { anchor, peekId } = props; const router = useRouter(); const searchParams = useSearchParams(); // query params @@ -34,21 +33,23 @@ export const IssuePeekOverview: FC = observer((props) => { const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { - if (workspaceSlug && projectId && peekId && issueStore.issues && issueStore.issues.length > 0) { + if (anchor && peekId && issueStore.issues && issueStore.issues.length > 0) { if (!issueDetails) { - issueDetailStore.fetchIssueDetails(workspaceSlug.toString(), projectId.toString(), peekId.toString()); + issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); } } - }, [workspaceSlug, projectId, issueDetailStore, issueDetails, peekId, issueStore.issues]); + }, [anchor, issueDetailStore, issueDetails, peekId, issueStore.issues]); const handleClose = () => { issueDetailStore.setPeekId(null); - let queryParams: any = { board: board }; + let queryParams: any = { + board, + }; if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority }; if (state && state.length > 0) queryParams = { ...queryParams, state: state }; if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels }; queryParams = new URLSearchParams(queryParams).toString(); - router.push(`/${workspaceSlug}/${projectId}?${queryParams}`); + router.push(`/issues/${anchor}?${queryParams}`); }; useEffect(() => { @@ -80,12 +81,7 @@ export const IssuePeekOverview: FC = observer((props) => { leaveTo="translate-x-full" > - + @@ -119,20 +115,10 @@ export const IssuePeekOverview: FC = observer((props) => { }`} > {issueDetailStore.peekMode === "modal" && ( - + )} {issueDetailStore.peekMode === "full" && ( - + )}
diff --git a/space/components/issues/peek-overview/side-peek-view.tsx b/space/components/issues/peek-overview/side-peek-view.tsx index a0b544bdd..89d36ecc9 100644 --- a/space/components/issues/peek-overview/side-peek-view.tsx +++ b/space/components/issues/peek-overview/side-peek-view.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react-lite"; // components import { Loader } from "@plane/ui"; @@ -7,22 +9,21 @@ import { PeekOverviewIssueDetails, PeekOverviewIssueProperties, } from "@/components/issues/peek-overview"; -// hooks -import { useProject } from "@/hooks/store"; +// store hooks +import { usePublish } from "@/hooks/store"; // types import { IIssue } from "@/types/issue"; type Props = { + anchor: string; handleClose: () => void; issueDetails: IIssue | undefined; - workspaceSlug: string; - projectId: string; }; export const SidePeekView: React.FC = observer((props) => { - const { handleClose, issueDetails, workspaceSlug, projectId } = props; - - const { settings } = useProject(); + const { anchor, handleClose, issueDetails } = props; + // store hooks + const { canComment } = usePublish(anchor); return (
@@ -33,7 +34,7 @@ export const SidePeekView: React.FC = observer((props) => {
{/* issue title and description */}
- +
{/* issue properties */}
@@ -42,13 +43,9 @@ export const SidePeekView: React.FC = observer((props) => { {/* divider */}
{/* issue activity/comments */} - {settings?.comments && ( + {canComment && (
- +
)}
diff --git a/space/components/ui/dropdown.tsx b/space/components/ui/dropdown.tsx deleted file mode 100644 index 788627094..000000000 --- a/space/components/ui/dropdown.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Fragment, useState, useRef } from "react"; -import Link from "next/link"; -import { Check, ChevronLeft } from "lucide-react"; -import { Popover, Transition } from "@headlessui/react"; -// hooks -import useOutSideClick from "hooks/use-outside-click"; - -type ItemOptionType = { - display: React.ReactNode; - as?: "button" | "link" | "div"; - href?: string; - isSelected?: boolean; - onClick?: () => void; - children?: ItemOptionType[] | null; -}; - -type DropdownItemProps = { - item: ItemOptionType; -}; - -type DropDownListProps = { - open: boolean; - handleClose?: () => void; - items: ItemOptionType[]; -}; - -type DropdownProps = { - button: React.ReactNode | (() => React.ReactNode); - items: ItemOptionType[]; -}; - -const DropdownList: React.FC = (props) => { - const { open, items, handleClose } = props; - - const ref = useRef(null); - - useOutSideClick(ref, () => { - if (handleClose) handleClose(); - }); - - return ( - - - -
- {items.map((item, index) => ( - - ))} -
-
-
-
- ); -}; - -const DropdownItem: React.FC = (props) => { - const { item } = props; - const { display, children, as: itemAs, href, onClick, isSelected } = item; - - const [open, setOpen] = useState(false); - - return ( -
- {(!itemAs || itemAs === "button" || itemAs === "div") && ( - - )} - - {itemAs === "link" && {display}} - - {children && setOpen(false)} items={children} />} -
- ); -}; - -const Dropdown: React.FC = (props) => { - const { button, items } = props; - - return ( - - {({ open }) => ( - <> - - {typeof button === "function" ? button() : button} - - - - -
- {items.map((item, index) => ( - - ))} -
-
-
- - )} -
- ); -}; - -export { Dropdown }; diff --git a/space/components/ui/index.ts b/space/components/ui/index.ts index 1e523d5dd..ccd2303c4 100644 --- a/space/components/ui/index.ts +++ b/space/components/ui/index.ts @@ -1,3 +1,2 @@ -export * from "./dropdown"; export * from "./icon"; export * from "./reaction-selector"; diff --git a/space/components/views/index.ts b/space/components/views/index.ts index 251de14e3..97ccf7649 100644 --- a/space/components/views/index.ts +++ b/space/components/views/index.ts @@ -1,2 +1 @@ export * from "./auth"; -export * from "./project-details"; diff --git a/space/constants/issue.ts b/space/constants/issue.ts index fb9c78fcd..77297946f 100644 --- a/space/constants/issue.ts +++ b/space/constants/issue.ts @@ -1,13 +1,7 @@ -// interfaces -import { - TIssueLayout, - TIssueLayoutViews, - TIssueFilterKeys, - TIssueFilterPriority, - TIssueFilterPriorityObject, - TIssueFilterState, - TIssueFilterStateObject, -} from "types/issue"; +import { Calendar, GanttChartSquare, Kanban, List, Sheet } from "lucide-react"; +// types +import { TIssuePriorities } from "@plane/types"; +import { TIssueLayout, TIssueFilterKeys, TIssueFilterPriorityObject } from "@/types/issue"; // issue filters export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]> } = { @@ -28,20 +22,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"f }, }; -export const issueLayoutViews: Partial = { - list: { - title: "List View", - icon: "format_list_bulleted", - className: "", - }, - kanban: { - title: "Board View", - icon: "grid_view", - className: "", - }, -}; +export const ISSUE_LAYOUTS: { + key: TIssueLayout; + title: string; + icon: any; +}[] = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + { key: "spreadsheet", title: "Spreadsheet", icon: Sheet }, + { key: "gantt", title: "Gantt chart", icon: GanttChartSquare }, +]; -// issue priority filters export const issuePriorityFilters: TIssueFilterPriorityObject[] = [ { key: "urgent", @@ -75,7 +67,7 @@ export const issuePriorityFilters: TIssueFilterPriorityObject[] = [ }, ]; -export const issuePriorityFilter = (priorityKey: TIssueFilterPriority): TIssueFilterPriorityObject | undefined => { +export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => { const currentIssuePriority: TIssueFilterPriorityObject | undefined = issuePriorityFilters && issuePriorityFilters.length > 0 ? issuePriorityFilters.find((_priority) => _priority.key === priorityKey) @@ -84,55 +76,3 @@ export const issuePriorityFilter = (priorityKey: TIssueFilterPriority): TIssueFi if (currentIssuePriority) return currentIssuePriority; return undefined; }; - -// issue group filters -export const issueGroupColors: { - [key in TIssueFilterState]: string; -} = { - backlog: "#d9d9d9", - unstarted: "#3f76ff", - started: "#f59e0b", - completed: "#16a34a", - cancelled: "#dc2626", -}; - -export const issueGroups: TIssueFilterStateObject[] = [ - { - key: "backlog", - title: "Backlog", - color: "#d9d9d9", - className: `text-[#d9d9d9] bg-[#d9d9d9]/10`, - }, - { - key: "unstarted", - title: "Unstarted", - color: "#3f76ff", - className: `text-[#3f76ff] bg-[#3f76ff]/10`, - }, - { - key: "started", - title: "Started", - color: "#f59e0b", - className: `text-[#f59e0b] bg-[#f59e0b]/10`, - }, - { - key: "completed", - title: "Completed", - color: "#16a34a", - className: `text-[#16a34a] bg-[#16a34a]/10`, - }, - { - key: "cancelled", - title: "Cancelled", - color: "#dc2626", - className: `text-[#dc2626] bg-[#dc2626]/10`, - }, -]; - -export const issueGroupFilter = (issueKey: TIssueFilterState): TIssueFilterStateObject | undefined => { - const currentIssueStateGroup: TIssueFilterStateObject | undefined = - issueGroups && issueGroups.length > 0 ? issueGroups.find((group) => group.key === issueKey) : undefined; - - if (currentIssueStateGroup) return currentIssueStateGroup; - return undefined; -}; diff --git a/web/constants/state.ts b/space/constants/state.ts similarity index 86% rename from web/constants/state.ts rename to space/constants/state.ts index 6f6f0b460..b0fd622be 100644 --- a/web/constants/state.ts +++ b/space/constants/state.ts @@ -33,3 +33,5 @@ export const STATE_GROUPS: { color: "#dc2626", }, }; + +export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; diff --git a/space/constants/workspace.ts b/space/constants/workspace.ts deleted file mode 100644 index 5ae5a7cf4..000000000 --- a/space/constants/workspace.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const USER_ROLES = [ - { value: "Product / Project Manager", label: "Product / Project Manager" }, - { value: "Development / Engineering", label: "Development / Engineering" }, - { value: "Founder / Executive", label: "Founder / Executive" }, - { value: "Freelancer / Consultant", label: "Freelancer / Consultant" }, - { value: "Marketing / Growth", label: "Marketing / Growth" }, - { value: "Sales / Business Development", label: "Sales / Business Development" }, - { value: "Support / Operations", label: "Support / Operations" }, - { value: "Student / Professor", label: "Student / Professor" }, - { value: "Human Resources", label: "Human Resources" }, - { value: "Other", label: "Other" }, -]; diff --git a/space/helpers/date-time.helper.ts b/space/helpers/date-time.helper.ts index f19a5358b..3930bcb83 100644 --- a/space/helpers/date-time.helper.ts +++ b/space/helpers/date-time.helper.ts @@ -1,3 +1,6 @@ +import { format, isValid } from "date-fns"; +import isNumber from "lodash/isNumber"; + export const timeAgo = (time: any) => { switch (typeof time) { case "number": @@ -14,24 +17,43 @@ export const timeAgo = (time: any) => { }; /** - * @description Returns date and month, if date is of the current year - * @description Returns date, month adn year, if date is of a different year than current - * @param {string} date - * @example renderFullDate("2023-01-01") // 1 Jan - * @example renderFullDate("2021-01-01") // 1 Jan, 2021 + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; -export const renderFullDate = (date: string): string => { - if (!date) return ""; + if (typeof date !== "string" && !(date instanceof String)) return date; - const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; - const currentDate: Date = new Date(); - const [year, month, day]: number[] = date.split("-").map(Number); - - const formattedMonth: string = months[month - 1]; - const formattedDay: string = day < 10 ? `0${day}` : day.toString(); - - if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`; - else return `${formattedDay} ${formattedMonth}, ${year}`; + return new Date(year, month - 1, day); + } catch (e) { + return undefined; + } +}; + +/** + * @returns {string | null} formatted date in the format of MMM dd, yyyy + * @description Returns date in the formatted format + * @param {Date | string} date + * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 + */ +export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { + // Parse the date to check if it is valid + const parsedDate = getDate(date); + // return if undefined + if (!parsedDate) return null; + // Check if the parsed date is valid before formatting + if (!isValid(parsedDate)) return null; // Return null for invalid dates + // Format the date in format (MMM dd, yyyy) + const formattedDate = format(parsedDate, "MMM dd, yyyy"); + return formattedDate; }; diff --git a/space/helpers/emoji.helper.tsx b/space/helpers/emoji.helper.tsx index 7c9f3cfcb..d5f9d1b5a 100644 --- a/space/helpers/emoji.helper.tsx +++ b/space/helpers/emoji.helper.tsx @@ -1,23 +1,3 @@ -export const getRandomEmoji = () => { - const emojis = [ - "8986", - "9200", - "128204", - "127773", - "127891", - "127947", - "128076", - "128077", - "128187", - "128188", - "128512", - "128522", - "128578", - ]; - - return emojis[Math.floor(Math.random() * emojis.length)]; -}; - export const renderEmoji = ( emoji: | string diff --git a/space/helpers/issue.helper.ts b/space/helpers/issue.helper.ts new file mode 100644 index 000000000..a5159edef --- /dev/null +++ b/space/helpers/issue.helper.ts @@ -0,0 +1,30 @@ +import { differenceInCalendarDays } from "date-fns"; +// types +import { TStateGroups } from "@plane/types"; +// constants +import { STATE_GROUPS } from "@/constants/state"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; + +/** + * @description check if the issue due date should be highlighted + * @param date + * @param stateGroup + * @returns boolean + */ +export const shouldHighlightIssueDueDate = ( + date: string | Date | null, + stateGroup: TStateGroups | undefined +): boolean => { + if (!date || !stateGroup) return false; + // if the issue is completed or cancelled, don't highlight the due date + if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false; + + const parsedDate = getDate(date); + if (!parsedDate) return false; + + const targetDateDistance = differenceInCalendarDays(parsedDate, new Date()); + + // if the issue is overdue, highlight the due date + return targetDateDistance <= 0; +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 525a9fc99..f6319bc75 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -3,7 +3,7 @@ import DOMPurify from "dompurify"; export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); const fallbackCopyTextToClipboard = (text: string) => { - var textArea = document.createElement("textarea"); + const textArea = document.createElement("textarea"); textArea.value = text; // Avoid scrolling to bottom @@ -18,7 +18,7 @@ const fallbackCopyTextToClipboard = (text: string) => { try { // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand - var successful = document.execCommand("copy"); + document.execCommand("copy"); } catch (err) {} document.body.removeChild(textArea); diff --git a/space/hooks/store/index.ts b/space/hooks/store/index.ts index 76b6f9315..3f82613d5 100644 --- a/space/hooks/store/index.ts +++ b/space/hooks/store/index.ts @@ -1,5 +1,5 @@ +export * from "./publish"; export * from "./use-instance"; -export * from "./use-project"; export * from "./use-issue"; export * from "./use-user"; export * from "./use-user-profile"; diff --git a/space/hooks/store/publish/index.ts b/space/hooks/store/publish/index.ts new file mode 100644 index 000000000..a7b42ad5b --- /dev/null +++ b/space/hooks/store/publish/index.ts @@ -0,0 +1,2 @@ +export * from "./use-publish-list"; +export * from "./use-publish"; diff --git a/space/hooks/store/publish/use-publish-list.ts b/space/hooks/store/publish/use-publish-list.ts new file mode 100644 index 000000000..aa50c295a --- /dev/null +++ b/space/hooks/store/publish/use-publish-list.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IPublishListStore } from "@/store/publish/publish_list.store"; + +export const usePublishList = (): IPublishListStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublishList must be used within StoreProvider"); + return context.publishList; +}; diff --git a/space/hooks/store/publish/use-publish.ts b/space/hooks/store/publish/use-publish.ts new file mode 100644 index 000000000..3d920e8cb --- /dev/null +++ b/space/hooks/store/publish/use-publish.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { PublishStore } from "@/store/publish/publish.store"; + +export const usePublish = (anchor: string): PublishStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublish must be used within StoreProvider"); + return context.publishList.publishMap?.[anchor] ?? {}; +}; diff --git a/space/hooks/store/use-project.ts b/space/hooks/store/use-project.ts deleted file mode 100644 index cd3e28958..000000000 --- a/space/hooks/store/use-project.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -// lib -import { StoreContext } from "@/lib/store-provider"; -// store -import { IProjectStore } from "@/store/project.store"; - -export const useProject = (): IProjectStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); - return context.project; -}; diff --git a/space/hooks/use-mention.tsx b/space/hooks/use-mention.tsx index 8b2d69720..9e33f7d90 100644 --- a/space/hooks/use-mention.tsx +++ b/space/hooks/use-mention.tsx @@ -1,7 +1,9 @@ import { useRef, useEffect } from "react"; import useSWR from "swr"; +// types import { IUser } from "@plane/types"; -import { UserService } from "services/user.service"; +// services +import { UserService } from "@/services/user.service"; export const useMention = () => { const userService = new UserService(); diff --git a/space/instrumentation.ts b/space/instrumentation.ts new file mode 100644 index 000000000..7b89a972e --- /dev/null +++ b/space/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} diff --git a/space/lib/user-provider.tsx b/space/lib/user-provider.tsx deleted file mode 100644 index 1ac1c786c..000000000 --- a/space/lib/user-provider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactNode } from "react"; -import { observer } from "mobx-react-lite"; -import useSWR from "swr"; -import { useUser } from "@/hooks/store"; - -export const UserProvider = observer(({ children }: { children: ReactNode }) => { - const { fetchCurrentUser } = useUser(); - - useSWR("CURRENT_USER", () => fetchCurrentUser()); - - return <>{children}; -}); diff --git a/space/next.config.js b/space/next.config.js index eb9dde88a..d18ce805f 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -28,12 +28,46 @@ const nextConfig = { }, }; -if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0", 10)) { - module.exports = withSentryConfig( - nextConfig, - { silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, - { hideSourceMaps: true } - ); + +const sentryConfig = { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: process.env.SENTRY_ORG_ID || "plane-hq", + project: process.env.SENTRY_PROJECT_ID || "plane-space", + authToken: process.env.SENTRY_AUTH_TOKEN, + // Only print logs for uploading source maps in CI + silent: true, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, +} + + +if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) { + module.exports = withSentryConfig(nextConfig, sentryConfig); } else { module.exports = nextConfig; } + diff --git a/space/package.json b/space/package.json index a084c143b..9ab1e423b 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.20.0", + "version": "0.21.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -17,15 +17,15 @@ "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.13", "@mui/material": "^5.14.1", - "@plane/constants": "*", "@plane/document-editor": "*", "@plane/lite-text-editor": "*", "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^7.108.0", + "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", + "date-fns": "^3.6.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", "js-cookie": "^3.0.1", diff --git a/space/sentry.client.config.js b/space/sentry.client.config.js deleted file mode 100644 index ca473045b..000000000 --- a/space/sentry.client.config.js +++ /dev/null @@ -1,18 +0,0 @@ -// This file configures the initialization of Sentry on the browser. -// The config you add here will be used whenever a page is visited. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/space/sentry.client.config.ts b/space/sentry.client.config.ts new file mode 100644 index 000000000..c81030622 --- /dev/null +++ b/space/sentry.client.config.ts @@ -0,0 +1,31 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/space/sentry.edge.config.js b/space/sentry.edge.config.js deleted file mode 100644 index 8374ed410..000000000 --- a/space/sentry.edge.config.js +++ /dev/null @@ -1,18 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever middleware or an Edge route handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/space/sentry.edge.config.ts b/space/sentry.edge.config.ts new file mode 100644 index 000000000..2dbc6e93a --- /dev/null +++ b/space/sentry.edge.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/space/sentry.server.config.js b/space/sentry.server.config.ts similarity index 56% rename from space/sentry.server.config.js rename to space/sentry.server.config.ts index d2acb07e1..e578f1530 100644 --- a/space/sentry.server.config.js +++ b/space/sentry.server.config.ts @@ -4,15 +4,16 @@ import * as Sentry from "@sentry/nextjs"; -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - Sentry.init({ - dsn: SENTRY_DSN, + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", + // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', }); diff --git a/space/services/file.service.ts b/space/services/file.service.ts index 0e277af1e..9fe06cd36 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -4,30 +4,6 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; -interface UnSplashImage { - id: string; - created_at: Date; - updated_at: Date; - promoted_at: Date; - width: number; - height: number; - color: string; - blur_hash: string; - description: null; - alt_description: string; - urls: UnSplashImageUrls; - [key: string]: any; -} - -interface UnSplashImageUrls { - raw: string; - full: string; - regular: string; - small: string; - thumb: string; - small_s3: string; -} - class FileService extends APIService { private cancelSource: any; @@ -123,40 +99,6 @@ class FileService extends APIService { throw error?.response?.data; }); } - - async deleteFile(workspaceId: string, assetUrl: string): Promise { - const lastIndex = assetUrl.lastIndexOf("/"); - const assetId = assetUrl.substring(lastIndex + 1); - - return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async uploadUserFile(file: FormData): Promise { - return this.post(`/api/users/file-assets/`, file, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async deleteUserFile(assetUrl: string): Promise { - const lastIndex = assetUrl.lastIndexOf("/"); - const assetId = assetUrl.substring(lastIndex + 1); - - return this.delete(`/api/users/file-assets/${assetId}`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const fileService = new FileService(); diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index 1913b678e..f86481812 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -1,14 +1,16 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; +// types +import { TIssuesResponse } from "@/types/issue"; class IssueService extends APIService { constructor() { super(API_BASE_URL); } - async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise { - return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`, { + async fetchPublicIssues(anchor: string, params: any): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/`, { params, }) .then((response) => response?.data) @@ -17,115 +19,88 @@ class IssueService extends APIService { }); } - async getIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/`) + async getIssueById(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async getIssueVotes(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`) + async getIssueVotes(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async createIssueVote(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`, - data - ) + async createIssueVote(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async deleteIssueVote(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.delete(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`) + async deleteIssueVote(anchor: string, issueID: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async getIssueReactions(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`) + async getIssueReactions(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async createIssueReaction(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`, - data - ) + async createIssueReaction(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async deleteIssueReaction( - workspaceSlug: string, - projectId: string, - issueId: string, - reactionId: string - ): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/${reactionId}/` - ) + async deleteIssueReaction(anchor: string, issueID: string, reactionId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async getIssueComments(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`) + async getIssueComments(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`, - data - ) + async createIssueComment(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async updateIssueComment( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ): Promise { - return this.patch( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`, - data - ) + async updateIssueComment(anchor: string, issueID: string, commentId: string, data: any): Promise { + return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async deleteIssueComment(workspaceSlug: string, projectId: string, issueId: string, commentId: string): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/` - ) + async deleteIssueComment(anchor: string, issueID: string, commentId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response; @@ -133,32 +108,21 @@ class IssueService extends APIService { } async createCommentReaction( - workspaceSlug: string, - projectId: string, + anchor: string, commentId: string, data: { reaction: string; } ): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`, - data - ) + return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response; }); } - async deleteCommentReaction( - workspaceSlug: string, - projectId: string, - commentId: string, - reactionHex: string - ): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/${reactionHex}/` - ) + async deleteCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`) .then((response) => response?.data) .catch((error) => { throw error?.response; diff --git a/space/services/project-member.service.ts b/space/services/project-member.service.ts index 264d53386..722380efa 100644 --- a/space/services/project-member.service.ts +++ b/space/services/project-member.service.ts @@ -9,16 +9,16 @@ export class ProjectMemberService extends APIService { super(API_BASE_URL); } - async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`) + async fetchProjectMembers(anchor: string): Promise { + return this.get(`/api/anchor/${anchor}/members/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`) + async getProjectMember(anchor: string, memberID: string): Promise { + return this.get(`/api/anchor/${anchor}/members/${memberID}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/space/services/project.service.ts b/space/services/project.service.ts deleted file mode 100644 index 14ed7837b..000000000 --- a/space/services/project.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -class ProjectService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getProjectSettings(workspace_slug: string, project_slug: string): Promise { - return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} - -export default ProjectService; diff --git a/space/services/publish.service.ts b/space/services/publish.service.ts new file mode 100644 index 000000000..0275142c8 --- /dev/null +++ b/space/services/publish.service.ts @@ -0,0 +1,30 @@ +// types +import { TPublishSettings } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +class PublishService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchPublishSettings(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/settings/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async fetchAnchorFromProjectDetails(workspaceSlug: string, projectID: string): Promise { + return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default PublishService; diff --git a/space/store/issue-detail.store.ts b/space/store/issue-detail.store.ts index 03f611cc0..672fe29ad 100644 --- a/space/store/issue-detail.store.ts +++ b/space/store/issue-detail.store.ts @@ -10,108 +10,102 @@ import { IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; error: any; - // peek info + // observables peekId: string | null; peekMode: IPeekMode; details: { [key: string]: IIssue; }; - // peek actions - setPeekId: (issueId: string | null) => void; + // actions + setPeekId: (issueID: string | null) => void; setPeekMode: (mode: IPeekMode) => void; - // issue details - fetchIssueDetails: (workspaceId: string, projectId: string, issueId: string) => void; - // issue comments - addIssueComment: (workspaceId: string, projectId: string, issueId: string, data: any) => Promise; - updateIssueComment: ( - workspaceId: string, - projectId: string, - issueId: string, - comment_id: string, - data: any - ) => Promise; - deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void; - addCommentReaction: ( - workspaceId: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => void; - removeCommentReaction: ( - workspaceId: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => void; - // issue reactions - addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; - removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; - // issue votes - addIssueVote: (workspaceId: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => Promise; - removeIssueVote: (workspaceId: string, projectId: string, issueId: string) => Promise; + // issue actions + fetchIssueDetails: (anchor: string, issueID: string) => void; + // comment actions + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; + deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; + addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + // reaction actions + addIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + removeIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + // vote actions + addIssueVote: (anchor: string, issueID: string, data: { vote: 1 | -1 }) => Promise; + removeIssueVote: (anchor: string, issueID: string) => Promise; } export class IssueDetailStore implements IIssueDetailStore { loader: boolean = false; error: any = null; + // observables peekId: string | null = null; peekMode: IPeekMode = "side"; details: { [key: string]: IIssue; } = {}; - issueService; + // root store rootStore: RootStore; + // services + issueService: IssueService; constructor(_rootStore: RootStore) { makeObservable(this, { loader: observable.ref, error: observable.ref, - // peek + // observables peekId: observable.ref, peekMode: observable.ref, - details: observable.ref, + details: observable, // actions setPeekId: action, setPeekMode: action, + // issue actions fetchIssueDetails: action, + // comment actions addIssueComment: action, updateIssueComment: action, deleteIssueComment: action, addCommentReaction: action, removeCommentReaction: action, + // reaction actions addIssueReaction: action, removeIssueReaction: action, + // vote actions addIssueVote: action, removeIssueVote: action, }); - this.issueService = new IssueService(); this.rootStore = _rootStore; + this.issueService = new IssueService(); } - setPeekId = (issueId: string | null) => { - this.peekId = issueId; + setPeekId = (issueID: string | null) => { + this.peekId = issueID; }; setPeekMode = (mode: IPeekMode) => { this.peekMode = mode; }; - fetchIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => { + /** + * @description fetc + * @param {string} anchor + * @param {string} issueID + */ + fetchIssueDetails = async (anchor: string, issueID: string) => { try { this.loader = true; this.error = null; - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueId); - const commentsResponse = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const commentsResponse = await this.issueService.getIssueComments(anchor, issueID); if (issueDetails) { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...(this.details[issueId] ?? issueDetails), + [issueID]: { + ...(this.details[issueID] ?? issueDetails), comments: commentsResponse, }, }; @@ -123,17 +117,17 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => { + addIssueComment = async (anchor: string, issueID: string, data: any) => { try { - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueId); - const issueCommentResponse = await this.issueService.createIssueComment(workspaceSlug, projectId, issueId, data); + const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data); if (issueDetails) { runInAction(() => { this.details = { ...this.details, - [issueId]: { + [issueID]: { ...issueDetails, - comments: [...this.details[issueId].comments, issueCommentResponse], + comments: [...this.details[issueID].comments, issueCommentResponse], }, }; }); @@ -145,36 +139,30 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - updateIssueComment = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ) => { + updateIssueComment = async (anchor: string, issueID: string, commentID: string, data: any) => { try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], - comments: this.details[issueId].comments.map((c) => ({ + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ ...c, - ...(c.id === commentId ? data : {}), + ...(c.id === commentID ? data : {}), })), }, }; }); - await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data); + await this.issueService.updateIssueComment(anchor, issueID, commentID, data); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -182,15 +170,15 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - deleteIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, comment_id: string) => { + deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => { try { - await this.issueService.deleteIssueComment(workspaceSlug, projectId, issueId, comment_id); - const remainingComments = this.details[issueId].comments.filter((c) => c.id != comment_id); + await this.issueService.deleteIssueComment(anchor, issueID, commentID); + const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: remainingComments, }, }; @@ -200,47 +188,41 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => { + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { const newReaction = { id: uuidv4(), - comment: commentId, + comment: commentID, reaction: reactionHex, actor_detail: this.rootStore.user.currentActor, }; - const newComments = this.details[issueId].comments.map((comment) => ({ + const newComments = this.details[issueID].comments.map((comment) => ({ ...comment, comment_reactions: - comment.id === commentId ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, + comment.id === commentID ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, })); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: [...newComments], }, }; }); - await this.issueService.createCommentReaction(workspaceSlug, projectId, commentId, { + await this.issueService.createCommentReaction(anchor, commentID, { reaction: reactionHex, }); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -248,39 +230,33 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => { + removeCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { try { - const comment = this.details[issueId].comments.find((c) => c.id === commentId); + const comment = this.details[issueID].comments.find((c) => c.id === commentID); const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? []; runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], - comments: this.details[issueId].comments.map((c) => ({ + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ ...c, - comment_reactions: c.id === commentId ? newCommentReactions : c.comment_reactions, + comment_reactions: c.id === commentID ? newCommentReactions : c.comment_reactions, })), }, }; }); - await this.issueService.deleteCommentReaction(workspaceSlug, projectId, commentId, reactionHex); + await this.issueService.deleteCommentReaction(anchor, commentID, reactionHex); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -288,18 +264,18 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => { + addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: [ - ...this.details[issueId].reactions, + ...this.details[issueID].reactions, { id: uuidv4(), - issue: issueId, + issue: issueID, reaction: reactionHex, actor_detail: this.rootStore.user.currentActor, }, @@ -308,17 +284,17 @@ export class IssueDetailStore implements IIssueDetailStore { }; }); - await this.issueService.createIssueReaction(workspaceSlug, projectId, issueId, { + await this.issueService.createIssueReaction(anchor, issueID, { reaction: reactionHex, }); } catch (error) { console.log("Failed to add issue vote"); - const issueReactions = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId); + const issueReactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: issueReactions, }, }; @@ -326,31 +302,31 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => { + removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { - const newReactions = this.details[issueId].reactions.filter( + const newReactions = this.details[issueID].reactions.filter( (_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id) ); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: newReactions, }, }; }); - await this.issueService.deleteIssueReaction(workspaceSlug, projectId, issueId, reactionHex); + await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex); } catch (error) { console.log("Failed to remove issue reaction"); - const reactions = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId); + const reactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: reactions, }, }; @@ -358,39 +334,44 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueVote = async (workspaceSlug: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => { + addIssueVote = async (anchor: string, issueID: string, data: { vote: 1 | -1 }) => { + const publishSettings = this.rootStore.publishList?.publishMap?.[anchor]; + const projectID = publishSettings?.project; + const workspaceSlug = publishSettings?.workspace_detail?.slug; + if (!projectID || !workspaceSlug) throw new Error("Publish settings not found"); + const newVote: IVote = { actor: this.rootStore.user.data?.id ?? "", actor_detail: this.rootStore.user.currentActor, - issue: issueId, - project: projectId, + issue: issueID, + project: projectID, workspace: workspaceSlug, vote: data.vote, }; - const filteredVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const filteredVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: [...filteredVotes, newVote], }, }; }); - await this.issueService.createIssueVote(workspaceSlug, projectId, issueId, data); + await this.issueService.createIssueVote(anchor, issueID, data); } catch (error) { console.log("Failed to add issue vote"); - const issueVotes = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId); + const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: issueVotes, }, }; @@ -398,30 +379,30 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeIssueVote = async (workspaceSlug: string, projectId: string, issueId: string) => { - const newVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + removeIssueVote = async (anchor: string, issueID: string) => { + const newVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: newVotes, }, }; }); - await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId); + await this.issueService.deleteIssueVote(anchor, issueID); } catch (error) { console.log("Failed to remove issue vote"); - const issueVotes = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId); + const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: issueVotes, }, }; diff --git a/space/store/issue-filters.store.ts b/space/store/issue-filters.store.ts index b7b311af4..daf797f90 100644 --- a/space/store/issue-filters.store.ts +++ b/space/store/issue-filters.store.ts @@ -1,7 +1,7 @@ import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import set from "lodash/set"; -import { action, makeObservable, observable, runInAction, computed } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; @@ -19,16 +19,17 @@ import { export interface IIssueFilterStore { // observables layoutOptions: TIssueLayoutOptions; - filters: { [projectId: string]: TIssueFilters } | undefined; + filters: { [anchor: string]: TIssueFilters } | undefined; // computed - issueFilters: TIssueFilters | undefined; - appliedFilters: TIssueQueryFiltersParams | undefined; - isIssueFiltersUpdated: (filters: TIssueFilters) => boolean; + isIssueFiltersUpdated: (anchor: string, filters: TIssueFilters) => boolean; + // helpers + getIssueFilters: (anchor: string) => TIssueFilters | undefined; + getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; // actions updateLayoutOptions: (layout: TIssueLayoutOptions) => void; - initIssueFilters: (projectId: string, filters: TIssueFilters) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters) => void; updateIssueFilters: ( - projectId: string, + anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filters: TIssueFilters[K][typeof filterKey] @@ -44,16 +45,13 @@ export class IssueFilterStore implements IIssueFilterStore { gantt: false, spreadsheet: false, }; - filters: { [projectId: string]: TIssueFilters } | undefined = undefined; + filters: { [anchor: string]: TIssueFilters } | undefined = undefined; constructor(private store: RootStore) { makeObservable(this, { // observables layoutOptions: observable, filters: observable, - // computed - issueFilters: computed, - appliedFilters: computed, // actions updateLayoutOptions: action, initIssueFilters: action, @@ -82,79 +80,70 @@ export class IssueFilterStore implements IIssueFilterStore { }; // computed - get issueFilters() { - const projectId = this.store.project.project?.id; - if (!projectId) return undefined; - - const currentFilters = this.filters?.[projectId]; - if (!currentFilters) return undefined; - + getIssueFilters = computedFn((anchor: string) => { + const currentFilters = this.filters?.[anchor]; return currentFilters; - } + }); - get appliedFilters() { - const currentIssueFilters = this.issueFilters; - if (!currentIssueFilters) return undefined; + getAppliedFilters = computedFn((anchor: string) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return undefined; - const currentLayout = currentIssueFilters?.display_filters?.layout; + const currentLayout = issueFilters?.display_filters?.layout; if (!currentLayout) return undefined; const currentFilters: TIssueQueryFilters = { - priority: currentIssueFilters?.filters?.priority || undefined, - state: currentIssueFilters?.filters?.state || undefined, - labels: currentIssueFilters?.filters?.labels || undefined, + priority: issueFilters?.filters?.priority || undefined, + state: issueFilters?.filters?.state || undefined, + labels: issueFilters?.filters?.labels || undefined, }; const filteredParams = ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[currentLayout]?.filters || []; const currentFilterQueryParams: TIssueQueryFiltersParams = this.computedFilter(currentFilters, filteredParams); return currentFilterQueryParams; - } + }); - isIssueFiltersUpdated = computedFn((userFilters: TIssueFilters) => { - if (!this.issueFilters) return false; + isIssueFiltersUpdated = computedFn((anchor: string, userFilters: TIssueFilters) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return false; const currentUserFilters = cloneDeep(userFilters?.filters || {}); - const currentIssueFilters = cloneDeep(this.issueFilters?.filters || {}); + const currentIssueFilters = cloneDeep(issueFilters?.filters || {}); return isEqual(currentUserFilters, currentIssueFilters); }); // actions updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); - initIssueFilters = async (projectId: string, initFilters: TIssueFilters) => { + initIssueFilters = async (anchor: string, initFilters: TIssueFilters) => { try { - if (!projectId) return; if (this.filters === undefined) runInAction(() => (this.filters = {})); - if (this.filters && initFilters) set(this.filters, [projectId], initFilters); + if (this.filters && initFilters) set(this.filters, [anchor], initFilters); - const workspaceSlug = this.store.project.workspace?.slug; - const currentAppliedFilters = this.appliedFilters; + const appliedFilters = this.getAppliedFilters(anchor); - if (!workspaceSlug) return; - await this.store.issue.fetchPublicIssues(workspaceSlug, projectId, currentAppliedFilters); + await this.store.issue.fetchPublicIssues(anchor, appliedFilters); } catch (error) { throw error; } }; updateIssueFilters = async ( - projectId: string, + anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filterValue: TIssueFilters[K][typeof filterKey] ) => { try { - if (!projectId || !filterKind || !filterKey || !filterValue) return; + if (!filterKind || !filterKey || !filterValue) return; if (this.filters === undefined) runInAction(() => (this.filters = {})); runInAction(() => { - if (this.filters) set(this.filters, [projectId, filterKind, filterKey], filterValue); + if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); }); - const workspaceSlug = this.store.project.workspace?.slug; - const currentAppliedFilters = this.appliedFilters; + const appliedFilters = this.getAppliedFilters(anchor); - if (!workspaceSlug) return; - await this.store.issue.fetchPublicIssues(workspaceSlug, projectId, currentAppliedFilters); + await this.store.issue.fetchPublicIssues(anchor, appliedFilters); } catch (error) { throw error; } diff --git a/space/store/issue.store.ts b/space/store/issue.store.ts index 7967aafb1..4f2d845b5 100644 --- a/space/store/issue.store.ts +++ b/space/store/issue.store.ts @@ -1,87 +1,87 @@ import { observable, action, makeObservable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// types +import { IStateLite } from "@plane/types"; // services import IssueService from "@/services/issue.service"; // types -import { IIssue, IIssueState, IIssueLabel } from "@/types/issue"; +import { IIssue, IIssueLabel } from "@/types/issue"; // store import { RootStore } from "./root.store"; -// import { IssueDetailType, TIssueBoardKeys } from "types/issue"; export interface IIssueStore { loader: boolean; error: any; - // issue options - issues: IIssue[] | null; - states: IIssueState[] | null; - labels: IIssueLabel[] | null; - // filtering + // observables + issues: IIssue[]; + states: IStateLite[]; + labels: IIssueLabel[]; + // filter observables filteredStates: string[]; filteredLabels: string[]; filteredPriorities: string[]; - // service - issueService: any; // actions - fetchPublicIssues: (workspace_slug: string, project_slug: string, params: any) => Promise; - getCountOfIssuesByState: (state: string) => number; - getFilteredIssuesByState: (state: string) => IIssue[]; + fetchPublicIssues: (anchor: string, params: any) => Promise; + // helpers + getCountOfIssuesByState: (stateID: string) => number; + getFilteredIssuesByState: (stateID: string) => IIssue[]; } export class IssueStore implements IIssueStore { loader: boolean = false; error: any | null = null; - - states: IIssueState[] | null = []; - labels: IIssueLabel[] | null = []; - + // observables + states: IStateLite[] = []; + labels: IIssueLabel[] = []; + issues: IIssue[] = []; + // filter observables filteredStates: string[] = []; filteredLabels: string[] = []; filteredPriorities: string[] = []; - - issues: IIssue[] | null = []; - issue_detail: any = {}; - + // root store rootStore: RootStore; - issueService: any; + // services + issueService: IssueService; - constructor(_rootStore: any) { + constructor(_rootStore: RootStore) { makeObservable(this, { - // observable - loader: observable, + loader: observable.ref, error: observable, - // issue options - states: observable.ref, - labels: observable.ref, - // filtering - filteredStates: observable.ref, - filteredLabels: observable.ref, - filteredPriorities: observable.ref, - // issues - issues: observable.ref, - issue_detail: observable.ref, + // observables + states: observable, + labels: observable, + issues: observable, + // filter observables + filteredStates: observable, + filteredLabels: observable, + filteredPriorities: observable, // actions fetchPublicIssues: action, - getFilteredIssuesByState: action, }); this.rootStore = _rootStore; this.issueService = new IssueService(); } - fetchPublicIssues = async (workspaceSlug: string, projectId: string, params: any) => { + /** + * @description fetch issues, states and labels + * @param {string} anchor + * @param params + */ + fetchPublicIssues = async (anchor: string, params: any) => { try { - this.loader = true; - this.error = null; + runInAction(() => { + this.loader = true; + this.error = null; + }); - const response = await this.issueService.getPublicIssues(workspaceSlug, projectId, params); + const response = await this.issueService.fetchPublicIssues(anchor, params); if (response) { - const states: IIssueState[] = [...response?.states]; - const labels: IIssueLabel[] = [...response?.labels]; - const issues: IIssue[] = [...response?.issues]; runInAction(() => { - this.states = states; - this.labels = labels; - this.issues = issues; + this.states = response.states; + this.labels = response.labels; + this.issues = response.issues; this.loader = false; }); } @@ -91,11 +91,21 @@ export class IssueStore implements IIssueStore { } }; - // computed - getCountOfIssuesByState(state_id: string): number { - return this.issues?.filter((issue) => issue.state == state_id).length || 0; - } + /** + * @description get total count of issues under a particular state + * @param {string} stateID + * @returns {number} + */ + getCountOfIssuesByState = computedFn( + (stateID: string) => this.issues?.filter((issue) => issue.state == stateID).length || 0 + ); - getFilteredIssuesByState = (state_id: string): IIssue[] | [] => - this.issues?.filter((issue) => issue.state == state_id) || []; + /** + * @description get array of issues under a particular state + * @param {string} stateID + * @returns {IIssue[]} + */ + getFilteredIssuesByState = computedFn( + (stateID: string) => this.issues?.filter((issue) => issue.state == stateID) || [] + ); } diff --git a/space/store/project.store.ts b/space/store/project.store.ts deleted file mode 100644 index 02f250323..000000000 --- a/space/store/project.store.ts +++ /dev/null @@ -1,96 +0,0 @@ -// mobx -import { observable, action, makeObservable, runInAction, computed } from "mobx"; -// service -import ProjectService from "@/services/project.service"; -// store types -import { RootStore } from "@/store/root.store"; -// types -import { TWorkspaceDetails, TProjectDetails, TProjectSettings } from "@/types/project"; - -export interface IProjectStore { - // observables - loader: boolean; - error: any | undefined; - settings: TProjectSettings | undefined; - workspace: TWorkspaceDetails | undefined; - project: TProjectDetails | undefined; - canReact: boolean; - canComment: boolean; - canVote: boolean; - // actions - fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise; - hydrate: (projectSettings: any) => void; -} - -export class ProjectStore implements IProjectStore { - // observables - loader: boolean = false; - error: any | undefined = undefined; - settings: TProjectSettings | undefined = undefined; - workspace: TWorkspaceDetails | undefined = undefined; - project: TProjectDetails | undefined = undefined; - // service - projectService; - - constructor(private store: RootStore) { - makeObservable(this, { - // loaders and error observables - loader: observable, - error: observable.ref, - // observable - workspace: observable, - project: observable, - settings: observable, - // computed - canReact: computed, - canComment: computed, - canVote: computed, - // actions - fetchProjectSettings: action, - hydrate: action, - }); - // services - this.projectService = new ProjectService(); - } - - // computed - get canReact() { - return this.settings?.reactions ?? false; - } - get canComment() { - return this.settings?.comments ?? false; - } - get canVote() { - return this.settings?.votes ?? false; - } - - fetchProjectSettings = async (workspace_slug: string, project_slug: string) => { - try { - this.loader = true; - this.error = null; - - const response = await this.projectService.getProjectSettings(workspace_slug, project_slug); - - if (response) { - this.store.issueFilter.updateLayoutOptions(response?.views); - runInAction(() => { - this.project = response?.project_details; - this.workspace = response?.workspace_detail; - this.settings = response; - this.loader = false; - }); - } - return response; - } catch (error) { - this.loader = false; - this.error = error; - return error; - } - }; - - hydrate = (projectSettings: TProjectSettings) => { - const { workspace_detail, project_details } = projectSettings; - this.workspace = workspace_detail; - this.project = project_details; - }; -} diff --git a/space/store/publish/publish.store.ts b/space/store/publish/publish.store.ts new file mode 100644 index 000000000..29cbc53ab --- /dev/null +++ b/space/store/publish/publish.store.ts @@ -0,0 +1,111 @@ +import { observable, makeObservable, computed } from "mobx"; +// types +import { IWorkspaceLite, TProjectDetails, TPublishEntityType, TPublishSettings, TPublishViewProps } from "@plane/types"; +// store types +import { RootStore } from "@/store/root.store"; + +export interface IPublishStore extends TPublishSettings { + // computed + workspaceSlug: string | undefined; + canComment: boolean; + canReact: boolean; + canVote: boolean; +} + +export class PublishStore implements IPublishStore { + // observables + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TPublishViewProps | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; + + constructor( + private store: RootStore, + publishSettings: TPublishSettings + ) { + this.anchor = publishSettings.anchor; + this.is_comments_enabled = publishSettings.is_comments_enabled; + this.created_at = publishSettings.created_at; + this.created_by = publishSettings.created_by; + this.entity_identifier = publishSettings.entity_identifier; + this.entity_name = publishSettings.entity_name; + this.id = publishSettings.id; + this.inbox = publishSettings.inbox; + this.project = publishSettings.project; + this.project_details = publishSettings.project_details; + this.is_reactions_enabled = publishSettings.is_reactions_enabled; + this.updated_at = publishSettings.updated_at; + this.updated_by = publishSettings.updated_by; + this.view_props = publishSettings.view_props; + this.is_votes_enabled = publishSettings.is_votes_enabled; + this.workspace = publishSettings.workspace; + this.workspace_detail = publishSettings.workspace_detail; + + makeObservable(this, { + // observables + anchor: observable.ref, + is_comments_enabled: observable.ref, + created_at: observable.ref, + created_by: observable.ref, + entity_identifier: observable.ref, + entity_name: observable.ref, + id: observable.ref, + inbox: observable, + project: observable.ref, + project_details: observable, + is_reactions_enabled: observable.ref, + updated_at: observable.ref, + updated_by: observable.ref, + view_props: observable, + is_votes_enabled: observable.ref, + workspace: observable.ref, + workspace_detail: observable, + // computed + workspaceSlug: computed, + canComment: computed, + canReact: computed, + canVote: computed, + }); + } + + /** + * @description returns the workspace slug from the workspace details + */ + get workspaceSlug() { + return this?.workspace_detail?.slug ?? undefined; + } + + /** + * @description returns whether commenting is enabled or not + */ + get canComment() { + return !!this.is_comments_enabled; + } + + /** + * @description returns whether reacting is enabled or not + */ + get canReact() { + return !!this.is_reactions_enabled; + } + + /** + * @description returns whether voting is enabled or not + */ + get canVote() { + return !!this.is_votes_enabled; + } +} diff --git a/space/store/publish/publish_list.store.ts b/space/store/publish/publish_list.store.ts new file mode 100644 index 000000000..b6722115d --- /dev/null +++ b/space/store/publish/publish_list.store.ts @@ -0,0 +1,55 @@ +import set from "lodash/set"; +import { makeObservable, observable, runInAction, action } from "mobx"; +// types +import { TPublishSettings } from "@plane/types"; +// services +import PublishService from "@/services/publish.service"; +// store +import { PublishStore } from "@/store/publish/publish.store"; +// store +import { RootStore } from "../root.store"; + +export interface IPublishListStore { + // observables + publishMap: Record; // anchor => PublishStore + // actions + fetchPublishSettings: (pageId: string) => Promise; +} + +export class PublishListStore implements IPublishListStore { + // observables + publishMap: Record = {}; // anchor => PublishStore + // service + publishService; + + constructor(private store: RootStore) { + makeObservable(this, { + // observables + publishMap: observable, + // actions + fetchPublishSettings: action, + }); + // services + this.publishService = new PublishService(); + } + + /** + * @description fetch publish settings + * @param {string} anchor + */ + fetchPublishSettings = async (anchor: string) => { + try { + const response = await this.publishService.fetchPublishSettings(anchor); + runInAction(() => { + if (response.anchor && response.view_props) { + this.store.issueFilter.updateLayoutOptions(response?.view_props); + set(this.publishMap, [response.anchor], new PublishStore(this.store, response)); + } + }); + + return response; + } catch (error) { + throw error; + } + }; +} diff --git a/space/store/root.store.ts b/space/store/root.store.ts index 4a31840db..082220f5d 100644 --- a/space/store/root.store.ts +++ b/space/store/root.store.ts @@ -3,30 +3,30 @@ import { enableStaticRendering } from "mobx-react-lite"; import { IInstanceStore, InstanceStore } from "@/store/instance.store"; import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"; import { IssueStore, IIssueStore } from "@/store/issue.store"; -import { IProjectStore, ProjectStore } from "@/store/project.store"; import { IUserStore, UserStore } from "@/store/user.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; import { IMentionsStore, MentionsStore } from "./mentions.store"; +import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; enableStaticRendering(typeof window === "undefined"); export class RootStore { instance: IInstanceStore; user: IUserStore; - project: IProjectStore; issue: IIssueStore; issueDetail: IIssueDetailStore; mentionStore: IMentionsStore; issueFilter: IIssueFilterStore; + publishList: IPublishListStore; constructor() { this.instance = new InstanceStore(this); this.user = new UserStore(this); - this.project = new ProjectStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -40,10 +40,10 @@ export class RootStore { localStorage.setItem("theme", "system"); this.instance = new InstanceStore(this); this.user = new UserStore(this); - this.project = new ProjectStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); }; } diff --git a/space/styles/globals.css b/space/styles/globals.css index 47804b768..0b41d8481 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -302,6 +302,23 @@ } } +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + ::-webkit-scrollbar { width: 5px; height: 5px; diff --git a/space/types/app.d.ts b/space/types/app.d.ts deleted file mode 100644 index bd4af3b0c..000000000 --- a/space/types/app.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface IAppConfig { - email_password_login: boolean; - file_size_limit: number; - google_client_id: string | null; - github_app_name: string | null; - github_client_id: string | null; - magic_login: boolean; - slack_client_id: string | null; - posthog_api_key: string | null; - posthog_host: string | null; - has_openai_configured: boolean; - has_unsplash_configured: boolean; - is_self_managed: boolean; -} diff --git a/space/types/issue.d.ts b/space/types/issue.d.ts index f2625fb76..5b729d1c0 100644 --- a/space/types/issue.d.ts +++ b/space/types/issue.d.ts @@ -1,27 +1,17 @@ +import { IStateLite, IWorkspaceLite, TIssuePriorities, TStateGroups } from "@plane/types"; + export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { [key in TIssueLayout]: boolean; }; -export type TIssueLayoutViews = { - [key in TIssueLayout]: { title: string; icon: string; className: string }; -}; -export type TIssueFilterPriority = "urgent" | "high" | "medium" | "low" | "none"; export type TIssueFilterPriorityObject = { - key: TIssueFilterPriority; + key: TIssuePriorities; title: string; className: string; icon: string; }; -export type TIssueFilterState = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; -export type TIssueFilterStateObject = { - key: TIssueFilterState; - title: string; - color: string; - className: string; -}; - export type TIssueFilterKeys = "priority" | "state" | "labels"; export type TDisplayFilters = { @@ -29,8 +19,8 @@ export type TDisplayFilters = { }; export type TFilters = { - state: TIssueFilterState[]; - priority: TIssueFilterPriority[]; + state: TStateGroups[]; + priority: TIssuePriorities[]; labels: string[]; }; @@ -43,6 +33,12 @@ export type TIssueQueryFilters = Partial; export type TIssueQueryFiltersParams = Partial>; +export type TIssuesResponse = { + states: IStateLite[]; + labels: IIssueLabel[]; + issues: IIssue[]; +}; + export interface IIssue { id: string; comments: Comment[]; @@ -68,17 +64,11 @@ export interface IIssue { export type IPeekMode = "side" | "modal" | "full"; -export interface IIssueState { - id: string; - name: string; - group: TIssueGroupKey; - color: string; -} - export interface IIssueLabel { id: string; name: string; color: string; + parent: string | null; } export interface IVote { @@ -114,7 +104,7 @@ export interface Comment { updated_at: Date; updated_by: string; workspace: string; - workspace_detail: WorkspaceDetail; + workspace_detail: IWorkspaceLite; } export interface IIssueReaction { @@ -175,52 +165,8 @@ export interface ProjectDetail { description: string; } -export interface WorkspaceDetail { - name: string; - slug: string; - id: string; -} - -export interface IssueDetailType { - [issueId: string]: { - issue: IIssue; - comments: Comment[]; - reactions: any[]; - votes: any[]; - }; -} - -export type TIssueGroupByOptions = "state" | "priority" | "labels" | null; - -export type TIssueParams = "priority" | "state" | "labels"; - export interface IIssueFilterOptions { state?: string[] | null; labels?: string[] | null; priority?: string[] | null; } - -// issues -export interface IGroupedIssues { - [group_id: string]: string[]; -} - -export interface ISubGroupedIssues { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; -} - -export type TUnGroupedIssues = string[]; - -export interface IIssueResponse { - [issue_id: string]: IIssue; -} - -export type TLoader = "init-loader" | "mutation" | undefined; - -export interface ViewFlags { - enableQuickAdd: boolean; - enableIssueCreation: boolean; - enableInlineEditing: boolean; -} diff --git a/space/types/project.d.ts b/space/types/project.d.ts index 99dbfec8b..c0ae02583 100644 --- a/space/types/project.d.ts +++ b/space/types/project.d.ts @@ -1,10 +1,4 @@ -import { TProjectLogoProps } from "@plane/types"; - -export type TWorkspaceDetails = { - name: string; - slug: string; - id: string; -}; +import { TLogoProps } from "@plane/types"; export type TViewDetails = { list: boolean; @@ -19,24 +13,6 @@ export type TProjectDetails = { identifier: string; name: string; cover_image: string | undefined; - logo_props: TProjectLogoProps; + logo_props: TLogoProps; description: string; }; - -export type TProjectSettings = { - id: string; - anchor: string; - comments: boolean; - reactions: boolean; - votes: boolean; - inbox: unknown; - workspace: string; - workspace_detail: TWorkspaceDetails; - project: string; - project_details: TProjectDetails; - views: TViewDetails; - created_by: string; - updated_by: string; - created_at: string; - updated_at: string; -}; diff --git a/space/types/publish.d.ts b/space/types/publish.d.ts new file mode 100644 index 000000000..482cbafec --- /dev/null +++ b/space/types/publish.d.ts @@ -0,0 +1,24 @@ +import { IWorkspaceLite } from "@plane/types"; +import { TProjectDetails, TViewDetails } from "@/types/project"; + +export type TPublishEntityType = "project"; + +export type TPublishSettings = { + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TViewDetails | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; +}; diff --git a/space/types/theme.d.ts b/space/types/theme.d.ts deleted file mode 100644 index ca306be51..000000000 --- a/space/types/theme.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IThemeStore { - theme: string; - setTheme: (theme: "light" | "dark" | string) => void; -} diff --git a/space/types/user.d.ts b/space/types/user.d.ts deleted file mode 100644 index d58827876..000000000 --- a/space/types/user.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface IUser { - avatar: string; - cover_image: string | null; - created_at: Date; - created_location: string; - date_joined: Date; - email: string; - display_name: string; - first_name: string; - id: string; - is_email_verified: boolean; - is_onboarded: boolean; - is_tour_completed: boolean; - last_location: string; - last_login: Date; - last_name: string; - mobile_number: string; - role: string; - is_password_autoset: boolean; - onboarding_step: { - workspace_join?: boolean; - profile_complete?: boolean; - workspace_create?: boolean; - workspace_invite?: boolean; - }; - token: string; - updated_at: Date; - username: string; - user_timezone: string; -} diff --git a/turbo.json b/turbo.json index c08733c85..0325c3b2b 100644 --- a/turbo.json +++ b/turbo.json @@ -8,10 +8,6 @@ "NEXT_PUBLIC_SPACE_BASE_URL", "NEXT_PUBLIC_SPACE_BASE_PATH", "NEXT_PUBLIC_WEB_BASE_URL", - "NEXT_PUBLIC_SENTRY_DSN", - "NEXT_PUBLIC_SENTRY_ENVIRONMENT", - "NEXT_PUBLIC_ENABLE_SENTRY", - "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", @@ -21,25 +17,41 @@ "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", "NEXT_PUBLIC_SUPPORT_EMAIL", - "SENTRY_AUTH_TOKEN" + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG_ID", + "SENTRY_PROJECT_ID", + "NEXT_PUBLIC_SENTRY_ENVIRONMENT", + "NEXT_PUBLIC_SENTRY_DSN", + "SENTRY_MONITORING_ENABLED" ], - "pipeline": { + "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "dist/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "dist/**" + ] }, "develop": { "cache": false, "persistent": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "dev": { "cache": false, "persistent": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "test": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [] }, "lint": { diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 000000000..84f01402d --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1,3 @@ +.next/* +out/* +public/* \ No newline at end of file diff --git a/web/components/headers/workspace-active-cycles.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx similarity index 98% rename from web/components/headers/workspace-active-cycles.tsx rename to web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 5861cba60..f6565f415 100644 --- a/web/components/headers/workspace-active-cycles.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react"; // ui import { Crown } from "lucide-react"; diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx new file mode 100644 index 000000000..cfe3ed842 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceActiveCycleHeader } from "./header"; + +export default function WorkspaceActiveCycleLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/active-cycles.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx similarity index 54% rename from web/pages/[workspaceSlug]/active-cycles.tsx rename to web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx index 86bb37fa0..5f556c4be 100644 --- a/web/pages/[workspaceSlug]/active-cycles.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx @@ -1,17 +1,13 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; // components import { PageHead } from "@/components/core"; -import { WorkspaceActiveCycleHeader } from "@/components/headers"; import { WorkspaceActiveCyclesUpgrade } from "@/components/workspace"; -// layouts -import { useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; // hooks +import { useWorkspace } from "@/hooks/store"; -const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => { +const WorkspaceActiveCyclesPage = observer(() => { const { currentWorkspace } = useWorkspace(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; @@ -24,8 +20,4 @@ const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceActiveCyclesPage.getLayout = function getLayout(page: ReactElement) { - return }>{page}; -}; - export default WorkspaceActiveCyclesPage; diff --git a/web/components/headers/workspace-analytics.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx similarity index 92% rename from web/components/headers/workspace-analytics.tsx rename to web/app/[workspaceSlug]/(projects)/analytics/header.tsx index 98ceccbca..dc503dd6d 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx @@ -1,17 +1,22 @@ +"use client"; + import { useEffect } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useSearchParams } from "next/navigation"; +// icons import { BarChart2, PanelRight } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useAppTheme } from "@/hooks/store"; export const WorkspaceAnalyticsHeader = observer(() => { - const router = useRouter(); - const { analytics_tab } = router.query; + const searchParams = useSearchParams(); + const analytics_tab = searchParams.get("analytics_tab"); // store hooks const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme(); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx new file mode 100644 index 000000000..8dfc8b3b0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceAnalyticsHeader } from "./header"; + +export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx new file mode 100644 index 000000000..240993a24 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { Tab } from "@headlessui/react"; +// components +import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; +import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; +// constants +import { ANALYTICS_TABS } from "@/constants/analytics"; +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useCommandPalette, useEventTracker, useProject, useWorkspace } from "@/hooks/store"; + +const AnalyticsPage = observer(() => { + const searchParams = useSearchParams(); + const analytics_tab = searchParams.get("analytics_tab"); + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Analytics` : undefined; + + // TODO: refactor loader implementation + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader ? ( +
+ + + {ANALYTICS_TABS.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + +
+ ) : ( + { + setTrackElement("Analytics empty state"); + toggleCreateProjectModal(true); + }} + /> + )} + + )} + + ); +}); + +export default AnalyticsPage; diff --git a/web/components/headers/workspace-dashboard.tsx b/web/app/[workspaceSlug]/(projects)/header.tsx similarity index 99% rename from web/components/headers/workspace-dashboard.tsx rename to web/app/[workspaceSlug]/(projects)/header.tsx index 880b44406..339744d5d 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/app/[workspaceSlug]/(projects)/header.tsx @@ -1,15 +1,18 @@ +"use client"; + import Image from "next/image"; import { useTheme } from "next-themes"; import { Home, Zap } from "lucide-react"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -// hooks -// components +// ui import { Breadcrumbs } from "@plane/ui"; +// components import { BreadcrumbLink } from "@/components/common"; // constants import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "@/constants/event-tracker"; +// hooks import { useEventTracker } from "@/hooks/store"; export const WorkspaceDashboardHeader = () => { diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 000000000..f8fe0f8f9 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { AppSidebar } from "./sidebar"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/web/app/[workspaceSlug]/(projects)/page.tsx b/web/app/[workspaceSlug]/(projects)/page.tsx new file mode 100644 index 000000000..d684b923e --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead, AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceDashboardView } from "@/components/page-views"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// local components +import { WorkspaceDashboardHeader } from "./header"; + +const WorkspaceDashboardPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Home` : undefined; + + return ( + <> + } /> + + + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 000000000..b6c1a22f0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { ProfileIssuesPage } from "@/components/profile/profile-issues"; + +const ProfilePageHeader = { + assigned: "Profile - Assigned", + created: "Profile - Created", + subscribed: "Profile - Subscribed", +}; + +const ProfileIssuesTypePage = () => { + const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined }; + + if (!profileViewId) return null; + + const header = ProfilePageHeader[profileViewId]; + + return ( + <> + + + + ); +}; + +export default ProfileIssuesTypePage; diff --git a/web/pages/[workspaceSlug]/profile/[userId]/activity.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx similarity index 75% rename from web/pages/[workspaceSlug]/profile/[userId]/activity.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index 543ac48e8..8b5ba07e0 100644 --- a/web/pages/[workspaceSlug]/profile/[userId]/activity.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -1,31 +1,27 @@ -import { ReactElement, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// hooks -import { Button } from "@plane/ui"; -import { UserProfileHeader } from "@/components/headers"; -import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { ProfileAuthWrapper } from "@/layouts/user-profile-layout"; -// components +import { useParams } from "next/navigation"; // ui -// types -import { NextPageWithLayout } from "@/lib/types"; +import { Button } from "@plane/ui"; +// components +import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile"; // constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; const PER_PAGE = 100; -const ProfileActivityPage: NextPageWithLayout = observer(() => { +const ProfileActivityPage = observer(() => { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); // router - const router = useRouter(); - const { userId } = router.query; + + const { userId } = useParams(); // store hooks const { data: currentUser } = useUser(); const { @@ -73,12 +69,4 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { ); }); -ProfileActivityPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default ProfileActivityPage; diff --git a/web/components/headers/user-profile.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx similarity index 96% rename from web/components/headers/user-profile.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 4f1f44659..f39ebfc44 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -1,8 +1,10 @@ +"use client"; + // ui import { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; import { Breadcrumbs, CustomMenu } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; @@ -18,8 +20,7 @@ type TUserProfileHeader = { export const UserProfileHeader: FC = observer((props) => { const { type = undefined } = props; // router - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); // store hooks const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); const { diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx new file mode 100644 index 000000000..b37fa1ec3 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProfileSidebar } from "@/components/profile"; +// constants +import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; +// local components +import { UserProfileHeader } from "./header"; +import { ProfileIssuesMobileHeader } from "./mobile-header"; +import { ProfileNavbar } from "./navbar"; + +type Props = { + children: React.ReactNode; +}; + +const AUTHORIZED_ROLES = [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.VIEWER]; + +const UseProfileLayout: React.FC = observer((props) => { + const { children } = props; + // router + const { workspaceSlug, userId } = useParams(); + const pathname = usePathname(); + // store hooks + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // derived values + const isAuthorized = currentWorkspaceRole && AUTHORIZED_ROLES.includes(currentWorkspaceRole); + const isAuthorizedPath = + pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + const currentTab = tabsList.find((tab) => pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`); + + return ( + <> + {/* Passing the type prop from the current route value as we need the header as top most component. + TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */} + } + mobileHeader={isIssuesTab && } + /> + +
+
+ + {isAuthorized || !isAuthorizedPath ? ( +
{children}
+ ) : ( +
+ You do not have the permission to access this page. +
+ )} +
+ +
+
+ + ); +}); + +export default UseProfileLayout; diff --git a/web/components/profile/profile-issues-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx similarity index 92% rename from web/components/profile/profile-issues-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index 657aab0a0..2855cc129 100644 --- a/web/components/profile/profile-issues-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // icons import { ChevronDown } from "lucide-react"; // types @@ -10,16 +12,21 @@ import { CustomMenu } from "@plane/ui"; // components import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { + EIssueFilterType, + EIssueLayoutTypes, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, + ISSUE_LAYOUTS, +} from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel } from "@/hooks/store"; -const ProfileIssuesMobileHeader = observer(() => { +export const ProfileIssuesMobileHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); // store hook const { issuesFilter: { issueFilters, updateFilters }, @@ -41,7 +48,7 @@ const ProfileIssuesMobileHeader = observer(() => { workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_FILTERS, - { layout: layout }, + { layout: layout as EIssueLayoutTypes | undefined }, userId.toString() ); }, @@ -147,6 +154,8 @@ const ProfileIssuesMobileHeader = observer(() => { } filters={issueFilters?.filters ?? {}} handleFiltersUpdate={handleFiltersUpdate} + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} states={states} labels={workspaceLabels} memberIds={members} @@ -178,5 +187,3 @@ const ProfileIssuesMobileHeader = observer(() => {
); }); - -export default ProfileIssuesMobileHeader; diff --git a/web/components/profile/navbar.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx similarity index 85% rename from web/components/profile/navbar.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx index 6aace8e35..ad77fe8b4 100644 --- a/web/components/profile/navbar.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx @@ -1,7 +1,7 @@ import React from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, usePathname } from "next/navigation"; // components import { ProfileIssuesFilter } from "@/components/profile"; @@ -16,8 +16,8 @@ type Props = { export const ProfileNavbar: React.FC = (props) => { const { isAuthorized, showProfileIssuesFilter } = props; - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); +const pathname = usePathname(); const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; @@ -28,7 +28,7 @@ export const ProfileNavbar: React.FC = (props) => { { - const router = useRouter(); - const { workspaceSlug, userId } = router.query; +export default function ProfileOverviewPage() { + const { workspaceSlug, userId } = useParams(); const { data: userProfile } = useSWR( workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null, @@ -56,14 +51,4 @@ const ProfileOverviewPage: NextPageWithLayout = () => {
); -}; - -ProfileOverviewPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ProfileOverviewPage; +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx new file mode 100644 index 000000000..ced92a6ac --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveCyclesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx similarity index 52% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx index bcc406fa0..7bc67af5b 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles"; -import { ProjectArchivesHeader } from "@/components/headers"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedCyclesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectArchivedCyclesPage; +export default ProjectArchivedCyclesPage; \ No newline at end of file diff --git a/web/components/headers/project-archives.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx similarity index 90% rename from web/components/headers/project-archives.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 6e5638c71..2d8f80e2a 100644 --- a/web/components/headers/project-archives.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -1,11 +1,12 @@ +"use client"; + import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useParams, usePathname, useRouter } from "next/navigation"; // ui import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; +import { BreadcrumbLink, Logo } from "@/components/common"; // constants import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives"; import { EIssuesStoreType } from "@/constants/issue"; @@ -16,8 +17,9 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; export const ProjectArchivesHeader: FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - const activeTab = router.pathname.split("/").pop(); + const { workspaceSlug, projectId } = useParams(); + const pathname = usePathname(); + const activeTab = pathname.split("/").pop(); // store hooks const { issuesFilter: { issueFilters }, @@ -49,7 +51,7 @@ export const ProjectArchivesHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx similarity index 86% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx index 8abb90e28..bc578bf5b 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -1,6 +1,8 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import useSWR from "swr"; // icons import { ArchiveRestoreIcon } from "lucide-react"; @@ -8,22 +10,17 @@ import { ArchiveRestoreIcon } from "lucide-react"; import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; // components import { PageHead } from "@/components/core"; -import { ProjectArchivedIssueDetailsHeader } from "@/components/headers"; import { IssueDetailRoot } from "@/components/issues"; // constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { +const ArchivedIssueDetailsPage = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, archivedIssueId } = router.query; + const { workspaceSlug, projectId, archivedIssueId } = useParams(); // states const [isRestoring, setIsRestoring] = useState(false); // hooks @@ -50,7 +47,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { // derived values const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; - const project = issue ? getProjectById(issue?.project_id) : undefined; + const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; // auth const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -137,12 +134,4 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { ); }); -ArchivedIssueDetailsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ArchivedIssueDetailsPage; +export default ArchivedIssueDetailsPage; \ No newline at end of file diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx similarity index 85% rename from web/components/headers/project-archived-issue-details.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index e32528e82..1ea183926 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -1,28 +1,24 @@ -import { FC } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks -import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; -import { ISSUE_DETAILS } from "@/constants/fetch-keys"; -import { useProject } from "@/hooks/store"; -// components // ui -// types -import { IssueArchiveService } from "@/services/issue"; -// constants -// services -// helpers +import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui"; // components +import { BreadcrumbLink, Logo } from "@/components/common"; +// constants +import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +// hooks +import { useProject } from "@/hooks/store"; +// services +import { IssueArchiveService } from "@/services/issue"; const issueArchiveService = new IssueArchiveService(); -export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { +export const ProjectArchivedIssueDetailsHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, archivedIssueId } = router.query; + const { workspaceSlug, projectId, archivedIssueId } = useParams(); // store hooks const { currentProjectDetails } = useProject(); @@ -52,7 +48,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx new file mode 100644 index 000000000..10e6cc582 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivedIssueDetailsHeader } from "./header"; + +export default function ProjectArchivedIssueDetailLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx new file mode 100644 index 000000000..d12a5e01d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../../header"; + +export default function ProjectArchiveIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx similarity index 52% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx index b2c08e0a3..b2298d540 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; -import { ProjectArchivesHeader } from "@/components/headers"; import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedIssuesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectArchivedIssuesPage; +export default ProjectArchivedIssuesPage; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx new file mode 100644 index 000000000..333bcf74d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveModulesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx similarity index 56% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx index 1e346098e..5c1d55d3e 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; -import { ProjectArchivesHeader } from "@/components/headers"; import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedModulesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectArchivedModulesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx similarity index 73% rename from web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index 6bde8da41..5c2340f12 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -1,32 +1,31 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import useSWR from "swr"; -// hooks +// components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { CycleDetailsSidebar } from "@/components/cycles"; -import { CycleMobileHeader } from "@/components/cycles/cycle-mobile-header"; -import { CycleIssuesHeader } from "@/components/headers"; import { CycleLayoutRoot } from "@/components/issues/issue-layouts"; +// constants +// import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useCycle, useProject } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// components -// ui // assets -import { NextPageWithLayout } from "@/lib/types"; -import emptyCycle from "public/empty-state/cycle.svg"; -// types +import emptyCycle from "@/public/empty-state/cycle.svg"; -const CycleDetailPage: NextPageWithLayout = observer(() => { +const CycleDetailPage = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, cycleId } = router.query; + const { workspaceSlug, projectId, cycleId } = useParams(); // store hooks const { fetchCycleDetails, getCycleById } = useCycle(); const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); // hooks const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); // fetching cycle details @@ -47,6 +46,8 @@ const CycleDetailPage: NextPageWithLayout = observer(() => { */ const toggleSidebar = () => setValue(`${!isSidebarCollapsed}`); + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + return ( <> @@ -68,7 +69,9 @@ const CycleDetailPage: NextPageWithLayout = observer(() => {
{cycleId && !isSidebarCollapsed && (
{ ); }); -CycleDetailPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default CycleDetailPage; diff --git a/web/components/headers/cycle-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx similarity index 91% rename from web/components/headers/cycle-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index e0d7e3c50..94ea42949 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -1,20 +1,21 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -import { ProjectLogo } from "@/components/project"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; @@ -37,8 +38,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { getCycleById } = useCycle(); // derived values @@ -62,7 +62,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useRouter(); - const { workspaceSlug, projectId, cycleId } = router.query as { + const { workspaceSlug, projectId, cycleId } = useParams() as { workspaceSlug: string; projectId: string; cycleId: string; @@ -70,7 +70,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters, updateFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.CYCLE); const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); @@ -96,7 +96,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { }; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); }, @@ -147,6 +147,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -170,7 +171,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -207,9 +208,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { {issuesCount && issuesCount > 0 ? ( 1 ? "issues" : "issue" - } in this cycle`} + tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue" + } in this cycle`} position="bottom" > @@ -231,7 +231,13 @@ export const CycleIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> @@ -242,6 +248,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} labels={projectLabels} memberIds={projectMemberIds ?? undefined} states={projectStates} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx new file mode 100644 index 000000000..196665754 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { CycleIssuesHeader } from "./header"; +import { CycleIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectCycleIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/cycles/cycle-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx similarity index 93% rename from web/components/cycles/cycle-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 3e37b586f..3735fd531 100644 --- a/web/components/cycles/cycle-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -1,22 +1,24 @@ +"use client"; + import { useCallback, useState } from "react"; -import router from "next/router"; +import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store"; -export const CycleMobileHeader = () => { +export const CycleIssuesMobileHeader = () => { const [analyticsModal, setAnalyticsModal] = useState(false); const { getCycleById } = useCycle(); const layouts = [ @@ -25,7 +27,7 @@ export const CycleMobileHeader = () => { { key: "calendar", title: "Calendar", icon: Calendar }, ]; - const { workspaceSlug, projectId, cycleId } = router.query; + const { workspaceSlug, projectId, cycleId } = useParams(); const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; // store hooks const { currentProjectDetails } = useProject(); @@ -35,7 +37,7 @@ export const CycleMobileHeader = () => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !cycleId) return; updateFilters( workspaceSlug.toString(), @@ -156,6 +158,8 @@ export const CycleMobileHeader = () => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} labels={projectLabels} memberIds={projectMemberIds ?? undefined} states={projectStates} diff --git a/web/components/headers/cycles.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx similarity index 87% rename from web/components/headers/cycles.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index 7b78e27fd..685352c1c 100644 --- a/web/components/headers/cycles.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -1,21 +1,22 @@ +"use client"; + import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; // ui import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { CyclesViewHeader } from "@/components/cycles"; -import { ProjectLogo } from "@/components/project"; // constants import { EUserProjectRoles } from "@/constants/project"; // hooks import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; -export const CyclesHeader: FC = observer(() => { +export const CyclesListHeader: FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -41,7 +42,7 @@ export const CyclesHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx new file mode 100644 index 000000000..76cbf685f --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { CyclesListHeader } from "./header"; +import { CyclesListMobileHeader } from "./mobile-header"; + +export default function ProjectCyclesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/cycles/cycles-list-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx similarity index 95% rename from web/components/cycles/cycles-list-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx index 590cb794f..ecb1d5b90 100644 --- a/web/components/cycles/cycles-list-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react"; // ui import { List } from "lucide-react"; @@ -8,7 +10,7 @@ import { CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; // hooks import { useCycleFilter, useProject } from "@/hooks/store"; -const CyclesListMobileHeader = observer(() => { +export const CyclesListMobileHeader = observer(() => { const { currentProjectDetails } = useProject(); // hooks const { updateDisplayFilters } = useCycleFilter(); @@ -48,5 +50,3 @@ const CyclesListMobileHeader = observer(() => {
); }); - -export default CyclesListMobileHeader; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx similarity index 81% rename from web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5b7f313e7..9cebd29ce 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -1,14 +1,14 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types import { TCycleFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; -import CyclesListMobileHeader from "@/components/cycles/cycles-list-mobile-header"; import { EmptyState } from "@/components/empty-state"; -import { CyclesHeader } from "@/components/headers"; import { CycleModuleListLayout } from "@/components/ui"; // constants import { EmptyStateType } from "@/constants/empty-state"; @@ -16,12 +16,8 @@ import { EmptyStateType } from "@/constants/empty-state"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectCyclesPage: NextPageWithLayout = observer(() => { +const ProjectCyclesPage = observer(() => { // states const [createModal, setCreateModal] = useState(false); // store hooks @@ -29,8 +25,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { const { currentProjectCycleIds, loader } = useCycle(); const { getProjectById, currentProjectDetails } = useProject(); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // cycle filters hook const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); // derived values @@ -103,12 +98,4 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { ); }); -ProjectCyclesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - -export default ProjectCyclesPage; +export default ProjectCyclesPage; \ No newline at end of file diff --git a/web/components/headers/project-draft-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx similarity index 90% rename from web/components/headers/project-draft-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index f6de97b52..227cdd982 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -1,16 +1,17 @@ +"use client"; + import { FC, useCallback } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -import { ProjectLogo } from "@/components/project"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -19,8 +20,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; export const ProjectDraftIssueHeader: FC = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -56,7 +56,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { ); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -101,7 +101,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -131,7 +131,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> @@ -139,6 +139,8 @@ export const ProjectDraftIssueHeader: FC = observer(() => { + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx similarity index 67% rename from web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx index f6e0658d9..350a18a6b 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx @@ -1,21 +1,17 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import { X, PenSquare } from "lucide-react"; -// layouts // components import { PageHead } from "@/components/core"; -import { ProjectDraftIssueHeader } from "@/components/headers"; import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root"; -// types // hooks import { useProject } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { NextPageWithLayout } from "@/lib/types"; -const ProjectDraftIssuesPage: NextPageWithLayout = observer(() => { +const ProjectDraftIssuesPage = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store const { getProjectById } = useProject(); // derived values @@ -43,12 +39,4 @@ const ProjectDraftIssuesPage: NextPageWithLayout = observer(() => { ); }); -ProjectDraftIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectDraftIssuesPage; +export default ProjectDraftIssuesPage; \ No newline at end of file diff --git a/web/components/headers/project-inbox.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx similarity index 83% rename from web/components/headers/project-inbox.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx index 082720358..043bf84eb 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx @@ -1,13 +1,14 @@ +"use client"; + import { FC, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { InboxIssueCreateEditModalRoot } from "@/components/inbox"; -import { ProjectLogo } from "@/components/project"; // hooks import { useProject, useProjectInbox } from "@/hooks/store"; @@ -15,8 +16,7 @@ export const ProjectInboxHeader: FC = observer(() => { // states const [createIssueModal, setCreateIssueModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { currentProjectDetails } = useProject(); const { loader } = useProjectInbox(); @@ -35,7 +35,7 @@ export const ProjectInboxHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -45,9 +45,7 @@ export const ProjectInboxHeader: FC = observer(() => { } /> - } + link={} />} /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx new file mode 100644 index 000000000..167823fc2 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectInboxHeader } from "./header"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx similarity index 59% rename from web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx index f32adaf5d..0a2ecd17d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx @@ -1,34 +1,28 @@ -import { ReactElement, useEffect } from "react"; +"use client"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; // components +import { useParams, useSearchParams } from "next/navigation"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { ProjectInboxHeader } from "@/components/headers"; import { InboxIssueRoot } from "@/components/inbox"; // constants import { EmptyStateType } from "@/constants/empty-state"; // helpers import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper"; // hooks -import { useProject, useProjectInbox } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; +import { useProject } from "@/hooks/store"; -const ProjectInboxPage: NextPageWithLayout = observer(() => { +const ProjectInboxPage = observer(() => { /// router - const router = useRouter(); - const { workspaceSlug, projectId, currentTab: navigationTab, inboxIssueId } = router.query; + const { workspaceSlug, projectId } = useParams(); + + const searchParams = useSearchParams(); + + const navigationTab = searchParams.get("currentTab"); + const inboxIssueId = searchParams.get("inboxIssueId"); + // hooks const { currentProjectDetails } = useProject(); - const { currentTab, handleCurrentTab } = useProjectInbox(); - - useEffect(() => { - if (navigationTab && currentTab != navigationTab) - handleCurrentTab(navigationTab === "open" ? EInboxIssueCurrentTab.OPEN : EInboxIssueCurrentTab.CLOSED); - }, [currentTab, navigationTab, handleCurrentTab]); // No access to inbox if (currentProjectDetails?.inbox_view === false) @@ -44,6 +38,12 @@ const ProjectInboxPage: NextPageWithLayout = observer(() => { // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox"; + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + if (!workspaceSlug || !projectId) return <>; return ( @@ -55,18 +55,11 @@ const ProjectInboxPage: NextPageWithLayout = observer(() => { projectId={projectId.toString()} inboxIssueId={inboxIssueId?.toString() || undefined} inboxAccessible={currentProjectDetails?.inbox_view || false} + navigationTab={currentNavigationTab} />
); }); -ProjectInboxPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectInboxPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx similarity index 85% rename from web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index cdd40a38e..0ff5757bd 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -1,6 +1,8 @@ -import React, { ReactElement, useEffect } from "react"; +"use client"; + +import React, { useEffect } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // ui @@ -8,22 +10,17 @@ import { Loader } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; -import { ProjectIssueDetailsHeader } from "@/components/headers"; import { IssueDetailRoot } from "@/components/issues"; // hooks import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; // assets import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; -const IssueDetailsPage: NextPageWithLayout = observer(() => { +const IssueDetailsPage = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId } = useParams(); // hooks const { resolvedTheme } = useTheme(); // store hooks @@ -108,12 +105,4 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { ); }); -IssueDetailsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default IssueDetailsPage; diff --git a/web/components/headers/project-issue-details.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx similarity index 86% rename from web/components/headers/project-issue-details.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx index 176732ca5..645013378 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx @@ -1,23 +1,21 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// hooks -import { PanelRight } from "lucide-react"; -import { Breadcrumbs, LayersIcon } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; -import { cn } from "@/helpers/common.helper"; -import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; -// ui -// helpers -// services -// constants -// components +"use client"; -export const ProjectIssueDetailsHeader: FC = observer(() => { +import { observer } from "mobx-react"; +import { useParams, useRouter } from "next/navigation"; +import { PanelRight } from "lucide-react"; +// ui +import { Breadcrumbs, LayersIcon } from "@plane/ui"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; + +export const ProjectIssueDetailsHeader = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId } = useParams(); // store hooks const { currentProjectDetails } = useProject(); const { issueDetailSidebarCollapsed, toggleIssueDetailSidebar } = useAppTheme(); @@ -42,7 +40,7 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx new file mode 100644 index 000000000..37f4a7749 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectIssueDetailsHeader } from "./header"; + +export default function ProjectIssueDetailsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/components/headers/project-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx similarity index 87% rename from web/components/headers/project-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx index 8ba44719e..06ade9367 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -1,22 +1,23 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; // icons import { Briefcase, Circle, ExternalLink } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -import { ProjectLogo } from "@/components/project"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers -import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; +import { SPACE_BASE_URL } from "@/helpers/common.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { @@ -31,19 +32,19 @@ import { import { useIssues } from "@/hooks/store/use-issues"; import { usePlatformOS } from "@/hooks/use-platform-os"; -export const ProjectIssuesHeader: React.FC = observer(() => { +export const ProjectIssuesHeader = observer(() => { // states const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { project: { projectMemberIds }, } = useMember(); const { issuesFilter: { issueFilters, updateFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.PROJECT); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -78,7 +79,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { ); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -101,12 +102,13 @@ export const ProjectIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const DEPLOY_URL = SPACE_BASE_URL + SPACE_BASE_PATH; + const publishedURL = `${SPACE_BASE_URL}/issues/${currentProjectDetails?.anchor}`; const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -130,7 +132,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { currentProjectDetails ? ( currentProjectDetails && ( - + ) ) : ( @@ -160,9 +162,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { ) : null}
- {currentProjectDetails?.is_deployed && DEPLOY_URL && ( + {currentProjectDetails?.anchor && ( {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> @@ -183,6 +190,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => { + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/issues/issues-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx similarity index 92% rename from web/components/issues/issues-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index 72df560a9..444e33a61 100644 --- a/web/components/issues/issues-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -1,30 +1,32 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import router from "next/router"; +import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; -export const IssuesMobileHeader = observer(() => { +export const ProjectIssuesMobileHeader = observer(() => { const layouts = [ { key: "list", title: "List", icon: List }, { key: "kanban", title: "Kanban", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; const [analyticsModal, setAnalyticsModal] = useState(false); - const { workspaceSlug, projectId } = router.query as { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string; }; @@ -42,7 +44,7 @@ export const IssuesMobileHeader = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -132,6 +134,8 @@ export const IssuesMobileHeader = observer(() => { { - const router = useRouter(); - const { projectId } = router.query; +const ProjectIssuesPage = observer(() => { + const { projectId } = useParams(); // store const { getProjectById } = useProject(); @@ -41,12 +35,4 @@ const ProjectIssuesPage: NextPageWithLayout = observer(() => { ); }); -ProjectIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default ProjectIssuesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx similarity index 69% rename from web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx index d6a0c0342..a89593e07 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -1,31 +1,29 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import useSWR from "swr"; -// hooks +// components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; -import { ModuleIssuesHeader } from "@/components/headers"; import { ModuleLayoutRoot } from "@/components/issues"; -import { ModuleDetailsSidebar } from "@/components/modules"; -import { ModuleMobileHeader } from "@/components/modules/module-mobile-header"; +import { ModuleAnalyticsSidebar } from "@/components/modules"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useModule, useProject } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// components // assets -import { NextPageWithLayout } from "@/lib/types"; -import emptyModule from "public/empty-state/module.svg"; -// types +import emptyModule from "@/public/empty-state/module.svg"; -const ModuleIssuesPage: NextPageWithLayout = observer(() => { +const ModuleIssuesPage = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query; + const { workspaceSlug, projectId, moduleId } = useParams(); // store hooks const { fetchModuleDetails, getModuleById } = useModule(); const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); // local storage const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; @@ -47,6 +45,8 @@ const ModuleIssuesPage: NextPageWithLayout = observer(() => { if (!workspaceSlug || !projectId || !moduleId) return <>; + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + return ( <> @@ -67,13 +67,15 @@ const ModuleIssuesPage: NextPageWithLayout = observer(() => {
{moduleId && !isSidebarCollapsed && (
- +
)}
@@ -82,12 +84,4 @@ const ModuleIssuesPage: NextPageWithLayout = observer(() => { ); }); -ModuleIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default ModuleIssuesPage; diff --git a/web/components/headers/module-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx similarity index 89% rename from web/components/headers/module-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 538eca2cd..f262ef5fd 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -1,20 +1,21 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -import { ProjectLogo } from "@/components/project"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; @@ -38,8 +39,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { getModuleById } = useModule(); // derived values @@ -65,13 +65,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query; + const { workspaceSlug, projectId, moduleId } = useParams(); // hooks const { isMobile } = usePlatformOS(); // store hooks const { issuesFilter: { issueFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.MODULE); const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); const { projectModuleIds, getModuleById } = useModule(); @@ -97,11 +97,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); const handleFiltersUpdate = useCallback( @@ -122,7 +122,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }); }, - [projectId, moduleId, issueFilters, updateFilters] + [projectId, issueFilters, updateFilters] ); const handleDisplayFilters = useCallback( @@ -130,7 +130,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); const handleDisplayProperties = useCallback( @@ -138,7 +138,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); // derived values @@ -147,6 +147,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -170,7 +171,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -207,9 +208,8 @@ export const ModuleIssuesHeader: React.FC = observer(() => { {issuesCount && issuesCount > 0 ? ( 1 ? "issues" : "issue" - } in this module`} + tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue" + } in this module`} position="bottom" > @@ -232,7 +232,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> @@ -240,6 +246,8 @@ export const ModuleIssuesHeader: React.FC = observer(() => { + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/modules/module-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx similarity index 93% rename from web/components/modules/module-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 822054c7f..af34457c7 100644 --- a/web/components/modules/module-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -1,23 +1,25 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import router from "next/router"; +import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; -export const ModuleMobileHeader = observer(() => { +export const ModuleIssuesMobileHeader = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); const { currentProjectDetails } = useProject(); const { getModuleById } = useModule(); @@ -26,7 +28,7 @@ export const ModuleMobileHeader = observer(() => { { key: "kanban", title: "Kanban", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; - const { workspaceSlug, projectId, moduleId } = router.query as { + const { workspaceSlug, projectId, moduleId } = useParams() as { workspaceSlug: string; projectId: string; moduleId: string; @@ -44,7 +46,7 @@ export const ModuleMobileHeader = observer(() => { } = useMember(); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); }, @@ -134,6 +136,8 @@ export const ModuleMobileHeader = observer(() => { { // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -41,7 +42,7 @@ export const ModulesListHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx new file mode 100644 index 000000000..12ebf41e0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ModulesListHeader } from "./header"; +import { ModulesListMobileHeader } from "./mobile-header"; + +export default function ProjectModulesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/modules/moduels-list-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx similarity index 94% rename from web/components/modules/moduels-list-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx index 94c089328..7a530ea14 100644 --- a/web/components/modules/moduels-list-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -1,9 +1,11 @@ +"use client"; + import { observer } from "mobx-react"; import { CustomMenu } from "@plane/ui"; import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; import { useModuleFilter, useProject } from "@/hooks/store"; -const ModulesListMobileHeader = observer(() => { +export const ModulesListMobileHeader = observer(() => { const { currentProjectDetails } = useProject(); const { updateDisplayFilters } = useModuleFilter(); @@ -36,5 +38,3 @@ const ModulesListMobileHeader = observer(() => {
); }); - -export default ModulesListMobileHeader; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx similarity index 78% rename from web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 08e621d0f..f8b474d94 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -1,28 +1,23 @@ -import { ReactElement, useCallback } from "react"; +"use client"; + +import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types import { TModuleFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { ModulesListHeader } from "@/components/headers"; import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; -import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header"; // constants import { EmptyStateType } from "@/constants/empty-state"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useModuleFilter, useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectModulesPage: NextPageWithLayout = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +const ProjectModulesPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); // store const { getProjectById, currentProjectDetails } = useProject(); const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } = @@ -82,12 +77,4 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => { ); }); -ProjectModulesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default ProjectModulesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx similarity index 74% rename from web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index f4c60be97..2c8df4618 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,7 +1,9 @@ -import { ReactElement, useRef, useState } from "react"; +"use client"; + +import { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; import useSWR from "swr"; // document-editor import { EditorRefApi, useEditorMarkings } from "@plane/document-editor"; @@ -12,19 +14,14 @@ import { TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; -import { PageDetailsHeader } from "@/components/headers"; import { IssuePeekOverview } from "@/components/issues"; import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { usePage, useProjectPages } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// lib -import { NextPageWithLayout } from "@/lib/types"; -const PageDetailsPage: NextPageWithLayout = observer(() => { +const PageDetailsPage = observer(() => { // states const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false); const [editorReady, setEditorReady] = useState(false); @@ -34,11 +31,11 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const readOnlyEditorRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId, pageId } = router.query; + const { workspaceSlug, projectId, pageId } = useParams(); // store hooks - const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? ""); + const { createPage, getPageById } = useProjectPages(); const page = usePage(pageId?.toString() ?? ""); - const { description_html, id, name } = page; + const { access, description_html, id, name } = page; // editor markings hook const { markings, updateMarkings } = useEditorMarkings(); // fetch page details @@ -81,6 +78,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const formData: Partial = { name: "Copy of " + name, description_html: description_html ?? "

", + access, }; await handleCreatePage(formData) @@ -99,20 +97,17 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
- {projectId && ( - setSidePeekVisible(state)} - /> - )} + setSidePeekVisible(state)} + /> setEditorReady(val)} @@ -130,12 +125,4 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { ); }); -PageDetailsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default PageDetailsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx new file mode 100644 index 000000000..73a9deda5 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { FileText } from "lucide-react"; +// types +import { TLogoProps } from "@plane/types"; +// ui +import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; +// helper +import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; +// hooks +import { usePage, useProject } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +export interface IPagesHeaderProps { + showButton?: boolean; +} + +export const PageDetailsHeader = observer(() => { + // router + const { workspaceSlug, pageId } = useParams(); + // state + const [isOpen, setIsOpen] = useState(false); + // store hooks + const { currentProjectDetails } = useProject(); + const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? ""); + + const handlePageLogoUpdate = async (data: TLogoProps) => { + if (data) { + updatePageLogo(data) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Logo Updated successfully.", + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }); + }); + } + }; + // use platform + const { platform } = usePlatformOS(); + // derived values + const isMac = platform === "MacOS"; + + return ( +
+
+
+ + + + + + + ) + } + /> + + + + + + } + /> + + } + /> + } + /> + setIsOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ + <> + {logo_props?.in_use ? ( + + ) : ( + + )} + + } + onChange={(val) => { + let logoValue = {}; + + if (val?.type === "emoji") + logoValue = { + value: convertHexEmojiToDecimal(val.value.unified), + url: val.value.imageUrl, + }; + else if (val?.type === "icon") logoValue = val.value; + + handlePageLogoUpdate({ + in_use: val?.type, + [val?.type]: logoValue, + }).finally(() => setIsOpen(false)); + }} + defaultIconColor={ + logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined + } + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + /> + } + /> + } + /> + +
+
+ {isContentEditable && ( + + )} +
+ ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx new file mode 100644 index 000000000..3c6f18cf1 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// component +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { PageDetailsHeader } from "./header"; + +export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/components/headers/pages.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx similarity index 77% rename from web/components/headers/pages.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index 7ab9cb75d..c3cee8e37 100644 --- a/web/components/headers/pages.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -1,21 +1,23 @@ +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useSearchParams } from "next/navigation"; import { FileText } from "lucide-react"; -// hooks // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; -import { EUserProjectRoles } from "@/constants/project"; +import { BreadcrumbLink, Logo } from "@/components/common"; // constants -// components +import { EPageAccess } from "@/constants/page"; +import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; -export const PagesHeader = observer(() => { +export const PagesListHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); + const searchParams = useSearchParams(); + const pageType = searchParams.get("type"); // store hooks const { toggleCreatePageModal } = useCommandPalette(); const { @@ -41,7 +43,7 @@ export const PagesHeader = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -62,7 +64,10 @@ export const PagesHeader = observer(() => { size="sm" onClick={() => { setTrackElement("Project pages page"); - toggleCreatePageModal(true); + toggleCreatePageModal({ + isOpen: true, + pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }); }} > Add Page diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx new file mode 100644 index 000000000..8b1bdd828 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { ContentWrapper, AppHeader } from "@/components/core"; +// local components +import { PagesListHeader } from "./header"; + +export default function ProjectPagesListLayout({ children }: { children: ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx similarity index 70% rename from web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 764c4a68a..5b536108e 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -1,23 +1,19 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useSearchParams } from "next/navigation"; // types import { TPageNavigationTabs } from "@plane/types"; // components import { PageHead } from "@/components/core"; -import { PagesHeader } from "@/components/headers"; import { PagesListRoot, PagesListView } from "@/components/pages"; // hooks import { useAppRouter, useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// lib -import { NextPageWithLayout } from "@/lib/types"; -const ProjectPagesPage: NextPageWithLayout = observer(() => { +const ProjectPagesPage = observer(() => { // router - const router = useRouter(); - const { type } = router.query; + const searchParams = useSearchParams(); + const type = searchParams.get("type"); // store hooks const { workspaceSlug, projectId } = useAppRouter(); const { getProjectById } = useProject(); @@ -51,12 +47,4 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { ); }); -ProjectPagesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectPagesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx similarity index 68% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx index 060520faf..1676b25aa 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx @@ -1,30 +1,22 @@ -import React, { ReactElement } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import { IProject } from "@plane/types"; -// hooks -import { TOAST_TYPE, setToast } from "@plane/ui"; -import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; -// layouts -// ui -// components -import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; -import { EUserProjectRoles } from "@/constants/project"; -import { useProject, useUser } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -// layouts -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// hooks -// components -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants +"use client"; -const AutomationSettingsPage: NextPageWithLayout = observer(() => { +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { IProject } from "@plane/types"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; +import { PageHead } from "@/components/core"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useProject, useUser } from "@/hooks/store"; + +const AutomationSettingsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { membership: { currentProjectRole }, @@ -61,12 +53,4 @@ const AutomationSettingsPage: NextPageWithLayout = observer(() => { ); }); -AutomationSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default AutomationSettingsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx new file mode 100644 index 000000000..f292bd6d9 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { EstimateRoot } from "@/components/estimates"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useUser, useProject } from "@/hooks/store"; + +const EstimatesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + + // derived values + const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; + + if (!workspaceSlug || !projectId) return <>; + return ( + <> + +
+ +
+ + ); +}); + +export default EstimatesSettingsPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx similarity index 66% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx index a55b58beb..9317003c7 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/features.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx @@ -1,24 +1,18 @@ -import { ReactElement } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// hooks -import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; -import { ProjectFeaturesList } from "@/components/project"; -import { EUserProjectRoles } from "@/constants/project"; -import { useProject, useUser } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// components -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants +"use client"; -const FeaturesSettingsPage: NextPageWithLayout = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { PageHead } from "@/components/core"; +import { ProjectFeaturesList } from "@/components/project"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useProject, useUser } from "@/hooks/store"; + +const FeaturesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); // store const { membership: { fetchUserProjectInfo }, @@ -52,12 +46,4 @@ const FeaturesSettingsPage: NextPageWithLayout = observer(() => { ); }); -FeaturesSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default FeaturesSettingsPage; +export default FeaturesSettingsPage; \ No newline at end of file diff --git a/web/components/headers/project-settings.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx similarity index 90% rename from web/components/headers/project-settings.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index 36b9cd247..49c65b9d3 100644 --- a/web/components/headers/project-settings.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -1,12 +1,13 @@ +"use client"; + import { FC } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams, useRouter } from "next/navigation"; // ui import { Settings } from "lucide-react"; import { Breadcrumbs, CustomMenu } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; +import { BreadcrumbLink, Logo } from "@/components/common"; // constants import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; // hooks @@ -15,7 +16,7 @@ import { useProject, useUser } from "@/hooks/store"; export const ProjectSettingHeader: FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { membership: { currentProjectRole }, @@ -39,7 +40,7 @@ export const ProjectSettingHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx similarity index 65% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx index 5c2644305..192d7147f 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx @@ -1,20 +1,16 @@ -import { ReactElement, useEffect, useRef } from "react"; +"use client"; + +import { useEffect, useRef } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; -// layouts -import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; -import { ProjectSettingsLabelList } from "@/components/labels"; -import { useProject } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; // components -// types -import { NextPageWithLayout } from "@/lib/types"; +import { PageHead } from "@/components/core"; +import { ProjectSettingsLabelList } from "@/components/labels"; // hooks +import { useProject } from "@/hooks/store"; -const LabelsSettingsPage: NextPageWithLayout = observer(() => { +const LabelsSettingsPage = observer(() => { const { currentProjectDetails } = useProject(); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; @@ -43,12 +39,4 @@ const LabelsSettingsPage: NextPageWithLayout = observer(() => { ); }); -LabelsSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default LabelsSettingsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx new file mode 100644 index 000000000..ac14c5019 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +// ui +import { Button, LayersIcon } from "@plane/ui"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { AppHeader, ContentWrapper } from "@/components/core"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useUser } from "@/hooks/store"; +// local components +import { ProjectSettingHeader } from "./header"; +import { ProjectSettingsSidebar } from "./sidebar"; + +export interface IProjectSettingLayout { + children: ReactNode; +} + +const ProjectSettingLayout: FC = observer((props) => { + const { children } = props; + // router + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + + const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER; + + if (restrictViewSettings) { + return ( + + + + } + /> + ); + } + + return ( + <> + } /> + +
+
+ +
+
+ {children} +
+
+
+ + ); +}); + +export default ProjectSettingLayout; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx similarity index 54% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx index 6d60c0e76..af1c82e12 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx @@ -1,18 +1,13 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -// layouts // components import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; -// types // hooks import { useProject } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -import { NextPageWithLayout } from "@/lib/types"; -const MembersSettingsPage: NextPageWithLayout = observer(() => { +const MembersSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); // derived values @@ -29,12 +24,4 @@ const MembersSettingsPage: NextPageWithLayout = observer(() => { ); }); -MembersSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default MembersSettingsPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx similarity index 79% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx index b07be48a5..cc5cfc855 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx @@ -1,10 +1,11 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; import { ArchiveRestoreProjectModal, ArchiveProjectSelection, @@ -15,19 +16,13 @@ import { } from "@/components/project"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const GeneralSettingsPage: NextPageWithLayout = observer(() => { +const GeneralSettingsPage = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { currentProjectDetails, fetchProjectDetails } = useProject(); // api call to fetch project details @@ -91,12 +86,4 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => { ); }); -GeneralSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default GeneralSettingsPage; diff --git a/web/layouts/settings-layout/project/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx similarity index 88% rename from web/layouts/settings-layout/project/sidebar.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 98fc16bb9..af5e5355a 100644 --- a/web/layouts/settings-layout/project/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -1,16 +1,18 @@ +"use client"; + import React from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, usePathname } from "next/navigation"; // ui import { Loader } from "@plane/ui"; -// hooks -import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; -import { useUser } from "@/hooks/store"; // constants +import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; +// hooks +import { useUser } from "@/hooks/store"; export const ProjectSettingsSidebar = () => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); + const pathname = usePathname(); // mobx store const { membership: { currentProjectRole }, @@ -44,7 +46,7 @@ export const ProjectSettingsSidebar = () => {
{ +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +import { ProjectSettingStateList } from "@/components/states"; +// hook +import { useProject } from "@/hooks/store"; + +const StatesSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); // derived values @@ -30,12 +25,4 @@ const StatesSettingsPage: NextPageWithLayout = observer(() => { ); }); -StatesSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default StatesSettingsPage; diff --git a/web/components/headers/project-view-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx similarity index 85% rename from web/components/headers/project-view-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 297c976ee..170880d97 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -1,17 +1,18 @@ +"use client"; + import { useCallback } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -import { ProjectLogo } from "@/components/project"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; @@ -31,8 +32,7 @@ import { export const ProjectViewIssuesHeader: React.FC = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, viewId } = router.query; + const { workspaceSlug, projectId, viewId } = useParams(); // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -53,7 +53,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !viewId) return; updateFilters( workspaceSlug.toString(), @@ -141,7 +141,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -164,7 +164,11 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { - + {viewDetails?.logo_props?.in_use ? ( + + ) : ( + + )} {viewDetails?.name && truncateText(viewDetails.name, 40)} } @@ -182,7 +186,11 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { href={`/${workspaceSlug}/projects/${projectId}/views/${viewId}`} className="flex items-center gap-1.5" > - + {view?.logo_props?.in_use ? ( + + ) : ( + + )} {truncateText(view.name, 40)} @@ -195,7 +203,13 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> @@ -209,6 +223,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { { + // router + const router = useRouter(); + const { workspaceSlug, projectId, viewId } = useParams(); + // store hooks + const { fetchViewDetails, getViewById } = useProjectView(); + const { getProjectById } = useProject(); + // derived values + const projectView = viewId ? getViewById(viewId.toString()) : undefined; + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined; + + const { error } = useSWR( + workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null, + workspaceSlug && projectId && viewId + ? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString()) + : null + ); + + if (error) { + router.push(`/${workspaceSlug}/projects/${projectId}/views`), + }} + />; + } + + return ( + <> + + + + ); +}); + +export default ProjectViewIssuesPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx new file mode 100644 index 000000000..92cbb7326 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectViewIssuesHeader } from "./[viewId]/header"; + +export default function ProjectViewIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/components/headers/project-views.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx similarity index 85% rename from web/components/headers/project-views.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index 3cd578847..ee5f0709c 100644 --- a/web/components/headers/project-views.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -1,20 +1,20 @@ +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// hooks -// components +import { useParams } from "next/navigation"; +// ui import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; -// helpers -import { ProjectLogo } from "@/components/project"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; import { ViewListHeader } from "@/components/views"; -import { EUserProjectRoles } from "@/constants/project"; // constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useCommandPalette, useProject, useUser } from "@/hooks/store"; -export const ProjectViewsHeader: React.FC = observer(() => { +export const ProjectViewsHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); const { @@ -40,7 +40,7 @@ export const ProjectViewsHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx new file mode 100644 index 000000000..69493402d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectViewsHeader } from "./header"; + +export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx similarity index 65% rename from web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index dbdc0f192..25daf594c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -1,24 +1,19 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { ProjectViewsHeader } from "@/components/headers"; import { ProjectViewsList } from "@/components/views"; // constants import { EmptyStateType } from "@/constants/empty-state"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectViewsPage: NextPageWithLayout = observer(() => { +const ProjectViewsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store const { getProjectById, currentProjectDetails } = useProject(); // derived values @@ -46,12 +41,4 @@ const ProjectViewsPage: NextPageWithLayout = observer(() => { ); }); -ProjectViewsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectViewsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx new file mode 100644 index 000000000..fc2ec0075 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout"; + +const ProjectDetailLayout = ({ children }: { children: ReactNode }) => ( + {children} +); + +export default ProjectDetailLayout; diff --git a/web/components/headers/projects.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx similarity index 99% rename from web/components/headers/projects.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx index 7126b2697..0d01696fc 100644 --- a/web/components/headers/projects.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Search, Briefcase, X, ListFilter } from "lucide-react"; @@ -18,7 +20,7 @@ import { calculateTotalFilters } from "@/helpers/filter.helper"; import { useAppRouter, useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -export const ProjectsHeader = observer(() => { +export const ProjectsListHeader = observer(() => { // states const [isSearchOpen, setIsSearchOpen] = useState(false); // refs diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx new file mode 100644 index 000000000..259c412dc --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectsListHeader } from "./header"; +import { ProjectsListMobileHeader } from "./mobile-header"; + +export default function ProjectListLayout({ children }: { children: ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/project/projects-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx similarity index 97% rename from web/components/project/projects-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx index e5a9ebbf8..b10e8db30 100644 --- a/web/components/project/projects-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx @@ -12,7 +12,7 @@ import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useAppRouter, useMember, useProjectFilter } from "@/hooks/store"; -const ProjectsMobileHeader = observer(() => { +export const ProjectsListMobileHeader = observer(() => { const { currentWorkspaceDisplayFilters: displayFilters, currentWorkspaceFilters: filters, @@ -87,5 +87,3 @@ const ProjectsMobileHeader = observer(() => {
); }); - -export default ProjectsMobileHeader; diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx similarity index 84% rename from web/pages/[workspaceSlug]/projects/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx index 5646a5c01..68ac3d125 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx @@ -1,20 +1,18 @@ -import { ReactElement, useCallback } from "react"; +"use client"; + +import { useCallback } from "react"; import { observer } from "mobx-react"; +// types import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; -import { ProjectsHeader } from "@/components/headers"; import { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project"; -// layouts -import ProjectsMobileHeader from "@/components/project/projects-mobile-header"; -import { calculateTotalFilters } from "@/helpers/filter.helper"; -import { useAppRouter, useProject, useProjectFilter, useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; // helpers -// types -import { NextPageWithLayout } from "@/lib/types"; +import { calculateTotalFilters } from "@/helpers/filter.helper"; +// hooks +import { useAppRouter, useProject, useProjectFilter, useWorkspace } from "@/hooks/store"; -const ProjectsPage: NextPageWithLayout = observer(() => { +const ProjectsPage = observer(() => { // store const { workspaceSlug } = useAppRouter(); const { currentWorkspace } = useWorkspace(); @@ -82,8 +80,4 @@ const ProjectsPage: NextPageWithLayout = observer(() => { ); }); -ProjectsPage.getLayout = function getLayout(page: ReactElement) { - return } mobileHeader={}>{page}; -}; - export default ProjectsPage; diff --git a/web/pages/[workspaceSlug]/settings/api-tokens.tsx b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx similarity index 82% rename from web/pages/[workspaceSlug]/settings/api-tokens.tsx rename to web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx index 464c5e373..906fee328 100644 --- a/web/pages/[workspaceSlug]/settings/api-tokens.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx @@ -1,37 +1,32 @@ +"use client"; + import React, { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// store hooks +// ui import { Button } from "@plane/ui"; +// component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { APITokenSettingsLoader } from "@/components/ui"; +// constants import { EmptyStateType } from "@/constants/empty-state"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; import { EUserWorkspaceRoles } from "@/constants/workspace"; +// store hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// component -// ui // services -import { NextPageWithLayout } from "@/lib/types"; import { APITokenService } from "@/services/api_token.service"; -// types -// constants const apiTokenService = new APITokenService(); -const ApiTokensPage: NextPageWithLayout = observer(() => { +const ApiTokensPage = observer(() => { // states const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { membership: { currentWorkspaceRole }, @@ -97,12 +92,4 @@ const ApiTokensPage: NextPageWithLayout = observer(() => { ); }); -ApiTokensPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ApiTokensPage; +export default ApiTokensPage; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/billing.tsx b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx similarity index 72% rename from web/pages/[workspaceSlug]/settings/billing.tsx rename to web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx index 583a78b17..96cf35a53 100644 --- a/web/pages/[workspaceSlug]/settings/billing.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx @@ -1,20 +1,17 @@ -import { observer } from "mobx-react"; -// hooks -import { Button } from "@plane/ui"; -import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// component -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants +"use client"; -const BillingSettingsPage: NextPageWithLayout = observer(() => { +import { observer } from "mobx-react"; +// ui +import { Button } from "@plane/ui"; +// component +import { PageHead } from "@/components/core"; +// constants +import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser, useWorkspace } from "@/hooks/store"; + +const BillingSettingsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -47,7 +44,7 @@ const BillingSettingsPage: NextPageWithLayout = observer(() => {
@@ -57,12 +54,4 @@ const BillingSettingsPage: NextPageWithLayout = observer(() => { ); }); -BillingSettingsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - export default BillingSettingsPage; diff --git a/web/pages/[workspaceSlug]/settings/exports.tsx b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx similarity index 70% rename from web/pages/[workspaceSlug]/settings/exports.tsx rename to web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx index 730f34e49..59fd4d2c7 100644 --- a/web/pages/[workspaceSlug]/settings/exports.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx @@ -1,19 +1,15 @@ +"use client"; + import { observer } from "mobx-react"; -// hooks +// components import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; -import { WorkspaceSettingHeader } from "@/components/headers"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser, useWorkspace } from "@/hooks/store"; -// layout -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// types -import { NextPageWithLayout } from "@/lib/types"; // constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser, useWorkspace } from "@/hooks/store"; -const ExportsPage: NextPageWithLayout = observer(() => { +const ExportsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -48,12 +44,4 @@ const ExportsPage: NextPageWithLayout = observer(() => { ); }); -ExportsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ExportsPage; +export default ExportsPage; \ No newline at end of file diff --git a/web/components/headers/workspace-settings.tsx b/web/app/[workspaceSlug]/(projects)/settings/header.tsx similarity index 95% rename from web/components/headers/workspace-settings.tsx rename to web/app/[workspaceSlug]/(projects)/settings/header.tsx index 2d3e9649e..aeaa80bb9 100644 --- a/web/components/headers/workspace-settings.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/header.tsx @@ -1,5 +1,7 @@ +"use client"; + import { FC } from "react"; -import { observer } from "mobx-react";; +import { observer } from "mobx-react"; import { Settings } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; diff --git a/web/pages/[workspaceSlug]/settings/imports.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx similarity index 70% rename from web/pages/[workspaceSlug]/settings/imports.tsx rename to web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx index 2eda7cce2..342313033 100644 --- a/web/pages/[workspaceSlug]/settings/imports.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx @@ -1,19 +1,15 @@ +"use client"; + import { observer } from "mobx-react"; // components import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import IntegrationGuide from "@/components/integration/guide"; // constants import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ImportsPage: NextPageWithLayout = observer(() => { +const ImportsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -47,12 +43,4 @@ const ImportsPage: NextPageWithLayout = observer(() => { ); }); -ImportsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - export default ImportsPage; diff --git a/web/pages/[workspaceSlug]/settings/integrations.tsx b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx similarity index 72% rename from web/pages/[workspaceSlug]/settings/integrations.tsx rename to web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx index 702ca5884..7db73cbe6 100644 --- a/web/pages/[workspaceSlug]/settings/integrations.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx @@ -1,10 +1,9 @@ -import { ReactElement } from "react"; +"use client" import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { SingleIntegrationCard } from "@/components/integration"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants @@ -12,20 +11,14 @@ import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; // services import { IntegrationService } from "@/services/integrations"; const integrationService = new IntegrationService(); -const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { +const WorkspaceIntegrationsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { membership: { currentWorkspaceRole }, @@ -69,12 +62,4 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WorkspaceIntegrationsPage; +export default WorkspaceIntegrationsPage; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx new file mode 100644 index 000000000..bd04e2d6e --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { WorkspaceSettingHeader } from "./header"; +import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) { + const { children } = props; + + return ( + <> + } /> + +
+
+ +
+
+ +
+ {children} +
+
+
+
+ + ); +} diff --git a/web/pages/[workspaceSlug]/settings/members.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx similarity index 84% rename from web/pages/[workspaceSlug]/settings/members.tsx rename to web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index e01528ddc..2899c5ed5 100644 --- a/web/pages/[workspaceSlug]/settings/members.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -1,34 +1,30 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { Search } from "lucide-react"; +// types import { IWorkspaceBulkInviteFormData } from "@plane/types"; -// hooks +// ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace"; +// constants import { MEMBER_INVITED } from "@/constants/event-tracker"; import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { getUserRole } from "@/helpers/user.helper"; -import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; // helpers -// constants +import { getUserRole } from "@/helpers/user.helper"; +// hooks +import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; -const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { +const WorkspaceMembersSettingsPage = observer(() => { // states const [inviteModal, setInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { captureEvent } = useEventTracker(); const { @@ -120,12 +116,4 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceMembersSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default WorkspaceMembersSettingsPage; diff --git a/web/components/workspace/settings/mobile-workspace-settings-tabs.tsx b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx similarity index 70% rename from web/components/workspace/settings/mobile-workspace-settings-tabs.tsx rename to web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx index 1cea866bd..d273779fe 100644 --- a/web/components/workspace/settings/mobile-workspace-settings-tabs.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx @@ -1,17 +1,18 @@ -import router from "next/router"; +import { useParams, usePathname, useRouter } from "next/navigation"; import { WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; -const MobileWorkspaceSettingsTabs = () => { - const { workspaceSlug } = router.query; +export const MobileWorkspaceSettingsTabs = () => { + const router = useRouter(); + const { workspaceSlug } = useParams(); + const pathname = usePathname(); return (
{WORKSPACE_SETTINGS_LINKS.map((item, index) => (
router.push(`/${workspaceSlug}${item.href}`)} > @@ -21,5 +22,3 @@ const MobileWorkspaceSettingsTabs = () => {
); }; - -export default MobileWorkspaceSettingsTabs; diff --git a/web/app/[workspaceSlug]/(projects)/settings/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/page.tsx new file mode 100644 index 000000000..c628e313c --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +import { WorkspaceDetails } from "@/components/workspace"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +const WorkspaceSettingsPage = observer(() => { + // store hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - General Settings` : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceSettingsPage; diff --git a/web/layouts/settings-layout/workspace/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx similarity index 73% rename from web/layouts/settings-layout/workspace/sidebar.tsx rename to web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx index f49eb84d9..8dfd0d7c3 100644 --- a/web/layouts/settings-layout/workspace/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx @@ -1,16 +1,18 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; -// hooks -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; -import { useUser } from "@/hooks/store"; +import { useParams, usePathname } from "next/navigation"; // constants +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; export const WorkspaceSettingsSidebar = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); + const pathname = usePathname(); // mobx store const { membership: { currentWorkspaceRole }, @@ -29,11 +31,10 @@ export const WorkspaceSettingsSidebar = observer(() => {
{link.label}
diff --git a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx similarity index 81% rename from web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx rename to web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx index 71a16ea8b..ce3e7a5eb 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx @@ -1,29 +1,24 @@ +"use client"; + import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; import { IWebhook } from "@plane/types"; -// hooks +// ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; +// hooks import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; -const WebhookDetailsPage: NextPageWithLayout = observer(() => { +const WebhookDetailsPage = observer(() => { // states const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, webhookId } = router.query; + const { workspaceSlug, webhookId } = useParams(); // mobx store const { membership: { currentWorkspaceRole }, @@ -105,12 +100,4 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => { ); }); -WebhookDetailsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WebhookDetailsPage; +export default WebhookDetailsPage; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx similarity index 82% rename from web/pages/[workspaceSlug]/settings/webhooks/index.tsx rename to web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx index 6046df7e1..695f1f16b 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx @@ -1,31 +1,26 @@ +"use client"; + import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks +// ui import { Button } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; -import { EmptyStateType } from "@/constants/empty-state"; -import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; // constants +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -const WebhooksListPage: NextPageWithLayout = observer(() => { +const WebhooksListPage = observer(() => { // states const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // mobx store const { membership: { currentWorkspaceRole }, @@ -100,12 +95,4 @@ const WebhooksListPage: NextPageWithLayout = observer(() => { ); }); -WebhooksListPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WebhooksListPage; +export default WebhooksListPage; \ No newline at end of file diff --git a/web/layouts/app-layout/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx similarity index 83% rename from web/layouts/app-layout/sidebar.tsx rename to web/app/[workspaceSlug]/(projects)/sidebar.tsx index dcd4e45d2..9ca29af3e 100644 --- a/web/layouts/app-layout/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -12,7 +12,7 @@ import { import { useAppTheme } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -export interface IAppSidebar {} +export interface IAppSidebar { } export const AppSidebar: FC = observer(() => { // store hooks @@ -32,10 +32,9 @@ export const AppSidebar: FC = observer(() => {
diff --git a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx similarity index 71% rename from web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx rename to web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx index 4e04fe7fa..bbb6f0329 100644 --- a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -1,24 +1,19 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// layouts +// components import { PageHead } from "@/components/core"; -import { GlobalIssuesHeader } from "@/components/headers"; import { AllIssueLayoutRoot } from "@/components/issues"; import { GlobalViewsHeader } from "@/components/workspace"; -import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; -import { useGlobalView, useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -// hooks -// components -// types -import { NextPageWithLayout } from "@/lib/types"; // constants +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; +// hooks +import { useGlobalView, useWorkspace } from "@/hooks/store"; -const GlobalViewIssuesPage: NextPageWithLayout = observer(() => { +const GlobalViewIssuesPage = observer(() => { // router - const router = useRouter(); - const { globalViewId } = router.query; + //const { globalViewId } = useParams(); + const globalViewId = "assigned"; // store hooks const { currentWorkspace } = useWorkspace(); const { getViewDetailsById } = useGlobalView(); @@ -45,8 +40,4 @@ const GlobalViewIssuesPage: NextPageWithLayout = observer(() => { ); }); -GlobalViewIssuesPage.getLayout = function getLayout(page: ReactElement) { - return }>{page}; -}; - export default GlobalViewIssuesPage; diff --git a/web/components/headers/global-issues.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx similarity index 94% rename from web/components/headers/global-issues.tsx rename to web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 1ec2a5d2c..8d9c8cf70 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -17,12 +19,11 @@ import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useLabel, useMember, useUser, useIssues } from "@/hooks/store"; -export const GlobalIssuesHeader: React.FC = observer(() => { +export const GlobalIssuesHeader = observer(() => { // states const [createViewModal, setCreateViewModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, globalViewId } = router.query; + const { workspaceSlug, globalViewId } = useParams(); // store hooks const { issuesFilter: { filters, updateFilters }, @@ -117,6 +118,8 @@ export const GlobalIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet} filters={issueFilters?.filters ?? {}} handleFiltersUpdate={handleFiltersUpdate} + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} labels={workspaceLabels ?? undefined} memberIds={workspaceMemberIds ?? undefined} /> diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx new file mode 100644 index 000000000..a40d9318c --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { GlobalIssuesHeader } from "./header"; + +export default function GlobalIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/workspace-views/index.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx similarity index 79% rename from web/pages/[workspaceSlug]/workspace-views/index.tsx rename to web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx index 4a1ceddd7..7de442584 100644 --- a/web/pages/[workspaceSlug]/workspace-views/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx @@ -1,23 +1,20 @@ -import React, { useState, ReactElement } from "react"; +"use client"; + +import React, { useState } from "react"; import { observer } from "mobx-react"; -// layouts -// components -// ui -import { Search } from "lucide-react"; -import { Input } from "@plane/ui"; // icons +import { Search } from "lucide-react"; +// ui +import { Input } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; -import { GlobalIssuesHeader } from "@/components/headers"; import { GlobalDefaultViewListItem, GlobalViewsList } from "@/components/workspace"; -// types // constants import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { NextPageWithLayout } from "@/lib/types"; -const WorkspaceViewsPage: NextPageWithLayout = observer(() => { +const WorkspaceViewsPage = observer(() => { const [query, setQuery] = useState(""); // store const { currentWorkspace } = useWorkspace(); @@ -51,8 +48,4 @@ const WorkspaceViewsPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceViewsPage.getLayout = function getLayout(page: ReactElement) { - return }>{page}; -}; - export default WorkspaceViewsPage; diff --git a/web/app/accounts/forgot-password/layout.tsx b/web/app/accounts/forgot-password/layout.tsx new file mode 100644 index 000000000..7ba8e8ded --- /dev/null +++ b/web/app/accounts/forgot-password/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Forgot Password - Plane", +}; + +export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/accounts/forgot-password/page.tsx b/web/app/accounts/forgot-password/page.tsx new file mode 100644 index 000000000..0e203a1f8 --- /dev/null +++ b/web/app/accounts/forgot-password/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { CircleCheck } from "lucide-react"; +// ui +import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// constants +import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +import { cn } from "@/helpers/common.helper"; +import { checkEmailValidity } from "@/helpers/string.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; +import useTimer from "@/hooks/use-timer"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// services +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { AuthService } from "@/services/auth.service"; + +type TForgotPasswordFormValues = { + email: string; +}; + +const defaultValues: TForgotPasswordFormValues = { + email: "", +}; + +// services +const authService = new AuthService(); + +export default function ForgotPasswordPage() { + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // store hooks + const { captureEvent } = useEventTracker(); + // hooks + const { resolvedTheme } = useTheme(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { + await authService + .sendResetPasswordLink({ + email: formData.email, + }) + .then(() => { + captureEvent(FORGOT_PASS_LINK, { + state: "SUCCESS", + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Email sent", + message: + "Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.", + }); + setResendCodeTimer(30); + }) + .catch((err) => { + captureEvent(FORGOT_PASS_LINK, { + state: "FAILED", + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }); + }); + }; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+ New to Plane?{" "} + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="font-semibold text-custom-primary-100 hover:underline" + > + Create an account + +
+
+
+
+
+

+ Reset your password +

+

+ Enter your user account{"'"}s verified email address and we will send you a password reset link. +

+
+
+
+ + checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + 0} + /> + )} + /> + {resendTimerCode > 0 && ( +

+ + We sent the reset link to your email address +

+ )} +
+ + + Back to sign in + +
+
+
+
+
+
+ ); +} diff --git a/web/app/accounts/reset-password/layout.tsx b/web/app/accounts/reset-password/layout.tsx new file mode 100644 index 000000000..dbc0a29b4 --- /dev/null +++ b/web/app/accounts/reset-password/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Reset Password - Plane", +}; + +export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/accounts/reset-password/page.tsx new file mode 100644 index 000000000..14bcf27d6 --- /dev/null +++ b/web/app/accounts/reset-password/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +// icons +import { useTheme } from "next-themes"; +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Input } from "@plane/ui"; +// components +import { AuthBanner, PasswordStrengthMeter } from "@/components/account"; +// helpers +import { + EAuthenticationErrorCodes, + EErrorAlertType, + EPageTypes, + TAuthErrorInfo, + authErrorHandler, +} from "@/helpers/authentication.helper"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// services +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { AuthService } from "@/services/auth.service"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export default function ResetPasswordPage() { + // search params + const searchParams = useSearchParams(); + const uidb64 = searchParams.get("uidb64"); + const token = searchParams.get("token"); + const email = searchParams.get("email"); + const error_code = searchParams.get("error_code"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [resetFormData, setResetFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + + // hooks + const { resolvedTheme } = useTheme(); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setResetFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isButtonDisabled = useMemo( + () => + !!resetFormData.password && + getPasswordStrength(resetFormData.password) >= 3 && + resetFormData.password === resetFormData.confirm_password + ? false + : true, + [resetFormData] + ); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const password = resetFormData?.password ?? ""; + const confirmPassword = resetFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+
+
+
+

+ Set new password +

+

Secure your account with a strong password

+
+ {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} +
+ +
+ +
+ +
+
+
+ +
+ handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ {isPasswordInputFocused && } +
+
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
+ {!!resetFormData.confirm_password && + resetFormData.password !== resetFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ +
+
+
+
+
+
+ ); +} diff --git a/web/app/accounts/set-password/layout.tsx b/web/app/accounts/set-password/layout.tsx new file mode 100644 index 000000000..dbd32e9e8 --- /dev/null +++ b/web/app/accounts/set-password/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Set Password - Plane", +}; + +export default function SetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/accounts/set-password/page.tsx b/web/app/accounts/set-password/page.tsx new file mode 100644 index 000000000..3dd78cb35 --- /dev/null +++ b/web/app/accounts/set-password/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +// icons +import { useTheme } from "next-themes"; +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { PasswordStrengthMeter } from "@/components/account"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// hooks +import { useUser } from "@/hooks/store"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// services +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { AuthService } from "@/services/auth.service"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +const SetPasswordPage = observer(() => { + // router + const router = useRouter(); + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [passwordFormData, setPasswordFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + // hooks + const { resolvedTheme } = useTheme(); + // hooks + const { data: user, handleSetPassword } = useUser(); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + const isButtonDisabled = useMemo( + () => + !!passwordFormData.password && + getPasswordStrength(passwordFormData.password) >= 3 && + passwordFormData.password === passwordFormData.confirm_password + ? false + : true, + [passwordFormData] + ); + + const handleSubmit = async (e: FormEvent) => { + try { + e.preventDefault(); + if (!csrfToken) throw new Error("csrf token not found"); + await handleSetPassword(csrfToken, { password: passwordFormData.password }); + router.push("/"); + } catch (err: any) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }); + } + }; + + const password = passwordFormData?.password ?? ""; + const confirmPassword = passwordFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+
+
+
+

+ Secure your account +

+

Setting password helps you login securely

+
+
handleSubmit(e)}> +
+ +
+ +
+
+
+ +
+ handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ {isPasswordInputFocused && } +
+
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
+ {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ +
+
+
+
+
+
+ ); +}); + +export default SetPasswordPage; diff --git a/web/app/create-workspace/layout.tsx b/web/app/create-workspace/layout.tsx new file mode 100644 index 000000000..32a220df7 --- /dev/null +++ b/web/app/create-workspace/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Workspace", +}; + +export default function CreateWorkspaceLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/create-workspace.tsx b/web/app/create-workspace/page.tsx similarity index 79% rename from web/pages/create-workspace.tsx rename to web/app/create-workspace/page.tsx index e5992e008..f723a4786 100644 --- a/web/pages/create-workspace.tsx +++ b/web/app/create-workspace/page.tsx @@ -1,26 +1,23 @@ -import { ReactElement, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { IWorkspace } from "@plane/types"; -// hooks -import { PageHead } from "@/components/core"; -import { CreateWorkspaceForm } from "@/components/workspace"; -import { useUser, useUserProfile } from "@/hooks/store"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; // components -// images -import { NextPageWithLayout } from "@/lib/types"; +import { CreateWorkspaceForm } from "@/components/workspace"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; -// types +// images +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; -const CreateWorkspacePage: NextPageWithLayout = observer(() => { +const CreateWorkspacePage = observer(() => { // router const router = useRouter(); // store hooks @@ -42,8 +39,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( - <> - +
@@ -72,16 +68,8 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => {
- +
); }); -CreateWorkspacePage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default CreateWorkspacePage; diff --git a/web/app/error.tsx b/web/app/error.tsx new file mode 100644 index 000000000..78ade092c --- /dev/null +++ b/web/app/error.tsx @@ -0,0 +1,90 @@ +"use client"; + +// import { useEffect } from "react"; +// import * as Sentry from "@sentry/nextjs"; +// import { useRouter } from "next/navigation"; +// services +import { Button } from "@plane/ui"; +// helpers +// import { API_BASE_URL } from "@/helpers/common.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// +// import { AuthService } from "@/services/auth.service"; +// layouts +// ui + +// services +// const authService = new AuthService(); + +// type props = { +// error: Error & { digest?: string }; +// }; + +// TODO: adding error sentry logging. +// const CustomErrorComponent = ({ error }: props) => { +const CustomErrorComponent = () => { + // const router = useRouter(); + + // useEffect(() => { + // Sentry.captureException(error); + // }, [error]); + + const handleRefresh = () => { + window.location.reload(); + }; + + const handleSignOut = async () => { + // await authService + // .signOut(API_BASE_URL) + // .catch(() => + // setToast({ + // type: TOAST_TYPE.ERROR, + // title: "Error!", + // message: "Failed to sign out. Please try again.", + // }) + // ) + // .finally(() => router.push("/")); + }; + + return ( + +
+ + ); +}; + +export default CustomErrorComponent; diff --git a/web/app/installations/[provider]/layout.tsx b/web/app/installations/[provider]/layout.tsx new file mode 100644 index 000000000..51978de9e --- /dev/null +++ b/web/app/installations/[provider]/layout.tsx @@ -0,0 +1,3 @@ +export default function InstallationProviderLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/installations/[provider]/index.tsx b/web/app/installations/[provider]/page.tsx similarity index 80% rename from web/pages/installations/[provider]/index.tsx rename to web/app/installations/[provider]/page.tsx index eb2e850c7..218d7dee2 100644 --- a/web/pages/installations/[provider]/index.tsx +++ b/web/app/installations/[provider]/page.tsx @@ -1,18 +1,23 @@ -import React, { useEffect, ReactElement } from "react"; -import { useRouter } from "next/router"; +"use client"; + +import React, { useEffect } from "react"; +import { useParams, useSearchParams } from "next/navigation"; // ui import { LogoSpinner } from "@/components/common"; -// types -import { NextPageWithLayout } from "@/lib/types"; // services import { AppInstallationService } from "@/services/app_installation.service"; // services const appInstallationService = new AppInstallationService(); -const AppPostInstallation: NextPageWithLayout = () => { - const router = useRouter(); - const { installation_id, state, provider, code } = router.query; +export default function AppPostInstallation() { + // params + const { provider } = useParams(); + // query params + const searchParams = useSearchParams(); + const installation_id = searchParams.get("installation_id"); + const state = searchParams.get("state"); + const code = searchParams.get("code"); useEffect(() => { if (provider === "github" && state && installation_id) { @@ -67,10 +72,4 @@ const AppPostInstallation: NextPageWithLayout = () => {
); -}; - -AppPostInstallation.getLayout = function getLayout(page: ReactElement) { - return
{page}
; -}; - -export default AppPostInstallation; +} diff --git a/web/app/invitations/layout.tsx b/web/app/invitations/layout.tsx new file mode 100644 index 000000000..2d9a7e688 --- /dev/null +++ b/web/app/invitations/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Invitations", +}; + +export default function InvitationsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/invitations/index.tsx b/web/app/invitations/page.tsx similarity index 92% rename from web/pages/invitations/index.tsx rename to web/app/invitations/page.tsx index aac6c833b..e050b998d 100644 --- a/web/pages/invitations/index.tsx +++ b/web/app/invitations/page.tsx @@ -1,8 +1,10 @@ -import React, { useState, ReactElement } from "react"; +"use client"; + +import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; // icons @@ -13,7 +15,6 @@ import type { IWorkspaceMemberInvitation } from "@plane/types"; import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; -import { PageHead } from "@/components/core"; // constants import { MEMBER_ACCEPTED } from "@/constants/event-tracker"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; @@ -22,21 +23,17 @@ import { ROLE } from "@/constants/workspace"; import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; -import DefaultLayout from "@/layouts/default-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -// wrappers -import { AuthenticationWrapper } from "@/lib/wrappers"; // services -import { WorkspaceService } from "@/services/workspace.service"; +import { AuthenticationWrapper } from "@/lib/wrappers"; // images -import emptyInvitation from "public/empty-state/invitation.svg"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; +import emptyInvitation from "@/public/empty-state/invitation.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { WorkspaceService } from "@/services/workspace.service"; const workspaceService = new WorkspaceService(); -const UserInvitationsPage: NextPageWithLayout = observer(() => { +const UserInvitationsPage = observer(() => { // states const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); @@ -130,8 +127,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( - <> - +
@@ -230,16 +226,8 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { ) ) : null}
- + ); }); -UserInvitationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default UserInvitationsPage; diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 000000000..231f4da2f --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,60 @@ +import { Metadata } from "next"; +import Script from "next/script"; +// styles +import "@/styles/globals.css"; +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@/styles/react-day-picker.css"; +// local +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + url: "https://app.plane.so/", + }, + keywords: + "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + + + +
+ +
{children}
+
+ + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + ); +} diff --git a/web/pages/404.tsx b/web/app/not-found.tsx similarity index 75% rename from web/pages/404.tsx rename to web/app/not-found.tsx index 514169dea..ecc01b500 100644 --- a/web/pages/404.tsx +++ b/web/app/not-found.tsx @@ -1,21 +1,20 @@ -import React from "react"; +"use client"; -import type { NextPage } from "next"; +import React from "react"; +import { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; -// components -import { Button } from "@plane/ui"; -import { PageHead } from "@/components/core"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; // ui +import { Button } from "@plane/ui"; // images -import Image404 from "public/404.svg"; -// types +import Image404 from "@/public/404.svg"; -const PageNotFound: NextPage = () => ( - - +export const metadata: Metadata = { + title: "404 - Page Not Found", +}; + +const PageNotFound = () => ( +
@@ -37,7 +36,7 @@ const PageNotFound: NextPage = () => (
- +
); export default PageNotFound; diff --git a/web/app/onboarding/layout.tsx b/web/app/onboarding/layout.tsx new file mode 100644 index 000000000..492ebc402 --- /dev/null +++ b/web/app/onboarding/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Onboarding", +}; + +export default function OnboardingLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/onboarding/index.tsx b/web/app/onboarding/page.tsx similarity index 81% rename from web/pages/onboarding/index.tsx rename to web/app/onboarding/page.tsx index 89263e57d..91d8749e6 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/app/onboarding/page.tsx @@ -1,12 +1,13 @@ -import { ReactElement, useEffect, useState } from "react"; +"use client"; + +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import useSWR from "swr"; // types import { TOnboardingSteps, TUserProfile } from "@plane/types"; // components import { LogoSpinner } from "@/components/common"; -import { PageHead } from "@/components/core"; import { InviteMembers, CreateOrJoinWorkspaces, ProfileSetup } from "@/components/onboarding"; // constants import { USER_ONBOARDING_COMPLETED } from "@/constants/event-tracker"; @@ -15,16 +16,12 @@ import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; import { EPageTypes } from "@/helpers/authentication.helper"; // hooks import { useUser, useWorkspace, useUserProfile, useEventTracker } from "@/hooks/store"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; -// lib types -import { NextPageWithLayout } from "@/lib/types"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; // services import { WorkspaceService } from "@/services/workspace.service"; -export enum EOnboardingSteps { +enum EOnboardingSteps { PROFILE_SETUP = "PROFILE_SETUP", WORKSPACE_CREATE_OR_JOIN = "WORKSPACE_CREATE_OR_JOIN", INVITE_MEMBERS = "INVITE_MEMBERS", @@ -32,7 +29,7 @@ export enum EOnboardingSteps { const workspaceService = new WorkspaceService(); -const OnboardingPage: NextPageWithLayout = observer(() => { +const OnboardingPage = observer(() => { // states const [step, setStep] = useState(null); const [totalSteps, setTotalSteps] = useState(null); @@ -40,14 +37,14 @@ const OnboardingPage: NextPageWithLayout = observer(() => { const router = useRouter(); // store hooks const { captureEvent } = useEventTracker(); - const { data: user, updateCurrentUser } = useUser(); + const { isLoading: userLoader, data: user, updateCurrentUser } = useUser(); const { data: profile, updateUserOnBoard, updateUserProfile } = useUserProfile(); const { workspaces, fetchWorkspaces } = useWorkspace(); // computed values const workspacesList = Object.values(workspaces ?? {}); // fetching workspaces list - useSWR(USER_WORKSPACES_LIST, () => fetchWorkspaces(), { + const { isLoading: workspaceListLoader } = useSWR(USER_WORKSPACES_LIST, () => fetchWorkspaces(), { shouldRetryOnError: false, }); // fetching user workspace invitations @@ -102,18 +99,21 @@ const OnboardingPage: NextPageWithLayout = observer(() => { }; useEffect(() => { - // If user is already invited to a workspace, only show profile setup steps. - if (workspacesList && workspacesList?.length > 0) { - // If password is auto set then show two different steps for profile setup, else merge them. - if (user?.is_password_autoset) setTotalSteps(2); - else setTotalSteps(1); - } else { - // If password is auto set then total steps will increase to 4 due to extra step at profile setup stage. - if (user?.is_password_autoset) setTotalSteps(4); - else setTotalSteps(3); + // Never update the total steps if it's already set. + if (!totalSteps && userLoader === false && workspaceListLoader === false) { + // If user is already invited to a workspace, only show profile setup steps. + if (workspacesList && workspacesList?.length > 0) { + // If password is auto set then show two different steps for profile setup, else merge them. + if (user?.is_password_autoset) setTotalSteps(2); + else setTotalSteps(1); + } else { + // If password is auto set then total steps will increase to 4 due to extra step at profile setup stage. + if (user?.is_password_autoset) setTotalSteps(4); + else setTotalSteps(3); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [userLoader, workspaceListLoader]); useEffect(() => { const handleStepChange = async () => { @@ -143,8 +143,7 @@ const OnboardingPage: NextPageWithLayout = observer(() => { }, [user, step, profile.onboarding_step, updateCurrentUser, workspacesList]); return ( - <> - + {user && totalSteps && step !== null && invitations ? (
{step === EOnboardingSteps.PROFILE_SETUP ? ( @@ -179,16 +178,8 @@ const OnboardingPage: NextPageWithLayout = observer(() => {
)} - +
); }); -OnboardingPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default OnboardingPage; diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 000000000..4982f4082 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { useTheme } from "next-themes"; +// components +import { AuthRoot } from "@/components/account"; +import { PageHead } from "@/components/core"; +// constants +import { NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const HomePage = observer(() => { + const { resolvedTheme } = useTheme(); + // hooks + const { captureEvent } = useEventTracker(); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + + + <> +
+ +
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+ New to Plane?{" "} + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="font-semibold text-custom-primary-100 hover:underline" + > + Create an account + +
+
+
+ +
+
+
+ +
+
+ ); +}); + +export default HomePage; diff --git a/web/pages/profile/activity.tsx b/web/app/profile/activity/page.tsx similarity index 77% rename from web/pages/profile/activity.tsx rename to web/app/profile/activity/page.tsx index 414969445..ad70c7df8 100644 --- a/web/pages/profile/activity.tsx +++ b/web/app/profile/activity/page.tsx @@ -1,32 +1,32 @@ -import { ReactElement, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; // ui import { Button } from "@plane/ui"; // components import { PageHead } from "@/components/core"; import { SidebarHamburgerToggle } from "@/components/core/sidebar"; +import { EmptyState } from "@/components/empty-state"; import { ProfileActivityListPage } from "@/components/profile"; -//hooks -import { useAppTheme } from "@/hooks/store"; -// layouts -import { ProfileSettingsLayout } from "@/layouts/settings-layout"; -// type -import { NextPageWithLayout } from "@/lib/types"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; const PER_PAGE = 100; -const ProfileActivityPage: NextPageWithLayout = observer(() => { +const ProfileActivityPage = observer(() => { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); - // store hooks - const { toggleSidebar } = useAppTheme(); + const [isEmpty, setIsEmpty] = useState(false); const updateTotalPages = (count: number) => setTotalPages(count); const updateResultsCount = (count: number) => setResultsCount(count); + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + const handleLoadMore = () => setPageCount((prev) => prev + 1); const activityPages: JSX.Element[] = []; @@ -38,17 +38,22 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { perPage={PER_PAGE} updateResultsCount={updateResultsCount} updateTotalPages={updateTotalPages} + updateEmptyState={updateEmptyState} /> ); const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + if (isEmpty) { + return ; + } + return ( <>
- toggleSidebar()} /> +

Activity

@@ -66,8 +71,4 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { ); }); -ProfileActivityPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - export default ProfileActivityPage; diff --git a/web/pages/profile/preferences/theme.tsx b/web/app/profile/appearance/page.tsx similarity index 77% rename from web/pages/profile/preferences/theme.tsx rename to web/app/profile/appearance/page.tsx index f35fe72b8..e34fe8cdd 100644 --- a/web/pages/profile/preferences/theme.tsx +++ b/web/app/profile/appearance/page.tsx @@ -1,21 +1,19 @@ -import { useEffect, useState, ReactElement } from "react"; +"use client"; + +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // ui import { setPromiseToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; -import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core"; +import { CustomThemeSelector, ThemeSwitch, PageHead, SidebarHamburgerToggle } from "@/components/core"; // constants import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes"; // hooks import { useUserProfile } from "@/hooks/store"; -// layouts -import { ProfilePreferenceSettingsLayout } from "@/layouts/settings-layout/profile/preferences"; -// type -import { NextPageWithLayout } from "@/lib/types"; -const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { +const ProfileAppearancePage = observer(() => { const { setTheme } = useTheme(); // states const [currentTheme, setCurrentTheme] = useState(null); @@ -53,8 +51,9 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { {userProfile ? (
-
-

Preferences

+
+ +

Appearance

@@ -76,8 +75,4 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { ); }); -ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default ProfilePreferencesThemePage; +export default ProfileAppearancePage; \ No newline at end of file diff --git a/web/layouts/settings-layout/profile/layout.tsx b/web/app/profile/layout.tsx similarity index 67% rename from web/layouts/settings-layout/profile/layout.tsx rename to web/app/profile/layout.tsx index e2d11155f..1f1b1dff4 100644 --- a/web/layouts/settings-layout/profile/layout.tsx +++ b/web/app/profile/layout.tsx @@ -1,18 +1,19 @@ -import { FC, ReactNode } from "react"; -// layout +"use client"; + +import { ReactNode } from "react"; +// components import { CommandPalette } from "@/components/command-palette"; -import { ProfileLayoutSidebar } from "@/layouts/settings-layout"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; -// components +// layout +import { ProfileLayoutSidebar } from "./sidebar"; -interface IProfileSettingsLayout { +type Props = { children: ReactNode; - header?: ReactNode; -} +}; -export const ProfileSettingsLayout: FC = (props) => { - const { children, header } = props; +export default function ProfileSettingsLayout(props: Props) { + const { children } = props; return ( <> @@ -21,11 +22,10 @@ export const ProfileSettingsLayout: FC = (props) => {
- {header}
{children}
); -}; +} diff --git a/web/pages/profile/preferences/email.tsx b/web/app/profile/notifications/page.tsx similarity index 51% rename from web/pages/profile/preferences/email.tsx rename to web/app/profile/notifications/page.tsx index 69dcb6ee6..76f1bf3d6 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/app/profile/notifications/page.tsx @@ -1,21 +1,20 @@ -import { ReactElement } from "react"; +"use client"; + import useSWR from "swr"; // layouts -import { PageHead } from "@/components/core"; -import { EmailNotificationForm } from "@/components/profile/preferences"; +import { PageHead, SidebarHamburgerToggle } from "@/components/core"; +import { EmailNotificationForm } from "@/components/profile/notification"; import { EmailSettingsLoader } from "@/components/ui"; -import { ProfilePreferenceSettingsLayout } from "@/layouts/settings-layout/profile/preferences"; // ui // components // services -import { NextPageWithLayout } from "@/lib/types"; import { UserService } from "@/services/user.service"; // type // services const userService = new UserService(); -const ProfilePreferencesThemePage: NextPageWithLayout = () => { +export default function ProfileNotificationPage() { // fetching user email notification settings const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => userService.currentUserEmailNotificationSettings() @@ -29,14 +28,17 @@ const ProfilePreferencesThemePage: NextPageWithLayout = () => { <>
+
+ +
+
Email notifications
+
+ Stay in the loop on Issues you are subscribed to. Enable this to get notified. +
+
+
); -}; - -ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default ProfilePreferencesThemePage; +} diff --git a/web/pages/profile/index.tsx b/web/app/profile/page.tsx similarity index 96% rename from web/pages/profile/index.tsx rename to web/app/profile/page.tsx index c49de68de..66a2a7b54 100644 --- a/web/pages/profile/index.tsx +++ b/web/app/profile/page.tsx @@ -1,7 +1,9 @@ -import React, { useEffect, useState, ReactElement } from "react"; +"use client"; + +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { ChevronDown, User2 } from "lucide-react"; +import { ChevronDown, CircleUserRound } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // services // hooks @@ -20,11 +22,9 @@ import { SidebarHamburgerToggle } from "@/components/core/sidebar"; import { TIME_ZONES } from "@/constants/timezones"; import { USER_ROLES } from "@/constants/workspace"; // hooks -import { useAppTheme, useUser } from "@/hooks/store"; -import { ProfileSettingsLayout } from "@/layouts/settings-layout"; +import { useUser } from "@/hooks/store"; +// import { ProfileSettingsLayout } from "@/layouts/settings-layout"; // layouts -// lib types -import type { NextPageWithLayout } from "@/lib/types"; import { FileService } from "@/services/file.service"; // services // types @@ -42,7 +42,7 @@ const defaultValues: Partial = { const fileService = new FileService(); -const ProfileSettingsPage: NextPageWithLayout = observer(() => { +const ProfileSettingsPage = observer(() => { // states const [isLoading, setIsLoading] = useState(false); const [isRemoving, setIsRemoving] = useState(false); @@ -54,11 +54,11 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { reset, watch, control, + setValue, formState: { errors }, } = useForm({ defaultValues }); // store hooks const { data: currentUser, updateCurrentUser } = useUser(); - const { toggleSidebar } = useAppTheme(); useEffect(() => { reset({ ...defaultValues, ...currentUser }); @@ -105,6 +105,7 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { message: "Profile picture deleted successfully.", }); setIsRemoving(false); + setValue("avatar", ""); }) .catch(() => { setToast({ @@ -113,7 +114,10 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { message: "There was some error in deleting your profile picture. Please try again.", }); }) - .finally(() => setIsRemoving(false)); + .finally(() => { + setIsRemoving(false); + setIsImageUploadModalOpen(false); + }); }); }; @@ -135,7 +139,7 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => {
- toggleSidebar()} /> +
{ + + +
+ ); +}); diff --git a/web/ce/components/estimates/index.ts b/web/ce/components/estimates/index.ts new file mode 100644 index 000000000..2dc47bba6 --- /dev/null +++ b/web/ce/components/estimates/index.ts @@ -0,0 +1,3 @@ +export * from "./estimate-list-item-buttons"; + +export * from "./update"; diff --git a/web/ce/components/estimates/update/index.ts b/web/ce/components/estimates/update/index.ts new file mode 100644 index 000000000..031608e25 --- /dev/null +++ b/web/ce/components/estimates/update/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ce/components/estimates/update/modal.tsx b/web/ce/components/estimates/update/modal.tsx new file mode 100644 index 000000000..12b4ea6f6 --- /dev/null +++ b/web/ce/components/estimates/update/modal.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +type TUpdateEstimateModal = { + workspaceSlug: string; + projectId: string; + estimateId: string | undefined; + isOpen: boolean; + handleClose: () => void; +}; + +export const UpdateEstimateModal: FC = observer(() => <>); diff --git a/web/ce/components/issues/bulk-operations/index.ts b/web/ce/components/issues/bulk-operations/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/web/ce/components/issues/bulk-operations/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/web/ce/components/issues/bulk-operations/root.tsx b/web/ce/components/issues/bulk-operations/root.tsx new file mode 100644 index 000000000..741a341be --- /dev/null +++ b/web/ce/components/issues/bulk-operations/root.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react"; +// components +import { BulkOperationsUpgradeBanner } from "@/components/issues"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; + +type Props = { + className?: string; + selectionHelpers: TSelectionHelper; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className, selectionHelpers } = props; + // store hooks + const { isSelectionActive } = useMultipleSelectStore(); + + if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null; + + return ; +}); diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts new file mode 100644 index 000000000..7a5275abe --- /dev/null +++ b/web/ce/components/issues/index.ts @@ -0,0 +1 @@ +export * from "./bulk-operations"; \ No newline at end of file diff --git a/web/ce/components/sidebar/app-switcher.tsx b/web/ce/components/sidebar/app-switcher.tsx new file mode 100644 index 000000000..53492e6b6 --- /dev/null +++ b/web/ce/components/sidebar/app-switcher.tsx @@ -0,0 +1 @@ +export const AppSwitcher = () => null; diff --git a/web/ce/components/sidebar/index.ts b/web/ce/components/sidebar/index.ts new file mode 100644 index 000000000..5cda1afb5 --- /dev/null +++ b/web/ce/components/sidebar/index.ts @@ -0,0 +1 @@ +export * from "./app-switcher"; diff --git a/web/ce/constants/estimates.ts b/web/ce/constants/estimates.ts new file mode 100644 index 000000000..5b068e400 --- /dev/null +++ b/web/ce/constants/estimates.ts @@ -0,0 +1,123 @@ +// types +import { TEstimateSystems } from "@plane/types"; + +export enum EEstimateSystem { + POINTS = "points", + CATEGORIES = "categories", + TIME = "time", +} + +export enum EEstimateUpdateStages { + CREATE = "create", + EDIT = "edit", + SWITCH = "switch", +} + +export const estimateCount = { + min: 2, + max: 6, +}; + +export const ESTIMATE_SYSTEMS: TEstimateSystems = { + points: { + name: "Points", + templates: { + fibonacci: { + title: "Fibonacci", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "5" }, + { id: undefined, key: 5, value: "8" }, + { id: undefined, key: 6, value: "13" }, + ], + }, + linear: { + title: "Linear", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "4" }, + { id: undefined, key: 5, value: "5" }, + { id: undefined, key: 6, value: "6" }, + ], + }, + squares: { + title: "Squares", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "4" }, + { id: undefined, key: 3, value: "9" }, + { id: undefined, key: 4, value: "16" }, + { id: undefined, key: 5, value: "25" }, + { id: undefined, key: 6, value: "36" }, + ], + }, + custom: { + title: "Custom", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + ], + hide: true, + }, + }, + is_available: true, + is_ee: false, + }, + categories: { + name: "Categories", + templates: { + t_shirt_sizes: { + title: "T-Shirt Sizes", + values: [ + { id: undefined, key: 1, value: "XS" }, + { id: undefined, key: 2, value: "S" }, + { id: undefined, key: 3, value: "M" }, + { id: undefined, key: 4, value: "L" }, + { id: undefined, key: 5, value: "XL" }, + { id: undefined, key: 6, value: "XXL" }, + ], + }, + easy_to_hard: { + title: "Easy to hard", + values: [ + { id: undefined, key: 1, value: "Easy" }, + { id: undefined, key: 2, value: "Medium" }, + { id: undefined, key: 3, value: "Hard" }, + { id: undefined, key: 4, value: "Very Hard" }, + ], + }, + custom: { + title: "Custom", + values: [ + { id: undefined, key: 1, value: "Easy" }, + { id: undefined, key: 2, value: "Hard" }, + ], + hide: true, + }, + }, + is_available: true, + is_ee: false, + }, + time: { + name: "Time", + templates: { + hours: { + title: "Hours", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "4" }, + { id: undefined, key: 5, value: "5" }, + { id: undefined, key: 6, value: "6" }, + ], + }, + }, + is_available: false, + is_ee: true, + }, +}; diff --git a/web/ce/constants/issue.ts b/web/ce/constants/issue.ts new file mode 100644 index 000000000..68622c8fe --- /dev/null +++ b/web/ce/constants/issue.ts @@ -0,0 +1 @@ +export const ENABLE_BULK_OPERATIONS = false; diff --git a/web/ce/store/root.store.ts b/web/ce/store/root.store.ts new file mode 100644 index 000000000..710462e13 --- /dev/null +++ b/web/ce/store/root.store.ts @@ -0,0 +1,8 @@ +// store +import { CoreRootStore } from "@/store/root.store"; + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } +} diff --git a/web/components/analytics/custom-analytics/select/y-axis.tsx b/web/components/analytics/custom-analytics/select/y-axis.tsx deleted file mode 100644 index a33feb967..000000000 --- a/web/components/analytics/custom-analytics/select/y-axis.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// ui -import { TYAxisValues } from "@plane/types"; -import { CustomSelect } from "@plane/ui"; -// types -import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics"; -// constants - -type Props = { - value: TYAxisValues; - onChange: () => void; -}; - -export const SelectYAxis: React.FC = ({ value, onChange }) => ( - {ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - > - {ANALYTICS_Y_AXIS_VALUES.map((item) => ( - - {item.label} - - ))} - -); diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx deleted file mode 100644 index db9d94a8f..000000000 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React from "react"; - -import Image from "next/image"; -// headless ui -import { Tab } from "@headlessui/react"; -import { - IIssueFilterOptions, - IIssueFilters, - IModule, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, - TStateGroups, -} from "@plane/types"; -// hooks -import { Avatar, StateGroupIcon } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import useLocalStorage from "@/hooks/use-local-storage"; -// images -import emptyLabel from "public/empty-state/empty_label.svg"; -import emptyMembers from "public/empty-state/empty_members.svg"; -// components -// ui -// types - -type Props = { - distribution: - | { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - } - | undefined; - groupedIssues: { - [key: string]: number; - }; - totalIssues: number; - module?: IModule; - roundedTab?: boolean; - noBackground?: boolean; - isPeekView?: boolean; - isCompleted?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -export const SidebarProgressStats: React.FC = ({ - distribution, - groupedIssues, - totalIssues, - module, - roundedTab, - noBackground, - isPeekView = false, - isCompleted = false, - filters, - handleFiltersUpdate, -}) => { - const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - case "States": - return 2; - default: - return 0; - } - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - case 2: - return setTab("States"); - default: - return setTab("Assignees"); - } - }} - > - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Assignees - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Labels - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - States - - - - - {distribution && distribution?.assignees.length > 0 ? ( - distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee?.display_name ?? ""} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - }) - ) : ( -
-
- empty members -
-
No assignees yet
-
- )} - - - {distribution && distribution?.labels.length > 0 ? ( - distribution.labels.map((label, index) => { - if (label.label_id) { - return ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""), - selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
No labels yet
-
- )} - - - {Object.keys(groupedIssues).map((group, index) => ( - - - {group} -
- } - completed={groupedIssues[group]} - total={totalIssues} - /> - ))} - - - - ); -}; diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx deleted file mode 100644 index e270b5ad8..000000000 --- a/web/components/cycles/active-cycle/productivity.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { FC } from "react"; -// types -import { ICycle } from "@plane/types"; -// components -import ProgressChart from "@/components/core/sidebar/progress-chart"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; - -export type ActiveCycleProductivityProps = { - cycle: ICycle; -}; - -export const ActiveCycleProductivity: FC = (props) => { - const { cycle } = props; - - return ( -
-
-

Issue burndown

-
- {cycle.total_issues > 0 ? ( - <> -
-
-
-
- - Ideal -
-
- - Current -
-
- {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} -
-
- -
-
- - ) : ( - <> -
- -
- - )} -
- ); -}; diff --git a/web/components/estimates/create-update-estimate-modal.tsx b/web/components/estimates/create-update-estimate-modal.tsx deleted file mode 100644 index c8b44cc32..000000000 --- a/web/components/estimates/create-update-estimate-modal.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import React, { useEffect } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -// types -import { IEstimate, IEstimateFormData } from "@plane/types"; -// ui -import { Button, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; -// helpers -import { checkDuplicates } from "@/helpers/array.helper"; -// hooks -import { useEstimate } from "@/hooks/store"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data?: IEstimate; -}; - -const defaultValues = { - name: "", - description: "", - value1: "", - value2: "", - value3: "", - value4: "", - value5: "", - value6: "", -}; - -type FormValues = typeof defaultValues; - -export const CreateUpdateEstimateModal: React.FC = observer((props) => { - const { handleClose, data, isOpen } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // store hooks - const { createEstimate, updateEstimate } = useEstimate(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - control, - reset, - } = useForm({ - defaultValues, - }); - - const onClose = () => { - handleClose(); - reset(); - }; - - const handleCreateEstimate = async (payload: IEstimateFormData) => { - if (!workspaceSlug || !projectId) return; - - await createEstimate(workspaceSlug.toString(), projectId.toString(), payload) - .then(() => { - onClose(); - }) - .catch((err) => { - const error = err?.error; - const errorString = Array.isArray(error) ? error[0] : error; - - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - errorString ?? err.status === 400 - ? "Estimate with that name already exists. Please try again with another name." - : "Estimate could not be created. Please try again.", - }); - }); - }; - - const handleUpdateEstimate = async (payload: IEstimateFormData) => { - if (!workspaceSlug || !projectId || !data) return; - - await updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload) - .then(() => { - onClose(); - }) - .catch((err) => { - const error = err?.error; - const errorString = Array.isArray(error) ? error[0] : error; - - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: errorString ?? "Estimate could not be updated. Please try again.", - }); - }); - }; - - const onSubmit = async (formData: FormValues) => { - if (!formData.name || formData.name === "") { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate title cannot be empty.", - }); - return; - } - - if ( - formData.value1 === "" || - formData.value2 === "" || - formData.value3 === "" || - formData.value4 === "" || - formData.value5 === "" || - formData.value6 === "" - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate point cannot be empty.", - }); - return; - } - - if ( - formData.value1.length > 20 || - formData.value2.length > 20 || - formData.value3.length > 20 || - formData.value4.length > 20 || - formData.value5.length > 20 || - formData.value6.length > 20 - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate point cannot have more than 20 characters.", - }); - return; - } - - if ( - checkDuplicates([ - formData.value1, - formData.value2, - formData.value3, - formData.value4, - formData.value5, - formData.value6, - ]) - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate points cannot have duplicate values.", - }); - return; - } - - const payload: IEstimateFormData = { - estimate: { - name: formData.name, - description: formData.description, - }, - estimate_points: [], - }; - - for (let i = 0; i < 6; i++) { - const point = { - key: i, - value: formData[`value${i + 1}` as keyof FormValues], - }; - - if (data) - payload.estimate_points.push({ - id: data.points[i].id, - ...point, - }); - else payload.estimate_points.push({ ...point }); - } - - if (data) await handleUpdateEstimate(payload); - else await handleCreateEstimate(payload); - }; - - useEffect(() => { - if (data) - reset({ - ...defaultValues, - ...data, - value1: data.points[0]?.value, - value2: data.points[1]?.value, - value3: data.points[2]?.value, - value4: data.points[3]?.value, - value5: data.points[4]?.value, - value6: data.points[5]?.value, - }); - else reset({ ...defaultValues }); - }, [data, reset]); - - return ( - - -
-
{data ? "Update" : "Create"} Estimate
-
-
- ( - - )} - /> -
-
- ( -