diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml new file mode 100644 index 00000000000..3d42f2ecdc1 --- /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 13c7ca221ce..0ccccda5fd6 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/Dockerfile b/Dockerfile deleted file mode 100644 index ec01b2a5584..00000000000 --- 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/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index 0979bbabe6a..a54ce6d8c4f 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -19,14 +19,14 @@ const InstanceAIPage = observer(() => { return ( <> -
-
+
+
AI features for all your workspaces
Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index b65b99205e2..8532910f7a2 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -64,8 +64,8 @@ const InstanceGithubAuthenticationPage = observer(() => { return ( <> -
-
+
+
{ withBorder={false} />
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx index 05117dbe336..fcdcd47ad0d 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -58,8 +58,8 @@ const InstanceGoogleAuthenticationPage = observer(() => { return ( <> -
-
+
+
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index 25be147ca68..d1e6fb0ba18 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -119,14 +119,14 @@ const InstanceAuthenticationPage = observer(() => { return ( <> -
-
+
+
Manage authentication for your instance
Configure authentication modes for your team and restrict sign ups to be invite only.
-
+
{formattedConfig ? (
Authentication modes
diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx index de776b17508..198020d4d61 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -19,8 +19,8 @@ const InstanceEmailPage = observer(() => { return ( <> -
-
+
+
Secure emails from your own instance
Plane can send useful emails to you and your users from your own instance without talking to the Internet. @@ -30,7 +30,7 @@ const InstanceEmailPage = observer(() => {
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/email/test-email-modal.tsx b/admin/app/email/test-email-modal.tsx index 0feea412868..6d5cb8032d0 100644 --- a/admin/app/email/test-email-modal.tsx +++ b/admin/app/email/test-email-modal.tsx @@ -51,7 +51,7 @@ export const SendTestEmailModal: FC = (props) => { setSendEmailStep(ESendEmailSteps.SUCCESS); }) .catch((error) => { - setError(error?.message || "Failed to send email"); + setError(error?.error || "Failed to send email"); setSendEmailStep(ESendEmailSteps.FAILED); }) .finally(() => { diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index bab2a94fcfc..5aaea9f8e7c 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -10,15 +10,15 @@ function GeneralPage() { console.log("instance", instance); return ( <> -
-
+
+
General settings
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.
-
+
{instance && instanceAdmins && ( )} diff --git a/admin/app/globals.css b/admin/app/globals.css index eefcb1b26d1..0a2218c219e 100644 --- a/admin/app/globals.css +++ b/admin/app/globals.css @@ -332,42 +332,90 @@ body { } /* scrollbar style */ -::-webkit-scrollbar { - display: none; +@-moz-document url-prefix() { + * { + scrollbar-width: none; + } + .vertical-scrollbar, + .horizontal-scrollbar { + scrollbar-width: initial; + scrollbar-color: rgba(96, 100, 108, 0.1) transparent; + } + .vertical-scrollbar:hover, + .horizontal-scrollbar:hover { + scrollbar-color: rgba(96, 100, 108, 0.25) transparent; + } + .vertical-scrollbar:active, + .horizontal-scrollbar:active { + scrollbar-color: rgba(96, 100, 108, 0.7) transparent; + } } -.horizontal-scroll-enable { - overflow-x: scroll; +.vertical-scrollbar { + overflow-y: auto; } - -.horizontal-scroll-enable::-webkit-scrollbar { +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { display: block; - height: 7px; - width: 0; } - -.horizontal-scroll-enable::-webkit-scrollbar-track { - height: 7px; - background-color: rgba(var(--color-background-100)); +.vertical-scrollbar::-webkit-scrollbar-track, +.horizontal-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 9999px; } - -.horizontal-scroll-enable::-webkit-scrollbar-thumb { - border-radius: 5px; - background-color: rgba(var(--color-scrollbar)); +.vertical-scrollbar::-webkit-scrollbar-thumb, +.horizontal-scrollbar::-webkit-scrollbar-thumb { + background-clip: padding-box; + background-color: rgba(96, 100, 108, 0.1); + border-radius: 9999px; } - -.vertical-scroll-enable::-webkit-scrollbar { - display: block; - width: 5px; +.vertical-scrollbar:hover::-webkit-scrollbar-thumb, +.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(96, 100, 108, 0.25); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:hover, +.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 100, 108, 0.5); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:active, +.horizontal-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(96, 100, 108, 0.7); +} +.vertical-scrollbar::-webkit-scrollbar-corner, +.horizontal-scrollbar::-webkit-scrollbar-corner { + background-color: transparent; +} +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; } -.vertical-scroll-enable::-webkit-scrollbar-track { - width: 5px; +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; } +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; +} +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar lg size */ -.vertical-scroll-enable::-webkit-scrollbar-thumb { - border-radius: 5px; - background-color: rgba(var(--color-background-90)); +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); } /* end scrollbar style */ diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx index 5c1b838bed3..ceaad61f244 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -19,14 +19,14 @@ const InstanceImagePage = observer(() => { return ( <> -
-
+
+
Third-party image libraries
Let your users search and choose images from third-party libraries
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 371bb49d882..56ccbcd8446 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/components/admin-sidebar/help-section.tsx @@ -38,7 +38,7 @@ export const HelpSection: FC = observer(() => { // refs const helpOptionsRef = useRef(null); - const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); + const redirectionLink = encodeURI(WEB_BASE_URL + "/"); return (
{ }; return ( -
+
{INSTANCE_ADMIN_LINKS.map((item, index) => { const isActive = item.href === pathName || pathName.includes(item.href); return ( diff --git a/admin/components/common/copy-field.tsx b/admin/components/common/copy-field.tsx index 47e1a3364e0..6322356b44d 100644 --- a/admin/components/common/copy-field.tsx +++ b/admin/components/common/copy-field.tsx @@ -24,7 +24,7 @@ export const CopyField: React.FC = (props) => { return (
-

{label}

+

{label}

-
{description}
+
{description}
); }; diff --git a/admin/components/instance/setup-form.tsx b/admin/components/instance/setup-form.tsx index 56d536c742c..77bf8e56228 100644 --- a/admin/components/instance/setup-form.tsx +++ b/admin/components/instance/setup-form.tsx @@ -158,6 +158,7 @@ export const InstanceSetupForm: FC = (props) => { onError={() => setIsSubmitting(false)} > +
@@ -319,8 +320,6 @@ export const InstanceSetupForm: FC = (props) => {
handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} checked={formData.is_telemetry_enabled} /> diff --git a/admin/components/new-user-popup.tsx b/admin/components/new-user-popup.tsx index 73a405d4a8d..840de0c3ac1 100644 --- a/admin/components/new-user-popup.tsx +++ b/admin/components/new-user-popup.tsx @@ -7,9 +7,9 @@ import { useTheme as nextUseTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/ui"; // helpers -import { resolveGeneralTheme } from "helpers/common.helper"; +import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; // hooks -import { useInstance, useTheme } from "@/hooks/store"; +import { useTheme } from "@/hooks/store"; // icons import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; @@ -17,11 +17,10 @@ import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; export const NewUserPopup: React.FC = observer(() => { // hooks const { isNewUserPopup, toggleNewUserPopup } = useTheme(); - const { config } = useInstance(); // theme const { resolvedTheme } = nextUseTheme(); - const redirectionLink = `${config?.app_base_url ? `${config?.app_base_url}/create-workspace` : `/god-mode/`}`; + const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); if (!isNewUserPopup) return <>; return ( diff --git a/admin/next.config.js b/admin/next.config.js index 07f6664af88..2109cec69fe 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 1e1bc372ebc..9c456707029 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", diff --git a/aio/Dockerfile b/aio/Dockerfile new file mode 100644 index 00000000000..94d61b86604 --- /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 00000000000..092deb79723 --- /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 00000000000..53adbf42b6d --- /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 00000000000..1a1f3c0b829 --- /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 00000000000..6f6ea88e6b9 --- /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 00000000000..8c6223fc464 --- /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 00000000000..fe6a76e4124 --- /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 00000000000..46ef1b4faed --- /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 317e82033bd..ecaf1194a8d 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/issue.py b/apiserver/plane/api/views/issue.py index a62278b1949..ce0501dd2ee 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -182,7 +182,6 @@ def get(self, request, slug, project_id, pk=None): 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/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 1a9ce52d18c..97fd479600f 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -66,6 +66,7 @@ class Meta: "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 6a0c4c94f3f..28d28d7dbc7 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -199,6 +199,7 @@ class Meta: "sort_order", "external_source", "external_id", + "logo_props", # computed fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 4f3cde39bb5..f13923831fc 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -39,6 +39,7 @@ class Meta: "created_by", "updated_by", "view_props", + "logo_props", ] read_only_fields = [ "workspace", @@ -106,7 +107,9 @@ class PageDetailSerializer(PageSerializer): description_html = serializers.CharField() class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + ["description_html"] + fields = PageSerializer.Meta.fields + [ + "description_html", + ] class SubPageSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 1a73e4ed306..a6d43600f12 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -6,6 +6,7 @@ PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) @@ -79,4 +80,14 @@ SubPagesEndpoint.as_view(), name="sub-page", ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + } + ), + name="page-description", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bf765e7192f..0c489593d63 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -177,6 +177,7 @@ PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) from .search import GlobalSearchEndpoint, IssueSearchEndpoint diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index e0b28ac7bbc..5982daf7f97 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -231,6 +231,7 @@ def list(self, request, slug, project_id): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", @@ -356,6 +357,7 @@ def list(self, request, slug, project_id): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", @@ -403,6 +405,7 @@ def create(self, request, slug, project_id): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "cancelled_issues", @@ -496,6 +499,7 @@ def partial_update(self, request, slug, project_id, pk): "external_source", "external_id", "progress_snapshot", + "logo_props", # meta fields "is_favorite", "total_issues", @@ -556,6 +560,7 @@ def retrieve(self, request, slug, project_id, pk): "external_id", "progress_snapshot", "sub_issues", + "logo_props", # meta fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index f98e0fbc2d2..56267554d71 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -225,6 +225,7 @@ def create(self, request, slug, project_id): "sort_order", "external_source", "external_id", + "logo_props", # computed fields "is_favorite", "cancelled_issues", @@ -281,6 +282,7 @@ def list(self, request, slug, project_id): "sort_order", "external_source", "external_id", + "logo_props", # computed fields "total_issues", "is_favorite", @@ -465,6 +467,7 @@ def partial_update(self, request, slug, project_id, pk): "sort_order", "external_source", "external_id", + "logo_props", # computed fields "is_favorite", "cancelled_issues", diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 16ea7803367..c7f53b9fe88 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -1,5 +1,6 @@ # Python imports import json +import base64 from datetime import datetime from django.core.serializers.json import DjangoJSONEncoder @@ -8,6 +9,7 @@ from django.db.models import Exists, OuterRef, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse # Third party imports from rest_framework import status @@ -388,3 +390,48 @@ def get(self, request, slug, project_id, page_id): return Response( SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK ) + + +class PagesDescriptionViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + def retrieve(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + binary_data = page.description_binary + + def stream_data(): + if binary_data: + yield binary_data + else: + yield b"" + + response = StreamingHttpResponse( + stream_data(), content_type="application/octet-stream" + ) + response["Content-Disposition"] = ( + 'attachment; filename="page_description.bin"' + ) + return response + + def partial_update(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + + base64_data = request.data.get("description_binary") + + if base64_data: + # Decode the base64 data to bytes + new_binary_data = base64.b64decode(base64_data) + + # Store the updated binary data + page.description_binary = new_binary_data + page.description_html = request.data.get("description_html") + page.save() + return Response({"message": "Updated successfully"}) + else: + return Response({"error": "No binary data provided"}) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 9a9cdde433d..de1559b0c6d 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 @@ def deactivate(self, request): 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 diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 457a67f4fa2..7b12db9456a 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -17,6 +17,7 @@ "INVALID_EMAIL_SIGN_UP": 5045, "INVALID_EMAIL_MAGIC_SIGN_UP": 5050, "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, + "EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056, # Sign In "USER_DOES_NOT_EXIST": 5060, "AUTHENTICATION_FAILED_SIGN_IN": 5065, diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index 60c2ea0c60e..a917c002aef 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -8,6 +8,10 @@ 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 @@ def authenticate(self): 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: + 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 set_user_data(self, data): self.user_data = data diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 7e4e619d8a2..4c776412858 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -41,8 +41,10 @@ def __init__( if ENABLE_EMAIL_PASSWORD == "0": raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], - error_message="ENABLE_EMAIL_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES[ + "EMAIL_PASSWORD_AUTHENTICATION_DISABLED" + ], + error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED", ) def set_user_data(self): diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index 798863d8f56..edd99b1ba32 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -105,14 +105,26 @@ def set_token_data(self): ) 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/db/management/commands/activate_user.py b/apiserver/plane/db/management/commands/activate_user.py new file mode 100644 index 00000000000..29123b4e557 --- /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/models/page.py b/apiserver/plane/db/models/page.py index 3602bce1fa5..e079dcbe581 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -18,6 +18,7 @@ def get_view_props(): class Page(ProjectBaseModel): name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) + description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) owned_by = models.ForeignKey( @@ -43,7 +44,6 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) - description_binary = models.BinaryField(null=True) class Meta: verbose_name = "Page" diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 92e82d01265..3b905e64dd3 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -13,12 +13,9 @@ class Meta: 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/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 525ab54ece7..1ec09fbb518 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -148,7 +148,7 @@ def get(self, request): data["app_base_url"] = settings.APP_BASE_URL instance_data = serializer.data - instance_data["workspaces_exist"] = Workspace.objects.count() > 1 + instance_data["workspaces_exist"] = Workspace.objects.count() >= 1 response_data = {"config": data, "instance": instance_data} return Response(response_data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index b5cc8a60d74..42676bb7287 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -49,8 +49,8 @@ def handle(self, *args, **options): 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 00000000000..3cdea79021d --- /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/models/instance.py b/apiserver/plane/license/models/instance.py index b8957e44fa4..ea88ba9bb9b 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,15 +11,23 @@ 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=10) + latest_version = models.CharField(max_length=10, null=True, blank=True) + product = models.CharField( + max_length=50, 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) # telemetry and support @@ -70,3 +81,20 @@ class Meta: 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=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) + + 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 853478c7539..40128f9ad4a 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -225,6 +225,9 @@ 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_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 @@ 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 @@ # 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/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index c75e9cfee4f..10f64fd1cba 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 7e1ed70e9c1..00000000000 --- 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 813f9bcd14b..57d69437816 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": [ diff --git a/packages/constants/package.json b/packages/constants/package.json index ac97dcb118c..28d84c32b9a 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.20.0", + "version": "0.21.0", "private": true, "main": "./src/index.ts", "exports": { diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index da2a02f6526..d91758019e6 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 778fdc5e4ec..76071791b6a 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -13,17 +13,21 @@ import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items import { EditorRefApi } from "src/types/editor-ref-api"; import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; -interface CustomEditorProps { +export type TFileHandler = { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; +}; + +export interface CustomEditorProps { id?: string; - uploadFile: UploadImage; - restoreFile: RestoreImage; - deleteFile: DeleteImage; - cancelUploadImage?: () => void; - initialValue: string; + fileHandler: TFileHandler; + initialValue?: string; editorClassName: string; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing - value: string | null | undefined; + value?: string | null | undefined; onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -38,19 +42,16 @@ interface CustomEditorProps { } export const useEditor = ({ - uploadFile, id = "", - deleteFile, - cancelUploadImage, editorProps = {}, initialValue, editorClassName, value, extensions = [], + fileHandler, onChange, forwardedRef, tabIndex, - restoreFile, handleEditorReady, mentionHandler, placeholder, @@ -67,10 +68,10 @@ export const useEditor = ({ mentionHighlights: mentionHandler.highlights ?? [], }, fileConfig: { - deleteFile, - restoreFile, - cancelUploadImage, - uploadFile, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + restoreFile: fileHandler.restore, + cancelUploadImage: fileHandler.cancel, }, placeholder, tabIndex, @@ -139,14 +140,14 @@ export const useEditor = ({ } }, executeMenuItemCommand: (itemName: EditorMenuItemNames) => { - const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); if (item) { if (item.key === "image") { - item.command(savedSelection); + item.command(savedSelectionRef.current); } else { item.command(); } @@ -155,7 +156,7 @@ export const useEditor = ({ } }, isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { - const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); @@ -177,10 +178,15 @@ export const useEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { 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."); @@ -199,7 +205,7 @@ export const useEditor = ({ } }, }), - [editorRef, savedSelection, uploadFile] + [editorRef, savedSelection, fileHandler.upload] ); if (!editor) { diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index 9607586d8e7..8b16d1e7668 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 336daed4363..493f02d2f10 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"; @@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items"; export * from "src/lib/editor-commands"; // types +export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor"; export type { DeleteImage } from "src/types/delete-image"; export type { UploadImage } from "src/types/upload-image"; export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index b82b1f3542b..911347e7fca 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -1,5 +1,5 @@ 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"; @@ -194,7 +194,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/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 84ad7046ee2..137c70c2e71 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -1,5 +1,7 @@ +import { Extensions, generateJSON, getSchema } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; +import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { noBorder?: boolean; @@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => { return url.protocol === "http:" || url.protocol === "https:"; }; + +/** + * @description return an object with contentJSON and editorSchema + * @description contentJSON- ProseMirror JSON from HTML content + * @description editorSchema- editor schema from extensions + * @param {string} html + * @returns {object} {contentJSON, editorSchema} + */ +export const generateJSONfromHTML = (html: string) => { + const extensions = CoreEditorExtensionsWithoutProps(); + const contentJSON = generateJSON(html ?? "

", extensions as Extensions); + const editorSchema = getSchema(extensions as Extensions); + return { + contentJSON, + editorSchema, + }; +}; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts index df5df2c7b4d..b15ae943d05 100644 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items"; export type EditorReadOnlyRefApi = { getMarkDown: () => string; + getHTML: () => string; clearEditor: () => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; @@ -14,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/core-without-props.tsx b/packages/editor/core/src/ui/extensions/core-without-props.tsx new file mode 100644 index 00000000000..3bb00010b53 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/core-without-props.tsx @@ -0,0 +1,121 @@ +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Markdown } from "tiptap-markdown"; + +import { Table } from "src/ui/extensions/table/table"; +import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; +import { TableRow } from "src/ui/extensions/table/table-row/table-row"; + +import { isValidHttpUrl } from "src/lib/utils"; + +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { CustomKeymap } from "src/ui/extensions/keymap"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; + +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; +import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; +import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin"; +import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props"; +import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props"; + +import StarterKit from "@tiptap/starter-kit"; + +export const CoreEditorExtensionsWithoutProps = () => [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, + }, + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 1, + }, + }), + CustomQuoteExtension, + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomKeymap, + // ListKeymap, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtensionWithoutProps().configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformPastedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + MentionsWithoutProps(), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + + const shouldHidePlaceholder = + editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + if (shouldHidePlaceholder) return ""; + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), +]; diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/core/src/ui/extensions/drop.tsx index ed206bc42a9..4bf4e2625d8 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/image-extension-without-props.tsx b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx new file mode 100644 index 00000000000..838a6a1c9a0 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx @@ -0,0 +1,33 @@ +import ImageExt from "@tiptap/extension-image"; +import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; +import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; + +export const ImageExtensionWithoutProps = () => + ImageExt.extend({ + addKeyboardShortcuts() { + return { + ArrowDown: insertLineBelowImageAction, + ArrowUp: insertLineAboveImageAction, + }; + }, + + // storage to keep track of image states Map + addStorage() { + return { + images: new Map(), + uploadInProgress: false, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index b85100fe569..7ea12fb11ca 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 425ad89b00f..2507aca36e6 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/mentions/mention-without-props.tsx b/packages/editor/core/src/ui/mentions/mention-without-props.tsx new file mode 100644 index 00000000000..a0d22ef4f8f --- /dev/null +++ b/packages/editor/core/src/ui/mentions/mention-without-props.tsx @@ -0,0 +1,79 @@ +import { CustomMention } from "./custom"; +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; + +import { MentionList } from "./mention-list"; + +export const MentionsWithoutProps = () => + CustomMention.configure({ + HTMLAttributes: { + class: "mention", + }, + // mentionHighlights: mentionHighlights, + suggestion: { + // @ts-expect-error - Tiptap types are incorrect + render: () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + if (!props.clientRect) { + return; + } + component = new ReactRenderer(MentionList, { + props: { ...props }, + editor: props.editor, + }); + props.editor.storage.mentionsOpen = true; + // @ts-expect-error - Tippy types are incorrect + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-expect-error - Tippy types are incorrect + component?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0].destroy(); + component?.destroy(); + }, + }; + }, + }, + }); 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 46b1ed92a75..ab2ad8ed4af 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 03b4dbd10f6..00000000000 --- 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 00000000000..72fae671089 --- /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 00000000000..645dda99eb8 --- /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 00000000000..0be22e0ddaf --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts @@ -0,0 +1,114 @@ +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 { + view.focus(); + + const src = await uploadAndValidateImage(file, 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; + } +} 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 00000000000..61a7a7a34a4 --- /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 00000000000..67afc8315a4 --- /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 00000000000..554e37de228 --- /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 00000000000..9636da4a7d2 --- /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 00000000000..a7952a0e116 --- /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 7a370da4e32..00000000000 --- 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 47e68a87e58..b565bfedf0a 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", @@ -34,12 +34,17 @@ "@plane/ui": "*", "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.1.13", + "@tiptap/extension-collaboration": "^2.3.2", "@tiptap/pm": "^2.1.13", "@tiptap/suggestion": "^2.1.13", "lucide-react": "^0.378.0", "react-popper": "^2.3.0", "tippy.js": "^6.3.7", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "y-indexeddb": "^9.0.12", + "y-prosemirror": "^1.2.5", + "y-protocols": "^1.0.6", + "yjs": "^13.6.15" }, "devDependencies": { "@types/node": "18.15.3", diff --git a/packages/editor/document-editor/src/hooks/use-document-editor.ts b/packages/editor/document-editor/src/hooks/use-document-editor.ts new file mode 100644 index 00000000000..c2070a9f39e --- /dev/null +++ b/packages/editor/document-editor/src/hooks/use-document-editor.ts @@ -0,0 +1,85 @@ +import { useEffect, useLayoutEffect, useMemo } from "react"; +import { EditorProps } from "@tiptap/pm/view"; +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; +// editor-core +import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core"; +// custom provider +import { CollaborationProvider } from "src/providers/collaboration-provider"; +// extensions +import { DocumentEditorExtensions } from "src/ui/extensions"; + +type DocumentEditorProps = { + id: string; + fileHandler: TFileHandler; + value: Uint8Array; + editorClassName: string; + onChange: (updates: Uint8Array) => void; + editorProps?: EditorProps; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + handleEditorReady?: (value: boolean) => void; + placeholder?: string | ((isFocused: boolean, value: string) => string); + setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void; + tabIndex?: number; +}; + +export const useDocumentEditor = ({ + id, + editorProps = {}, + value, + editorClassName, + fileHandler, + onChange, + forwardedRef, + tabIndex, + handleEditorReady, + mentionHandler, + placeholder, + setHideDragHandleFunction, +}: DocumentEditorProps) => { + const provider = useMemo( + () => + new CollaborationProvider({ + name: id, + onChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [id] + ); + + // update document on value change + useEffect(() => { + if (value.byteLength > 0) Y.applyUpdate(provider.document, value); + }, [value, provider.document]); + + // indexedDB provider + useLayoutEffect(() => { + const localProvider = new IndexeddbPersistence(id, provider.document); + return () => { + localProvider?.destroy(); + }; + }, [provider, id]); + + const editor = useEditor({ + id, + editorProps, + editorClassName, + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, + extensions: DocumentEditorExtensions({ + uploadFile: fileHandler.upload, + setHideDragHandle: setHideDragHandleFunction, + provider, + }), + placeholder, + tabIndex, + }); + + return editor; +}; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index f8eea14ce71..9e8407ce3f5 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re // hooks export { useEditorMarkings } from "src/hooks/use-editor-markings"; +// utils +export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs"; export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; diff --git a/packages/editor/document-editor/src/providers/collaboration-provider.ts b/packages/editor/document-editor/src/providers/collaboration-provider.ts new file mode 100644 index 00000000000..b61ceebd55d --- /dev/null +++ b/packages/editor/document-editor/src/providers/collaboration-provider.ts @@ -0,0 +1,60 @@ +import * as Y from "yjs"; + +export interface CompleteCollaboratorProviderConfiguration { + /** + * The identifier/name of your document + */ + name: string; + /** + * The actual Y.js document + */ + document: Y.Doc; + /** + * onChange callback + */ + onChange: (updates: Uint8Array) => void; +} + +export type CollaborationProviderConfiguration = Required> & + Partial; + +export class CollaborationProvider { + public configuration: CompleteCollaboratorProviderConfiguration = { + name: "", + // @ts-expect-error cannot be undefined + document: undefined, + onChange: () => {}, + }; + + constructor(configuration: CollaborationProviderConfiguration) { + this.setConfiguration(configuration); + + this.configuration.document = configuration.document ?? new Y.Doc(); + this.document.on("update", this.documentUpdateHandler.bind(this)); + this.document.on("destroy", this.documentDestroyHandler.bind(this)); + } + + public setConfiguration(configuration: Partial = {}): void { + this.configuration = { + ...this.configuration, + ...configuration, + }; + } + + get document() { + return this.configuration.document; + } + + documentUpdateHandler(update: Uint8Array, origin: any) { + // return if the update is from the provider itself + if (origin === this) return; + + // call onChange with the update + this.configuration.onChange?.(update); + } + + documentDestroyHandler() { + this.document.off("update", this.documentUpdateHandler); + this.document.off("destroy", this.documentDestroyHandler); + } +} diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index b2816974e11..10c9fa596e9 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { UploadImage } from "@plane/editor-core"; +import { CollaborationProvider } from "src/providers/collaboration-provider"; +import Collaboration from "@tiptap/extension-collaboration"; type TArguments = { uploadFile: UploadImage; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + provider: CollaborationProvider; }; -export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ +export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [ SlashCommand(uploadFile), DragAndDrop(setHideDragHandle), IssueWidgetPlaceholder(), + Collaboration.configure({ + document: provider.document, + }), ]; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 1f1c5f70675..1cafe6de74e 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,30 +1,25 @@ import React, { useState } from "react"; +// editor-core import { - UploadImage, - DeleteImage, - RestoreImage, getEditorClassNames, - useEditor, EditorRefApi, IMentionHighlight, IMentionSuggestion, + TFileHandler, } from "@plane/editor-core"; -import { DocumentEditorExtensions } from "src/ui/extensions"; +// components import { PageRenderer } from "src/ui/components/page-renderer"; +// hooks +import { useDocumentEditor } from "src/hooks/use-document-editor"; interface IDocumentEditor { - initialValue: string; - value?: string; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + id: string; + value: Uint8Array; + fileHandler: TFileHandler; handleEditorReady?: (value: boolean) => void; containerClassName?: string; editorClassName?: string; - onChange: (json: object, html: string) => void; + onChange: (updates: Uint8Array) => void; forwardedRef?: React.MutableRefObject; mentionHandler: { highlights: () => Promise; @@ -37,7 +32,7 @@ interface IDocumentEditor { const DocumentEditor = (props: IDocumentEditor) => { const { onChange, - initialValue, + id, value, fileHandler, containerClassName, @@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => { } = props; // states const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); - // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin // loads such that we can invoke it from react when the cursor leaves the container const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); }; - // use editor - const editor = useEditor({ - onChange(json, html) { - onChange(json, html); - }, + + // use document editor + const editor = useDocumentEditor({ + id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, - initialValue, + fileHandler, value, + onChange, handleEditorReady, forwardedRef, mentionHandler, - extensions: DocumentEditorExtensions({ - uploadFile: fileHandler.upload, - setHideDragHandle: setHideDragHandleFunction, - }), placeholder, + setHideDragHandleFunction, tabIndex, }); diff --git a/packages/editor/document-editor/src/utils/yjs.ts b/packages/editor/document-editor/src/utils/yjs.ts new file mode 100644 index 00000000000..71a945d3cc5 --- /dev/null +++ b/packages/editor/document-editor/src/utils/yjs.ts @@ -0,0 +1,76 @@ +import { Schema } from "@tiptap/pm/model"; +import { prosemirrorJSONToYDoc } from "y-prosemirror"; +import * as Y from "yjs"; + +const defaultSchema: Schema = new Schema({ + nodes: { + text: {}, + doc: { content: "text*" }, + }, +}); + +/** + * @description converts ProseMirror JSON to Yjs document + * @param document prosemirror JSON + * @param fieldName + * @param schema + * @returns {Y.Doc} Yjs document + */ +export const proseMirrorJSONToBinaryString = ( + document: any, + fieldName: string | Array = "default", + schema?: Schema +): string => { + if (!document) { + throw new Error( + `You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}` + ); + } + + // allow a single field name + if (typeof fieldName === "string") { + const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName); + const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); + const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); + return base64Doc; + } + + const yDoc = new Y.Doc(); + + fieldName.forEach((field) => { + const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field)); + + Y.applyUpdate(yDoc, update); + }); + + const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); + const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); + + return base64Doc; +}; + +/** + * @description apply updates to a doc and return the updated doc in base64(binary) format + * @param {Uint8Array} document + * @param {Uint8Array} updates + * @returns {string} base64(binary) form of the updated doc + */ +export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + Y.applyUpdate(yDoc, updates); + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + const base64Updates = Buffer.from(encodedDoc).toString("base64"); + return base64Updates; +}; + +/** + * @description merge multiple updates into one single update + * @param {Uint8Array[]} updates + * @returns {Uint8Array} merged updates + */ +export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => { + const mergedUpdates = Y.mergeUpdates(updates); + return mergedUpdates; +}; diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index e59bac106c0..7a5d2093368 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/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index a2136cb8a4d..8e03e82acb5 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/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 6b22809d62a..77d3ca0ecff 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -1,27 +1,22 @@ import * as React from "react"; +// editor-core import { - UploadImage, - DeleteImage, IMentionSuggestion, - RestoreImage, EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor, IMentionHighlight, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; +// extensions import { LiteTextEditorExtensions } from "src/ui/extensions"; export interface ILiteTextEditor { initialValue: string; value?: string | null; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; containerClassName?: string; editorClassName?: string; onChange?: (json: object, html: string) => void; @@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => { value, id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), mentionHandler, diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 698c0b9236e..c0b3d28cc95 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 00000000000..70037f0460f --- /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 406fb677fda..4face2cb72f 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 0cb32e5434a..2b8348a62dd 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -1,30 +1,26 @@ "use client"; +import * as React from "react"; +// editor-core import { - DeleteImage, EditorContainer, EditorContentWrapper, getEditorClassNames, IMentionHighlight, IMentionSuggestion, - RestoreImage, - UploadImage, useEditor, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; -import * as React from "react"; +// extensions import { RichTextEditorExtensions } from "src/ui/extensions"; +// components import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { initialValue: string; value?: string | null; dragDropEnabled?: boolean; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; id?: string; containerClassName?: string; editorClassName?: string; @@ -37,6 +33,7 @@ export type IRichTextEditor = { }; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; + onEnterKeyPress?: (e?: any) => void; }; const RichTextEditor = (props: IRichTextEditor) => { @@ -54,6 +51,7 @@ const RichTextEditor = (props: IRichTextEditor) => { placeholder, tabIndex, mentionHandler, + onEnterKeyPress, } = props; const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -67,10 +65,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const editor = useEditor({ id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, onChange, initialValue, value, @@ -80,6 +75,7 @@ const RichTextEditor = (props: IRichTextEditor) => { uploadFile: fileHandler.upload, dragDropEnabled, setHideDragHandle: setHideDragHandleFunction, + onEnterKeyPress, }), tabIndex, mentionHandler, diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b6bc7b2c13f..fb34284c3bd 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 550308e577c..18718802962 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/tsconfig/package.json b/packages/tsconfig/package.json index a181b028904..a2f084dbc8b 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 487505f7df8..918e9e77ddd 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/common.d.ts b/packages/types/src/common.d.ts index d347ecef1a4..6a8c725a82d 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/instance/base.d.ts b/packages/types/src/instance/base.d.ts index d51978c067e..53804dec35c 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/notifications.d.ts b/packages/types/src/notifications.d.ts index 571b7576551..d739b230959 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 4871ddc06e7..1c94dfc063c 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 459d9f0e2fe..ee974fd6392 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; @@ -46,7 +35,7 @@ export interface IProject { is_deployed: boolean; 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/views.d.ts b/packages/types/src/views.d.ts index f9f7ee3852f..9415f7488f2 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 62c335839b3..5444b673e98 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", @@ -26,6 +26,7 @@ "@popperjs/core": "^2.11.8", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", + "lucide-react": "^0.379.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", "react-popper": "^2.3.0", diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index 61426e44bfb..df195847656 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -2,7 +2,7 @@ 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; @@ -17,7 +17,7 @@ export const ControlLink = React.forwardRef((pr const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; if (!clickCondition) { event.preventDefault(); - onClick(); + onClick(event); } }; diff --git a/packages/ui/src/drag-handle.tsx b/packages/ui/src/drag-handle.tsx index 0496f86dee4..89037a5ca40 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 ( + + {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 5bfcdbe1722..c531dd16879 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 f55da881b47..0352e1ec8ef 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 72aacf18bb7..3d650e244ef 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 97345413903..128b802925e 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 00000000000..799f0919dcc --- /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 887bc60740d..3c45cf4f574 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} /> = ({ className = "text-current", ...rest }) => ( + + + + + +); 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 7cad96d231c..00000000000 --- 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/space/.gitignore b/space/.gitignore index a2a963ee7e0..a64f113f12d 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/components/common/project-logo.tsx b/space/components/common/project-logo.tsx index 9b69e96167d..dfb3a4b80e2 100644 --- a/space/components/common/project-logo.tsx +++ b/space/components/common/project-logo.tsx @@ -1,11 +1,11 @@ +// types +import { TLogoProps } from "@plane/types"; // helpers -import { TProjectLogoProps } from "@plane/types"; import { cn } from "@/helpers/common.helper"; -// types type Props = { className?: string; - logo: TProjectLogoProps; + logo: TLogoProps; }; export const ProjectLogo: React.FC = (props) => { diff --git a/space/instrumentation.ts b/space/instrumentation.ts new file mode 100644 index 00000000000..7b89a972e15 --- /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/next.config.js b/space/next.config.js index eb9dde88a32..d18ce805f4d 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 a084c143b9c..e3dadbff8e5 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", @@ -23,7 +23,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^7.108.0", + "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", "dompurify": "^3.0.11", diff --git a/space/sentry.client.config.js b/space/sentry.client.config.js deleted file mode 100644 index ca473045b45..00000000000 --- 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 00000000000..c8103062290 --- /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 8374ed4101e..00000000000 --- 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 00000000000..2dbc6e93afb --- /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 d2acb07e154..e578f1530c0 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/types/project.d.ts b/space/types/project.d.ts index 99dbfec8bb5..90c89ed80c4 100644 --- a/space/types/project.d.ts +++ b/space/types/project.d.ts @@ -1,4 +1,4 @@ -import { TProjectLogoProps } from "@plane/types"; +import { TLogoProps } from "@plane/types"; export type TWorkspaceDetails = { name: string; @@ -19,7 +19,7 @@ export type TProjectDetails = { identifier: string; name: string; cover_image: string | undefined; - logo_props: TProjectLogoProps; + logo_props: TLogoProps; description: string; }; diff --git a/turbo.json b/turbo.json index c08733c85c4..fde4ffc79b5 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,7 +17,12 @@ "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": { "build": { diff --git a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx index 0a61e06acef..d7080746763 100644 --- a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -1,10 +1,11 @@ import { observer } from "mobx-react"; -// hooks // icons import { Contrast, LayoutGrid, Users } from "lucide-react"; +// components +import { Logo } from "@/components/common"; // helpers -import { ProjectLogo } from "@/components/project"; import { truncateText } from "@/helpers/string.helper"; +// hooks import { useProject } from "@/hooks/store"; type Props = { @@ -29,7 +30,7 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro
- +

{truncateText(project.name, 20)}

diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index 6954a897368..ec1eb3ee35b 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -1,13 +1,13 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -import { ProjectLogo } from "@/components/project"; +// components +import { Logo } from "@/components/common"; +// constants import { NETWORK_CHOICES } from "@/constants/project"; +// helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks import { useCycle, useMember, useModule, useProject } from "@/hooks/store"; -// components -// helpers -// constants export const CustomAnalyticsSidebarHeader = observer(() => { const router = useRouter(); @@ -84,7 +84,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
{projectDetails && ( - + )}

{projectDetails?.name}

diff --git a/web/components/analytics/project-modal/main-content.tsx b/web/components/analytics/project-modal/main-content.tsx index 030760a1c65..e912828018d 100644 --- a/web/components/analytics/project-modal/main-content.tsx +++ b/web/components/analytics/project-modal/main-content.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; import { ICycle, IModule, IProject } from "@plane/types"; @@ -20,20 +20,21 @@ export const ProjectAnalyticsModalMainContent: React.FC = observer((props return ( - + {ANALYTICS_TABS.map((tab) => ( - - `rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${ - selected - ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" - : "border-transparent" - }` - } - onClick={() => {}} - > - {tab.title} + + {({ selected }) => ( + + )} ))} diff --git a/web/components/api-token/delete-token-modal.tsx b/web/components/api-token/delete-token-modal.tsx index 014531c4240..6918df463c4 100644 --- a/web/components/api-token/delete-token-modal.tsx +++ b/web/components/api-token/delete-token-modal.tsx @@ -69,7 +69,7 @@ export const DeleteApiTokenModal: FC = (props) => { = observer((props) => { className="focus:outline-none" >
- + Assign to...
diff --git a/web/components/command-palette/actions/project-actions.tsx b/web/components/command-palette/actions/project-actions.tsx index 32e7ed04596..ed4bdcadccf 100644 --- a/web/components/command-palette/actions/project-actions.tsx +++ b/web/components/command-palette/actions/project-actions.tsx @@ -71,7 +71,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { onSelect={() => { closePalette(); setTrackElement("Command palette"); - toggleCreatePageModal(true); + toggleCreatePageModal({ isOpen: true }); }} className="focus:outline-none" > diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 9143d44c77c..41afbb5e2f1 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -50,7 +50,7 @@ export const CommandPalette: FC = observer(() => { toggleCreateIssueModal, isCreateCycleModalOpen, toggleCreateCycleModal, - isCreatePageModalOpen, + createPageModal, toggleCreatePageModal, isCreateProjectModalOpen, toggleCreateProjectModal, @@ -150,7 +150,7 @@ export const CommandPalette: FC = observer(() => { d: { title: "Create a new page", description: "Create a new page in the current project", - action: () => toggleCreatePageModal(true), + action: () => toggleCreatePageModal({ isOpen: true }), }, m: { title: "Create a new module", @@ -297,8 +297,9 @@ export const CommandPalette: FC = observer(() => { toggleCreatePageModal(false)} + isModalOpen={createPageModal.isOpen} + pageAccess={createPageModal.pageAccess} + handleModalClose={() => toggleCreatePageModal({ isOpen: false })} redirectionEnabled /> diff --git a/web/components/common/index.ts b/web/components/common/index.ts index 816562488be..1ca40f81060 100644 --- a/web/components/common/index.ts +++ b/web/components/common/index.ts @@ -3,3 +3,4 @@ export * from "./empty-state"; export * from "./latest-feature-block"; export * from "./breadcrumb-link"; export * from "./logo-spinner"; +export * from "./logo"; diff --git a/web/components/common/logo.tsx b/web/components/common/logo.tsx new file mode 100644 index 00000000000..d091dedd4c1 --- /dev/null +++ b/web/components/common/logo.tsx @@ -0,0 +1,69 @@ +import { FC } from "react"; +// emoji-picker-react +import { Emoji } from "emoji-picker-react"; +// import { icons } from "lucide-react"; +import { TLogoProps } from "@plane/types"; +// helpers +import { LUCIDE_ICONS_LIST } from "@plane/ui"; +import { emojiCodeToUnicode } from "@/helpers/emoji.helper"; + +type Props = { + logo: TLogoProps; + size?: number; + type?: "lucide" | "material"; +}; + +export const Logo: FC = (props) => { + const { logo, size = 16, type = "material" } = props; + + // destructuring the logo object + const { in_use, emoji, icon } = logo; + + // derived values + const value = in_use === "emoji" ? emoji?.value : icon?.name; + const color = icon?.color; + const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value); + + // if no value, return empty fragment + if (!value) return <>; + + // emoji + if (in_use === "emoji") { + return ; + } + + // icon + if (in_use === "icon") { + return ( + <> + {type === "lucide" ? ( + <> + {lucideIcon && ( + + )} + + ) : ( + + {value} + + )} + + ); + } + + // if no value, return empty fragment + return <>; +}; diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 97927d216af..28d84ffe47b 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -502,7 +502,7 @@ const activityDetails: { name: { message: (activity, showIssue) => ( <> - set the name to {activity.new_value} + set the title to {activity.new_value} {showIssue && ( <> {" "} diff --git a/web/components/core/index.ts b/web/components/core/index.ts index 3f753e0258f..81649c64834 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -1,5 +1,6 @@ export * from "./filters"; export * from "./modals"; +export * from "./multiple-select"; export * from "./sidebar"; export * from "./activity"; export * from "./favorite-star"; diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx index ae32c9b3177..8527d56b501 100644 --- a/web/components/core/list/list-item.tsx +++ b/web/components/core/list/list-item.tsx @@ -1,7 +1,9 @@ import React, { FC } from "react"; -import Link from "next/link"; +import { useRouter } from "next/router"; // ui -import { Tooltip } from "@plane/ui"; +import { ControlLink, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; interface IListItemProps { title: string; @@ -12,6 +14,8 @@ interface IListItemProps { actionableItems?: JSX.Element; isMobile?: boolean; parentRef: React.RefObject; + disableLink?: boolean; + className?: string; } export const ListItem: FC = (props) => { @@ -24,12 +28,28 @@ export const ListItem: FC = (props) => { onItemClick, isMobile = false, parentRef, + disableLink = false, + className = "", } = props; + // router + const router = useRouter(); + + // handlers + const handleControlLinkClick = (e: React.MouseEvent) => { + if (onItemClick) onItemClick(e); + else router.push(itemLink); + }; + return (
- -
+ +
@@ -43,7 +63,7 @@ export const ListItem: FC = (props) => {
- + {actionableItems && (
diff --git a/web/components/core/modals/alert-modal.tsx b/web/components/core/modals/alert-modal.tsx index fbc5e250354..d864c2b38a0 100644 --- a/web/components/core/modals/alert-modal.tsx +++ b/web/components/core/modals/alert-modal.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, LucideIcon } from "lucide-react"; +import { AlertTriangle, Info, LucideIcon } from "lucide-react"; // ui import { Button, TButtonVariant } from "@plane/ui"; // components @@ -6,14 +6,14 @@ import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; -export type TModalVariant = "danger"; +export type TModalVariant = "danger" | "primary"; type Props = { content: React.ReactNode | string; handleClose: () => void; handleSubmit: () => Promise; hideIcon?: boolean; - isDeleting: boolean; + isSubmitting: boolean; isOpen: boolean; position?: EModalPosition; primaryButtonText?: { @@ -28,14 +28,17 @@ type Props = { const VARIANT_ICONS: Record = { danger: AlertTriangle, + primary: Info, }; const BUTTON_VARIANTS: Record = { danger: "danger", + primary: "primary", }; const VARIANT_CLASSES: Record = { danger: "bg-red-500/20 text-red-500", + primary: "bg-custom-primary-100/20 text-custom-primary-100", }; export const AlertModalCore: React.FC = (props) => { @@ -44,7 +47,7 @@ export const AlertModalCore: React.FC = (props) => { handleClose, handleSubmit, hideIcon = false, - isDeleting, + isSubmitting, isOpen, position = EModalPosition.CENTER, primaryButtonText = { @@ -81,8 +84,8 @@ export const AlertModalCore: React.FC = (props) => { -
diff --git a/web/components/core/modals/gpt-assistant-popover.tsx b/web/components/core/modals/gpt-assistant-popover.tsx index a67b13f7ef9..5fd992a28c2 100644 --- a/web/components/core/modals/gpt-assistant-popover.tsx +++ b/web/components/core/modals/gpt-assistant-popover.tsx @@ -173,13 +173,15 @@ export const GptAssistantPopover: React.FC = (props) => { const generateResponseButtonText = isSubmitting ? "Generating response..." : response === "" - ? "Generate response" - : "Generate again"; + ? "Generate response" + : "Generate again"; return ( - + = (props) => { + const { className, disabled = false, groupId, id, selectionHelpers } = props; + // derived values + const isSelected = selectionHelpers.getIsEntitySelected(id); + + return ( + { + e.stopPropagation(); + selectionHelpers.handleEntityClick(e, id, groupId); + }} + checked={isSelected} + data-entity-group-id={groupId} + data-entity-id={id} + disabled={disabled} + readOnly + /> + ); +}; diff --git a/web/components/core/multiple-select/group-select-action.tsx b/web/components/core/multiple-select/group-select-action.tsx new file mode 100644 index 00000000000..ae2532153a9 --- /dev/null +++ b/web/components/core/multiple-select/group-select-action.tsx @@ -0,0 +1,30 @@ +// ui +import { Checkbox } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; + +type Props = { + className?: string; + disabled?: boolean; + groupID: string; + selectionHelpers: TSelectionHelper; +}; + +export const MultipleSelectGroupAction: React.FC = (props) => { + const { className, disabled = false, groupID, selectionHelpers } = props; + // derived values + const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID); + + return ( + selectionHelpers.handleGroupClick(groupID)} + checked={groupSelectionStatus === "complete"} + indeterminate={groupSelectionStatus === "partial"} + disabled={disabled} + /> + ); +}; diff --git a/web/components/core/multiple-select/index.ts b/web/components/core/multiple-select/index.ts new file mode 100644 index 00000000000..b2cdf13c361 --- /dev/null +++ b/web/components/core/multiple-select/index.ts @@ -0,0 +1,3 @@ +export * from "./entity-select-action"; +export * from "./group-select-action"; +export * from "./select-group"; diff --git a/web/components/core/multiple-select/select-group.tsx b/web/components/core/multiple-select/select-group.tsx new file mode 100644 index 00000000000..6f47b063292 --- /dev/null +++ b/web/components/core/multiple-select/select-group.tsx @@ -0,0 +1,22 @@ +import { observer } from "mobx-react"; +// hooks +import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select"; + +type Props = { + children: (helpers: TSelectionHelper) => React.ReactNode; + containerRef: React.MutableRefObject; + entities: Record; // { groupID: entityIds[] } +}; + +export const MultipleSelectGroup: React.FC = observer((props) => { + const { children, containerRef, entities } = props; + + const helpers = useMultipleSelect({ + containerRef, + entities, + }); + + return <>{children(helpers)}; +}); + +MultipleSelectGroup.displayName = "MultipleSelectGroup"; diff --git a/web/components/core/sidebar/progress-chart.tsx b/web/components/core/sidebar/progress-chart.tsx index 68b1708fe82..5f0e56fb77b 100644 --- a/web/components/core/sidebar/progress-chart.tsx +++ b/web/components/core/sidebar/progress-chart.tsx @@ -56,21 +56,18 @@ const ProgressChart: React.FC = ({ distribution, startDate, endDate, tota dates = eachDayOfInterval({ start, end }); } - const maxDates = 4; - const totalDates = dates.length; + if (dates.length === 0) return []; - if (totalDates <= maxDates) return dates.map((d) => renderFormattedDateWithoutYear(d)); - else { - const interval = Math.ceil(totalDates / maxDates); - const limitedDates = []; + const formattedDates = dates.map((d) => renderFormattedDateWithoutYear(d)); + const firstDate = formattedDates[0]; + const lastDate = formattedDates[formattedDates.length - 1]; - for (let i = 0; i < totalDates; i += interval) limitedDates.push(renderFormattedDateWithoutYear(dates[i])); + if (formattedDates.length <= 2) return [firstDate, lastDate]; - if (!limitedDates.includes(renderFormattedDateWithoutYear(dates[totalDates - 1]))) - limitedDates.push(renderFormattedDateWithoutYear(dates[totalDates - 1])); + const middleDateIndex = Math.floor(formattedDates.length / 2); + const middleDate = formattedDates[middleDateIndex]; - return limitedDates; - } + return [firstDate, middleDate, lastDate]; }; return ( diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index db9d94a8fd7..0194ba01f19 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,5 +1,5 @@ import React from "react"; - +import { observer } from "mobx-react"; import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; @@ -15,6 +15,7 @@ import { // hooks import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; +import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; // images import emptyLabel from "public/empty-state/empty_label.svg"; @@ -44,20 +45,23 @@ type Props = { 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, -}) => { +export const SidebarProgressStats: React.FC = observer((props) => { + const { + distribution, + groupedIssues, + totalIssues, + module, + roundedTab, + noBackground, + isPeekView = false, + isCompleted = false, + filters, + handleFiltersUpdate, + } = props; const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); + const { groupedProjectStates } = useProjectState(); + const currentValue = (tab: string | null) => { switch (tab) { case "Assignees": @@ -71,6 +75,12 @@ export const SidebarProgressStats: React.FC = ({ } }; + const getStateGroupState = (stateGroup: string) => { + const stateGroupStates = groupedProjectStates?.[stateGroup]; + const stateGroupStatesId = stateGroupStates?.map((state) => state.id); + return stateGroupStatesId; + }; + return ( = ({ } completed={groupedIssues[group]} total={totalIssues} + {...(!isPeekView && + !isCompleted && { + onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []), + })} /> ))} ); -}; +}); diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx index e270b5ad8a6..32d17df759d 100644 --- a/web/components/cycles/active-cycle/productivity.tsx +++ b/web/components/cycles/active-cycle/productivity.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // components @@ -8,14 +9,19 @@ import { EmptyState } from "@/components/empty-state"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProductivityProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProductivity: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; return ( -
+

Issue burndown

@@ -53,6 +59,6 @@ export const ActiveCycleProductivity: FC = (props)
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx index 6aae998bed1..fd537148ce1 100644 --- a/web/components/cycles/active-cycle/progress.tsx +++ b/web/components/cycles/active-cycle/progress.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import Link from "next/link"; // types import { ICycle } from "@plane/types"; // ui @@ -10,11 +11,13 @@ import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProgressProps = { + workspaceSlug: string; + projectId: string; cycle: ICycle; }; export const ActiveCycleProgress: FC = (props) => { - const { cycle } = props; + const { workspaceSlug, projectId, cycle } = props; const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, @@ -31,7 +34,10 @@ export const ActiveCycleProgress: FC = (props) => { }; return ( -
+

Progress

@@ -85,6 +91,6 @@ export const ActiveCycleProgress: FC = (props) => {
)} -
+ ); }; diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 625210fd49c..8b51a692bf7 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -62,13 +62,18 @@ export const ActiveCycleRoot: React.FC = observer((props) = cycleId={currentProjectActiveCycleId} workspaceSlug={workspaceSlug} projectId={projectId} + className="!border-b-transparent" /> )} -
-
- - - +
+
+ + +
diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx index 9b63b0f6f61..a66af73c39f 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -2,7 +2,7 @@ import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { User2 } from "lucide-react"; +import { Users } from "lucide-react"; // ui import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui"; // components @@ -112,9 +112,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { })} ) : ( - - - + )} = observer((props) => { = observer((props) => { })} ) : ( - - - + )}
diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 92c11dd6919..414c8081a19 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -22,10 +22,11 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; + className?: string; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; + const { cycleId, workspaceSlug, projectId, className = "" } = props; // refs const parentRef = useRef(null); // router @@ -76,13 +77,19 @@ export const CyclesListItem: FC = observer((props) => { } }; + // handlers + const handleArchivedCycleClick = (e: MouseEvent) => { + openCycleOverview(e); + }; + + const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined; + return ( { - if (cycleDetails.archived_at) openCycleOverview(e); - }} + onItemClick={handleItemClick} + className={className} prependTitleElement={ {isCompleted ? ( diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 2aa3afc48cd..595fe9b7a48 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; +import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -9,10 +10,10 @@ import { ChevronDown, LinkIcon, Trash2, - UserCircle2, AlertCircle, ChevronRight, CalendarClock, + SquareUser, } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // types @@ -199,14 +200,18 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); @@ -427,7 +432,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- + Lead
diff --git a/web/components/dashboard/widgets/recent-projects.tsx b/web/components/dashboard/widgets/recent-projects.tsx index 24c85b6f228..803edc8e28a 100644 --- a/web/components/dashboard/widgets/recent-projects.tsx +++ b/web/components/dashboard/widgets/recent-projects.tsx @@ -7,8 +7,8 @@ import { TRecentProjectsWidgetResponse } from "@plane/types"; // ui import { Avatar, AvatarGroup } from "@plane/ui"; // components +import { Logo } from "@/components/common"; import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets"; -import { ProjectLogo } from "@/components/project"; // constants import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard"; import { EUserWorkspaceRoles } from "@/constants/workspace"; @@ -38,7 +38,7 @@ const ProjectListItem: React.FC = observer((props) => { className={`grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-transparent ${randomBgColor}`} >
- +
diff --git a/web/components/dropdowns/member/avatar.tsx b/web/components/dropdowns/member/avatar.tsx index 15e1fbd8c8f..868c286654f 100644 --- a/web/components/dropdowns/member/avatar.tsx +++ b/web/components/dropdowns/member/avatar.tsx @@ -1,16 +1,19 @@ import { observer } from "mobx-react"; +// icons +import { LucideIcon, Users } from "lucide-react"; +// ui +import { Avatar, AvatarGroup } from "@plane/ui"; // hooks -import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; import { useMember } from "@/hooks/store"; -// ui type AvatarProps = { showTooltip: boolean; userIds: string | string[] | null; + icon?: LucideIcon; }; export const ButtonAvatars: React.FC = observer((props) => { - const { showTooltip, userIds } = props; + const { showTooltip, userIds, icon: Icon } = props; // store hooks const { getUserDetails } = useMember(); @@ -33,5 +36,9 @@ export const ButtonAvatars: React.FC = observer((props) => { } } - return ; + return Icon ? ( + + ) : ( + + ); }); diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx index d14cf316fad..7af6f4fe1b6 100644 --- a/web/components/dropdowns/member/index.tsx +++ b/web/components/dropdowns/member/index.tsx @@ -1,6 +1,6 @@ import { Fragment, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, LucideIcon } from "lucide-react"; // headless ui import { Combobox } from "@headlessui/react"; // helpers @@ -19,6 +19,7 @@ import { MemberDropdownProps } from "./types"; type Props = { projectId?: string; + icon?: LucideIcon; onClose?: () => void; } & MemberDropdownProps; @@ -43,6 +44,7 @@ export const MemberDropdown: React.FC = observer((props) => { showTooltip = false, tabIndex, value, + icon, } = props; // states const [isOpen, setIsOpen] = useState(false); @@ -115,7 +117,7 @@ export const MemberDropdown: React.FC = observer((props) => { showTooltip={showTooltip} variant={buttonVariant} > - {!hideIcon && } + {!hideIcon && } {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {Array.isArray(value) && value.length > 0 diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index 1bf0bc933c1..382911b082f 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -1,7 +1,7 @@ import { Fragment, ReactNode, useRef, useState } from "react"; import { useTheme } from "next-themes"; import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; +import { Check, ChevronDown, Search, SignalHigh } from "lucide-react"; import { Combobox } from "@headlessui/react"; // types import { TIssuePriorities } from "@plane/types"; @@ -26,7 +26,7 @@ type Props = TDropdownProps & { highlightUrgent?: boolean; onChange: (val: TIssuePriorities) => void; onClose?: () => void; - value: TIssuePriorities; + value: TIssuePriorities | undefined; }; type ButtonProps = { @@ -37,7 +37,8 @@ type ButtonProps = { hideText?: boolean; isActive?: boolean; highlightUrgent: boolean; - priority: TIssuePriorities; + placeholder: string; + priority: TIssuePriorities | undefined; showTooltip: boolean; }; @@ -49,6 +50,7 @@ const BorderButton = (props: ButtonProps) => { hideIcon = false, hideText = false, highlightUrgent, + placeholder, priority, showTooltip, } = props; @@ -75,7 +77,7 @@ const BorderButton = (props: ButtonProps) => { + ) : ( + + ))} + {!hideText && {priorityDetails?.title ?? placeholder}} {dropdownArrow && (
); diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 7ab9cb75d1a..893c4409df0 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -1,21 +1,20 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; 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(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, type: pageType } = router.query; // store hooks const { toggleCreatePageModal } = useCommandPalette(); const { @@ -41,7 +40,7 @@ export const PagesHeader = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -62,7 +61,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/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index e32528e821b..c874745a4bc 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; import useSWR from "swr"; // hooks import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; +import { BreadcrumbLink, Logo } from "@/components/common"; import { ISSUE_DETAILS } from "@/constants/fetch-keys"; import { useProject } from "@/hooks/store"; // components @@ -52,7 +51,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/components/headers/project-archives.tsx b/web/components/headers/project-archives.tsx index 6e5638c7144..5022414613d 100644 --- a/web/components/headers/project-archives.tsx +++ b/web/components/headers/project-archives.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; // 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"; @@ -49,7 +48,7 @@ export const ProjectArchivesHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index f6de97b5240..8c8a25c9efd 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -6,9 +6,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // 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"; // helpers @@ -101,7 +100,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index d61e2492db3..ce76f3e4066 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -5,9 +5,8 @@ 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"; @@ -35,7 +34,7 @@ export const ProjectInboxHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -46,7 +45,7 @@ export const ProjectInboxHeader: FC = observer(() => { } /> + } /> } /> diff --git a/web/components/headers/project-issue-details.tsx b/web/components/headers/project-issue-details.tsx index 176732ca5d8..890bd59e50d 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/components/headers/project-issue-details.tsx @@ -4,8 +4,7 @@ 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 { BreadcrumbLink, Logo } from "@/components/common"; import { cn } from "@/helpers/common.helper"; import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; // ui @@ -42,7 +41,7 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 8ba44719e93..7042d2c28d6 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -9,9 +9,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption 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 { EUserProjectRoles } from "@/constants/project"; @@ -130,7 +129,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { currentProjectDetails ? ( currentProjectDetails && ( - + ) ) : ( diff --git a/web/components/headers/project-settings.tsx b/web/components/headers/project-settings.tsx index f25bfe8040f..2fe48969d42 100644 --- a/web/components/headers/project-settings.tsx +++ b/web/components/headers/project-settings.tsx @@ -2,22 +2,16 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // ui +import { Settings } from "lucide-react"; import { Breadcrumbs, CustomMenu } from "@plane/ui"; -// helper -import { BreadcrumbLink } from "@/components/common"; -import { ProjectLogo } from "@/components/project"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; +// constants import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; // hooks import { useProject, useUser } from "@/hooks/store"; -// constants -// components - -export interface IProjectSettingHeader { - title: string; -} -export const ProjectSettingHeader: FC = observer((props) => { - const { title } = props; +export const ProjectSettingHeader: FC = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -44,7 +38,7 @@ export const ProjectSettingHeader: FC = observer((props) icon={ currentProjectDetails && ( - + ) } @@ -52,7 +46,12 @@ export const ProjectSettingHeader: FC = observer((props) } />
- } /> + } /> + } + />
@@ -62,7 +61,7 @@ export const ProjectSettingHeader: FC = observer((props) maxHeight="lg" customButton={ - {title} + Settings } placement="bottom-start" diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index 297c976eeab..0e8f59e6c3d 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -7,9 +7,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // 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 { EUserProjectRoles } from "@/constants/project"; @@ -141,7 +140,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } @@ -164,7 +163,11 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { - + {viewDetails?.logo_props?.in_use ? ( + + ) : ( + + )} {viewDetails?.name && truncateText(viewDetails.name, 40)} } @@ -182,7 +185,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)} diff --git a/web/components/headers/project-views.tsx b/web/components/headers/project-views.tsx index 3cd5788470d..7f1d1a725eb 100644 --- a/web/components/headers/project-views.tsx +++ b/web/components/headers/project-views.tsx @@ -1,14 +1,13 @@ import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -// components +// 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(() => { @@ -40,7 +39,7 @@ export const ProjectViewsHeader: React.FC = observer(() => { icon={ currentProjectDetails && ( - + ) } diff --git a/web/components/headers/workspace-settings.tsx b/web/components/headers/workspace-settings.tsx index c73d06547ce..2d3e9649e1e 100644 --- a/web/components/headers/workspace-settings.tsx +++ b/web/components/headers/workspace-settings.tsx @@ -1,21 +1,15 @@ import { FC } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { observer } from "mobx-react";; import { Settings } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; +// hooks +import { useWorkspace } from "@/hooks/store"; -export interface IWorkspaceSettingHeader { - title: string; -} - -export const WorkspaceSettingHeader: FC = observer((props) => { - const { title } = props; - const router = useRouter(); - - const { workspaceSlug } = router.query; +export const WorkspaceSettingHeader: FC = observer(() => { + const { currentWorkspace } = useWorkspace(); return (
@@ -26,13 +20,13 @@ export const WorkspaceSettingHeader: FC = observer((pro type="text" link={ } /> } /> - } /> + } />
diff --git a/web/components/inbox/content/inbox-issue-header.tsx b/web/components/inbox/content/inbox-issue-header.tsx index 7fd038faade..8a3401569bc 100644 --- a/web/components/inbox/content/inbox-issue-header.tsx +++ b/web/components/inbox/content/inbox-issue-header.tsx @@ -52,7 +52,7 @@ export const InboxIssueActionsHeader: FC = observer((p const [declineIssueModal, setDeclineIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false); // store - const { currentTab, deleteInboxIssue, inboxIssuesArray } = useProjectInbox(); + const { currentTab, deleteInboxIssue, inboxIssueIds } = useProjectInbox(); const { data: currentUser } = useUser(); const { membership: { currentProjectRole }, @@ -76,11 +76,11 @@ export const InboxIssueActionsHeader: FC = observer((p const redirectIssue = (): string | undefined => { let nextOrPreviousIssueId: string | undefined = undefined; - const currentIssueIndex = inboxIssuesArray.findIndex((i) => i.issue.id === currentInboxIssueId); - if (inboxIssuesArray[currentIssueIndex + 1]) - nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex + 1].issue.id; - else if (inboxIssuesArray[currentIssueIndex - 1]) - nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex - 1].issue.id; + const currentIssueIndex = inboxIssueIds.findIndex((id) => id === currentInboxIssueId); + if (inboxIssueIds[currentIssueIndex + 1]) + nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex + 1]; + else if (inboxIssueIds[currentIssueIndex - 1]) + nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex - 1]; else nextOrPreviousIssueId = undefined; return nextOrPreviousIssueId; }; @@ -134,22 +134,22 @@ export const InboxIssueActionsHeader: FC = observer((p }) ); - const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0; + const currentIssueIndex = inboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0; const handleInboxIssueNavigation = useCallback( (direction: "next" | "prev") => { - if (!inboxIssuesArray || !currentInboxIssueId) return; + if (!inboxIssueIds || !currentInboxIssueId) return; const activeElement = document.activeElement as HTMLElement; if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; const nextIssueIndex = direction === "next" - ? (currentIssueIndex + 1) % inboxIssuesArray.length - : (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length; - const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id; + ? (currentIssueIndex + 1) % inboxIssueIds.length + : (currentIssueIndex - 1 + inboxIssueIds.length) % inboxIssueIds.length; + const nextIssueId = inboxIssueIds[nextIssueIndex]; if (!nextIssueId) return; router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`); }, - [currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug] + [currentInboxIssueId, currentIssueIndex, inboxIssueIds, projectId, router, workspaceSlug] ); const onKeyDown = useCallback( diff --git a/web/components/inbox/content/issue-properties.tsx b/web/components/inbox/content/issue-properties.tsx index 9074f67ca5a..92205e626cd 100644 --- a/web/components/inbox/content/issue-properties.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { CalendarCheck2, CopyPlus, Signal, Tag } from "lucide-react"; +import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react"; import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; -import { ControlLink, DoubleCircleIcon, Tooltip, UserGroupIcon } from "@plane/ui"; +import { ControlLink, DoubleCircleIcon, Tooltip } from "@plane/ui"; // components import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns"; import { IssueLabel, TIssueOperations } from "@/components/issues"; @@ -64,7 +64,7 @@ export const InboxIssueContentProperties: React.FC = observer((props) => {/* Assignee */}
- + Assignees
= observer((props) => { const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props; + /// router + const router = useRouter(); // states const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks - const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox(); + const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox(); const inboxIssue = getIssueInboxByIssueId(inboxIssueId); const { membership: { currentProjectRole }, } = useUser(); + // derived values + const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || ""); + + useEffect(() => { + if (!isIssueAvailable && inboxIssueId) { + router.replace(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${currentTab}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isIssueAvailable]); useSWR( workspaceSlug && projectId && inboxIssueId diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index 27e710a5fb0..36f1b0abe58 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -18,6 +18,7 @@ import { ISSUE_CREATED } from "@/constants/event-tracker"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; +import useKeypress from "@/hooks/use-keypress"; type TInboxIssueCreateRoot = { workspaceSlug: string; @@ -42,6 +43,7 @@ export const InboxIssueCreateRoot: FC = observer((props) const router = useRouter(); // refs const descriptionEditorRef = useRef(null); + const submitBtnRef = useRef(null); // hooks const { captureIssueEvent } = useEventTracker(); const { createInboxIssue } = useProjectInbox(); @@ -61,8 +63,33 @@ export const InboxIssueCreateRoot: FC = observer((props) [formData] ); + const handleEscKeyDown = (event: KeyboardEvent) => { + if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { + handleModalClose(); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + event.preventDefault(); // Prevent default action if editor is not ready to discard + } + }; + + useKeypress("Escape", handleEscKeyDown); + const handleFormSubmit = async (event: FormEvent) => { event.preventDefault(); + + if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + return; + } + const payload: Partial = { name: formData.name || "", description_html: formData.description_html || "

", @@ -139,6 +166,7 @@ export const InboxIssueCreateRoot: FC = observer((props) handleData={handleFormData} editorRef={descriptionEditorRef} containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" + onEnterKeyPress={() => submitBtnRef?.current?.click()} />
@@ -153,11 +181,27 @@ export const InboxIssueCreateRoot: FC = observer((props) Create more
-
@@ -160,6 +162,7 @@ export const InboxIssueEditRoot: FC = observer((props) => { variant="primary" size="sm" type="button" + ref={submitBtnRef} loading={formSubmitting} disabled={isTitleLengthMoreThan255Character} onClick={handleFormSubmit} diff --git a/web/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/components/inbox/modals/create-edit-modal/issue-description.tsx index 882fb0f955e..4b4cb261e74 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -18,11 +18,13 @@ type TInboxIssueDescription = { data: Partial; handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; editorRef: RefObject; + onEnterKeyPress?: (e?: any) => void; }; // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; + const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } = + props; // hooks const { loader } = useProjectInbox(); @@ -44,6 +46,7 @@ export const InboxIssueDescription: FC = observer((props onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} placeholder={getDescriptionPlaceholder} containerClassName={containerClassName} + onEnterKeyPress={onEnterKeyPress} /> ); }); diff --git a/web/components/inbox/modals/decline-issue-modal.tsx b/web/components/inbox/modals/decline-issue-modal.tsx index 5c7b35a1c2e..4ca784ec150 100644 --- a/web/components/inbox/modals/decline-issue-modal.tsx +++ b/web/components/inbox/modals/decline-issue-modal.tsx @@ -36,7 +36,7 @@ export const DeclineIssueModal: React.FC = (props) => { = observer(({ isOpen, onClos void; }; export const InboxIssueListItem: FC = observer((props) => { - const { workspaceSlug, projectId, inboxIssue, projectIdentifier, setIsMobileSidebar } = props; + const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props; // router const router = useRouter(); - const { inboxIssueId } = router.query; + const { inboxIssueId: selectedInboxIssueId } = router.query; // store - const { currentTab } = useProjectInbox(); + const { currentTab, getIssueInboxByIssueId } = useProjectInbox(); const { projectLabels } = useLabel(); const { isMobile } = usePlatformOS(); const { getUserDetails } = useMember(); - const issue = inboxIssue.issue; + const inboxIssue = getIssueInboxByIssueId(inboxIssueId); + const issue = inboxIssue?.issue; const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => { - if (inboxIssueId === currentIssueId) event.preventDefault(); + if (selectedInboxIssueId === currentIssueId) event.preventDefault(); setIsMobileSidebar(false); }; @@ -55,7 +54,7 @@ export const InboxIssueListItem: FC = observer((props)
diff --git a/web/components/inbox/sidebar/inbox-list.tsx b/web/components/inbox/sidebar/inbox-list.tsx index be435cd7752..95d692b6059 100644 --- a/web/components/inbox/sidebar/inbox-list.tsx +++ b/web/components/inbox/sidebar/inbox-list.tsx @@ -2,30 +2,28 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; // components import { InboxIssueListItem } from "@/components/inbox"; -// store -import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; export type InboxIssueListProps = { workspaceSlug: string; projectId: string; projectIdentifier?: string; - inboxIssues: IInboxIssueStore[]; + inboxIssueIds: string[]; setIsMobileSidebar: (value: boolean) => void; }; export const InboxIssueList: FC = observer((props) => { - const { workspaceSlug, projectId, projectIdentifier, inboxIssues, setIsMobileSidebar } = props; + const { workspaceSlug, projectId, projectIdentifier, inboxIssueIds, setIsMobileSidebar } = props; return ( <> - {inboxIssues.map((inboxIssue) => ( - + {inboxIssueIds.map((inboxIssueId) => ( + ))} diff --git a/web/components/inbox/sidebar/root.tsx b/web/components/inbox/sidebar/root.tsx index f33cb3c2f24..ed6d0cdd2d9 100644 --- a/web/components/inbox/sidebar/root.tsx +++ b/web/components/inbox/sidebar/root.tsx @@ -44,7 +44,7 @@ export const InboxSidebar: FC = observer((props) => { currentTab, handleCurrentTab, loader, - inboxIssuesArray, + inboxIssueIds, inboxIssuePaginationInfo, fetchInboxPaginationIssues, getAppliedFiltersCount, @@ -56,13 +56,9 @@ export const InboxSidebar: FC = observer((props) => { if (!workspaceSlug || !projectId) return; fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString()); }, [workspaceSlug, projectId, fetchInboxPaginationIssues]); + // page observer - useIntersectionObserver({ - containerRef, - elementRef, - callback: fetchNextPages, - rootMargin: "20%", - }); + useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%"); return (
@@ -108,13 +104,13 @@ export const InboxSidebar: FC = observer((props) => { className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md" ref={containerRef} > - {inboxIssuesArray.length > 0 ? ( + {inboxIssueIds.length > 0 ? ( ) : (
@@ -130,15 +126,14 @@ export const InboxSidebar: FC = observer((props) => { />
)} - -
- {inboxIssuePaginationInfo?.next_page_results && ( + {inboxIssuePaginationInfo?.next_page_results && ( +
- )} -
+
+ )}
)}
diff --git a/web/components/integration/github/root.tsx b/web/components/integration/github/root.tsx index d5866e95a0c..7e9322a5a61 100644 --- a/web/components/integration/github/root.tsx +++ b/web/components/integration/github/root.tsx @@ -10,9 +10,9 @@ import useSWR, { mutate } from "swr"; // react-hook-form // services // components -import { ArrowLeft, Check, List, Settings, UploadCloud } from "lucide-react"; +import { ArrowLeft, Check, List, Settings, UploadCloud, Users } from "lucide-react"; import { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types"; -import { UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { GithubImportConfigure, GithubImportData, @@ -72,7 +72,7 @@ const integrationWorkflowData = [ { title: "Users", key: "import-users", - icon: UserGroupIcon, + icon: Users, }, { title: "Confirm", diff --git a/web/components/integration/jira/root.tsx b/web/components/integration/jira/root.tsx index b95ec198602..1b98c27ba9d 100644 --- a/web/components/integration/jira/root.tsx +++ b/web/components/integration/jira/root.tsx @@ -5,12 +5,12 @@ import { useRouter } from "next/router"; import { FormProvider, useForm } from "react-hook-form"; import { mutate } from "swr"; // icons -import { ArrowLeft, Check, List, Settings } from "lucide-react"; +import { ArrowLeft, Check, List, Settings, Users } from "lucide-react"; import { IJiraImporterForm } from "@plane/types"; // services // fetch keys // components -import { Button, UserGroupIcon } from "@plane/ui"; +import { Button } from "@plane/ui"; import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; // assets import { JiraImporterService } from "@/services/integrations"; @@ -44,7 +44,7 @@ const integrationWorkflowData: Array<{ { title: "Users", key: "import-users", - icon: UserGroupIcon, + icon: Users, }, { title: "Confirm", diff --git a/web/components/issues/attachment/delete-attachment-modal.tsx b/web/components/issues/attachment/delete-attachment-modal.tsx index 94ff60c947e..98687f53875 100644 --- a/web/components/issues/attachment/delete-attachment-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-modal.tsx @@ -35,7 +35,7 @@ export const IssueAttachmentDeleteModal: FC = (props) => { handleDeletion(data.id)} - isDeleting={loader} + isSubmitting={loader} isOpen={isOpen} title="Delete attachment" content={ diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index 49c1a870099..46f8e733de8 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -66,7 +66,7 @@ export const DeleteIssueModal: React.FC = (props) => { = observer((props if (!activity) return <>; return ( } + icon={} activityId={activityId} ends={ends} > diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index d8399fc02be..402319af4c9 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -103,7 +103,7 @@ export const IssueParentSelect: React.FC = observer((props)
= observer((props) => { Sibling issues
- + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx index c6eef2a9e32..c66a1889943 100644 --- a/web/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; // ui import { CustomMenu, LayersIcon } from "@plane/ui"; @@ -6,15 +7,15 @@ import { CustomMenu, LayersIcon } from "@plane/ui"; import { useIssueDetail, useProject } from "@/hooks/store"; type TIssueParentSiblingItem = { + workspaceSlug: string; issueId: string; }; -export const IssueParentSiblingItem: FC = (props) => { - const { issueId } = props; +export const IssueParentSiblingItem: FC = observer((props) => { + const { workspaceSlug, issueId } = props; // hooks const { getProjectById } = useProject(); const { - peekIssue, issue: { getIssueById }, } = useIssueDetail(); @@ -27,7 +28,7 @@ export const IssueParentSiblingItem: FC = (props) => { <> @@ -36,4 +37,4 @@ export const IssueParentSiblingItem: FC = (props) => { ); -}; +}); diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index 56e93fc0f11..e23d8a595fe 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { TIssue } from "@plane/types"; // components @@ -8,25 +9,25 @@ import { useIssueDetail } from "@/hooks/store"; import { IssueParentSiblingItem } from "./sibling-item"; export type TIssueParentSiblings = { + workspaceSlug: string; currentIssue: TIssue; parentIssue: TIssue; }; -export const IssueParentSiblings: FC = (props) => { - const { currentIssue, parentIssue } = props; +export const IssueParentSiblings: FC = observer((props) => { + const { workspaceSlug, currentIssue, parentIssue } = props; // hooks const { - peekIssue, fetchSubIssues, subIssues: { subIssuesByIssueId }, } = useIssueDetail(); const { isLoading } = useSWR( - peekIssue && parentIssue && parentIssue.project_id - ? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` + parentIssue && parentIssue.project_id + ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` : null, - peekIssue && parentIssue && parentIssue.project_id - ? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) + parentIssue && parentIssue.project_id + ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id) : null ); @@ -40,7 +41,10 @@ export const IssueParentSiblings: FC = (props) => {
) : subIssueIds && subIssueIds.length > 0 ? ( subIssueIds.map( - (issueId) => currentIssue.id != issueId && + (issueId) => + currentIssue.id != issueId && ( + + ) ) ) : (
@@ -49,4 +53,4 @@ export const IssueParentSiblings: FC = (props) => { )}
); -}; +}); diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index a9a23ebdcbe..e5526a4a81a 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -12,6 +12,7 @@ import { Tag, Trash2, Triangle, + Users, XCircle, } from "lucide-react"; // hooks @@ -24,7 +25,6 @@ import { RelatedIcon, TOAST_TYPE, Tooltip, - UserGroupIcon, setToast, } from "@plane/ui"; import { @@ -48,7 +48,7 @@ import { } from "@/components/issues"; // helpers // types -import { STATE_GROUPS } from "@/constants/state"; +import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state"; import { cn } from "@/helpers/common.helper"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; @@ -117,8 +117,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const stateDetails = getStateById(issue.state_id); // auth const isArchivingAllowed = !is_archived && issueOperations.archive && isEditable; - const isInArchivableGroup = - !!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group); + const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); const minDate = issue.start_date ? getDate(issue.start_date) : null; minDate?.setDate(minDate.getDate()); @@ -219,7 +218,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
- + Assignees
= observer((props) => { ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list"; - const emptyStateSize = isEmptyFilters ? "lg" : "sm"; return ( <> @@ -84,7 +83,6 @@ export const CycleEmptyState: React.FC = observer((props) => { { diff --git a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx index 07cb70ceb37..7fc6811c857 100644 --- a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx @@ -41,14 +41,12 @@ export const ProjectDraftEmptyState: React.FC = observer(() => { const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER : EmptyStateType.PROJECT_DRAFT_NO_ISSUES; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; - const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm"; return (
0 ? handleClearAllFilters : undefined} />
diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index a75cec391a7..536bd985be5 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -55,7 +55,6 @@ export const ModuleEmptyState: React.FC = observer((props) => { const emptyStateType = isEmptyFilters ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_MODULE_ISSUES; const additionalPath = activeLayout ?? "list"; - const emptyStateSize = isEmptyFilters ? "lg" : "sm"; return ( <> @@ -71,7 +70,6 @@ export const ModuleEmptyState: React.FC = observer((props) => { { const emptyStateType = issueFilterCount > 0 ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_NO_ISSUES; const additionalPath = issueFilterCount > 0 ? activeLayout ?? "list" : undefined; - const emptyStateSize = issueFilterCount > 0 ? "lg" : "sm"; return (
0 ? undefined diff --git a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx index 54dc039191b..190d9f1fa15 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx @@ -1,9 +1,9 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; +// components +import { Logo } from "@/components/common"; // hooks -import { ProjectLogo } from "@/components/project"; import { useProject } from "@/hooks/store"; -// components type Props = { handleRemove: (val: string) => void; @@ -26,7 +26,7 @@ export const AppliedProjectFilters: React.FC = observer((props) => { return (
- + {projectDetails.name} {editable && ( diff --git a/web/components/issues/issue-layouts/filters/header/filters/project.tsx b/web/components/issues/issue-layouts/filters/header/filters/project.tsx index 26b0bb46bae..d739674813e 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/project.tsx @@ -1,15 +1,13 @@ import React, { useMemo, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; -// components +// ui import { Loader } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; import { FilterHeader, FilterOption } from "@/components/issues"; // hooks -import { ProjectLogo } from "@/components/project"; import { useProject } from "@/hooks/store"; -// components -// ui -// helpers type Props = { appliedFilters: string[] | null; @@ -65,7 +63,7 @@ export const FilterProjects: React.FC = observer((props) => { onClick={() => handleUpdate(project.id)} icon={ - + } title={project.name} diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index b8060d7cf82..831c3119ef9 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -182,14 +182,14 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }; const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { - if (workspaceSlug && projectId) { + if (workspaceSlug) { let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; if (kanbanFilters.includes(value)) { kanbanFilters = kanbanFilters.filter((_value) => _value != value); } else { kanbanFilters.push(value); } - updateFilters(projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { + updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, { [toggle]: kanbanFilters, }); } diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index da46e862775..fec907a7888 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -169,9 +169,9 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: subGroupValue }; } else if (subGroupByKey === "priority") { preloadedData = { ...preloadedData, priority: subGroupValue }; - } else if (groupByKey === "cycle") { + } else if (subGroupByKey === "cycle") { preloadedData = { ...preloadedData, cycle_id: subGroupValue }; - } else if (groupByKey === "module") { + } else if (subGroupByKey === "module") { preloadedData = { ...preloadedData, module_ids: [subGroupValue] }; } else if (subGroupByKey === "labels" && subGroupValue != "None") { preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 3cf82cd319b..2ff31ab1d4c 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -67,8 +67,8 @@ export const IssueBlock = observer((props: IssueBlockProps) => { !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id, nestingLevel: nestingLevel }); - const issue = issuesMap[issueId]; - const subIssuesCount = issue.sub_issues_count; + const issue = issuesMap[issueId]; + const subIssuesCount = issue?.sub_issues_count ?? 0; const { isMobile } = usePlatformOS(); @@ -131,8 +131,14 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
-
- +
+
{subIssuesCount > 0 && (
- ( - { - onChange(description_html); - handleFormChange(); - }} - ref={editorRef} - tabIndex={getTabIndex("description_html")} - placeholder={getDescriptionPlaceholder} - containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" - /> - )} - /> )}
@@ -738,7 +766,22 @@ export const IssueFormRoot: FC = observer((props) => { )}
- {isDraft && ( @@ -770,6 +813,7 @@ export const IssueFormRoot: FC = observer((props) => { variant="primary" type="submit" size="sm" + ref={submitBtnRef} loading={isSubmitting} tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")} > diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 38b328df65b..6558c55c4f3 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -140,6 +140,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop } setActiveProjectId(null); + setChangesMade(null); onClose(); }; diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx index 9f146cdc006..05337649113 100644 --- a/web/components/issues/peek-overview/header.tsx +++ b/web/components/issues/peek-overview/header.tsx @@ -15,7 +15,7 @@ import { } from "@plane/ui"; // components import { IssueSubscription, IssueUpdateStatus } from "@/components/issues"; -import { STATE_GROUPS } from "@/constants/state"; +import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state"; // helpers import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; @@ -100,8 +100,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr }; // auth const isArchivingAllowed = !isArchived && !disabled; - const isInArchivableGroup = - !!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group); + const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); const isRestoringAllowed = isArchived && !disabled; return ( diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index cbc35b5e295..5364dacbb39 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -10,10 +10,11 @@ import { XCircle, CalendarClock, CalendarCheck2, + Users, } from "lucide-react"; // hooks // ui icons -import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; +import { DiceIcon, DoubleCircleIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; // components import { DateDropdown, @@ -94,7 +95,7 @@ export const PeekOverviewProperties: FC = observer((pro {/* assignee */}
- + Assignees
= observer((props) => { }, issueId ); + const handleKeyDown = () => { const slashCommandDropdownElement = document.querySelector("#slash-command"); const dropdownElement = document.activeElement?.tagName === "INPUT"; @@ -74,6 +75,7 @@ export const IssueView: FC = observer((props) => { if (issueElement) issueElement?.focus(); } }; + useKeypress("Escape", handleKeyDown); const handleRestore = async () => { diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index fee060d1b86..ddc0e41b3fd 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -4,13 +4,14 @@ import { useRouter } from "next/router"; import { usePopper } from "react-popper"; import { Check, Component, Plus, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; -// hooks +// components import { IssueLabelsList } from "@/components/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useLabel } from "@/hooks/store"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -// ui -// icons type Props = { setIsOpen: React.Dispatch>; @@ -21,10 +22,21 @@ type Props = { disabled?: boolean; tabIndex?: number; createLabelEnabled?: boolean; + buttonClassName?: string; }; export const IssueLabelSelect: React.FC = observer((props) => { - const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex, createLabelEnabled = true } = props; + const { + setIsOpen, + value, + onChange, + projectId, + label, + disabled = false, + tabIndex, + createLabelEnabled = true, + buttonClassName, + } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -101,7 +113,7 @@ export const IssueLabelSelect: React.FC = observer((props) => {