mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' into fix/table-colors-row-col-add
This commit is contained in:
commit
2ca8448350
165
.github/workflows/build-branch.yml
vendored
165
.github/workflows/build-branch.yml
vendored
@ -1,61 +1,30 @@
|
|||||||
name: Branch Build
|
name: Branch Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_dispatch:
|
||||||
types:
|
inputs:
|
||||||
- closed
|
branch_name:
|
||||||
|
description: "Branch Name"
|
||||||
|
required: true
|
||||||
|
default: "preview"
|
||||||
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- preview
|
- preview
|
||||||
- qa
|
|
||||||
- develop
|
- develop
|
||||||
- release-*
|
|
||||||
release:
|
release:
|
||||||
types: [released, prereleased]
|
types: [released, prereleased]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
|
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
branch_build_setup:
|
branch_build_setup:
|
||||||
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
|
|
||||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v3.3.0
|
uses: actions/checkout@v3.3.0
|
||||||
|
|
||||||
- name: Uploading Proxy Source
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: proxy-src-code
|
|
||||||
path: ./nginx
|
|
||||||
- name: Uploading Backend Source
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: backend-src-code
|
|
||||||
path: ./apiserver
|
|
||||||
- name: Uploading Web Source
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: web-src-code
|
|
||||||
path: |
|
|
||||||
./
|
|
||||||
!./apiserver
|
|
||||||
!./nginx
|
|
||||||
!./deploy
|
|
||||||
!./space
|
|
||||||
- name: Uploading Space Source
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: space-src-code
|
|
||||||
path: |
|
|
||||||
./
|
|
||||||
!./apiserver
|
|
||||||
!./nginx
|
|
||||||
!./deploy
|
|
||||||
!./web
|
|
||||||
outputs:
|
outputs:
|
||||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
||||||
|
|
||||||
@ -63,33 +32,38 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Frontend Docker Tag
|
- name: Set Frontend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.FRONTEND_TAG }}
|
TAG=${{ env.FRONTEND_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
- name: Docker Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Downloading Web Source Code
|
|
||||||
uses: actions/download-artifact@v3
|
- name: Check out the repo
|
||||||
with:
|
uses: actions/checkout@v4.1.1
|
||||||
name: web-src-code
|
|
||||||
|
|
||||||
- name: Build and Push Frontend to Docker Container Registry
|
- name: Build and Push Frontend to Docker Container Registry
|
||||||
uses: docker/build-push-action@v4.0.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./web/Dockerfile.web
|
file: ./web/Dockerfile.web
|
||||||
@ -105,33 +79,39 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Space Docker Tag
|
- name: Set Space Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.SPACE_TAG }}
|
TAG=${{ env.SPACE_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Docker Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Downloading Space Source Code
|
|
||||||
uses: actions/download-artifact@v3
|
- name: Check out the repo
|
||||||
with:
|
uses: actions/checkout@v4.1.1
|
||||||
name: space-src-code
|
|
||||||
|
|
||||||
- name: Build and Push Space to Docker Hub
|
- name: Build and Push Space to Docker Hub
|
||||||
uses: docker/build-push-action@v4.0.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./space/Dockerfile.space
|
file: ./space/Dockerfile.space
|
||||||
@ -147,36 +127,42 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Backend Docker Tag
|
- name: Set Backend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.BACKEND_TAG }}
|
TAG=${{ env.BACKEND_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Docker Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Downloading Backend Source Code
|
|
||||||
uses: actions/download-artifact@v3
|
- name: Check out the repo
|
||||||
with:
|
uses: actions/checkout@v4.1.1
|
||||||
name: backend-src-code
|
|
||||||
|
|
||||||
- name: Build and Push Backend to Docker Hub
|
- name: Build and Push Backend to Docker Hub
|
||||||
uses: docker/build-push-action@v4.0.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ./apiserver
|
||||||
file: ./Dockerfile.api
|
file: ./apiserver/Dockerfile.api
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.BACKEND_TAG }}
|
tags: ${{ env.BACKEND_TAG }}
|
||||||
@ -189,37 +175,42 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Proxy Docker Tag
|
- name: Set Proxy Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.PROXY_TAG }}
|
TAG=${{ env.PROXY_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Docker Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Downloading Proxy Source Code
|
- name: Check out the repo
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/checkout@v4.1.1
|
||||||
with:
|
|
||||||
name: proxy-src-code
|
|
||||||
|
|
||||||
- name: Build and Push Plane-Proxy to Docker Hub
|
- name: Build and Push Plane-Proxy to Docker Hub
|
||||||
uses: docker/build-push-action@v4.0.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ./nginx
|
||||||
file: ./Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
tags: ${{ env.PROXY_TAG }}
|
tags: ${{ env.PROXY_TAG }}
|
||||||
push: true
|
push: true
|
||||||
|
@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
|
|||||||
|
|
||||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||||
|
|
||||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose).
|
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||||
|
|
||||||
## ⚡️ Contributors Quick Start
|
## ⚡️ Contributors Quick Start
|
||||||
|
|
||||||
|
@ -8,11 +8,11 @@ SENTRY_DSN=""
|
|||||||
SENTRY_ENVIRONMENT="development"
|
SENTRY_ENVIRONMENT="development"
|
||||||
|
|
||||||
# Database Settings
|
# Database Settings
|
||||||
PGUSER="plane"
|
POSTGRES_USER="plane"
|
||||||
PGPASSWORD="plane"
|
POSTGRES_PASSWORD="plane"
|
||||||
PGHOST="plane-db"
|
POSTGRES_HOST="plane-db"
|
||||||
PGDATABASE="plane"
|
POSTGRES_DB="plane"
|
||||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||||
|
|
||||||
# Oauth variables
|
# Oauth variables
|
||||||
GOOGLE_CLIENT_ID=""
|
GOOGLE_CLIENT_ID=""
|
||||||
|
@ -39,7 +39,6 @@ from plane.app.serializers import (
|
|||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
WorkspaceUserPermission
|
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
from datetime import timedelta, date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
|
||||||
from .base import BaseViewSet, BaseAPIView
|
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import (
|
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||||
Page,
|
PageLogSerializer, PageSerializer,
|
||||||
PageFavorite,
|
SubPageSerializer)
|
||||||
Issue,
|
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||||
IssueAssignee,
|
PageFavorite, PageLog, ProjectMember)
|
||||||
IssueActivity,
|
|
||||||
PageLog,
|
# Module imports
|
||||||
ProjectMember,
|
from .base import BaseAPIView, BaseViewSet
|
||||||
)
|
|
||||||
from plane.app.serializers import (
|
|
||||||
PageSerializer,
|
|
||||||
PageFavoriteSerializer,
|
|
||||||
PageLogSerializer,
|
|
||||||
IssueLiteSerializer,
|
|
||||||
SubPageSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||||
@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
member=request.user,
|
member=request.user,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
role__gt=20,
|
role__gte=20,
|
||||||
).exists()
|
).exists()
|
||||||
or request.user.id != page.owned_by_id
|
or request.user.id != page.owned_by_id
|
||||||
):
|
):
|
||||||
|
@ -41,7 +41,7 @@ from plane.app.serializers import (
|
|||||||
ProjectMemberSerializer,
|
ProjectMemberSerializer,
|
||||||
WorkspaceThemeSerializer,
|
WorkspaceThemeSerializer,
|
||||||
IssueActivitySerializer,
|
IssueActivitySerializer,
|
||||||
IssueLiteSerializer,
|
IssueSerializer,
|
||||||
WorkspaceMemberAdminSerializer,
|
WorkspaceMemberAdminSerializer,
|
||||||
WorkspaceMemberMeSerializer,
|
WorkspaceMemberMeSerializer,
|
||||||
ProjectMemberRoleSerializer,
|
ProjectMemberRoleSerializer,
|
||||||
@ -1339,23 +1339,10 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.annotate(
|
.select_related("workspace", "project", "state", "parent")
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
|
||||||
parent=OuterRef("id")
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.select_related("project", "workspace", "state", "parent")
|
|
||||||
.prefetch_related("assignees", "labels")
|
.prefetch_related("assignees", "labels")
|
||||||
.prefetch_related(
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
Prefetch(
|
.annotate(module_id=F("issue_module__module_id"))
|
||||||
"issue_reactions",
|
|
||||||
queryset=IssueReaction.objects.select_related("actor"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("-created_at")
|
|
||||||
.annotate(
|
.annotate(
|
||||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||||
.order_by()
|
.order_by()
|
||||||
@ -1370,6 +1357,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.order_by("created_at")
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
# Priority Ordering
|
# Priority Ordering
|
||||||
@ -1432,7 +1426,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
else:
|
else:
|
||||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||||
|
|
||||||
issues = IssueLiteSerializer(
|
issues = IssueSerializer(
|
||||||
issue_queryset, many=True, fields=fields if fields else None
|
issue_queryset, many=True, fields=fields if fields else None
|
||||||
).data
|
).data
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
|||||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||||
try:
|
try:
|
||||||
relative_link = (
|
relative_link = (
|
||||||
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}"
|
f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
|
||||||
)
|
)
|
||||||
abs_url = str(current_site) + relative_link
|
abs_url = str(current_site) + relative_link
|
||||||
|
|
||||||
|
@ -7,19 +7,17 @@ from . import ProjectBaseModel
|
|||||||
|
|
||||||
|
|
||||||
def get_default_filters():
|
def get_default_filters():
|
||||||
return (
|
return {
|
||||||
{
|
"priority": None,
|
||||||
"priority": None,
|
"state": None,
|
||||||
"state": None,
|
"state_group": None,
|
||||||
"state_group": None,
|
"assignees": None,
|
||||||
"assignees": None,
|
"created_by": None,
|
||||||
"created_by": None,
|
"labels": None,
|
||||||
"labels": None,
|
"start_date": None,
|
||||||
"start_date": None,
|
"target_date": None,
|
||||||
"target_date": None,
|
"subscriber": None,
|
||||||
"subscriber": None,
|
}
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_display_filters():
|
def get_default_display_filters():
|
||||||
|
@ -482,19 +482,16 @@ export class TableView implements NodeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateControls() {
|
updateControls() {
|
||||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
||||||
(acc, curr) => {
|
if (curr.spec.hoveredCell !== undefined) {
|
||||||
if (curr.spec.hoveredCell !== undefined) {
|
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (curr.spec.hoveredTable !== undefined) {
|
if (curr.spec.hoveredTable !== undefined) {
|
||||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
}, {} as Record<string, HTMLElement>) as any;
|
||||||
{} as Record<string, HTMLElement>
|
|
||||||
) as any;
|
|
||||||
|
|
||||||
if (table === undefined || cell === undefined) {
|
if (table === undefined || cell === undefined) {
|
||||||
return this.root.classList.add("controls--disabled");
|
return this.root.classList.add("controls--disabled");
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
"@plane/editor-core": "*",
|
"@plane/editor-core": "*",
|
||||||
"@plane/editor-extensions": "*",
|
"@plane/editor-extensions": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
"@tiptap/extension-placeholder": "^2.1.13",
|
"@tiptap/extension-placeholder": "^2.1.13",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
|
|
||||||
type IPageRenderer = {
|
type IPageRenderer = {
|
||||||
documentDetails: DocumentDetails;
|
documentDetails: DocumentDetails;
|
||||||
updatePageTitle: (title: string) => Promise<void>;
|
updatePageTitle: (title: string) => void;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
onActionCompleteHandler: (action: {
|
onActionCompleteHandler: (action: {
|
||||||
title: string;
|
title: string;
|
||||||
@ -30,18 +30,6 @@ type IPageRenderer = {
|
|||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debounce = (func: (...args: any[]) => void, wait: number) => {
|
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
|
||||||
return function executedFunction(...args: any[]) {
|
|
||||||
const later = () => {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageRenderer = (props: IPageRenderer) => {
|
export const PageRenderer = (props: IPageRenderer) => {
|
||||||
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
|
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
|
||||||
|
|
||||||
@ -64,11 +52,26 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
|
|
||||||
const { getFloatingProps } = useInteractions([dismiss]);
|
const { getFloatingProps } = useInteractions([dismiss]);
|
||||||
|
|
||||||
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
|
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange: setIsOpen,
|
||||||
|
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = useDismiss(context, {
|
||||||
|
ancestorScroll: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getFloatingProps } = useInteractions([dismiss]);
|
||||||
|
|
||||||
const handlePageTitleChange = (title: string) => {
|
const handlePageTitleChange = (title: string) => {
|
||||||
setPagetitle(title);
|
setPagetitle(title);
|
||||||
debouncedUpdatePageTitle(title);
|
updatePageTitle(title);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [cleanup, setcleanup] = useState(() => () => {});
|
const [cleanup, setcleanup] = useState(() => () => {});
|
||||||
|
@ -26,7 +26,7 @@ export const DocumentEditorExtensions = (
|
|||||||
.focus()
|
.focus()
|
||||||
.insertContentAt(
|
.insertContentAt(
|
||||||
range,
|
range,
|
||||||
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
|
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>\n"
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
|
@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
|
|||||||
title: suggestion.name,
|
title: suggestion.name,
|
||||||
priority: suggestion.priority.toString(),
|
priority: suggestion.priority.toString(),
|
||||||
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
|
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
|
||||||
state: suggestion.state_detail.name,
|
state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
|
||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
|
@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
|||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
char: "#issue_",
|
||||||
|
allowSpaces: true,
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||||
props.command({ editor, range });
|
props.command({ editor, range });
|
||||||
},
|
},
|
||||||
@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
|||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
Suggestion({
|
Suggestion({
|
||||||
char: "#issue_",
|
|
||||||
pluginKey: new PluginKey("issue-embed-suggestions"),
|
pluginKey: new PluginKey("issue-embed-suggestions"),
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
allowSpaces: true,
|
|
||||||
|
|
||||||
...this.options.suggestion,
|
...this.options.suggestion,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
@ -53,7 +53,7 @@ const IssueSuggestionList = ({
|
|||||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||||
let totalLength = 0;
|
let totalLength = 0;
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
|
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
|
||||||
@ -65,8 +65,8 @@ const IssueSuggestionList = ({
|
|||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const selectItem = useCallback(
|
const selectItem = useCallback(
|
||||||
(index: number) => {
|
(section: string, index: number) => {
|
||||||
const item = displayedItems[currentSection][index];
|
const item = displayedItems[section][index];
|
||||||
if (item) {
|
if (item) {
|
||||||
command(item);
|
command(item);
|
||||||
}
|
}
|
||||||
@ -87,6 +87,7 @@ const IssueSuggestionList = ({
|
|||||||
setSelectedIndex(
|
setSelectedIndex(
|
||||||
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
|
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
|
||||||
);
|
);
|
||||||
|
e.stopPropagation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
@ -101,10 +102,12 @@ const IssueSuggestionList = ({
|
|||||||
[currentSection]: [...prevItems[currentSection], ...nextItems],
|
[currentSection]: [...prevItems[currentSection], ...nextItems],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
e.stopPropagation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
selectItem(selectedIndex);
|
selectItem(currentSection, selectedIndex);
|
||||||
|
e.stopPropagation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
@ -112,6 +115,7 @@ const IssueSuggestionList = ({
|
|||||||
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
|
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
|
||||||
setCurrentSection(sections[nextSectionIndex]);
|
setCurrentSection(sections[nextSectionIndex]);
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
|
e.stopPropagation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -172,7 +176,7 @@ const IssueSuggestionList = ({
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
key={item.identifier}
|
key={item.identifier}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(section, index)}
|
||||||
>
|
>
|
||||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
||||||
<PriorityIcon priority={item.priority} />
|
<PriorityIcon priority={item.priority} />
|
||||||
@ -195,7 +199,7 @@ export const IssueListRenderer = () => {
|
|||||||
let popup: any | null = null;
|
let popup: any | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
component = new ReactRenderer(IssueSuggestionList, {
|
component = new ReactRenderer(IssueSuggestionList, {
|
||||||
props,
|
props,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -210,10 +214,10 @@ export const IssueListRenderer = () => {
|
|||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
placement: "right",
|
placement: "bottom-start",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
component?.updateProps(props);
|
component?.updateProps(props);
|
||||||
|
|
||||||
popup &&
|
popup &&
|
||||||
|
@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => {
|
|||||||
setIssueDetails(issue);
|
setIssueDetails(issue);
|
||||||
setLoading(0);
|
setLoading(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
console.log(error);
|
|
||||||
setLoading(-1);
|
setLoading(-1);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => {
|
|||||||
{loading == 0 ? (
|
{loading == 0 ? (
|
||||||
<div
|
<div
|
||||||
onClick={completeIssueEmbedAction}
|
onClick={completeIssueEmbedAction}
|
||||||
className="w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs"
|
className={`${
|
||||||
|
props.selected ? "border-custom-primary-200 border-[2px]" : ""
|
||||||
|
} w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs`}
|
||||||
>
|
>
|
||||||
<h5 className="text-xs text-custom-text-300">
|
<h5 className="text-xs text-custom-text-300">
|
||||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||||
|
@ -16,7 +16,7 @@ interface IDocumentEditor {
|
|||||||
// document info
|
// document info
|
||||||
documentDetails: DocumentDetails;
|
documentDetails: DocumentDetails;
|
||||||
value: string;
|
value: string;
|
||||||
rerenderOnPropsChange: {
|
rerenderOnPropsChange?: {
|
||||||
id: string;
|
id: string;
|
||||||
description_html: string;
|
description_html: string;
|
||||||
};
|
};
|
||||||
@ -39,7 +39,7 @@ interface IDocumentEditor {
|
|||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
updatePageTitle: (title: string) => Promise<void>;
|
updatePageTitle: (title: string) => void;
|
||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
isSubmitting: "submitting" | "submitted" | "saved";
|
isSubmitting: "submitting" | "submitted" | "saved";
|
||||||
|
|
||||||
|
10
packages/types/src/app.d.ts
vendored
10
packages/types/src/app.d.ts
vendored
@ -1,14 +1,14 @@
|
|||||||
export interface IAppConfig {
|
export interface IAppConfig {
|
||||||
email_password_login: boolean;
|
email_password_login: boolean;
|
||||||
file_size_limit: number;
|
file_size_limit: number;
|
||||||
google_client_id: string | null;
|
|
||||||
github_app_name: string | null;
|
github_app_name: string | null;
|
||||||
github_client_id: string | null;
|
github_client_id: string | null;
|
||||||
magic_login: boolean;
|
google_client_id: string | null;
|
||||||
slack_client_id: string | null;
|
|
||||||
posthog_api_key: string | null;
|
|
||||||
posthog_host: string | null;
|
|
||||||
has_openai_configured: boolean;
|
has_openai_configured: boolean;
|
||||||
has_unsplash_configured: boolean;
|
has_unsplash_configured: boolean;
|
||||||
is_smtp_configured: boolean;
|
is_smtp_configured: boolean;
|
||||||
|
magic_login: boolean;
|
||||||
|
posthog_api_key: string | null;
|
||||||
|
posthog_host: string | null;
|
||||||
|
slack_client_id: string | null;
|
||||||
}
|
}
|
||||||
|
@ -101,7 +101,7 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -100,7 +100,7 @@ export const EmailForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
|
@ -61,7 +61,7 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -155,7 +155,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
|
@ -97,7 +97,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
|
@ -87,7 +87,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -182,7 +182,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
|
@ -104,7 +104,7 @@ const HomePage: NextPage = () => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -1,137 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
// ui
|
|
||||||
import { Button, Input } from "@plane/ui";
|
|
||||||
// types
|
|
||||||
type EmailPasswordFormValues = {
|
|
||||||
email: string;
|
|
||||||
password?: string;
|
|
||||||
confirm_password: string;
|
|
||||||
medium?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmailSignUpForm: React.FC<Props> = (props) => {
|
|
||||||
const { onSubmit } = props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
control,
|
|
||||||
watch,
|
|
||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
|
||||||
} = useForm<EmailPasswordFormValues>({
|
|
||||||
defaultValues: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
confirm_password: "",
|
|
||||||
medium: "email",
|
|
||||||
},
|
|
||||||
mode: "onChange",
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form className="mx-auto mt-10 w-full space-y-4 sm:w-[360px]" onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email"
|
|
||||||
rules={{
|
|
||||||
required: "Email address is required",
|
|
||||||
validate: (value) =>
|
|
||||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
|
||||||
value
|
|
||||||
) || "Email address is not valid",
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.email)}
|
|
||||||
placeholder="Enter your email address..."
|
|
||||||
className="h-[46px] w-full border-custom-border-300"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="password"
|
|
||||||
rules={{
|
|
||||||
required: "Password is required",
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={onChange}
|
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.password)}
|
|
||||||
placeholder="Enter your password..."
|
|
||||||
className="h-[46px] w-full border-custom-border-300"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="confirm_password"
|
|
||||||
rules={{
|
|
||||||
required: "Password is required",
|
|
||||||
validate: (val: string) => {
|
|
||||||
if (watch("password") != val) {
|
|
||||||
return "Your passwords do no match";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="confirm_password"
|
|
||||||
name="confirm_password"
|
|
||||||
type="password"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.confirm_password)}
|
|
||||||
placeholder="Confirm your password..."
|
|
||||||
className="h-[46px] w-full border-custom-border-300"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-xs">
|
|
||||||
<Link href="/">
|
|
||||||
<span className="text-custom-text-200 hover:text-custom-primary-100">
|
|
||||||
Already have an account? Sign in.
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
size="xl"
|
|
||||||
disabled={!isValid && isDirty}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Signing up..." : "Sign up"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,5 +1,4 @@
|
|||||||
|
export * from "./o-auth";
|
||||||
export * from "./sign-in-forms";
|
export * from "./sign-in-forms";
|
||||||
|
export * from "./sign-up-forms";
|
||||||
export * from "./deactivate-account-modal";
|
export * from "./deactivate-account-modal";
|
||||||
export * from "./github-sign-in";
|
|
||||||
export * from "./google-sign-in";
|
|
||||||
export * from "./email-signup-form";
|
|
||||||
|
@ -12,10 +12,11 @@ import githubDarkModeImage from "/public/logos/github-dark.svg";
|
|||||||
type Props = {
|
type Props = {
|
||||||
handleSignIn: React.Dispatch<string>;
|
handleSignIn: React.Dispatch<string>;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
type: "sign_in" | "sign_up";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GitHubSignInButton: FC<Props> = (props) => {
|
export const GitHubSignInButton: FC<Props> = (props) => {
|
||||||
const { handleSignIn, clientId } = props;
|
const { handleSignIn, clientId, type } = props;
|
||||||
// states
|
// states
|
||||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||||
@ -53,7 +54,7 @@ export const GitHubSignInButton: FC<Props> = (props) => {
|
|||||||
width={20}
|
width={20}
|
||||||
alt="GitHub Logo"
|
alt="GitHub Logo"
|
||||||
/>
|
/>
|
||||||
<span className="text-onboarding-text-200">Sign-in with GitHub</span>
|
<span className="text-onboarding-text-200">{type === "sign_in" ? "Sign-in" : "Sign-up"} with GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
@ -4,10 +4,11 @@ import Script from "next/script";
|
|||||||
type Props = {
|
type Props = {
|
||||||
handleSignIn: React.Dispatch<any>;
|
handleSignIn: React.Dispatch<any>;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
type: "sign_in" | "sign_up";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GoogleSignInButton: FC<Props> = (props) => {
|
export const GoogleSignInButton: FC<Props> = (props) => {
|
||||||
const { handleSignIn, clientId } = props;
|
const { handleSignIn, clientId, type } = props;
|
||||||
// refs
|
// refs
|
||||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||||
// states
|
// states
|
||||||
@ -29,7 +30,7 @@ export const GoogleSignInButton: FC<Props> = (props) => {
|
|||||||
theme: "outline",
|
theme: "outline",
|
||||||
size: "large",
|
size: "large",
|
||||||
logo_alignment: "center",
|
logo_alignment: "center",
|
||||||
text: "signin_with",
|
text: type === "sign_in" ? "signin_with" : "signup_with",
|
||||||
width: 384,
|
width: 384,
|
||||||
} as GsiButtonConfiguration // customization attributes
|
} as GsiButtonConfiguration // customization attributes
|
||||||
);
|
);
|
||||||
@ -40,7 +41,7 @@ export const GoogleSignInButton: FC<Props> = (props) => {
|
|||||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||||
|
|
||||||
setGsiScriptLoaded(true);
|
setGsiScriptLoaded(true);
|
||||||
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
}, [handleSignIn, gsiScriptLoaded, clientId, type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window?.google?.accounts?.id) {
|
if (window?.google?.accounts?.id) {
|
3
web/components/account/o-auth/index.ts
Normal file
3
web/components/account/o-auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./github-sign-in";
|
||||||
|
export * from "./google-sign-in";
|
||||||
|
export * from "./o-auth-options";
|
@ -9,19 +9,22 @@ import { GitHubSignInButton, GoogleSignInButton } from "components/account";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleSignInRedirection: () => Promise<void>;
|
handleSignInRedirection: () => Promise<void>;
|
||||||
|
type: "sign_in" | "sign_up";
|
||||||
};
|
};
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
export const OAuthOptions: React.FC<Props> = observer((props) => {
|
export const OAuthOptions: React.FC<Props> = observer((props) => {
|
||||||
const { handleSignInRedirection } = props;
|
const { handleSignInRedirection, type } = props;
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// mobx store
|
// mobx store
|
||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
// derived values
|
||||||
|
const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id;
|
||||||
|
|
||||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||||
try {
|
try {
|
||||||
@ -72,12 +75,14 @@ export const OAuthOptions: React.FC<Props> = observer((props) => {
|
|||||||
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">Or continue with</p>
|
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">Or continue with</p>
|
||||||
<hr className="w-full border-onboarding-border-100" />
|
<hr className="w-full border-onboarding-border-100" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-7 space-y-4 overflow-hidden sm:w-96">
|
<div className={`mx-auto mt-7 grid gap-4 overflow-hidden sm:w-96 ${areBothOAuthEnabled ? "grid-cols-2" : ""}`}>
|
||||||
{envConfig?.google_client_id && (
|
{envConfig?.google_client_id && (
|
||||||
<GoogleSignInButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} />
|
<div className="h-[42px] flex items-center !overflow-hidden">
|
||||||
|
<GoogleSignInButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} type={type} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{envConfig?.github_client_id && (
|
{envConfig?.github_client_id && (
|
||||||
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} />
|
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} type={type} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
110
web/components/account/sign-in-forms/email.tsx
Normal file
110
web/components/account/sign-in-forms/email.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { XCircle } from "lucide-react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "services/auth.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Button, Input } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { IEmailCheckData } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (isPasswordAutoset: boolean) => void;
|
||||||
|
updateEmail: (email: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TEmailFormValues = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
export const SignInEmailForm: React.FC<Props> = observer((props) => {
|
||||||
|
const { onSubmit, updateEmail } = props;
|
||||||
|
// hooks
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors, isSubmitting, isValid },
|
||||||
|
handleSubmit,
|
||||||
|
} = useForm<TEmailFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
reValidateMode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: TEmailFormValues) => {
|
||||||
|
const payload: IEmailCheckData = {
|
||||||
|
email: data.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// update the global email state
|
||||||
|
updateEmail(data.email);
|
||||||
|
|
||||||
|
await authService
|
||||||
|
.emailCheck(payload)
|
||||||
|
.then((res) => onSubmit(res.is_password_autoset))
|
||||||
|
.catch((err) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||||
|
Welcome back, let{"'"}s get you on board
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
|
Get back to your issues, projects and workspaces.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-8 space-y-4 sm:w-96">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
rules={{
|
||||||
|
required: "Email is required",
|
||||||
|
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
hasError={Boolean(errors.email)}
|
||||||
|
placeholder="name@company.com"
|
||||||
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<XCircle
|
||||||
|
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,54 @@
|
|||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
import { Popover } from "@headlessui/react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export const ForgotPasswordPopover = () => {
|
||||||
|
// popper-js refs
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
// popper-js init
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: "right-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover className="relative">
|
||||||
|
<Popover.Button as={Fragment}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setReferenceElement}
|
||||||
|
className="text-xs font-medium text-custom-primary-100 outline-none"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</button>
|
||||||
|
</Popover.Button>
|
||||||
|
<Popover.Panel className="fixed z-10">
|
||||||
|
{({ close }) => (
|
||||||
|
<div
|
||||||
|
className="border border-onboarding-border-300 bg-onboarding-background-100 rounded z-10 py-1 px-2 w-64 break-words flex items-start gap-3 text-left ml-3"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">🤥</span>
|
||||||
|
<p className="text-xs">
|
||||||
|
We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link
|
||||||
|
</p>
|
||||||
|
<button type="button" className="flex-shrink-0" onClick={() => close()}>
|
||||||
|
<X className="h-3 w-3 text-onboarding-text-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover.Panel>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
@ -1,9 +1,6 @@
|
|||||||
export * from "./create-password";
|
export * from "./email";
|
||||||
export * from "./email-form";
|
export * from "./forgot-password-popover";
|
||||||
export * from "./o-auth-options";
|
|
||||||
export * from "./optional-set-password";
|
export * from "./optional-set-password";
|
||||||
export * from "./password";
|
export * from "./password";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
export * from "./self-hosted-sign-in";
|
|
||||||
export * from "./set-password-link";
|
|
||||||
export * from "./unique-code";
|
export * from "./unique-code";
|
||||||
|
@ -1,36 +1,76 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "services/auth.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// constants
|
|
||||||
import { ESignInSteps } from "components/account";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
handleStepChange: (step: ESignInSteps) => void;
|
|
||||||
handleSignInRedirection: () => Promise<void>;
|
handleSignInRedirection: () => Promise<void>;
|
||||||
isOnboarded: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
type TCreatePasswordFormValues = {
|
||||||
const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props;
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: TCreatePasswordFormValues = {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// services
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||||
|
const { email, handleSignInRedirection } = props;
|
||||||
// states
|
// states
|
||||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||||
|
// toast alert
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { errors, isValid },
|
formState: { errors, isSubmitting, isValid },
|
||||||
} = useForm({
|
handleSubmit,
|
||||||
|
} = useForm<TCreatePasswordFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
...defaultValues,
|
||||||
email,
|
email,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
|
||||||
|
const payload = {
|
||||||
|
password: formData.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
await authService
|
||||||
|
.setPassword(payload)
|
||||||
|
.then(async () => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Password created successfully.",
|
||||||
|
});
|
||||||
|
await handleSignInRedirection();
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleGoToWorkspace = async () => {
|
const handleGoToWorkspace = async () => {
|
||||||
setIsGoingToWorkspace(true);
|
setIsGoingToWorkspace(true);
|
||||||
|
|
||||||
@ -39,12 +79,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set a password</h1>
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set your password</h1>
|
||||||
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
If you{"'"}d like to do away with codes, set a password here.
|
If you{"'"}d like to do away with codes, set a password here.
|
||||||
</p>
|
</p>
|
||||||
|
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
<form className="mx-auto mt-5 space-y-4 sm:w-96">
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -61,22 +100,47 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
rules={{
|
||||||
|
required: "Password is required",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.password)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
minLength={8}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||||
|
Whatever you choose now will be your account{"'"}s password until you change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => handleStepChange(ESignInSteps.CREATE_PASSWORD)}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="xl"
|
size="xl"
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
Create password
|
Set password
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -84,20 +148,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
size="xl"
|
size="xl"
|
||||||
onClick={handleGoToWorkspace}
|
onClick={handleGoToWorkspace}
|
||||||
disabled={!isValid}
|
|
||||||
loading={isGoingToWorkspace}
|
loading={isGoingToWorkspace}
|
||||||
>
|
>
|
||||||
{isOnboarded ? "Go to workspace" : "Set up workspace"}
|
Skip to workspace
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-onboarding-text-200">
|
|
||||||
When you click{" "}
|
|
||||||
<span className="text-custom-primary-100">{isOnboarded ? "Go to workspace" : "Set up workspace"}</span> above,
|
|
||||||
you agree with our{" "}
|
|
||||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { XCircle } from "lucide-react";
|
import { XCircle } from "lucide-react";
|
||||||
// services
|
// services
|
||||||
@ -7,22 +8,20 @@ import { AuthService } from "services/auth.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IPasswordSignInData } from "@plane/types";
|
import { IPasswordSignInData } from "@plane/types";
|
||||||
// constants
|
|
||||||
import { ESignInSteps } from "components/account";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
updateEmail: (email: string) => void;
|
|
||||||
handleStepChange: (step: ESignInSteps) => void;
|
handleStepChange: (step: ESignInSteps) => void;
|
||||||
handleSignInRedirection: () => Promise<void>;
|
|
||||||
handleEmailClear: () => void;
|
handleEmailClear: () => void;
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TPasswordFormValues = {
|
type TPasswordFormValues = {
|
||||||
@ -37,24 +36,24 @@ const defaultValues: TPasswordFormValues = {
|
|||||||
|
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
export const PasswordForm: React.FC<Props> = observer((props) => {
|
export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||||
const { email, updateEmail, handleStepChange, handleSignInRedirection, handleEmailClear } = props;
|
const { email, handleStepChange, handleEmailClear, onSubmit } = props;
|
||||||
// states
|
// states
|
||||||
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
|
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
|
||||||
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
|
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
// derived values
|
||||||
|
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||||
// form info
|
// form info
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
formState: { errors, isSubmitting, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
setFocus,
|
|
||||||
} = useForm<TPasswordFormValues>({
|
} = useForm<TPasswordFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
@ -65,8 +64,6 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: TPasswordFormValues) => {
|
const handleFormSubmit = async (formData: TPasswordFormValues) => {
|
||||||
updateEmail(formData.email);
|
|
||||||
|
|
||||||
const payload: IPasswordSignInData = {
|
const payload: IPasswordSignInData = {
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
@ -74,7 +71,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
await authService
|
await authService
|
||||||
.passwordSignIn(payload)
|
.passwordSignIn(payload)
|
||||||
.then(async () => await handleSignInRedirection())
|
.then(async () => await onSubmit())
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -84,31 +81,6 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForgotPassword = async () => {
|
|
||||||
const emailFormValue = getValues("email");
|
|
||||||
|
|
||||||
const isEmailValid = checkEmailValidity(emailFormValue);
|
|
||||||
|
|
||||||
if (!isEmailValid) {
|
|
||||||
setError("email", { message: "Email is invalid" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSendingResetPasswordLink(true);
|
|
||||||
|
|
||||||
authService
|
|
||||||
.sendResetPasswordLink({ email: emailFormValue })
|
|
||||||
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
|
|
||||||
.catch((err) =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.finally(() => setIsSendingResetPasswordLink(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendUniqueCode = async () => {
|
const handleSendUniqueCode = async () => {
|
||||||
const emailFormValue = getValues("email");
|
const emailFormValue = getValues("email");
|
||||||
|
|
||||||
@ -134,16 +106,15 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
.finally(() => setIsSendingUniqueCode(false));
|
.finally(() => setIsSendingUniqueCode(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFocus("password");
|
|
||||||
}, [setFocus]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100">
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||||
Get on your flight deck
|
Welcome back, let{"'"}s get you on board
|
||||||
</h1>
|
</h1>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
|
Get back to your issues, projects and workspaces.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@ -161,14 +132,17 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
disabled
|
disabled={isSmtpConfigured}
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
<XCircle
|
<XCircle
|
||||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||||
onClick={handleEmailClear}
|
onClick={() => {
|
||||||
|
if (isSmtpConfigured) handleEmailClear();
|
||||||
|
else onChange("");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +154,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="password"
|
name="password"
|
||||||
rules={{
|
rules={{
|
||||||
required: dirtyFields.email ? false : "Password is required",
|
required: "Password is required",
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input
|
<Input
|
||||||
@ -190,23 +164,34 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
hasError={Boolean(errors.password)}
|
hasError={Boolean(errors.password)}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="w-full text-right">
|
<div className="w-full text-right mt-2 pb-3">
|
||||||
<button
|
{isSmtpConfigured ? (
|
||||||
type="button"
|
<Link
|
||||||
onClick={handleForgotPassword}
|
href={`/accounts/forgot-password?email=${email}`}
|
||||||
className={`text-xs font-medium ${
|
className="text-xs font-medium text-custom-primary-100"
|
||||||
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100"
|
>
|
||||||
}`}
|
Forgot your password?
|
||||||
disabled={isSendingResetPasswordLink}
|
</Link>
|
||||||
>
|
) : (
|
||||||
{isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"}
|
<ForgotPasswordPopover />
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="space-y-2.5">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
size="xl"
|
||||||
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{envConfig?.is_smtp_configured ? "Continue" : "Go to workspace"}
|
||||||
|
</Button>
|
||||||
{envConfig && envConfig.is_smtp_configured && (
|
{envConfig && envConfig.is_smtp_configured && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -216,26 +201,10 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
size="xl"
|
size="xl"
|
||||||
loading={isSendingUniqueCode}
|
loading={isSendingUniqueCode}
|
||||||
>
|
>
|
||||||
{isSendingUniqueCode ? "Sending code" : "Use unique code"}
|
{isSendingUniqueCode ? "Sending code" : "Sign in with unique code"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
className="w-full"
|
|
||||||
size="xl"
|
|
||||||
disabled={!isValid}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-onboarding-text-200">
|
|
||||||
When you click <span className="text-custom-primary-100">Go to workspace</span> above, you agree with our{" "}
|
|
||||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
@ -6,115 +7,115 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
|
|||||||
// components
|
// components
|
||||||
import { LatestFeatureBlock } from "components/common";
|
import { LatestFeatureBlock } from "components/common";
|
||||||
import {
|
import {
|
||||||
EmailForm,
|
SignInEmailForm,
|
||||||
UniqueCodeForm,
|
SignInUniqueCodeForm,
|
||||||
PasswordForm,
|
SignInPasswordForm,
|
||||||
SetPasswordLink,
|
|
||||||
OAuthOptions,
|
OAuthOptions,
|
||||||
OptionalSetPasswordForm,
|
SignInOptionalSetPasswordForm,
|
||||||
CreatePasswordForm,
|
|
||||||
} from "components/account";
|
} from "components/account";
|
||||||
|
|
||||||
export enum ESignInSteps {
|
export enum ESignInSteps {
|
||||||
EMAIL = "EMAIL",
|
EMAIL = "EMAIL",
|
||||||
PASSWORD = "PASSWORD",
|
PASSWORD = "PASSWORD",
|
||||||
SET_PASSWORD_LINK = "SET_PASSWORD_LINK",
|
|
||||||
UNIQUE_CODE = "UNIQUE_CODE",
|
UNIQUE_CODE = "UNIQUE_CODE",
|
||||||
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
||||||
CREATE_PASSWORD = "CREATE_PASSWORD",
|
|
||||||
USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
|
USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
|
||||||
}
|
}
|
||||||
|
|
||||||
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
|
|
||||||
|
|
||||||
export const SignInRoot = observer(() => {
|
export const SignInRoot = observer(() => {
|
||||||
// states
|
// states
|
||||||
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
|
const [signInStep, setSignInStep] = useState<ESignInSteps | null>(null);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isOnboarded, setIsOnboarded] = useState(false);
|
|
||||||
// sign in redirection hook
|
// sign in redirection hook
|
||||||
const { handleRedirection } = useSignInRedirection();
|
const { handleRedirection } = useSignInRedirection();
|
||||||
// mobx store
|
// mobx store
|
||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
// derived values
|
||||||
|
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||||
|
|
||||||
|
// step 1 submit handler- email verification
|
||||||
|
const handleEmailVerification = (isPasswordAutoset: boolean) => {
|
||||||
|
if (isSmtpConfigured && isPasswordAutoset) setSignInStep(ESignInSteps.UNIQUE_CODE);
|
||||||
|
else setSignInStep(ESignInSteps.PASSWORD);
|
||||||
|
};
|
||||||
|
|
||||||
|
// step 2 submit handler- unique code sign in
|
||||||
|
const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => {
|
||||||
|
if (isPasswordAutoset) setSignInStep(ESignInSteps.OPTIONAL_SET_PASSWORD);
|
||||||
|
else await handleRedirection();
|
||||||
|
};
|
||||||
|
|
||||||
|
// step 3 submit handler- password sign in
|
||||||
|
const handlePasswordSignIn = async () => {
|
||||||
|
await handleRedirection();
|
||||||
|
};
|
||||||
|
|
||||||
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
|
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL);
|
||||||
|
else setSignInStep(ESignInSteps.PASSWORD);
|
||||||
|
}, [isSmtpConfigured]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto flex flex-col">
|
<div className="mx-auto flex flex-col">
|
||||||
<>
|
<>
|
||||||
{signInStep === ESignInSteps.EMAIL && (
|
{signInStep === ESignInSteps.EMAIL && (
|
||||||
<EmailForm
|
<SignInEmailForm onSubmit={handleEmailVerification} updateEmail={(newEmail) => setEmail(newEmail)} />
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
)}
|
||||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
{signInStep === ESignInSteps.UNIQUE_CODE && (
|
||||||
|
<SignInUniqueCodeForm
|
||||||
|
email={email}
|
||||||
|
handleEmailClear={() => {
|
||||||
|
setEmail("");
|
||||||
|
setSignInStep(ESignInSteps.EMAIL);
|
||||||
|
}}
|
||||||
|
onSubmit={handleUniqueCodeSignIn}
|
||||||
|
submitButtonText="Continue"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{signInStep === ESignInSteps.PASSWORD && (
|
{signInStep === ESignInSteps.PASSWORD && (
|
||||||
<PasswordForm
|
<SignInPasswordForm
|
||||||
email={email}
|
email={email}
|
||||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
|
||||||
handleEmailClear={() => {
|
handleEmailClear={() => {
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setSignInStep(ESignInSteps.EMAIL);
|
setSignInStep(ESignInSteps.EMAIL);
|
||||||
}}
|
}}
|
||||||
handleSignInRedirection={handleRedirection}
|
onSubmit={handlePasswordSignIn}
|
||||||
|
handleStepChange={(step) => setSignInStep(step)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{signInStep === ESignInSteps.SET_PASSWORD_LINK && (
|
|
||||||
<SetPasswordLink email={email} updateEmail={(newEmail) => setEmail(newEmail)} />
|
|
||||||
)}
|
|
||||||
{signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
|
{signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
|
||||||
<UniqueCodeForm
|
<SignInUniqueCodeForm
|
||||||
email={email}
|
email={email}
|
||||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
|
||||||
handleSignInRedirection={handleRedirection}
|
|
||||||
submitButtonLabel="Go to workspace"
|
|
||||||
showTermsAndConditions
|
|
||||||
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
|
|
||||||
handleEmailClear={() => {
|
|
||||||
setEmail("");
|
|
||||||
setSignInStep(ESignInSteps.EMAIL);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{signInStep === ESignInSteps.UNIQUE_CODE && (
|
|
||||||
<UniqueCodeForm
|
|
||||||
email={email}
|
|
||||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
|
||||||
handleSignInRedirection={handleRedirection}
|
|
||||||
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
|
|
||||||
handleEmailClear={() => {
|
handleEmailClear={() => {
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setSignInStep(ESignInSteps.EMAIL);
|
setSignInStep(ESignInSteps.EMAIL);
|
||||||
}}
|
}}
|
||||||
|
onSubmit={handleUniqueCodeSignIn}
|
||||||
|
submitButtonText="Go to workspace"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
|
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
|
||||||
<OptionalSetPasswordForm
|
<SignInOptionalSetPasswordForm email={email} handleSignInRedirection={handleRedirection} />
|
||||||
email={email}
|
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
|
||||||
handleSignInRedirection={handleRedirection}
|
|
||||||
isOnboarded={isOnboarded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{signInStep === ESignInSteps.CREATE_PASSWORD && (
|
|
||||||
<CreatePasswordForm
|
|
||||||
email={email}
|
|
||||||
handleStepChange={(step) => setSignInStep(step)}
|
|
||||||
handleSignInRedirection={handleRedirection}
|
|
||||||
isOnboarded={isOnboarded}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
{isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && (
|
{isOAuthEnabled &&
|
||||||
<OAuthOptions handleSignInRedirection={handleRedirection} />
|
(signInStep === ESignInSteps.EMAIL || (!isSmtpConfigured && signInStep === ESignInSteps.PASSWORD)) && (
|
||||||
)}
|
<>
|
||||||
|
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
|
||||||
|
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||||
|
Don{"'"}t have an account?{" "}
|
||||||
|
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<LatestFeatureBlock />
|
<LatestFeatureBlock />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,103 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
// services
|
|
||||||
import { AuthService } from "services/auth.service";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
|
||||||
import { Button, Input } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
|
||||||
// types
|
|
||||||
import { IEmailCheckData } from "@plane/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
email: string;
|
|
||||||
updateEmail: (email: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const authService = new AuthService();
|
|
||||||
|
|
||||||
export const SetPasswordLink: React.FC<Props> = (props) => {
|
|
||||||
const { email, updateEmail } = props;
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
formState: { errors, isSubmitting, isValid },
|
|
||||||
handleSubmit,
|
|
||||||
} = useForm({
|
|
||||||
defaultValues: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
mode: "onChange",
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendNewLink = async (formData: { email: string }) => {
|
|
||||||
updateEmail(formData.email);
|
|
||||||
|
|
||||||
const payload: IEmailCheckData = {
|
|
||||||
email: formData.email,
|
|
||||||
};
|
|
||||||
|
|
||||||
await authService
|
|
||||||
.sendResetPasswordLink(payload)
|
|
||||||
.then(() =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "We have sent a new link to your email.",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((err) =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
|
||||||
Get on your flight deck
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
|
|
||||||
We have sent a link to <span className="font-semibold text-custom-primary-100">{email},</span> so you can set a
|
|
||||||
password
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(handleSendNewLink)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email"
|
|
||||||
rules={{
|
|
||||||
required: "Email is required",
|
|
||||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
hasError={Boolean(errors.email)}
|
|
||||||
placeholder="orville.wright@frstflt.com"
|
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
|
||||||
{isSubmitting ? "Sending new link" : "Get link again"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { CornerDownLeft, XCircle } from "lucide-react";
|
import { XCircle } from "lucide-react";
|
||||||
// services
|
// services
|
||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
import { UserService } from "services/user.service";
|
import { UserService } from "services/user.service";
|
||||||
@ -14,18 +13,12 @@ import { Button, Input } from "@plane/ui";
|
|||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||||
// constants
|
|
||||||
import { ESignInSteps } from "components/account";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
updateEmail: (email: string) => void;
|
onSubmit: (isPasswordAutoset: boolean) => Promise<void>;
|
||||||
handleStepChange: (step: ESignInSteps) => void;
|
|
||||||
handleSignInRedirection: () => Promise<void>;
|
|
||||||
submitButtonLabel?: string;
|
|
||||||
showTermsAndConditions?: boolean;
|
|
||||||
updateUserOnboardingStatus: (value: boolean) => void;
|
|
||||||
handleEmailClear: () => void;
|
handleEmailClear: () => void;
|
||||||
|
submitButtonText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TUniqueCodeFormValues = {
|
type TUniqueCodeFormValues = {
|
||||||
@ -42,17 +35,8 @@ const defaultValues: TUniqueCodeFormValues = {
|
|||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
export const UniqueCodeForm: React.FC<Props> = (props) => {
|
export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||||
const {
|
const { email, onSubmit, handleEmailClear, submitButtonText } = props;
|
||||||
email,
|
|
||||||
updateEmail,
|
|
||||||
handleStepChange,
|
|
||||||
handleSignInRedirection,
|
|
||||||
submitButtonLabel = "Continue",
|
|
||||||
showTermsAndConditions = false,
|
|
||||||
updateUserOnboardingStatus,
|
|
||||||
handleEmailClear,
|
|
||||||
} = props;
|
|
||||||
// states
|
// states
|
||||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||||
// toast alert
|
// toast alert
|
||||||
@ -62,11 +46,10 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
// form info
|
// form info
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
formState: { errors, isSubmitting, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setFocus,
|
|
||||||
} = useForm<TUniqueCodeFormValues>({
|
} = useForm<TUniqueCodeFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
@ -88,10 +71,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
const currentUser = await userService.currentUser();
|
const currentUser = await userService.currentUser();
|
||||||
|
|
||||||
updateUserOnboardingStatus(currentUser.is_onboarded);
|
await onSubmit(currentUser.is_password_autoset);
|
||||||
|
|
||||||
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
|
|
||||||
else await handleSignInRedirection();
|
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -131,13 +111,6 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: TUniqueCodeFormValues) => {
|
|
||||||
updateEmail(formData.email);
|
|
||||||
|
|
||||||
if (dirtyFields.email) await handleSendNewCode(formData);
|
|
||||||
else await handleUniqueCodeSignIn(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestNewCode = async () => {
|
const handleRequestNewCode = async () => {
|
||||||
setIsRequestingNewCode(true);
|
setIsRequestingNewCode(true);
|
||||||
|
|
||||||
@ -147,21 +120,16 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||||
const hasEmailChanged = dirtyFields.email;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFocus("token");
|
|
||||||
}, [setFocus]);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
|
||||||
Get on your flight deck
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
Paste the code you got at <span className="font-semibold text-custom-primary-100">{email}</span> below.
|
Paste the code you got at
|
||||||
|
<br />
|
||||||
|
<span className="font-semibold text-custom-primary-100">{email}</span> below.
|
||||||
</p>
|
</p>
|
||||||
|
<form onSubmit={handleSubmit(handleUniqueCodeSignIn)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@ -178,12 +146,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
type="email"
|
type="email"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={() => {
|
|
||||||
if (hasEmailChanged) handleSendNewCode(getValues());
|
|
||||||
}}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
@ -196,21 +161,13 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{hasEmailChanged && (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-1.5 flex items-center gap-1 border-none bg-transparent text-xs text-onboarding-text-300 outline-none"
|
|
||||||
>
|
|
||||||
Hit <CornerDownLeft className="h-2.5 w-2.5" /> or <span className="italic">Tab</span> to get a new code
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="token"
|
name="token"
|
||||||
rules={{
|
rules={{
|
||||||
required: hasEmailChanged ? false : "Code is required",
|
required: "Code is required",
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input
|
<Input
|
||||||
@ -219,6 +176,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
hasError={Boolean(errors.token)}
|
hasError={Boolean(errors.token)}
|
||||||
placeholder="gets-sets-flys"
|
placeholder="gets-sets-flys"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -241,24 +199,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||||
type="submit"
|
{submitButtonText}
|
||||||
variant="primary"
|
|
||||||
className="w-full"
|
|
||||||
size="xl"
|
|
||||||
disabled={!isValid || hasEmailChanged}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{submitButtonLabel}
|
|
||||||
</Button>
|
</Button>
|
||||||
{showTermsAndConditions && (
|
|
||||||
<p className="text-xs text-onboarding-text-200">
|
|
||||||
When you click the button above, you agree with our{" "}
|
|
||||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { XCircle } from "lucide-react";
|
import { XCircle } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
@ -13,11 +13,9 @@ import { Button, Input } from "@plane/ui";
|
|||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IEmailCheckData } from "@plane/types";
|
import { IEmailCheckData } from "@plane/types";
|
||||||
// constants
|
|
||||||
import { ESignInSteps } from "components/account";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleStepChange: (step: ESignInSteps) => void;
|
onSubmit: () => void;
|
||||||
updateEmail: (email: string) => void;
|
updateEmail: (email: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -27,18 +25,14 @@ type TEmailFormValues = {
|
|||||||
|
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
export const EmailForm: React.FC<Props> = observer((props) => {
|
export const SignUpEmailForm: React.FC<Props> = observer((props) => {
|
||||||
const { handleStepChange, updateEmail } = props;
|
const { onSubmit, updateEmail } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
const {
|
|
||||||
config: { envConfig },
|
|
||||||
} = useApplication();
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, isValid },
|
formState: { errors, isSubmitting, isValid },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setFocus,
|
|
||||||
} = useForm<TEmailFormValues>({
|
} = useForm<TEmailFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
@ -57,14 +51,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
await authService
|
await authService
|
||||||
.emailCheck(payload)
|
.emailCheck(payload)
|
||||||
.then((res) => {
|
.then(() => onSubmit())
|
||||||
// if the password has been auto set, send the user to magic sign-in
|
|
||||||
if (res.is_password_autoset && envConfig?.is_smtp_configured) {
|
|
||||||
handleStepChange(ESignInSteps.UNIQUE_CODE);
|
|
||||||
}
|
|
||||||
// if the password has not been auto set, send them to password sign-in
|
|
||||||
else handleStepChange(ESignInSteps.PASSWORD);
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -74,10 +61,6 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFocus("email");
|
|
||||||
}, [setFocus]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||||
@ -96,7 +79,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
|||||||
required: "Email is required",
|
required: "Email is required",
|
||||||
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -104,10 +87,10 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
|||||||
type="email"
|
type="email"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
<XCircle
|
<XCircle
|
||||||
@ -120,7 +103,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||||
Continue
|
Verify
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
5
web/components/account/sign-up-forms/index.ts
Normal file
5
web/components/account/sign-up-forms/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "./email";
|
||||||
|
export * from "./optional-set-password";
|
||||||
|
export * from "./password";
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./unique-code";
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// services
|
// services
|
||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
@ -10,13 +9,12 @@ import { Button, Input } from "@plane/ui";
|
|||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// constants
|
// constants
|
||||||
import { ESignInSteps } from "components/account";
|
import { ESignUpSteps } from "components/account";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
handleStepChange: (step: ESignInSteps) => void;
|
handleStepChange: (step: ESignUpSteps) => void;
|
||||||
handleSignInRedirection: () => Promise<void>;
|
handleSignInRedirection: () => Promise<void>;
|
||||||
isOnboarded: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TCreatePasswordFormValues = {
|
type TCreatePasswordFormValues = {
|
||||||
@ -32,8 +30,10 @@ const defaultValues: TCreatePasswordFormValues = {
|
|||||||
// services
|
// services
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
export const CreatePasswordForm: React.FC<Props> = (props) => {
|
export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||||
const { email, handleSignInRedirection, isOnboarded } = props;
|
const { email, handleSignInRedirection } = props;
|
||||||
|
// states
|
||||||
|
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
@ -41,7 +41,6 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
|||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, isValid },
|
formState: { errors, isSubmitting, isValid },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setFocus,
|
|
||||||
} = useForm<TCreatePasswordFormValues>({
|
} = useForm<TCreatePasswordFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
@ -75,16 +74,21 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleGoToWorkspace = async () => {
|
||||||
setFocus("password");
|
setIsGoingToWorkspace(true);
|
||||||
}, [setFocus]);
|
|
||||||
|
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
|
||||||
Get on your flight deck
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
</h1>
|
Let{"'"}s set a password so
|
||||||
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
<br />
|
||||||
|
you can do away with codes.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -101,40 +105,58 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<div>
|
||||||
control={control}
|
<Controller
|
||||||
name="password"
|
control={control}
|
||||||
rules={{
|
name="password"
|
||||||
required: "Password is required",
|
rules={{
|
||||||
}}
|
required: "Password is required",
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
}}
|
||||||
<Input
|
render={({ field: { value, onChange } }) => (
|
||||||
type="password"
|
<Input
|
||||||
value={value}
|
type="password"
|
||||||
onChange={onChange}
|
value={value}
|
||||||
ref={ref}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.password)}
|
hasError={Boolean(errors.password)}
|
||||||
placeholder="Choose password"
|
placeholder="Enter password"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
autoFocus
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
/>
|
||||||
{isOnboarded ? "Go to workspace" : "Set up workspace"}
|
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||||
</Button>
|
This password will continue to be your account{"'"}s password.
|
||||||
<p className="text-xs text-onboarding-text-200">
|
</p>
|
||||||
When you click the button above, you agree with our{" "}
|
</div>
|
||||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
<div className="space-y-2.5">
|
||||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
<Button
|
||||||
</Link>
|
type="submit"
|
||||||
</p>
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
size="xl"
|
||||||
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
Set password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline-primary"
|
||||||
|
className="w-full"
|
||||||
|
size="xl"
|
||||||
|
onClick={handleGoToWorkspace}
|
||||||
|
loading={isGoingToWorkspace}
|
||||||
|
>
|
||||||
|
Skip to setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { XCircle } from "lucide-react";
|
import { XCircle } from "lucide-react";
|
||||||
// services
|
// services
|
||||||
@ -14,9 +15,7 @@ import { checkEmailValidity } from "helpers/string.helper";
|
|||||||
import { IPasswordSignInData } from "@plane/types";
|
import { IPasswordSignInData } from "@plane/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
onSubmit: () => Promise<void>;
|
||||||
updateEmail: (email: string) => void;
|
|
||||||
handleSignInRedirection: () => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TPasswordFormValues = {
|
type TPasswordFormValues = {
|
||||||
@ -31,20 +30,18 @@ const defaultValues: TPasswordFormValues = {
|
|||||||
|
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
|
||||||
const { email, updateEmail, handleSignInRedirection } = props;
|
const { onSubmit } = props;
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { dirtyFields, errors, isSubmitting },
|
formState: { errors, isSubmitting, isValid },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setFocus,
|
|
||||||
} = useForm<TPasswordFormValues>({
|
} = useForm<TPasswordFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
email,
|
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
@ -56,11 +53,9 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
password: formData.password,
|
password: formData.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateEmail(formData.email);
|
|
||||||
|
|
||||||
await authService
|
await authService
|
||||||
.passwordSignIn(payload)
|
.passwordSignIn(payload)
|
||||||
.then(async () => await handleSignInRedirection())
|
.then(async () => await onSubmit())
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -70,16 +65,15 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFocus("email");
|
|
||||||
}, [setFocus]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100">
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||||
Get on your flight deck
|
Get on your flight deck
|
||||||
</h1>
|
</h1>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
|
Create or join a workspace. Start with your e-mail.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@ -97,7 +91,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
@ -115,7 +109,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="password"
|
name="password"
|
||||||
rules={{
|
rules={{
|
||||||
required: dirtyFields.email ? false : "Password is required",
|
required: "Password is required",
|
||||||
}}
|
}}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input
|
<Input
|
||||||
@ -125,12 +119,16 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
hasError={Boolean(errors.password)}
|
hasError={Boolean(errors.password)}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||||
|
This password will continue to be your account{"'"}s password.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="primary" className="w-full" size="xl" loading={isSubmitting}>
|
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||||
Continue
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-onboarding-text-200">
|
<p className="text-xs text-onboarding-text-200">
|
||||||
When you click the button above, you agree with our{" "}
|
When you click the button above, you agree with our{" "}
|
||||||
@ -141,4 +139,4 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
|||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
97
web/components/account/sign-up-forms/root.tsx
Normal file
97
web/components/account/sign-up-forms/root.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useApplication } from "hooks/store";
|
||||||
|
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
OAuthOptions,
|
||||||
|
SignUpEmailForm,
|
||||||
|
SignUpOptionalSetPasswordForm,
|
||||||
|
SignUpPasswordForm,
|
||||||
|
SignUpUniqueCodeForm,
|
||||||
|
} from "components/account";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export enum ESignUpSteps {
|
||||||
|
EMAIL = "EMAIL",
|
||||||
|
UNIQUE_CODE = "UNIQUE_CODE",
|
||||||
|
PASSWORD = "PASSWORD",
|
||||||
|
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAUTH_ENABLED_STEPS = [ESignUpSteps.EMAIL];
|
||||||
|
|
||||||
|
export const SignUpRoot = observer(() => {
|
||||||
|
// states
|
||||||
|
const [signInStep, setSignInStep] = useState<ESignUpSteps | null>(null);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
// sign in redirection hook
|
||||||
|
const { handleRedirection } = useSignInRedirection();
|
||||||
|
// mobx store
|
||||||
|
const {
|
||||||
|
config: { envConfig },
|
||||||
|
} = useApplication();
|
||||||
|
|
||||||
|
// step 1 submit handler- email verification
|
||||||
|
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
|
||||||
|
|
||||||
|
// step 2 submit handler- unique code sign in
|
||||||
|
const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => {
|
||||||
|
if (isPasswordAutoset) setSignInStep(ESignUpSteps.OPTIONAL_SET_PASSWORD);
|
||||||
|
else await handleRedirection();
|
||||||
|
};
|
||||||
|
|
||||||
|
// step 3 submit handler- password sign in
|
||||||
|
const handlePasswordSignIn = async () => {
|
||||||
|
await handleRedirection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (envConfig?.is_smtp_configured) setSignInStep(ESignUpSteps.EMAIL);
|
||||||
|
else setSignInStep(ESignUpSteps.PASSWORD);
|
||||||
|
}, [envConfig?.is_smtp_configured]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto flex flex-col">
|
||||||
|
<>
|
||||||
|
{signInStep === ESignUpSteps.EMAIL && (
|
||||||
|
<SignUpEmailForm onSubmit={handleEmailVerification} updateEmail={(newEmail) => setEmail(newEmail)} />
|
||||||
|
)}
|
||||||
|
{signInStep === ESignUpSteps.UNIQUE_CODE && (
|
||||||
|
<SignUpUniqueCodeForm
|
||||||
|
email={email}
|
||||||
|
handleEmailClear={() => {
|
||||||
|
setEmail("");
|
||||||
|
setSignInStep(ESignUpSteps.EMAIL);
|
||||||
|
}}
|
||||||
|
onSubmit={handleUniqueCodeSignIn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{signInStep === ESignUpSteps.PASSWORD && <SignUpPasswordForm onSubmit={handlePasswordSignIn} />}
|
||||||
|
{signInStep === ESignUpSteps.OPTIONAL_SET_PASSWORD && (
|
||||||
|
<SignUpOptionalSetPasswordForm
|
||||||
|
email={email}
|
||||||
|
handleSignInRedirection={handleRedirection}
|
||||||
|
handleStepChange={(step) => setSignInStep(step)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
{isOAuthEnabled && signInStep && OAUTH_ENABLED_STEPS.includes(signInStep) && (
|
||||||
|
<>
|
||||||
|
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
|
||||||
|
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||||
|
Already using Plane?{" "}
|
||||||
|
<Link href="/" className="text-custom-primary-100 font-medium underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
215
web/components/account/sign-up-forms/unique-code.tsx
Normal file
215
web/components/account/sign-up-forms/unique-code.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { XCircle } from "lucide-react";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "services/auth.service";
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
import useTimer from "hooks/use-timer";
|
||||||
|
// ui
|
||||||
|
import { Button, Input } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email: string;
|
||||||
|
handleEmailClear: () => void;
|
||||||
|
onSubmit: (isPasswordAutoset: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TUniqueCodeFormValues = {
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: TUniqueCodeFormValues = {
|
||||||
|
email: "",
|
||||||
|
token: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// services
|
||||||
|
const authService = new AuthService();
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||||
|
const { email, handleEmailClear, onSubmit } = props;
|
||||||
|
// states
|
||||||
|
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||||
|
// toast alert
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
// timer
|
||||||
|
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||||
|
// form info
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors, isSubmitting, isValid },
|
||||||
|
getValues,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
} = useForm<TUniqueCodeFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
...defaultValues,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
reValidateMode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => {
|
||||||
|
const payload: IMagicSignInData = {
|
||||||
|
email: formData.email,
|
||||||
|
key: `magic_${formData.email}`,
|
||||||
|
token: formData.token,
|
||||||
|
};
|
||||||
|
|
||||||
|
await authService
|
||||||
|
.magicSignIn(payload)
|
||||||
|
.then(async () => {
|
||||||
|
const currentUser = await userService.currentUser();
|
||||||
|
|
||||||
|
await onSubmit(currentUser.is_password_autoset);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||||
|
const payload: IEmailCheckData = {
|
||||||
|
email: formData.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
await authService
|
||||||
|
.generateUniqueCode(payload)
|
||||||
|
.then(() => {
|
||||||
|
setResendCodeTimer(30);
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "A new unique code has been sent to your email.",
|
||||||
|
});
|
||||||
|
|
||||||
|
reset({
|
||||||
|
email: formData.email,
|
||||||
|
token: "",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestNewCode = async () => {
|
||||||
|
setIsRequestingNewCode(true);
|
||||||
|
|
||||||
|
await handleSendNewCode(getValues())
|
||||||
|
.then(() => setResendCodeTimer(30))
|
||||||
|
.finally(() => setIsRequestingNewCode(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
|
||||||
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
|
||||||
|
Paste the code you got at
|
||||||
|
<br />
|
||||||
|
<span className="font-semibold text-custom-primary-100">{email}</span> below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(handleUniqueCodeSignIn)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
rules={{
|
||||||
|
required: "Email is required",
|
||||||
|
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.email)}
|
||||||
|
placeholder="name@company.com"
|
||||||
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<XCircle
|
||||||
|
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||||
|
onClick={handleEmailClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="token"
|
||||||
|
rules={{
|
||||||
|
required: "Code is required",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
hasError={Boolean(errors.token)}
|
||||||
|
placeholder="gets-sets-flys"
|
||||||
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="w-full text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRequestNewCode}
|
||||||
|
className={`text-xs ${
|
||||||
|
isRequestNewCodeDisabled
|
||||||
|
? "text-onboarding-text-300"
|
||||||
|
: "text-onboarding-text-200 hover:text-custom-primary-100"
|
||||||
|
}`}
|
||||||
|
disabled={isRequestNewCodeDisabled}
|
||||||
|
>
|
||||||
|
{resendTimerCode > 0
|
||||||
|
? `Request new code in ${resendTimerCode}s`
|
||||||
|
: isRequestingNewCode
|
||||||
|
? "Requesting new code"
|
||||||
|
: "Request new code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-onboarding-text-200">
|
||||||
|
When you click the button above, you agree with our{" "}
|
||||||
|
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
||||||
|
<span className="font-semibold underline">terms and conditions of service.</span>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,8 +1,10 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Command } from "cmdk";
|
import { Command } from "cmdk";
|
||||||
// icons
|
// hooks
|
||||||
import { SettingIcon } from "components/icons";
|
import { useUser } from "hooks/store";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
closePalette: () => void;
|
closePalette: () => void;
|
||||||
@ -10,60 +12,35 @@ type Props = {
|
|||||||
|
|
||||||
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
||||||
const { closePalette } = props;
|
const { closePalette } = props;
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
// mobx store
|
||||||
|
const {
|
||||||
|
membership: { currentWorkspaceRole },
|
||||||
|
} = useUser();
|
||||||
|
// derived values
|
||||||
|
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
{WORKSPACE_SETTINGS_LINKS.map(
|
||||||
<Link href={`/${workspaceSlug}/settings`}>
|
(setting) =>
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
workspaceMemberInfo >= setting.access && (
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
<Command.Item
|
||||||
General
|
key={setting.key}
|
||||||
</div>
|
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||||
</Link>
|
className="focus:outline-none"
|
||||||
</Command.Item>
|
>
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
<Link href={`/${workspaceSlug}${setting.href}`}>
|
||||||
<Link href={`/${workspaceSlug}/settings/members`}>
|
<div className="flex items-center gap-2 text-custom-text-200">
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
<setting.Icon className="h-4 w-4 text-custom-text-200" />
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
{setting.label}
|
||||||
Members
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
</Command.Item>
|
||||||
</Command.Item>
|
)
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
)}
|
||||||
<Link href={`/${workspaceSlug}/settings/billing`}>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
|
||||||
Billing and Plans
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
|
||||||
<Link href={`/${workspaceSlug}/settings/integrations`}>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
|
||||||
Integrations
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
|
||||||
<Link href={`/${workspaceSlug}/settings/imports`}>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
|
||||||
Import
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
|
||||||
<Link href={`/${workspaceSlug}/settings/exports`}>
|
|
||||||
<div className="flex items-center gap-2 text-custom-text-200">
|
|
||||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
|
||||||
Export
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Command.Item>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,23 +1,17 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
|
||||||
import { FileText, Plus } from "lucide-react";
|
import { FileText, Plus } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useProject } from "hooks/store";
|
import { useApplication, usePage, useProject } from "hooks/store";
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, Button } from "@plane/ui";
|
import { Breadcrumbs, Button } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
// fetch-keys
|
|
||||||
import { PAGE_DETAILS } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
export interface IPagesHeaderProps {
|
export interface IPagesHeaderProps {
|
||||||
showButton?: boolean;
|
showButton?: boolean;
|
||||||
}
|
}
|
||||||
const pageService = new PageService();
|
|
||||||
|
|
||||||
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||||
const { showButton = false } = props;
|
const { showButton = false } = props;
|
||||||
@ -28,12 +22,7 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
|||||||
const { commandPalette: commandPaletteStore } = useApplication();
|
const { commandPalette: commandPaletteStore } = useApplication();
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
|
|
||||||
const { data: pageDetails } = useSWR(
|
const pageDetails = usePage(pageId as string);
|
||||||
workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null,
|
|
||||||
workspaceSlug && currentProjectDetails?.id
|
|
||||||
? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
|
@ -88,7 +88,7 @@ export const InstanceSetupSignInForm: FC<IInstanceSetupEmailForm> = (props) => {
|
|||||||
type="email"
|
type="email"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
|
||||||
/>
|
/>
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
|
@ -5,19 +5,17 @@ import useSWR from "swr";
|
|||||||
// hooks
|
// hooks
|
||||||
import { useGlobalView, useIssues, useUser } from "hooks/store";
|
import { useGlobalView, useIssues, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { GlobalViewsAppliedFiltersRoot } from "components/issues";
|
import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues";
|
||||||
import { SpreadsheetView } from "components/issues/issue-layouts";
|
import { SpreadsheetView } from "components/issues/issue-layouts";
|
||||||
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
|
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
// ui
|
// ui
|
||||||
import { Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { TIssue, IIssueDisplayFilterOptions, TStaticViewTypes, TUnGroupedIssues } from "@plane/types";
|
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -48,11 +46,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
|||||||
if (workspaceSlug && globalViewId) {
|
if (workspaceSlug && globalViewId) {
|
||||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||||
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
||||||
await fetchIssues(
|
await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
|
||||||
workspaceSlug.toString(),
|
|
||||||
globalViewId.toString(),
|
|
||||||
groupedIssueIds ? "mutation" : "init-loader"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -138,6 +132,9 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* peek overview */}
|
||||||
|
<IssuePeekOverview />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -35,12 +35,9 @@ export type TIssuePeekOperations = {
|
|||||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||||
const { is_archived = false, onIssueUpdate } = props;
|
const { is_archived = false, onIssueUpdate } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
|
||||||
project: {},
|
|
||||||
} = useMember();
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentWorkspaceAllProjectsRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const {
|
const {
|
||||||
issues: { removeIssue: removeArchivedIssue },
|
issues: { removeIssue: removeArchivedIssue },
|
||||||
@ -198,6 +195,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
|
|
||||||
const issue = getIssueById(peekIssue.issueId) || undefined;
|
const issue = getIssueById(peekIssue.issueId) || undefined;
|
||||||
|
|
||||||
|
const currentProjectRole = currentWorkspaceAllProjectsRole?.[peekIssue?.projectId];
|
||||||
// Check if issue is editable, based on user role
|
// Check if issue is editable, based on user role
|
||||||
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
const isLoading = !issue || loader ? true : false;
|
const isLoading = !issue || loader ? true : false;
|
||||||
|
@ -7,7 +7,7 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
|
|||||||
// components
|
// components
|
||||||
import { SignInRoot } from "components/account";
|
import { SignInRoot } from "components/account";
|
||||||
// ui
|
// ui
|
||||||
import { Loader, Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
// images
|
// images
|
||||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export const SignInView = observer(() => {
|
|||||||
handleRedirection();
|
handleRedirection();
|
||||||
}, [handleRedirection]);
|
}, [handleRedirection]);
|
||||||
|
|
||||||
if (isRedirecting || currentUser)
|
if (isRedirecting || currentUser || !envConfig)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-screen place-items-center">
|
<div className="grid h-screen place-items-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@ -35,32 +35,16 @@ export const SignInView = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-onboarding-gradient-100">
|
<div className="h-full w-full bg-onboarding-gradient-100">
|
||||||
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 ">
|
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
|
||||||
<div className="flex items-center gap-x-2 py-10">
|
<div className="flex items-center gap-x-2 py-10">
|
||||||
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||||
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 ">
|
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
|
||||||
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
||||||
{!envConfig ? (
|
<SignInRoot />
|
||||||
<div className="mx-auto flex justify-center pt-10">
|
|
||||||
<div>
|
|
||||||
<Loader className="mx-auto w-full space-y-4 pb-4">
|
|
||||||
<Loader.Item height="46px" width="360px" />
|
|
||||||
<Loader.Item height="46px" width="360px" />
|
|
||||||
</Loader>
|
|
||||||
|
|
||||||
<Loader className="mx-auto w-full space-y-4 pt-4">
|
|
||||||
<Loader.Item height="46px" width="360px" />
|
|
||||||
<Loader.Item height="46px" width="360px" />
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SignInRoot />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,132 +2,56 @@ import React, { FC } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, usePage, useWorkspace } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// components
|
// components
|
||||||
import { PageForm } from "./page-form";
|
import { PageForm } from "./page-form";
|
||||||
// types
|
// types
|
||||||
import { IPage } from "@plane/types";
|
import { IPage } from "@plane/types";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-page";
|
||||||
|
import { IPageStore } from "store/page.store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: IPage | null;
|
// data?: IPage | null;
|
||||||
|
pageStore?: IPageStore;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateUpdatePageModal: FC<Props> = (props) => {
|
export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||||
const { isOpen, handleClose, data, projectId } = props;
|
const { isOpen, handleClose, projectId, pageStore } = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { createPage } = useProjectPages();
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
eventTracker: { postHogEventTracker },
|
eventTracker: { postHogEventTracker },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const { createPage, updatePage } = usePage();
|
|
||||||
// toast alert
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createProjectPage = async (payload: IPage) => {
|
const createProjectPage = async (payload: IPage) => {
|
||||||
if (!workspaceSlug) return;
|
if (!workspaceSlug) return;
|
||||||
|
await createPage(workspaceSlug.toString(), projectId, payload);
|
||||||
// await createPage(workspaceSlug.toString(), projectId, payload)
|
|
||||||
// .then((res) => {
|
|
||||||
// router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
|
|
||||||
// onClose();
|
|
||||||
// setToastAlert({
|
|
||||||
// type: "success",
|
|
||||||
// title: "Success!",
|
|
||||||
// message: "Page created successfully.",
|
|
||||||
// });
|
|
||||||
// postHogEventTracker(
|
|
||||||
// "PAGE_CREATED",
|
|
||||||
// {
|
|
||||||
// ...res,
|
|
||||||
// state: "SUCCESS",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// isGrouping: true,
|
|
||||||
// groupType: "Workspace_metrics",
|
|
||||||
// groupId: currentWorkspace?.id!,
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
// })
|
|
||||||
// .catch((err) => {
|
|
||||||
// setToastAlert({
|
|
||||||
// type: "error",
|
|
||||||
// title: "Error!",
|
|
||||||
// message: err.detail ?? "Page could not be created. Please try again.",
|
|
||||||
// });
|
|
||||||
// postHogEventTracker(
|
|
||||||
// "PAGE_CREATED",
|
|
||||||
// {
|
|
||||||
// state: "FAILED",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// isGrouping: true,
|
|
||||||
// groupType: "Workspace_metrics",
|
|
||||||
// groupId: currentWorkspace?.id!,
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProjectPage = async (payload: IPage) => {
|
|
||||||
if (!data || !workspaceSlug) return;
|
|
||||||
|
|
||||||
// await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
|
|
||||||
// .then((res) => {
|
|
||||||
// onClose();
|
|
||||||
// setToastAlert({
|
|
||||||
// type: "success",
|
|
||||||
// title: "Success!",
|
|
||||||
// message: "Page updated successfully.",
|
|
||||||
// });
|
|
||||||
// postHogEventTracker(
|
|
||||||
// "PAGE_UPDATED",
|
|
||||||
// {
|
|
||||||
// ...res,
|
|
||||||
// state: "SUCCESS",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// isGrouping: true,
|
|
||||||
// groupType: "Workspace_metrics",
|
|
||||||
// groupId: currentWorkspace?.id!,
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
// })
|
|
||||||
// .catch((err) => {
|
|
||||||
// setToastAlert({
|
|
||||||
// type: "error",
|
|
||||||
// title: "Error!",
|
|
||||||
// message: err.detail ?? "Page could not be updated. Please try again.",
|
|
||||||
// });
|
|
||||||
// postHogEventTracker(
|
|
||||||
// "PAGE_UPDATED",
|
|
||||||
// {
|
|
||||||
// state: "FAILED",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// isGrouping: true,
|
|
||||||
// groupType: "Workspace_metrics",
|
|
||||||
// groupId: currentWorkspace?.id!,
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: IPage) => {
|
const handleFormSubmit = async (formData: IPage) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
try {
|
||||||
if (!data) await createProjectPage(formData);
|
if (pageStore) {
|
||||||
else await updateProjectPage(formData);
|
if (pageStore.name !== formData.name) {
|
||||||
|
await pageStore.updateName(formData.name);
|
||||||
|
}
|
||||||
|
if (pageStore.access !== formData.access) {
|
||||||
|
formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await createProjectPage(formData);
|
||||||
|
}
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -157,7 +81,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
|
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
|
||||||
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
|
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} pageStore={pageStore} />
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,37 +9,45 @@ import useToast from "hooks/use-toast";
|
|||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import type { IPage } from "@plane/types";
|
import { useProjectPages } from "hooks/store/use-project-page";
|
||||||
|
|
||||||
type TConfirmPageDeletionProps = {
|
type TConfirmPageDeletionProps = {
|
||||||
data?: IPage | null;
|
pageId: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
|
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
|
||||||
const { data, isOpen, onClose } = props;
|
const { pageId, isOpen, onClose } = props;
|
||||||
|
|
||||||
// states
|
// states
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { deletePage } = usePage();
|
const { deletePage } = useProjectPages();
|
||||||
|
const pageStore = usePage(pageId);
|
||||||
|
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
if (!pageStore) return null;
|
||||||
|
|
||||||
|
const { name } = pageStore;
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!data || !workspaceSlug || !projectId) return;
|
if (!pageId || !workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
await deletePage(workspaceSlug.toString(), data.project, data.id)
|
// Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted
|
||||||
|
await deletePage(workspaceSlug.toString(), projectId as string, pageId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
handleClose();
|
handleClose();
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -99,8 +107,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to delete page-{" "}
|
Are you sure you want to delete page-{" "}
|
||||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
|
<span className="break-words font-medium text-custom-text-100">{name}</span>? The Page will be
|
||||||
will be deleted permanently. This action cannot be undone.
|
deleted permanently. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,11 +5,12 @@ import { Button, Input, Tooltip } from "@plane/ui";
|
|||||||
import { IPage } from "@plane/types";
|
import { IPage } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
|
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
|
||||||
|
import { IPageStore } from "store/page.store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleFormSubmit: (values: IPage) => Promise<void>;
|
handleFormSubmit: (values: IPage) => Promise<void>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
data?: IPage | null;
|
pageStore?: IPageStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
@ -19,24 +20,24 @@ const defaultValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PageForm: React.FC<Props> = (props) => {
|
export const PageForm: React.FC<Props> = (props) => {
|
||||||
const { handleFormSubmit, handleClose, data } = props;
|
const { handleFormSubmit, handleClose, pageStore } = props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
} = useForm<IPage>({
|
} = useForm<IPage>({
|
||||||
defaultValues: { ...defaultValues, ...data },
|
defaultValues: pageStore
|
||||||
|
? { name: pageStore.name, description: pageStore.description, access: pageStore.access }
|
||||||
|
: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateUpdatePage = async (formData: IPage) => {
|
const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData);
|
||||||
await handleFormSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3>
|
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{pageStore ? "Update" : "Create"} Page</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
@ -104,7 +105,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
|
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
|
||||||
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
|
{pageStore ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage } from "hooks/store";
|
|
||||||
// components
|
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const AllPagesList: FC = observer(() => {
|
export const AllPagesList: FC = observer(() => {
|
||||||
// store
|
const pageStores = useProjectPages();
|
||||||
const { projectPageIds } = usePage();
|
// subscribing to the projectPageStore
|
||||||
|
const { projectPageIds } = pageStores;
|
||||||
|
|
||||||
if (!projectPageIds)
|
if (!projectPageIds) {
|
||||||
return (
|
return (
|
||||||
<Loader className="space-y-4">
|
<Loader className="space-y-4">
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => {
|
|||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return <PagesListView pageIds={projectPageIds} />;
|
return <PagesListView pageIds={projectPageIds} />;
|
||||||
});
|
});
|
||||||
|
@ -3,14 +3,15 @@ import { observer } from "mobx-react-lite";
|
|||||||
// components
|
// components
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage } from "hooks/store";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const ArchivedPagesList: FC = observer(() => {
|
export const ArchivedPagesList: FC = observer(() => {
|
||||||
const { archivedProjectPageIds } = usePage();
|
const projectPageStore = useProjectPages();
|
||||||
|
const { archivedPageIds } = projectPageStore;
|
||||||
|
|
||||||
if (!archivedProjectPageIds)
|
if (!archivedPageIds)
|
||||||
return (
|
return (
|
||||||
<Loader className="space-y-4">
|
<Loader className="space-y-4">
|
||||||
<Loader.Item height="40px" />
|
<Loader.Item height="40px" />
|
||||||
@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => {
|
|||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <PagesListView pageIds={archivedProjectPageIds} />;
|
return <PagesListView pageIds={archivedPageIds} />;
|
||||||
});
|
});
|
||||||
|
@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
|
|||||||
// components
|
// components
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage } from "hooks/store";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const FavoritePagesList: FC = observer(() => {
|
export const FavoritePagesList: FC = observer(() => {
|
||||||
const { favoriteProjectPageIds } = usePage();
|
const projectPageStore = useProjectPages();
|
||||||
|
const { favoriteProjectPageIds } = projectPageStore;
|
||||||
|
|
||||||
if (!favoriteProjectPageIds)
|
if (!favoriteProjectPageIds)
|
||||||
return (
|
return (
|
||||||
|
@ -13,10 +13,6 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// hooks
|
|
||||||
import { useMember, usePage, useUser } from "hooks/store";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// helpers
|
|
||||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
|
import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
|
||||||
// ui
|
// ui
|
||||||
@ -25,142 +21,120 @@ import { CustomMenu, Tooltip } from "@plane/ui";
|
|||||||
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
|
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
|
||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
import { useMember, usePage, useUser } from "hooks/store";
|
||||||
|
import { IIssueLabel } from "@plane/types";
|
||||||
|
|
||||||
export interface IPagesListItem {
|
export interface IPagesListItem {
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId }: IPagesListItem) => {
|
||||||
const { workspaceSlug, projectId, pageId } = props;
|
const projectPageStore = useProjectPages();
|
||||||
|
// Now, I am observing only the projectPages, out of the projectPageStore.
|
||||||
|
const { archivePage, restorePage } = projectPageStore;
|
||||||
|
|
||||||
|
const pageStore = usePage(pageId);
|
||||||
|
|
||||||
// states
|
// states
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||||
|
|
||||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||||
// store hooks
|
|
||||||
const {
|
const {
|
||||||
currentUser,
|
currentUser,
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const {
|
|
||||||
getArchivedPageById,
|
|
||||||
getUnArchivedPageById,
|
|
||||||
archivePage,
|
|
||||||
removeFromFavorites,
|
|
||||||
addToFavorites,
|
|
||||||
makePrivate,
|
|
||||||
makePublic,
|
|
||||||
restorePage,
|
|
||||||
} = usePage();
|
|
||||||
const {
|
const {
|
||||||
project: { getProjectMemberDetails },
|
project: { getProjectMemberDetails },
|
||||||
} = useMember();
|
} = useMember();
|
||||||
// toast alert
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
// derived values
|
|
||||||
const pageDetails = getUnArchivedPageById(pageId) ?? getArchivedPageById(pageId);
|
|
||||||
|
|
||||||
const handleCopyUrl = (e: any) => {
|
if (!pageStore) return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
archived_at,
|
||||||
|
label_details,
|
||||||
|
access,
|
||||||
|
is_favorite,
|
||||||
|
owned_by,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
makePublic,
|
||||||
|
makePrivate,
|
||||||
|
addToFavorites,
|
||||||
|
removeFromFavorites,
|
||||||
|
} = pageStore;
|
||||||
|
|
||||||
|
const handleCopyUrl = async (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => {
|
await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`);
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Page link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToFavorites = (e: any) => {
|
const handleAddToFavorites = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
addToFavorites();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
addToFavorites(workspaceSlug, projectId, pageId)
|
removeFromFavorites();
|
||||||
.then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Successfully added the page to favorites.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Couldn't add the page to favorites. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromFavorites = (e: any) => {
|
const handleMakePublic = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
removeFromFavorites(workspaceSlug, projectId, pageId)
|
makePublic();
|
||||||
.then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Successfully removed the page from favorites.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Couldn't remove the page from favorites. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMakePublic = (e: any) => {
|
const handleMakePrivate = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
makePublic(workspaceSlug, projectId, pageId);
|
makePrivate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMakePrivate = (e: any) => {
|
const handleArchivePage = async (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
makePrivate(workspaceSlug, projectId, pageId);
|
await archivePage(workspaceSlug as string, projectId as string, pageId as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchivePage = (e: any) => {
|
const handleRestorePage = async (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
archivePage(workspaceSlug, projectId, pageId);
|
await restorePage(workspaceSlug as string, projectId as string, pageId as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestorePage = (e: any) => {
|
const handleDeletePage = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
restorePage(workspaceSlug, projectId, pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePage = (e: any) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setDeletePageModal(true);
|
setDeletePageModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditPage = (e: any) => {
|
const handleEditPage = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setCreateUpdatePageModal(true);
|
setCreateUpdatePageModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pageDetails) return null;
|
const ownerDetails = getProjectMemberDetails(owned_by);
|
||||||
|
const isCurrentUserOwner = owned_by === currentUser?.id;
|
||||||
const ownerDetails = getProjectMemberDetails(pageDetails.owned_by);
|
|
||||||
const isCurrentUserOwner = pageDetails.owned_by === currentUser?.id;
|
|
||||||
|
|
||||||
const userCanEdit =
|
const userCanEdit =
|
||||||
isCurrentUserOwner ||
|
isCurrentUserOwner ||
|
||||||
@ -173,22 +147,21 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateUpdatePageModal
|
<CreateUpdatePageModal
|
||||||
|
pageStore={pageStore}
|
||||||
isOpen={createUpdatePageModal}
|
isOpen={createUpdatePageModal}
|
||||||
handleClose={() => setCreateUpdatePageModal(false)}
|
handleClose={() => setCreateUpdatePageModal(false)}
|
||||||
data={pageDetails}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={pageDetails} />
|
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={pageId} />
|
||||||
<li>
|
<li>
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageDetails.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}>
|
||||||
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
<FileText className="h-4 w-4 shrink-0" />
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
<p className="mr-2 truncate text-sm text-custom-text-100">{pageDetails.name}</p>
|
<p className="mr-2 truncate text-sm text-custom-text-100">{name}</p>
|
||||||
{/* FIXME: replace any with proper type */}
|
{label_details.length > 0 &&
|
||||||
{pageDetails.label_details.length > 0 &&
|
label_details.map((label: IIssueLabel) => (
|
||||||
pageDetails.label_details.map((label: any) => (
|
|
||||||
<div
|
<div
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||||
@ -207,26 +180,26 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{pageDetails.archived_at ? (
|
{archived_at ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={`Archived at ${renderFormattedTime(
|
tooltipContent={`Archived at ${renderFormattedTime(archived_at)} on ${renderFormattedDate(
|
||||||
pageDetails.archived_at
|
archived_at
|
||||||
)} on ${renderFormattedDate(pageDetails.archived_at)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.archived_at)}</p>
|
<p className="text-sm text-custom-text-200">{renderFormattedTime(archived_at)}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={`Last updated at ${renderFormattedTime(
|
tooltipContent={`Last updated at ${renderFormattedTime(updated_at)} on ${renderFormattedDate(
|
||||||
pageDetails.updated_at
|
updated_at
|
||||||
)} on ${renderFormattedDate(pageDetails.updated_at)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.updated_at)}</p>
|
<p className="text-sm text-custom-text-200">{renderFormattedTime(updated_at)}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{isEditingAllowed && (
|
{isEditingAllowed && (
|
||||||
<Tooltip tooltipContent={`${pageDetails.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
<Tooltip tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
||||||
{pageDetails.is_favorite ? (
|
{is_favorite ? (
|
||||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||||
<Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" />
|
<Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" />
|
||||||
</button>
|
</button>
|
||||||
@ -240,12 +213,10 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
|||||||
{userCanChangeAccess && (
|
{userCanChangeAccess && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={`${
|
tooltipContent={`${
|
||||||
pageDetails.access
|
access ? "This page is only visible to you" : "This page can be viewed by anyone in the project"
|
||||||
? "This page is only visible to you"
|
|
||||||
: "This page can be viewed by anyone in the project"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{pageDetails.access ? (
|
{access ? (
|
||||||
<button type="button" onClick={handleMakePublic}>
|
<button type="button" onClick={handleMakePublic}>
|
||||||
<Lock className="h-3.5 w-3.5" />
|
<Lock className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -259,13 +230,13 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
position="top-right"
|
position="top-right"
|
||||||
tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate(
|
tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate(
|
||||||
pageDetails.created_at
|
created_at
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||||
{pageDetails.archived_at ? (
|
{archived_at ? (
|
||||||
<>
|
<>
|
||||||
{userCanArchive && (
|
{userCanArchive && (
|
||||||
<CustomMenu.MenuItem onClick={handleRestorePage}>
|
<CustomMenu.MenuItem onClick={handleRestorePage}>
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { PagesListItem } from "./list-item";
|
|
||||||
import { NewEmptyState } from "components/common/new-empty-state";
|
import { NewEmptyState } from "components/common/new-empty-state";
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
@ -13,14 +11,17 @@ import { Loader } from "@plane/ui";
|
|||||||
import emptyPage from "public/empty-state/empty_page.png";
|
import emptyPage from "public/empty-state/empty_page.png";
|
||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
import { PagesListItem } from "./list-item";
|
||||||
|
|
||||||
type IPagesListView = {
|
type IPagesListView = {
|
||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PagesListView: FC<IPagesListView> = observer((props) => {
|
export const PagesListView: FC<IPagesListView> = (props) => {
|
||||||
const { pageIds } = props;
|
const { pageIds: projectPageIds } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
|
// trace(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
commandPalette: { toggleCreatePageModal },
|
commandPalette: { toggleCreatePageModal },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
@ -31,21 +32,18 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
// here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pageIds && workspaceSlug && projectId ? (
|
{projectPageIds && workspaceSlug && projectId ? (
|
||||||
<div className="h-full space-y-4 overflow-y-auto">
|
<div className="h-full space-y-4 overflow-y-auto">
|
||||||
{pageIds.length > 0 ? (
|
{projectPageIds.length > 0 ? (
|
||||||
<ul role="list" className="divide-y divide-custom-border-200">
|
<ul role="list" className="divide-y divide-custom-border-200">
|
||||||
{pageIds.map((pageId) => (
|
{projectPageIds.map((pageId: string) => (
|
||||||
<PagesListItem
|
<PagesListItem key={pageId} pageId={pageId} projectId={projectId.toString()} />
|
||||||
key={pageId}
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
|
||||||
pageId={pageId}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
@ -77,4 +75,4 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage } from "hooks/store";
|
|
||||||
// components
|
// components
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const PrivatePagesList: FC = observer(() => {
|
export const PrivatePagesList: FC = observer(() => {
|
||||||
const { privateProjectPageIds } = usePage();
|
const projectPageStore = useProjectPages();
|
||||||
|
const { privateProjectPageIds } = projectPageStore;
|
||||||
|
|
||||||
if (!privateProjectPageIds)
|
if (!privateProjectPageIds)
|
||||||
return (
|
return (
|
||||||
|
@ -2,7 +2,7 @@ import React, { FC } from "react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, usePage, useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
import { NewEmptyState } from "components/common/new-empty-state";
|
import { NewEmptyState } from "components/common/new-empty-state";
|
||||||
@ -14,6 +14,7 @@ import emptyPage from "public/empty-state/empty_page.png";
|
|||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const RecentPagesList: FC = observer(() => {
|
export const RecentPagesList: FC = observer(() => {
|
||||||
// store hooks
|
// store hooks
|
||||||
@ -21,7 +22,7 @@ export const RecentPagesList: FC = observer(() => {
|
|||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { recentProjectPages } = usePage();
|
const { recentProjectPages } = useProjectPages();
|
||||||
|
|
||||||
// FIXME: replace any with proper type
|
// FIXME: replace any with proper type
|
||||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);
|
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);
|
||||||
|
@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
|
|||||||
// components
|
// components
|
||||||
import { PagesListView } from "components/pages/pages-list";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage } from "hooks/store";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
|
||||||
export const SharedPagesList: FC = observer(() => {
|
export const SharedPagesList: FC = observer(() => {
|
||||||
const { publicProjectPageIds } = usePage();
|
const projectPageStore = useProjectPages();
|
||||||
|
const { publicProjectPageIds } = projectPageStore;
|
||||||
|
|
||||||
if (!publicProjectPageIds)
|
if (!publicProjectPageIds)
|
||||||
return (
|
return (
|
||||||
|
@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
|
|||||||
// components
|
// components
|
||||||
import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root";
|
import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root";
|
||||||
import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root";
|
import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root";
|
||||||
import { ProfileIssuesAppliedFiltersRoot } from "components/issues";
|
import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues";
|
||||||
import { Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssues } from "hooks/store";
|
import { useIssues } from "hooks/store";
|
||||||
@ -34,7 +34,7 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
|
|||||||
async () => {
|
async () => {
|
||||||
if (workspaceSlug && userId) {
|
if (workspaceSlug && userId) {
|
||||||
await fetchFilters(workspaceSlug, userId);
|
await fetchFilters(workspaceSlug, userId);
|
||||||
await fetchIssues(workspaceSlug, userId, groupedIssueIds ? "mutation" : "init-loader", undefined, type);
|
await fetchIssues(workspaceSlug, undefined, groupedIssueIds ? "mutation" : "init-loader", userId, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -57,6 +57,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
|
|||||||
<ProfileIssuesKanBanLayout />
|
<ProfileIssuesKanBanLayout />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{/* peek overview */}
|
||||||
|
<IssuePeekOverview />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
@ -11,34 +10,7 @@ import { NotificationPopover } from "components/notifications";
|
|||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { SIDEBAR_MENU_ITEMS } from "constants/dashboard";
|
||||||
const workspaceLinks = (workspaceSlug: string) => [
|
|
||||||
{
|
|
||||||
Icon: LayoutGrid,
|
|
||||||
name: "Dashboard",
|
|
||||||
href: `/${workspaceSlug}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Icon: BarChart2,
|
|
||||||
name: "Analytics",
|
|
||||||
href: `/${workspaceSlug}/analytics`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Icon: Briefcase,
|
|
||||||
name: "Projects",
|
|
||||||
href: `/${workspaceSlug}/projects`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Icon: CheckCircle,
|
|
||||||
name: "All Issues",
|
|
||||||
href: `/${workspaceSlug}/workspace-views/all-issues`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Icon: SendToBack,
|
|
||||||
name: "Active cycles",
|
|
||||||
href: `/${workspaceSlug}/active-cycles`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WorkspaceSidebarMenu = observer(() => {
|
export const WorkspaceSidebarMenu = observer(() => {
|
||||||
// store hooks
|
// store hooks
|
||||||
@ -50,48 +22,36 @@ export const WorkspaceSidebarMenu = observer(() => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
// computed
|
// computed
|
||||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full cursor-pointer space-y-1 p-4">
|
<div className="w-full cursor-pointer space-y-2 p-4">
|
||||||
{workspaceLinks(workspaceSlug as string).map((link, index) => {
|
{SIDEBAR_MENU_ITEMS.map(
|
||||||
const isActive = link.name === "Settings" ? router.asPath.includes(link.href) : router.asPath === link.href;
|
(link) =>
|
||||||
if (!isAuthorizedUser && link.name === "Analytics") return;
|
workspaceMemberInfo >= link.access && (
|
||||||
return (
|
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||||
<Link key={index} href={link.href}>
|
<span className="block w-full my-1">
|
||||||
<span className="block w-full">
|
<Tooltip
|
||||||
<Tooltip
|
tooltipContent={link.label}
|
||||||
tooltipContent={link.name}
|
position="right"
|
||||||
position="right"
|
className="ml-2"
|
||||||
className="ml-2"
|
disabled={!themeStore?.sidebarCollapsed}
|
||||||
disabled={!themeStore?.sidebarCollapsed}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
|
||||||
isActive
|
|
||||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
|
||||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
|
||||||
} ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
|
|
||||||
>
|
>
|
||||||
{<link.Icon className="h-4 w-4" />}
|
<div
|
||||||
{!themeStore?.sidebarCollapsed && link.name}
|
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||||
{link.name === "Active Cycles" && (
|
link.highlight(router.asPath, `/${workspaceSlug}`)
|
||||||
<span
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl"
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
style={{
|
} ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
|
||||||
color: "#F59E0B",
|
>
|
||||||
backgroundColor: "#F59E0B20",
|
{<link.Icon className="h-4 w-4" />}
|
||||||
}}
|
{!themeStore?.sidebarCollapsed && link.label}
|
||||||
>
|
</div>
|
||||||
Beta
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
)}
|
</Link>
|
||||||
</div>
|
)
|
||||||
</Tooltip>
|
)}
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<NotificationPopover />
|
<NotificationPopover />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1 @@
|
|||||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
export const isNil = (value: any) => {
|
|
||||||
if (value === undefined || value === null) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
@ -14,6 +14,11 @@ import CompletedCreatedIssuesDark from "public/empty-state/dashboard/dark/comple
|
|||||||
import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg";
|
import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg";
|
||||||
// types
|
// types
|
||||||
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
|
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
|
||||||
|
import { Props } from "components/icons/types";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "./workspace";
|
||||||
|
// icons
|
||||||
|
import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
// gradients for issues by priority widget graph bars
|
// gradients for issues by priority widget graph bars
|
||||||
export const PRIORITY_GRAPH_GRADIENTS = [
|
export const PRIORITY_GRAPH_GRADIENTS = [
|
||||||
@ -246,3 +251,45 @@ export const CREATED_ISSUES_EMPTY_STATES = {
|
|||||||
lightImage: CompletedCreatedIssuesLight,
|
lightImage: CompletedCreatedIssuesLight,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SIDEBAR_MENU_ITEMS: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
access: EUserWorkspaceRoles;
|
||||||
|
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||||
|
Icon: React.FC<Props>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
href: ``,
|
||||||
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}`,
|
||||||
|
Icon: LayoutGrid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "analytics",
|
||||||
|
label: "Analytics",
|
||||||
|
href: `/analytics`,
|
||||||
|
access: EUserWorkspaceRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/analytics`,
|
||||||
|
Icon: BarChart2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
href: `/projects`,
|
||||||
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/projects`,
|
||||||
|
Icon: Briefcase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "all-issues",
|
||||||
|
label: "All Issues",
|
||||||
|
href: `/workspace-views/all-issues`,
|
||||||
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/workspace-views/all-issues`,
|
||||||
|
Icon: CheckCircle,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
40
web/constants/profile.ts
Normal file
40
web/constants/profile.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from "react";
|
||||||
|
// icons
|
||||||
|
import { Activity, CircleUser, KeyRound, LucideProps, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const PROFILE_ACTION_LINKS: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
highlight: (pathname: string) => boolean;
|
||||||
|
Icon: React.FC<LucideProps>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
label: "Profile",
|
||||||
|
href: `/profile`,
|
||||||
|
highlight: (pathname: string) => pathname === "/profile",
|
||||||
|
Icon: CircleUser,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "change-password",
|
||||||
|
label: "Change password",
|
||||||
|
href: `/profile/change-password`,
|
||||||
|
highlight: (pathname: string) => pathname === "/profile/change-password",
|
||||||
|
Icon: KeyRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activity",
|
||||||
|
label: "Activity",
|
||||||
|
href: `/profile/activity`,
|
||||||
|
highlight: (pathname: string) => pathname === "/profile/activity",
|
||||||
|
Icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "preferences",
|
||||||
|
label: "Preferences",
|
||||||
|
href: `/profile/preferences`,
|
||||||
|
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
|
||||||
|
Icon: Settings2,
|
||||||
|
},
|
||||||
|
];
|
@ -1,4 +1,8 @@
|
|||||||
|
// icons
|
||||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||||
|
import { SettingIcon } from "components/icons";
|
||||||
|
// types
|
||||||
|
import { Props } from "components/icons/types";
|
||||||
|
|
||||||
export enum EUserProjectRoles {
|
export enum EUserProjectRoles {
|
||||||
GUEST = 5,
|
GUEST = 5,
|
||||||
@ -71,3 +75,77 @@ export const PROJECT_UNSPLASH_COVERS = [
|
|||||||
"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
|
"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
|
||||||
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
|
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PROJECT_SETTINGS_LINKS: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
access: EUserProjectRoles;
|
||||||
|
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||||
|
Icon: React.FC<Props>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "general",
|
||||||
|
label: "General",
|
||||||
|
href: `/settings`,
|
||||||
|
access: EUserProjectRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Members",
|
||||||
|
href: `/settings/members`,
|
||||||
|
access: EUserProjectRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "features",
|
||||||
|
label: "Features",
|
||||||
|
href: `/settings/features`,
|
||||||
|
access: EUserProjectRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "states",
|
||||||
|
label: "States",
|
||||||
|
href: `/settings/states`,
|
||||||
|
access: EUserProjectRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "labels",
|
||||||
|
label: "Labels",
|
||||||
|
href: `/settings/labels`,
|
||||||
|
access: EUserProjectRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "integrations",
|
||||||
|
label: "Integrations",
|
||||||
|
href: `/settings/integrations`,
|
||||||
|
access: EUserProjectRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "estimates",
|
||||||
|
label: "Estimates",
|
||||||
|
href: `/settings/estimates`,
|
||||||
|
access: EUserProjectRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "automations",
|
||||||
|
label: "Automations",
|
||||||
|
href: `/settings/automations`,
|
||||||
|
access: EUserProjectRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@ -6,6 +6,9 @@ import ExcelLogo from "public/services/excel.svg";
|
|||||||
import JSONLogo from "public/services/json.svg";
|
import JSONLogo from "public/services/json.svg";
|
||||||
// types
|
// types
|
||||||
import { TStaticViewTypes } from "@plane/types";
|
import { TStaticViewTypes } from "@plane/types";
|
||||||
|
import { Props } from "components/icons/types";
|
||||||
|
// icons
|
||||||
|
import { SettingIcon } from "components/icons";
|
||||||
|
|
||||||
export enum EUserWorkspaceRoles {
|
export enum EUserWorkspaceRoles {
|
||||||
GUEST = 5,
|
GUEST = 5,
|
||||||
@ -115,48 +118,75 @@ export const RESTRICTED_URLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const WORKSPACE_SETTINGS_LINKS: {
|
export const WORKSPACE_SETTINGS_LINKS: {
|
||||||
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
access: EUserWorkspaceRoles;
|
access: EUserWorkspaceRoles;
|
||||||
|
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||||
|
Icon: React.FC<Props>;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
|
key: "general",
|
||||||
label: "General",
|
label: "General",
|
||||||
href: `/settings`,
|
href: `/settings`,
|
||||||
access: EUserWorkspaceRoles.GUEST,
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "members",
|
||||||
label: "Members",
|
label: "Members",
|
||||||
href: `/settings/members`,
|
href: `/settings/members`,
|
||||||
access: EUserWorkspaceRoles.GUEST,
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "billing-and-plans",
|
||||||
label: "Billing and plans",
|
label: "Billing and plans",
|
||||||
href: `/settings/billing`,
|
href: `/settings/billing`,
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "integrations",
|
||||||
label: "Integrations",
|
label: "Integrations",
|
||||||
href: `/settings/integrations`,
|
href: `/settings/integrations`,
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "import",
|
||||||
label: "Imports",
|
label: "Imports",
|
||||||
href: `/settings/imports`,
|
href: `/settings/imports`,
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "export",
|
||||||
label: "Exports",
|
label: "Exports",
|
||||||
href: `/settings/exports`,
|
href: `/settings/exports`,
|
||||||
access: EUserWorkspaceRoles.MEMBER,
|
access: EUserWorkspaceRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "webhooks",
|
||||||
label: "Webhooks",
|
label: "Webhooks",
|
||||||
href: `/settings/webhooks`,
|
href: `/settings/webhooks`,
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "api-tokens",
|
||||||
label: "API tokens",
|
label: "API tokens",
|
||||||
href: `/settings/api-tokens`,
|
href: `/settings/api-tokens`,
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens`,
|
||||||
|
Icon: SettingIcon,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -1,11 +1,21 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { StoreContext } from "contexts/store-context";
|
import { StoreContext } from "contexts/store-context";
|
||||||
// types
|
|
||||||
import { IPageStore } from "store/page.store";
|
|
||||||
|
|
||||||
export const usePage = (): IPageStore => {
|
export const usePage = (pageId: string) => {
|
||||||
const context = useContext(StoreContext);
|
const context = useContext(StoreContext);
|
||||||
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
|
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
|
||||||
return context.page;
|
|
||||||
|
const { projectPageMap, projectArchivedPageMap } = context.projectPages;
|
||||||
|
|
||||||
|
const { projectId, workspaceSlug } = context.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) throw new Error("usePage must be used within ProjectProvider");
|
||||||
|
|
||||||
|
if (projectPageMap[projectId] && projectPageMap[projectId][pageId]) {
|
||||||
|
return projectPageMap[projectId][pageId];
|
||||||
|
} else if (projectArchivedPageMap[projectId] && projectArchivedPageMap[projectId][pageId]) {
|
||||||
|
return projectArchivedPageMap[projectId][pageId];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { StoreContext } from "contexts/store-context";
|
import { StoreContext } from "contexts/store-context";
|
||||||
|
import { IProjectPageStore } from "store/project-page.store";
|
||||||
|
|
||||||
export const useProjectPages = () => {
|
export const useProjectPages = (): IProjectPageStore => {
|
||||||
const context = useContext(StoreContext);
|
const context = useContext(StoreContext);
|
||||||
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
|
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
|
||||||
return context.projectPages;
|
return context.projectPages;
|
||||||
|
11
web/hooks/store/use-project-specific-pages.ts
Normal file
11
web/hooks/store/use-project-specific-pages.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
// mobx store
|
||||||
|
import { StoreContext } from "contexts/store-context";
|
||||||
|
// types
|
||||||
|
import { IProjectPageStore } from "store/project-page.store";
|
||||||
|
|
||||||
|
export const useProjectPages = (): IProjectPageStore => {
|
||||||
|
const context = useContext(StoreContext);
|
||||||
|
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
|
||||||
|
return context.projectPages;
|
||||||
|
};
|
48
web/hooks/use-issue-embeds.tsx
Normal file
48
web/hooks/use-issue-embeds.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { TIssue } from "@plane/types";
|
||||||
|
import { PROJECT_ISSUES_LIST, STATES_LIST } from "constants/fetch-keys";
|
||||||
|
import { EIssuesStoreType } from "constants/issue";
|
||||||
|
import { StoreContext } from "contexts/store-context";
|
||||||
|
import { autorun, toJS } from "mobx";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { IssueService } from "services/issue";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useIssueDetail, useIssues, useMember, useProject, useProjectState } from "./store";
|
||||||
|
|
||||||
|
const issueService = new IssueService();
|
||||||
|
|
||||||
|
export const useIssueEmbeds = () => {
|
||||||
|
const workspaceSlug = useContext(StoreContext).app.router.workspaceSlug;
|
||||||
|
const projectId = useContext(StoreContext).app.router.projectId;
|
||||||
|
|
||||||
|
const { getProjectById } = useProject();
|
||||||
|
const { setPeekIssue } = useIssueDetail();
|
||||||
|
const { getStateById } = useProjectState();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
|
||||||
|
const { data: issuesResponse } = useSWR(
|
||||||
|
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||||
|
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const issues = Object.values(issuesResponse ?? {});
|
||||||
|
const issuesWithStateAndProject = issues.map((issue) => ({
|
||||||
|
...issue,
|
||||||
|
state_detail: toJS(getStateById(issue.state_id)),
|
||||||
|
project_detail: toJS(getProjectById(issue.project_id)),
|
||||||
|
assignee_details: issue.assignee_ids.map((assigneeid) => toJS(getUserDetails(assigneeid))),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchIssue = async (issueId: string) => issuesWithStateAndProject.find((issue) => issue.id === issueId);
|
||||||
|
|
||||||
|
const issueWidgetClickAction = (issueId: string) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
setPeekIssue({ workspaceSlug, projectId: projectId, issueId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues: issuesWithStateAndProject,
|
||||||
|
fetchIssue,
|
||||||
|
issueWidgetClickAction,
|
||||||
|
};
|
||||||
|
};
|
@ -4,39 +4,14 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { Activity, ChevronLeft, CircleUser, KeyRound, LogOut, MoveLeft, Plus, Settings2, UserPlus } from "lucide-react";
|
import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser, useWorkspace } from "hooks/store";
|
import { useApplication, useUser, useWorkspace } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip } from "@plane/ui";
|
||||||
|
// constants
|
||||||
const PROFILE_ACTION_LINKS = [
|
import { PROFILE_ACTION_LINKS } from "constants/profile";
|
||||||
{
|
|
||||||
key: "profile",
|
|
||||||
label: "Profile",
|
|
||||||
href: `/profile`,
|
|
||||||
Icon: CircleUser,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "change-password",
|
|
||||||
label: "Change password",
|
|
||||||
href: `/profile/change-password`,
|
|
||||||
Icon: KeyRound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "activity",
|
|
||||||
label: "Activity",
|
|
||||||
href: `/profile/activity`,
|
|
||||||
Icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "preferences",
|
|
||||||
label: "Preferences",
|
|
||||||
href: `/profile/preferences`,
|
|
||||||
Icon: Settings2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const WORKSPACE_ACTION_LINKS = [
|
const WORKSPACE_ACTION_LINKS = [
|
||||||
{
|
{
|
||||||
@ -130,7 +105,7 @@ export const ProfileLayoutSidebar = observer(() => {
|
|||||||
<Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}>
|
<Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}>
|
||||||
<div
|
<div
|
||||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||||
router.pathname === link.href
|
link.highlight(router.pathname)
|
||||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||||
} ${sidebarCollapsed ? "justify-center" : ""}`}
|
} ${sidebarCollapsed ? "justify-center" : ""}`}
|
||||||
|
@ -1,66 +1,42 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
// hooks
|
||||||
|
import { useUser } from "hooks/store";
|
||||||
|
// constants
|
||||||
|
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "constants/project";
|
||||||
|
|
||||||
export const ProjectSettingsSidebar = () => {
|
export const ProjectSettingsSidebar = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
// mobx store
|
||||||
|
const {
|
||||||
|
membership: { currentProjectRole },
|
||||||
|
} = useUser();
|
||||||
|
|
||||||
|
const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST;
|
||||||
|
|
||||||
const projectLinks: Array<{
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
label: "General",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Members",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Features",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "States",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Labels",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Integrations",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Estimates",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Automations",
|
|
||||||
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-80 flex-col gap-6 px-5">
|
<div className="flex w-80 flex-col gap-6 px-5">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
{projectLinks.map((link) => (
|
{PROJECT_SETTINGS_LINKS.map(
|
||||||
<Link key={link.href} href={link.href}>
|
(link) =>
|
||||||
<div
|
projectMemberInfo >= link.access && (
|
||||||
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||||
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href)
|
<div
|
||||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
link.highlight(router.asPath, `/${workspaceSlug}/projects/${projectId}`)
|
||||||
}`}
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
>
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
{link.label}
|
}`}
|
||||||
</div>
|
>
|
||||||
</Link>
|
{link.label}
|
||||||
))}
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,11 +25,11 @@ export const WorkspaceSettingsSidebar = () => {
|
|||||||
{WORKSPACE_SETTINGS_LINKS.map(
|
{WORKSPACE_SETTINGS_LINKS.map(
|
||||||
(link) =>
|
(link) =>
|
||||||
workspaceMemberInfo >= link.access && (
|
workspaceMemberInfo >= link.access && (
|
||||||
<Link key={link.href} href={`/${workspaceSlug}${link.href}`}>
|
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||||
<span>
|
<span>
|
||||||
<div
|
<div
|
||||||
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||||
router.pathname.split("/")?.[3] === link.href.split("/")?.[2]
|
link.highlight(router.asPath, `/${workspaceSlug}`)
|
||||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
}`}
|
}`}
|
||||||
|
@ -26,8 +26,8 @@
|
|||||||
"@plane/document-editor": "*",
|
"@plane/document-editor": "*",
|
||||||
"@plane/lite-text-editor": "*",
|
"@plane/lite-text-editor": "*",
|
||||||
"@plane/rich-text-editor": "*",
|
"@plane/rich-text-editor": "*",
|
||||||
"@plane/ui": "*",
|
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
|
"@plane/ui": "*",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@sentry/nextjs": "^7.85.0",
|
"@sentry/nextjs": "^7.85.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
@ -39,7 +39,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"mobx": "^6.10.0",
|
"mobx": "^6.10.0",
|
||||||
"mobx-react-lite": "^4.0.3",
|
"mobx-react": "^9.1.0",
|
||||||
"next": "^14.0.3",
|
"next": "^14.0.3",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
|
@ -1,47 +1,44 @@
|
|||||||
import React, { useEffect, useRef, useState, ReactElement, useCallback } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import useSWR, { MutatorOptions } from "swr";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { Sparkle } from "lucide-react";
|
import { Sparkle } from "lucide-react";
|
||||||
import debounce from "lodash/debounce";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useIssues, usePage, useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
// services
|
// services
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
import { IssueService } from "services/issue";
|
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "layouts/app-layout";
|
import { AppLayout } from "layouts/app-layout";
|
||||||
// components
|
// components
|
||||||
import { GptAssistantPopover } from "components/core";
|
import { GptAssistantPopover } from "components/core";
|
||||||
import { PageDetailsHeader } from "components/headers/page-details";
|
import { PageDetailsHeader } from "components/headers/page-details";
|
||||||
import { EmptyState } from "components/common";
|
|
||||||
// ui
|
// ui
|
||||||
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
||||||
import { Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
// assets
|
// assets
|
||||||
import emptyPage from "public/empty-state/page.svg";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
|
||||||
// types
|
// types
|
||||||
|
import { IPage } from "@plane/types";
|
||||||
import { NextPageWithLayout } from "lib/types";
|
import { NextPageWithLayout } from "lib/types";
|
||||||
import { IPage, TIssue } from "@plane/types";
|
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
|
||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||||
|
import { useIssueEmbeds } from "hooks/use-issue-embeds";
|
||||||
|
import { IssuePeekOverview } from "components/issues";
|
||||||
|
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||||
|
import { IssueService } from "services/issue";
|
||||||
|
import { EIssuesStoreType } from "constants/issue";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
const pageService = new PageService();
|
|
||||||
const issueService = new IssueService();
|
const issueService = new IssueService();
|
||||||
|
|
||||||
const PageDetailsPage: NextPageWithLayout = observer(() => {
|
const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||||
// states
|
// states
|
||||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
|
||||||
const [gptModalOpen, setGptModal] = useState(false);
|
const [gptModalOpen, setGptModal] = useState(false);
|
||||||
// refs
|
// refs
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
@ -59,18 +56,82 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
//TODO:fix reload confirmations, with mobx
|
||||||
const { setShowAlert } = useReloadConfirmations();
|
const { setShowAlert } = useReloadConfirmations();
|
||||||
|
|
||||||
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
|
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
|
||||||
defaultValues: { name: "", description_html: "" },
|
defaultValues: { name: "", description_html: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: issuesResponse } = useSWR(
|
const {
|
||||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
archivePage: archivePageAction,
|
||||||
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
restorePage: restorePageAction,
|
||||||
|
createPage: createPageAction,
|
||||||
|
projectPageMap,
|
||||||
|
projectArchivedPageMap,
|
||||||
|
fetchProjectPages,
|
||||||
|
fetchArchivedProjectPages,
|
||||||
|
} = useProjectPages();
|
||||||
|
|
||||||
|
useSWR(
|
||||||
|
workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null,
|
||||||
|
workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string]
|
||||||
|
? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
// fetching archived pages from API
|
||||||
|
useSWR(
|
||||||
|
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
|
||||||
|
workspaceSlug && projectId && !projectArchivedPageMap[projectId as string] && !projectPageMap[projectId as string]
|
||||||
|
? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString())
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const issues = Object.values(issuesResponse ?? {});
|
const { issues, fetchIssue, issueWidgetClickAction } = useIssueEmbeds();
|
||||||
|
|
||||||
|
const pageStore = usePage(pageId as string);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (pageStore) {
|
||||||
|
pageStore.cleanup();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pageStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pageStore) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full w-full place-items-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to get the values of title and description from the page store but we don't have to subscribe to those values
|
||||||
|
const pageTitle = pageStore?.name;
|
||||||
|
const pageDescription = pageStore?.description_html;
|
||||||
|
const {
|
||||||
|
lockPage: lockPageAction,
|
||||||
|
unlockPage: unlockPageAction,
|
||||||
|
updateName: updateNameAction,
|
||||||
|
updateDescription: updateDescriptionAction,
|
||||||
|
id: pageIdMobx,
|
||||||
|
isSubmitting,
|
||||||
|
setIsSubmitting,
|
||||||
|
owned_by,
|
||||||
|
is_locked,
|
||||||
|
archived_at,
|
||||||
|
created_at,
|
||||||
|
created_by,
|
||||||
|
updated_at,
|
||||||
|
updated_by,
|
||||||
|
} = pageStore;
|
||||||
|
|
||||||
|
const updatePage = async (formData: IPage) => {
|
||||||
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
await updateDescriptionAction(formData.description_html);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAiAssistance = async (response: string) => {
|
const handleAiAssistance = async (response: string) => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
@ -78,47 +139,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
const newDescription = `${watch("description_html")}<p>${response}</p>`;
|
const newDescription = `${watch("description_html")}<p>${response}</p>`;
|
||||||
setValue("description_html", newDescription);
|
setValue("description_html", newDescription);
|
||||||
editorRef.current?.setEditorValue(newDescription);
|
editorRef.current?.setEditorValue(newDescription);
|
||||||
|
updateDescriptionAction(newDescription);
|
||||||
pageService
|
|
||||||
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), {
|
|
||||||
description_html: newDescription,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
mutatePageDetails((prevData) => ({ ...prevData, description_html: newDescription } as IPage), false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// =================== Fetching Page Details ======================
|
|
||||||
const {
|
|
||||||
data: pageDetails,
|
|
||||||
mutate: mutatePageDetails,
|
|
||||||
error,
|
|
||||||
} = useSWR(
|
|
||||||
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
|
|
||||||
workspaceSlug && projectId && pageId
|
|
||||||
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
|
|
||||||
: null,
|
|
||||||
{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchIssue = async (issueId: string) => {
|
|
||||||
const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string);
|
|
||||||
return issue as TIssue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const issueWidgetClickAction = (issueId: string) => {
|
|
||||||
const url = new URL(router.asPath, window.location.origin);
|
|
||||||
const params = new URLSearchParams(url.search);
|
|
||||||
|
|
||||||
if (params.has("peekIssueId")) {
|
|
||||||
params.set("peekIssueId", issueId);
|
|
||||||
} else {
|
|
||||||
params.append("peekIssueId", issueId);
|
|
||||||
}
|
|
||||||
// Replace the current URL with the new one
|
|
||||||
router.replace(`${url.pathname}?${params.toString()}`, undefined, { shallow: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionCompleteAlert = ({
|
const actionCompleteAlert = ({
|
||||||
@ -137,122 +158,14 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const updatePageTitle = (title: string) => {
|
||||||
if (isSubmitting === "submitted") {
|
|
||||||
setShowAlert(false);
|
|
||||||
setTimeout(async () => {
|
|
||||||
setIsSubmitting("saved");
|
|
||||||
}, 2000);
|
|
||||||
} else if (isSubmitting === "submitting") {
|
|
||||||
setShowAlert(true);
|
|
||||||
}
|
|
||||||
}, [isSubmitting, setShowAlert]);
|
|
||||||
|
|
||||||
// adding pageDetails.description_html to dependency array causes
|
|
||||||
// editor rerendering on every save
|
|
||||||
useEffect(() => {
|
|
||||||
if (pageDetails?.description_html) {
|
|
||||||
setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pageDetails?.description_html]); // TODO: Verify the exhaustive-deps warning
|
|
||||||
|
|
||||||
function createObjectFromArray(keys: string[], options: any): any {
|
|
||||||
return keys.reduce((obj, key) => {
|
|
||||||
if (options[key] !== undefined) {
|
|
||||||
obj[key] = options[key];
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}, {} as { [key: string]: any });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutatePageDetailsHelper = (
|
|
||||||
serverMutatorFn: Promise<any>,
|
|
||||||
dataToMutate: Partial<IPage>,
|
|
||||||
formDataValues: Array<keyof IPage>,
|
|
||||||
onErrorAction: () => void
|
|
||||||
) => {
|
|
||||||
const commonSwrOptions: MutatorOptions = {
|
|
||||||
revalidate: false,
|
|
||||||
populateCache: false,
|
|
||||||
rollbackOnError: () => {
|
|
||||||
onErrorAction();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const formData = getValues();
|
|
||||||
const formDataMutationObject = createObjectFromArray(formDataValues, formData);
|
|
||||||
|
|
||||||
mutatePageDetails(async () => serverMutatorFn, {
|
|
||||||
optimisticData: (prevData) => {
|
|
||||||
if (!prevData) return;
|
|
||||||
return {
|
|
||||||
...prevData,
|
|
||||||
description_html: formData["description_html"],
|
|
||||||
...formDataMutationObject,
|
|
||||||
...dataToMutate,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
...commonSwrOptions,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mutatePageDetails(undefined, {
|
|
||||||
revalidate: true,
|
|
||||||
populateCache: true,
|
|
||||||
rollbackOnError: () => {
|
|
||||||
actionCompleteAlert({
|
|
||||||
title: `Page could not be updated`,
|
|
||||||
message: `Sorry, page could not be updated, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updatePage = async (formData: IPage) => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
updateNameAction(title);
|
||||||
formData.name = pageDetails?.name as string;
|
|
||||||
|
|
||||||
if (!formData?.name || formData?.name.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData);
|
|
||||||
} catch (error) {
|
|
||||||
actionCompleteAlert({
|
|
||||||
title: `Page could not be updated`,
|
|
||||||
message: `Sorry, page could not be updated, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePageTitle = async (title: string) => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
|
||||||
|
|
||||||
mutatePageDetailsHelper(
|
|
||||||
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { name: title }),
|
|
||||||
{
|
|
||||||
name: title,
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
() =>
|
|
||||||
actionCompleteAlert({
|
|
||||||
title: `Page Title could not be updated`,
|
|
||||||
message: `Sorry, page title could not be updated, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPage = async (payload: Partial<IPage>) => {
|
const createPage = async (payload: Partial<IPage>) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
await createPageAction(workspaceSlug as string, projectId as string, payload);
|
||||||
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ================ Page Menu Actions ==================
|
// ================ Page Menu Actions ==================
|
||||||
@ -260,121 +173,84 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
const currentPageValues = getValues();
|
const currentPageValues = getValues();
|
||||||
|
|
||||||
if (!currentPageValues?.description_html) {
|
if (!currentPageValues?.description_html) {
|
||||||
currentPageValues.description_html = pageDetails?.description_html as string;
|
// TODO: We need to get latest data the above variable will give us stale data
|
||||||
|
currentPageValues.description_html = pageDescription as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData: Partial<IPage> = {
|
const formData: Partial<IPage> = {
|
||||||
name: "Copy of " + pageDetails?.name,
|
name: "Copy of " + pageTitle,
|
||||||
description_html: currentPageValues.description_html,
|
description_html: currentPageValues.description_html,
|
||||||
};
|
};
|
||||||
await createPage(formData);
|
|
||||||
|
try {
|
||||||
|
await createPage(formData);
|
||||||
|
} catch (error) {
|
||||||
|
actionCompleteAlert({
|
||||||
|
title: `Page could not be duplicated`,
|
||||||
|
message: `Sorry, page could not be duplicated, please try again later`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const archivePage = async () => {
|
const archivePage = async () => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
mutatePageDetailsHelper(
|
try {
|
||||||
pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
await archivePageAction(workspaceSlug as string, projectId as string, pageId as string);
|
||||||
{
|
} catch (error) {
|
||||||
archived_at: renderFormattedPayloadDate(new Date()),
|
actionCompleteAlert({
|
||||||
},
|
title: `Page could not be archived`,
|
||||||
["description_html"],
|
message: `Sorry, page could not be archived, please try again later`,
|
||||||
() =>
|
type: "error",
|
||||||
actionCompleteAlert({
|
});
|
||||||
title: `Page could not be Archived`,
|
}
|
||||||
message: `Sorry, page could not be Archived, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unArchivePage = async () => {
|
const unArchivePage = async () => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
try {
|
||||||
mutatePageDetailsHelper(
|
await restorePageAction(workspaceSlug as string, projectId as string, pageId as string);
|
||||||
pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
} catch (error) {
|
||||||
{
|
actionCompleteAlert({
|
||||||
archived_at: null,
|
title: `Page could not be restored`,
|
||||||
},
|
message: `Sorry, page could not be restored, please try again later`,
|
||||||
["description_html"],
|
type: "error",
|
||||||
() =>
|
});
|
||||||
actionCompleteAlert({
|
}
|
||||||
title: `Page could not be Restored`,
|
|
||||||
message: `Sorry, page could not be Restored, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================= Page Lock ==========================
|
|
||||||
const lockPage = async () => {
|
const lockPage = async () => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
mutatePageDetailsHelper(
|
try {
|
||||||
pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
await lockPageAction();
|
||||||
{
|
} catch (error) {
|
||||||
is_locked: true,
|
actionCompleteAlert({
|
||||||
},
|
title: `Page could not be locked`,
|
||||||
["description_html"],
|
message: `Sorry, page could not be locked, please try again later`,
|
||||||
() =>
|
type: "error",
|
||||||
actionCompleteAlert({
|
});
|
||||||
title: `Page cannot be Locked`,
|
}
|
||||||
message: `Sorry, page cannot be Locked, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlockPage = async () => {
|
const unlockPage = async () => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
try {
|
||||||
mutatePageDetailsHelper(
|
await unlockPageAction();
|
||||||
pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
} catch (error) {
|
||||||
{
|
actionCompleteAlert({
|
||||||
is_locked: false,
|
title: `Page could not be unlocked`,
|
||||||
},
|
message: `Sorry, page could not be unlocked, please try again later`,
|
||||||
["description_html"],
|
type: "error",
|
||||||
() =>
|
});
|
||||||
actionCompleteAlert({
|
}
|
||||||
title: `Page could not be Unlocked`,
|
|
||||||
message: `Sorry, page could not be Unlocked, please try again later`,
|
|
||||||
type: "error",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [localPageDescription, setLocalIssueDescription] = useState({
|
|
||||||
id: pageId as string,
|
|
||||||
description_html: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADDING updatePage TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
|
|
||||||
// TODO: Verify the exhaustive-deps warning
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const debouncedFormSave = useCallback(
|
|
||||||
debounce(async () => {
|
|
||||||
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
|
|
||||||
}, 1500),
|
|
||||||
[handleSubmit, pageDetails]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error)
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
image={emptyPage}
|
|
||||||
title="Page does not exist"
|
|
||||||
description="The page you are looking for does not exist or has been deleted."
|
|
||||||
primaryButton={{
|
|
||||||
text: "View other pages",
|
|
||||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/pages`),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPageReadOnly =
|
const isPageReadOnly =
|
||||||
pageDetails?.is_locked ||
|
is_locked ||
|
||||||
pageDetails?.archived_at ||
|
archived_at ||
|
||||||
(currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole));
|
(currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole));
|
||||||
|
|
||||||
const isCurrentUserOwner = pageDetails?.owned_by === currentUser?.id;
|
const isCurrentUserOwner = owned_by === currentUser?.id;
|
||||||
|
|
||||||
const userCanDuplicate =
|
const userCanDuplicate =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
@ -382,144 +258,132 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
const userCanLock =
|
const userCanLock =
|
||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
return (
|
return pageIdMobx && issues ? (
|
||||||
<>
|
<div className="flex h-full flex-col justify-between">
|
||||||
{pageDetails && issuesResponse ? (
|
<div className="h-full w-full overflow-hidden">
|
||||||
<div className="flex h-full flex-col justify-between">
|
{isPageReadOnly ? (
|
||||||
<div className="h-full w-full overflow-hidden">
|
<DocumentReadOnlyEditorWithRef
|
||||||
{isPageReadOnly ? (
|
onActionCompleteHandler={actionCompleteAlert}
|
||||||
<DocumentReadOnlyEditorWithRef
|
ref={editorRef}
|
||||||
onActionCompleteHandler={actionCompleteAlert}
|
value={pageDescription}
|
||||||
ref={editorRef}
|
customClassName={"tracking-tight w-full px-0"}
|
||||||
value={localPageDescription.description_html}
|
borderOnFocus={false}
|
||||||
rerenderOnPropsChange={localPageDescription}
|
noBorder
|
||||||
customClassName={"tracking-tight w-full px-0"}
|
documentDetails={{
|
||||||
borderOnFocus={false}
|
title: pageTitle,
|
||||||
noBorder
|
created_by: created_by,
|
||||||
documentDetails={{
|
created_on: created_at,
|
||||||
title: pageDetails.name,
|
last_updated_at: updated_at,
|
||||||
created_by: pageDetails.created_by,
|
last_updated_by: updated_by,
|
||||||
created_on: pageDetails.created_at,
|
}}
|
||||||
last_updated_at: pageDetails.updated_at,
|
pageLockConfig={userCanLock && !archived_at ? { action: unlockPage, is_locked: is_locked } : undefined}
|
||||||
last_updated_by: pageDetails.updated_by,
|
pageDuplicationConfig={userCanDuplicate && !archived_at ? { action: duplicate_page } : undefined}
|
||||||
}}
|
pageArchiveConfig={
|
||||||
pageLockConfig={
|
userCanArchive
|
||||||
userCanLock && !pageDetails.archived_at
|
? {
|
||||||
? { action: unlockPage, is_locked: pageDetails.is_locked }
|
action: archived_at ? unArchivePage : archivePage,
|
||||||
: undefined
|
is_archived: archived_at ? true : false,
|
||||||
}
|
archived_at: archived_at ? new Date(archived_at) : undefined,
|
||||||
pageDuplicationConfig={
|
}
|
||||||
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pageArchiveConfig={
|
embedConfig={{
|
||||||
userCanArchive
|
issueEmbedConfig: {
|
||||||
? {
|
issues: issues,
|
||||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
fetchIssue: fetchIssue,
|
||||||
is_archived: pageDetails.archived_at ? true : false,
|
clickAction: issueWidgetClickAction,
|
||||||
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined,
|
},
|
||||||
}
|
}}
|
||||||
: undefined
|
/>
|
||||||
}
|
) : (
|
||||||
embedConfig={{
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
issueEmbedConfig: {
|
<Controller
|
||||||
issues: issues,
|
name="description_html"
|
||||||
fetchIssue: fetchIssue,
|
control={control}
|
||||||
clickAction: issueWidgetClickAction,
|
render={({ field: { onChange } }) => (
|
||||||
},
|
<DocumentEditorWithRef
|
||||||
}}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
documentDetails={{
|
||||||
) : (
|
title: pageTitle,
|
||||||
<div className="relative h-full w-full overflow-hidden">
|
created_by: created_by,
|
||||||
<Controller
|
created_on: created_at,
|
||||||
name="description_html"
|
last_updated_at: updated_at,
|
||||||
control={control}
|
last_updated_by: updated_by,
|
||||||
render={({ field: { onChange } }) => (
|
}}
|
||||||
<DocumentEditorWithRef
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
isSubmitting={isSubmitting}
|
value={pageDescription}
|
||||||
documentDetails={{
|
setShouldShowAlert={setShowAlert}
|
||||||
title: pageDetails.name,
|
deleteFile={fileService.deleteImage}
|
||||||
created_by: pageDetails.created_by,
|
restoreFile={fileService.restoreImage}
|
||||||
created_on: pageDetails.created_at,
|
cancelUploadImage={fileService.cancelUpload}
|
||||||
last_updated_at: pageDetails.updated_at,
|
ref={editorRef}
|
||||||
last_updated_by: pageDetails.updated_by,
|
debouncedUpdatesEnabled={false}
|
||||||
}}
|
setIsSubmitting={setIsSubmitting}
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
updatePageTitle={updatePageTitle}
|
||||||
setShouldShowAlert={setShowAlert}
|
onActionCompleteHandler={actionCompleteAlert}
|
||||||
deleteFile={fileService.deleteImage}
|
customClassName="tracking-tight self-center px-0 h-full w-full"
|
||||||
restoreFile={fileService.restoreImage}
|
onChange={(_description_json: Object, description_html: string) => {
|
||||||
cancelUploadImage={fileService.cancelUpload}
|
setShowAlert(true);
|
||||||
ref={editorRef}
|
onChange(description_html);
|
||||||
debouncedUpdatesEnabled={false}
|
handleSubmit(updatePage)();
|
||||||
setIsSubmitting={setIsSubmitting}
|
}}
|
||||||
updatePageTitle={updatePageTitle}
|
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
|
||||||
value={localPageDescription.description_html}
|
pageArchiveConfig={
|
||||||
rerenderOnPropsChange={localPageDescription}
|
userCanArchive
|
||||||
onActionCompleteHandler={actionCompleteAlert}
|
? {
|
||||||
customClassName="tracking-tight self-center px-0 h-full w-full"
|
is_archived: archived_at ? true : false,
|
||||||
onChange={(_description_json: Object, description_html: string) => {
|
action: archived_at ? unArchivePage : archivePage,
|
||||||
setShowAlert(true);
|
}
|
||||||
onChange(description_html);
|
: undefined
|
||||||
setIsSubmitting("submitting");
|
}
|
||||||
debouncedFormSave();
|
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
|
||||||
}}
|
embedConfig={{
|
||||||
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
|
issueEmbedConfig: {
|
||||||
pageArchiveConfig={
|
issues: issues,
|
||||||
userCanArchive
|
fetchIssue: fetchIssue,
|
||||||
? {
|
clickAction: issueWidgetClickAction,
|
||||||
is_archived: pageDetails.archived_at ? true : false,
|
},
|
||||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
}}
|
||||||
}
|
/>
|
||||||
: undefined
|
)}
|
||||||
}
|
/>
|
||||||
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
|
{projectId && envConfig?.has_openai_configured && (
|
||||||
embedConfig={{
|
<div className="absolute right-[68px] top-2.5">
|
||||||
issueEmbedConfig: {
|
<GptAssistantPopover
|
||||||
issues: issues,
|
isOpen={gptModalOpen}
|
||||||
fetchIssue: fetchIssue,
|
projectId={projectId.toString()}
|
||||||
clickAction: issueWidgetClickAction,
|
handleClose={() => {
|
||||||
},
|
setGptModal((prevData) => !prevData);
|
||||||
}}
|
// this is done so that the title do not reset after gpt popover closed
|
||||||
/>
|
reset(getValues());
|
||||||
)}
|
}}
|
||||||
|
onResponse={(response) => {
|
||||||
|
handleAiAssistance(response);
|
||||||
|
}}
|
||||||
|
placement="top-end"
|
||||||
|
button={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||||
|
onClick={() => setGptModal((prevData) => !prevData)}
|
||||||
|
>
|
||||||
|
<Sparkle className="h-4 w-4" />
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
className="!min-w-[38rem]"
|
||||||
/>
|
/>
|
||||||
{projectId && envConfig?.has_openai_configured && (
|
|
||||||
<div className="absolute right-[68px] top-2.5">
|
|
||||||
<GptAssistantPopover
|
|
||||||
isOpen={gptModalOpen}
|
|
||||||
projectId={projectId.toString()}
|
|
||||||
handleClose={() => {
|
|
||||||
setGptModal((prevData) => !prevData);
|
|
||||||
// this is done so that the title do not reset after gpt popover closed
|
|
||||||
reset(getValues());
|
|
||||||
}}
|
|
||||||
onResponse={(response) => {
|
|
||||||
handleAiAssistance(response);
|
|
||||||
}}
|
|
||||||
placement="top-end"
|
|
||||||
button={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
|
||||||
onClick={() => setGptModal((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
<Sparkle className="h-4 w-4" />
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
className="!min-w-[38rem]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : (
|
<IssuePeekOverview />
|
||||||
<div className="grid h-full w-full place-items-center">
|
</div>
|
||||||
<Spinner />
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="grid h-full w-full place-items-center">
|
||||||
</>
|
<Spinner />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { Tab } from "@headlessui/react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { usePage, useUser } from "hooks/store";
|
import { useUser } from "hooks/store";
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
// layouts
|
// layouts
|
||||||
@ -17,6 +17,7 @@ import { PagesHeader } from "components/headers";
|
|||||||
import { NextPageWithLayout } from "lib/types";
|
import { NextPageWithLayout } from "lib/types";
|
||||||
// constants
|
// constants
|
||||||
import { PAGE_TABS_LIST } from "constants/page";
|
import { PAGE_TABS_LIST } from "constants/page";
|
||||||
|
import { useProjectPages } from "hooks/store/use-project-page";
|
||||||
|
|
||||||
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
|
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@ -45,8 +46,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
|||||||
// states
|
// states
|
||||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||||
// store
|
// store
|
||||||
const { fetchProjectPages, fetchArchivedProjectPages } = usePage();
|
|
||||||
const { currentUser, currentUserLoader } = useUser();
|
const { currentUser, currentUserLoader } = useUser();
|
||||||
|
|
||||||
|
const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages();
|
||||||
// hooks
|
// hooks
|
||||||
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
|
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
|
||||||
// local storage
|
// local storage
|
||||||
|
138
web/pages/accounts/forgot-password.tsx
Normal file
138
web/pages/accounts/forgot-password.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "services/auth.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
import useTimer from "hooks/use-timer";
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/default-layout";
|
||||||
|
// components
|
||||||
|
import { LatestFeatureBlock } from "components/common";
|
||||||
|
// ui
|
||||||
|
import { Button, Input } from "@plane/ui";
|
||||||
|
// images
|
||||||
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
|
// helpers
|
||||||
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
|
// type
|
||||||
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
|
||||||
|
type TForgotPasswordFormValues = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: TForgotPasswordFormValues = {
|
||||||
|
email: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// services
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
const ForgotPasswordPage: NextPageWithLayout = () => {
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { email } = router.query;
|
||||||
|
// toast
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
// timer
|
||||||
|
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
|
||||||
|
// form info
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors, isSubmitting, isValid },
|
||||||
|
handleSubmit,
|
||||||
|
} = useForm<TForgotPasswordFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
...defaultValues,
|
||||||
|
email: email?.toString() ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleForgotPassword = async (formData: TForgotPasswordFormValues) => {
|
||||||
|
await authService
|
||||||
|
.sendResetPasswordLink({
|
||||||
|
email: formData.email,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Email sent",
|
||||||
|
message:
|
||||||
|
"Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.",
|
||||||
|
});
|
||||||
|
setResendCodeTimer(30);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-onboarding-gradient-100">
|
||||||
|
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 ">
|
||||||
|
<div className="flex items-center gap-x-2 py-10">
|
||||||
|
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||||
|
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 ">
|
||||||
|
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
||||||
|
<div className="mx-auto flex flex-col divide-y divide-custom-border-200 sm:w-96">
|
||||||
|
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||||
|
Get on your flight deck
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">Get a link to reset your password</p>
|
||||||
|
<form onSubmit={handleSubmit(handleForgotPassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
rules={{
|
||||||
|
required: "Email is required",
|
||||||
|
validate: (value) => checkEmailValidity(value) || "Email is invalid",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.email)}
|
||||||
|
placeholder="name@company.com"
|
||||||
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
size="xl"
|
||||||
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting || resendTimerCode > 0}
|
||||||
|
>
|
||||||
|
{resendTimerCode > 0 ? `Request new link in ${resendTimerCode}s` : "Get link"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<LatestFeatureBlock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ForgotPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <DefaultLayout>{page}</DefaultLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordPage;
|
@ -1,9 +1,6 @@
|
|||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { Lightbulb } from "lucide-react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// services
|
// services
|
||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
@ -12,11 +9,12 @@ import useToast from "hooks/use-toast";
|
|||||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||||
// layouts
|
// layouts
|
||||||
import DefaultLayout from "layouts/default-layout";
|
import DefaultLayout from "layouts/default-layout";
|
||||||
|
// components
|
||||||
|
import { LatestFeatureBlock } from "components/common";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// images
|
// images
|
||||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
import latestFeatures from "public/onboarding/onboarding-pages.svg";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// type
|
// type
|
||||||
@ -35,12 +33,10 @@ const defaultValues: TResetPasswordFormValues = {
|
|||||||
// services
|
// services
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
const HomePage: NextPageWithLayout = () => {
|
const ResetPasswordPage: NextPageWithLayout = () => {
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { uidb64, token, email } = router.query;
|
const { uidb64, token, email } = router.query;
|
||||||
// next-themes
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
// toast
|
// toast
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// sign in redirection hook
|
// sign in redirection hook
|
||||||
@ -108,35 +104,30 @@ const HomePage: NextPageWithLayout = () => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.email)}
|
hasError={Boolean(errors.email)}
|
||||||
placeholder="orville.wright@frstflt.com"
|
placeholder="name@company.com"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<Controller
|
||||||
<Controller
|
control={control}
|
||||||
control={control}
|
name="password"
|
||||||
name="password"
|
rules={{
|
||||||
rules={{
|
required: "Password is required",
|
||||||
required: "Password is required",
|
}}
|
||||||
}}
|
render={({ field: { value, onChange } }) => (
|
||||||
render={({ field: { value, onChange } }) => (
|
<Input
|
||||||
<Input
|
type="password"
|
||||||
type="password"
|
value={value}
|
||||||
value={value}
|
onChange={onChange}
|
||||||
onChange={onChange}
|
hasError={Boolean(errors.password)}
|
||||||
hasError={Boolean(errors.password)}
|
placeholder="Enter password"
|
||||||
placeholder="Choose password"
|
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
minLength={8}
|
||||||
minLength={8}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
<p className="mt-3 text-xs text-onboarding-text-200">
|
|
||||||
Whatever you choose now will be your account{"'"}s password until you change it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@ -145,44 +136,19 @@ const HomePage: NextPageWithLayout = () => {
|
|||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Signing in..." : "Go to workspace"}
|
Set password
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-onboarding-text-200">
|
|
||||||
When you click the button above, you agree with our{" "}
|
|
||||||
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span className="font-semibold underline">terms and conditions of service.</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-onboarding-border-200 bg-onboarding-background-100 py-2 sm:w-96">
|
<LatestFeatureBlock />
|
||||||
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
|
|
||||||
<p className="text-left text-sm text-onboarding-text-100">
|
|
||||||
Try the latest features, like Tiptap editor, to write compelling responses.{" "}
|
|
||||||
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
|
|
||||||
<span className="text-sm font-medium underline hover:cursor-pointer">See new features</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto mt-8 overflow-hidden rounded-md border border-onboarding-border-200 bg-onboarding-background-100 object-cover sm:h-52 sm:w-96">
|
|
||||||
<div className="h-[90%]">
|
|
||||||
<Image
|
|
||||||
src={latestFeatures}
|
|
||||||
alt="Plane Issues"
|
|
||||||
className={`-mt-2 ml-8 h-full rounded-md ${
|
|
||||||
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
|
|
||||||
} `}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
ResetPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <DefaultLayout>{page}</DefaultLayout>;
|
return <DefaultLayout>{page}</DefaultLayout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomePage;
|
export default ResetPasswordPage;
|
@ -1,97 +1,52 @@
|
|||||||
import React, { useEffect, ReactElement } from "react";
|
import React from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// next-themes
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
// services
|
|
||||||
import { AuthService } from "services/auth.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// layouts
|
// layouts
|
||||||
import DefaultLayout from "layouts/default-layout";
|
import DefaultLayout from "layouts/default-layout";
|
||||||
// components
|
// components
|
||||||
import { EmailSignUpForm } from "components/account";
|
import { SignUpRoot } from "components/account";
|
||||||
// images
|
// ui
|
||||||
|
import { Spinner } from "@plane/ui";
|
||||||
|
// assets
|
||||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
// types
|
// types
|
||||||
import { NextPageWithLayout } from "lib/types";
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
|
||||||
type EmailPasswordFormValues = {
|
|
||||||
email: string;
|
|
||||||
password?: string;
|
|
||||||
medium?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// services
|
|
||||||
const authService = new AuthService();
|
|
||||||
|
|
||||||
const SignUpPage: NextPageWithLayout = observer(() => {
|
const SignUpPage: NextPageWithLayout = observer(() => {
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
// toast alert
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
// next-themes
|
|
||||||
const { setTheme } = useTheme();
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentUser, fetchCurrentUser, currentUserLoader } = useUser();
|
const {
|
||||||
// custom hooks
|
config: { envConfig },
|
||||||
const {} = useUserAuth({ routeAuth: "sign-in", user: currentUser, isLoading: currentUserLoader });
|
} = useApplication();
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
const handleSignUp = async (formData: EmailPasswordFormValues) => {
|
if (currentUser || !envConfig)
|
||||||
const payload = {
|
return (
|
||||||
email: formData.email,
|
<div className="grid h-screen place-items-center">
|
||||||
password: formData.password ?? "",
|
<Spinner />
|
||||||
};
|
</div>
|
||||||
|
);
|
||||||
await authService
|
|
||||||
.emailSignUp(payload)
|
|
||||||
.then(async (response) => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Account created successfully.",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response) await fetchCurrentUser();
|
|
||||||
router.push("/onboarding");
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTheme("system");
|
|
||||||
}, [setTheme]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="h-full w-full bg-onboarding-gradient-100">
|
||||||
<div className="left-20 top-0 hidden h-screen w-[0.5px] border-r-[0.5px] border-custom-border-200 sm:fixed sm:block lg:left-32" />
|
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
|
||||||
<div className="fixed left-7 top-11 grid place-items-center bg-custom-background-100 sm:left-16 sm:top-12 sm:py-5 lg:left-28">
|
<div className="flex items-center gap-x-2 py-10">
|
||||||
<div className="grid place-items-center bg-custom-background-100">
|
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
|
||||||
<div className="h-[30px] w-[30px]">
|
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||||
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid h-full w-full place-items-center overflow-y-auto px-7 py-5">
|
|
||||||
<div>
|
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
|
||||||
<h1 className="font- text-center text-2xl">SignUp on Plane</h1>
|
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
|
||||||
<EmailSignUpForm onSubmit={handleSignUp} />
|
<SignUpRoot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
SignUpPage.getLayout = function getLayout(page: ReactElement) {
|
SignUpPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||||
return <DefaultLayout>{page}</DefaultLayout>;
|
return <DefaultLayout>{page}</DefaultLayout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5,8 +5,9 @@ import { IAppConfig } from "@plane/types";
|
|||||||
import { AppConfigService } from "services/app_config.service";
|
import { AppConfigService } from "services/app_config.service";
|
||||||
|
|
||||||
export interface IAppConfigStore {
|
export interface IAppConfigStore {
|
||||||
|
// observables
|
||||||
envConfig: IAppConfig | null;
|
envConfig: IAppConfig | null;
|
||||||
// action
|
// actions
|
||||||
fetchAppConfig: () => Promise<any>;
|
fetchAppConfig: () => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -148,8 +150,13 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
|||||||
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const appliedFilters = _filters.filters || {};
|
||||||
this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.archivedIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation"
|
||||||
|
);
|
||||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -158,7 +160,14 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId);
|
const appliedFilters = _filters.filters || {};
|
||||||
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.cycleIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation",
|
||||||
|
cycleId
|
||||||
|
);
|
||||||
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -143,8 +145,13 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
|||||||
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const appliedFilters = _filters.filters || {};
|
||||||
this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.draftIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation"
|
||||||
|
);
|
||||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, {
|
this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
|
@ -11,7 +11,6 @@ import {
|
|||||||
TStaticViewTypes,
|
TStaticViewTypes,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { isNil } from "constants/common";
|
|
||||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||||
// lib
|
// lib
|
||||||
import { storage } from "lib/local-storage";
|
import { storage } from "lib/local-storage";
|
||||||
@ -76,8 +75,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
|||||||
target_date: filters?.target_date || undefined,
|
target_date: filters?.target_date || undefined,
|
||||||
// display filters
|
// display filters
|
||||||
type: displayFilters?.type || undefined,
|
type: displayFilters?.type || undefined,
|
||||||
sub_issue: isNil(displayFilters?.sub_issue) ? true : displayFilters?.sub_issue,
|
sub_issue: displayFilters?.sub_issue ?? true,
|
||||||
start_target_date: isNil(displayFilters?.start_target_date) ? true : displayFilters?.start_target_date,
|
start_target_date: displayFilters?.start_target_date ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
|
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
|
||||||
@ -169,19 +168,19 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
|||||||
* @returns {IIssueDisplayProperties}
|
* @returns {IIssueDisplayProperties}
|
||||||
*/
|
*/
|
||||||
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({
|
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({
|
||||||
assignee: displayProperties?.assignee || false,
|
assignee: displayProperties?.assignee ?? true,
|
||||||
start_date: displayProperties?.start_date || false,
|
start_date: displayProperties?.start_date ?? true,
|
||||||
due_date: displayProperties?.due_date || false,
|
due_date: displayProperties?.due_date ?? true,
|
||||||
labels: displayProperties?.labels || false,
|
labels: displayProperties?.labels ?? true,
|
||||||
priority: displayProperties?.priority || false,
|
priority: displayProperties?.priority ?? true,
|
||||||
state: displayProperties?.state || false,
|
state: displayProperties?.state ?? true,
|
||||||
sub_issue_count: displayProperties?.sub_issue_count || false,
|
sub_issue_count: displayProperties?.sub_issue_count ?? true,
|
||||||
attachment_count: displayProperties?.attachment_count || false,
|
attachment_count: displayProperties?.attachment_count ?? true,
|
||||||
estimate: displayProperties?.estimate || false,
|
link: displayProperties?.link ?? true,
|
||||||
link: displayProperties?.link || false,
|
estimate: displayProperties?.estimate ?? true,
|
||||||
key: displayProperties?.key || false,
|
key: displayProperties?.key ?? true,
|
||||||
created_on: displayProperties?.created_on || false,
|
created_on: displayProperties?.created_on ?? true,
|
||||||
updated_on: displayProperties?.updated_on || false,
|
updated_on: displayProperties?.updated_on ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
handleIssuesLocalFilters = {
|
handleIssuesLocalFilters = {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -157,8 +159,14 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul
|
|||||||
set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const appliedFilters = _filters.filters || {};
|
||||||
this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId);
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.moduleIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation",
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
|
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -150,13 +152,16 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const appliedFilters = _filters.filters || {};
|
||||||
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
this.rootIssueStore.profileIssues.fetchIssues(
|
this.rootIssueStore.profileIssues.fetchIssues(
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
undefined,
|
undefined,
|
||||||
"mutation",
|
isEmpty(filteredFilters) ? "init-loader" : "mutation",
|
||||||
userId,
|
userId,
|
||||||
this.rootIssueStore.profileIssues.currentView
|
this.rootIssueStore.profileIssues.currentView
|
||||||
);
|
);
|
||||||
|
|
||||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
@ -178,10 +183,10 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
|
|||||||
_filters.displayFilters.sub_group_by = null;
|
_filters.displayFilters.sub_group_by = null;
|
||||||
updatedDisplayFilters.sub_group_by = null;
|
updatedDisplayFilters.sub_group_by = null;
|
||||||
}
|
}
|
||||||
// set group_by to state if layout is switched to kanban and group_by is null
|
// set group_by to priority if layout is switched to kanban and group_by is null
|
||||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||||
_filters.displayFilters.group_by = "state";
|
_filters.displayFilters.group_by = "priority";
|
||||||
updatedDisplayFilters.group_by = "state";
|
updatedDisplayFilters.group_by = "priority";
|
||||||
}
|
}
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
@ -97,7 +97,9 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
|
|||||||
const orderBy = displayFilters?.order_by;
|
const orderBy = displayFilters?.order_by;
|
||||||
const layout = displayFilters?.layout;
|
const layout = displayFilters?.layout;
|
||||||
|
|
||||||
const userIssueIds = this.issues[userId][currentView] ?? [];
|
const userIssueIds = this.issues[userId]?.[currentView];
|
||||||
|
|
||||||
|
if (!userIssueIds) return;
|
||||||
|
|
||||||
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds);
|
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds);
|
||||||
if (!_issues) return undefined;
|
if (!_issues) return undefined;
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -159,7 +161,14 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId);
|
const appliedFilters = _filters.filters || {};
|
||||||
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.projectViewIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation",
|
||||||
|
viewId
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case EIssueFilterType.DISPLAY_FILTERS:
|
case EIssueFilterType.DISPLAY_FILTERS:
|
||||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -155,7 +157,13 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
const appliedFilters = _filters.filters || {};
|
||||||
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.projectIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation"
|
||||||
|
);
|
||||||
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
|
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
|
||||||
filters: _filters.filters,
|
filters: _filters.filters,
|
||||||
});
|
});
|
||||||
|
@ -123,6 +123,7 @@ export class IssueRootStore implements IIssueRootStore {
|
|||||||
moduleId: observable.ref,
|
moduleId: observable.ref,
|
||||||
viewId: observable.ref,
|
viewId: observable.ref,
|
||||||
userId: observable.ref,
|
userId: observable.ref,
|
||||||
|
globalViewId: observable.ref,
|
||||||
states: observable,
|
states: observable,
|
||||||
stateDetails: observable,
|
stateDetails: observable,
|
||||||
labels: observable,
|
labels: observable,
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
|
import pickBy from "lodash/pickBy";
|
||||||
|
import isArray from "lodash/isArray";
|
||||||
// base class
|
// base class
|
||||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||||
// helpers
|
// helpers
|
||||||
@ -180,7 +182,13 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
|
|||||||
set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation");
|
const appliedFilters = _filters.filters || {};
|
||||||
|
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
|
||||||
|
this.rootIssueStore.workspaceIssues.fetchIssues(
|
||||||
|
workspaceSlug,
|
||||||
|
viewId,
|
||||||
|
isEmpty(filteredFilters) ? "init-loader" : "mutation"
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case EIssueFilterType.DISPLAY_FILTERS:
|
case EIssueFilterType.DISPLAY_FILTERS:
|
||||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||||
|
@ -1,374 +1,277 @@
|
|||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, makeObservable, observable, reaction, runInAction } from "mobx";
|
||||||
import set from "lodash/set";
|
|
||||||
import omit from "lodash/omit";
|
import { IIssueLabel, IPage } from "@plane/types";
|
||||||
import isToday from "date-fns/isToday";
|
|
||||||
import isThisWeek from "date-fns/isThisWeek";
|
|
||||||
import isYesterday from "date-fns/isYesterday";
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
import { PageService } from "services/page.service";
|
||||||
// helpers
|
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
|
||||||
// types
|
|
||||||
import { IPage, IRecentPages } from "@plane/types";
|
|
||||||
// store
|
|
||||||
import { RootStore } from "./root.store";
|
import { RootStore } from "./root.store";
|
||||||
|
|
||||||
export interface IPageStore {
|
export interface IPageStore {
|
||||||
pages: Record<string, IPage>;
|
// Page Properties
|
||||||
archivedPages: Record<string, IPage>;
|
access: number;
|
||||||
// project computed
|
archived_at: string | null;
|
||||||
projectPageIds: string[] | null;
|
color: string;
|
||||||
favoriteProjectPageIds: string[] | null;
|
created_at: Date;
|
||||||
privateProjectPageIds: string[] | null;
|
created_by: string;
|
||||||
publicProjectPageIds: string[] | null;
|
description: string;
|
||||||
archivedProjectPageIds: string[] | null;
|
description_html: string;
|
||||||
recentProjectPages: IRecentPages | null;
|
description_stripped: string | null;
|
||||||
// fetch page information actions
|
id: string;
|
||||||
getUnArchivedPageById: (pageId: string) => IPage | null;
|
is_favorite: boolean;
|
||||||
getArchivedPageById: (pageId: string) => IPage | null;
|
label_details: IIssueLabel[];
|
||||||
// fetch actions
|
is_locked: boolean;
|
||||||
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
labels: string[];
|
||||||
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
name: string;
|
||||||
// favorites actions
|
owned_by: string;
|
||||||
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
project: string;
|
||||||
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
updated_at: Date;
|
||||||
// crud
|
updated_by: string;
|
||||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
workspace: string;
|
||||||
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
|
|
||||||
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
// Actions
|
||||||
// access control actions
|
makePublic: () => Promise<void>;
|
||||||
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
makePrivate: () => Promise<void>;
|
||||||
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
lockPage: () => Promise<void>;
|
||||||
// archive actions
|
unlockPage: () => Promise<void>;
|
||||||
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
addToFavorites: () => Promise<void>;
|
||||||
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
removeFromFavorites: () => Promise<void>;
|
||||||
|
updateName: (name: string) => Promise<void>;
|
||||||
|
updateDescription: (description: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
disposers: Array<() => void>;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
oldName: string;
|
||||||
|
cleanup: () => void;
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved";
|
||||||
|
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageStore implements IPageStore {
|
export class PageStore implements IPageStore {
|
||||||
pages: Record<string, IPage> = {};
|
access = 0;
|
||||||
archivedPages: Record<string, IPage> = {};
|
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
|
||||||
// services
|
archived_at: string | null;
|
||||||
|
color: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string;
|
||||||
|
description: string;
|
||||||
|
description_html = "";
|
||||||
|
description_stripped: string | null;
|
||||||
|
id: string;
|
||||||
|
is_favorite = false;
|
||||||
|
is_locked = true;
|
||||||
|
labels: string[];
|
||||||
|
name = "";
|
||||||
|
owned_by: string;
|
||||||
|
project: string;
|
||||||
|
updated_at: Date;
|
||||||
|
updated_by: string;
|
||||||
|
workspace: string;
|
||||||
|
oldName = "";
|
||||||
|
label_details: IIssueLabel[] = [];
|
||||||
|
disposers: Array<() => void> = [];
|
||||||
|
|
||||||
pageService;
|
pageService;
|
||||||
// stores
|
// root store
|
||||||
rootStore;
|
rootStore;
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(page: IPage, _rootStore: RootStore) {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
pages: observable,
|
name: observable.ref,
|
||||||
archivedPages: observable,
|
description_html: observable.ref,
|
||||||
// computed
|
is_favorite: observable.ref,
|
||||||
projectPageIds: computed,
|
is_locked: observable.ref,
|
||||||
favoriteProjectPageIds: computed,
|
isSubmitting: observable.ref,
|
||||||
publicProjectPageIds: computed,
|
access: observable.ref,
|
||||||
privateProjectPageIds: computed,
|
|
||||||
archivedProjectPageIds: computed,
|
|
||||||
recentProjectPages: computed,
|
|
||||||
// computed actions
|
|
||||||
getUnArchivedPageById: action,
|
|
||||||
getArchivedPageById: action,
|
|
||||||
// fetch actions
|
|
||||||
fetchProjectPages: action,
|
|
||||||
fetchArchivedProjectPages: action,
|
|
||||||
// favorites actions
|
|
||||||
addToFavorites: action,
|
|
||||||
removeFromFavorites: action,
|
|
||||||
// crud
|
|
||||||
createPage: action,
|
|
||||||
updatePage: action,
|
|
||||||
deletePage: action,
|
|
||||||
// access control actions
|
|
||||||
makePublic: action,
|
makePublic: action,
|
||||||
makePrivate: action,
|
makePrivate: action,
|
||||||
// archive actions
|
addToFavorites: action,
|
||||||
archivePage: action,
|
removeFromFavorites: action,
|
||||||
restorePage: action,
|
updateName: action,
|
||||||
|
updateDescription: action,
|
||||||
|
setIsSubmitting: action,
|
||||||
|
cleanup: action,
|
||||||
});
|
});
|
||||||
// stores
|
this.created_by = page?.created_by || "";
|
||||||
this.rootStore = rootStore;
|
this.created_at = page?.created_at || new Date();
|
||||||
// services
|
this.color = page?.color || "";
|
||||||
|
this.archived_at = page?.archived_at || null;
|
||||||
|
this.name = page?.name || "";
|
||||||
|
this.description = page?.description || "";
|
||||||
|
this.description_stripped = page?.description_stripped || "";
|
||||||
|
this.description_html = page?.description_html || "";
|
||||||
|
this.access = page?.access || 0;
|
||||||
|
this.workspace = page?.workspace || "";
|
||||||
|
this.updated_by = page?.updated_by || "";
|
||||||
|
this.updated_at = page?.updated_at || new Date();
|
||||||
|
this.project = page?.project || "";
|
||||||
|
this.owned_by = page?.owned_by || "";
|
||||||
|
this.labels = page?.labels || [];
|
||||||
|
this.label_details = page?.label_details || [];
|
||||||
|
this.is_locked = page?.is_locked || false;
|
||||||
|
this.id = page?.id || "";
|
||||||
|
this.is_favorite = page?.is_favorite || false;
|
||||||
|
this.oldName = page?.name || "";
|
||||||
|
|
||||||
|
this.rootStore = _rootStore;
|
||||||
this.pageService = new PageService();
|
this.pageService = new PageService();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const descriptionDisposer = reaction(
|
||||||
* retrieves all pages for a projectId that is available in the url.
|
() => this.description_html,
|
||||||
*/
|
(description_html) => {
|
||||||
get projectPageIds() {
|
//TODO: Fix reaction to only run when the data is changed, not when the page is loaded
|
||||||
const projectId = this.rootStore.app.router.projectId;
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
if (!projectId) return null;
|
if (!projectId || !workspaceSlug) return;
|
||||||
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId);
|
this.isSubmitting = "submitting";
|
||||||
return projectPageIds ?? null;
|
this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => {
|
||||||
}
|
runInAction(() => {
|
||||||
|
this.isSubmitting = "submitted";
|
||||||
/**
|
|
||||||
* retrieves all favorite pages for a projectId that is available in the url.
|
|
||||||
*/
|
|
||||||
get favoriteProjectPageIds() {
|
|
||||||
if (!this.projectPageIds) return null;
|
|
||||||
const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite);
|
|
||||||
return favoritePagesIds ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves all private pages for a projectId that is available in the url.
|
|
||||||
*/
|
|
||||||
get privateProjectPageIds() {
|
|
||||||
if (!this.projectPageIds) return null;
|
|
||||||
const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1);
|
|
||||||
return privatePagesIds ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url.
|
|
||||||
*/
|
|
||||||
get publicProjectPageIds() {
|
|
||||||
if (!this.projectPageIds) return null;
|
|
||||||
const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0);
|
|
||||||
return publicPagesIds ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves all recent pages for a projectId that is available in the url.
|
|
||||||
* In format where today, yesterday, this_week, older are keys.
|
|
||||||
*/
|
|
||||||
get recentProjectPages() {
|
|
||||||
if (!this.projectPageIds) return null;
|
|
||||||
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
|
|
||||||
data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.updated_at))) || [];
|
|
||||||
data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.updated_at))) || [];
|
|
||||||
data.this_week =
|
|
||||||
this.projectPageIds.filter((p) => {
|
|
||||||
const pageUpdatedAt = this.pages?.[p]?.updated_at;
|
|
||||||
return (
|
|
||||||
isThisWeek(new Date(pageUpdatedAt)) &&
|
|
||||||
!isToday(new Date(pageUpdatedAt)) &&
|
|
||||||
!isYesterday(new Date(pageUpdatedAt))
|
|
||||||
);
|
|
||||||
}) || [];
|
|
||||||
data.older =
|
|
||||||
this.projectPageIds.filter((p) => {
|
|
||||||
const pageUpdatedAt = this.pages?.[p]?.updated_at;
|
|
||||||
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
|
|
||||||
}) || [];
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves all archived pages for a projectId that is available in the url.
|
|
||||||
*/
|
|
||||||
get archivedProjectPageIds() {
|
|
||||||
const projectId = this.rootStore.app.router.projectId;
|
|
||||||
if (!projectId) return null;
|
|
||||||
const archivedProjectPageIds = Object.keys(this.archivedPages).filter(
|
|
||||||
(pageId) => this.archivedPages?.[pageId]?.project === projectId
|
|
||||||
);
|
|
||||||
return archivedProjectPageIds ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves a page from pages by id.
|
|
||||||
* @param pageId
|
|
||||||
* @returns IPage | null
|
|
||||||
*/
|
|
||||||
getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves a page from archived pages by id.
|
|
||||||
* @param pageId
|
|
||||||
* @returns IPage | null
|
|
||||||
*/
|
|
||||||
getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetches all pages for a project.
|
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @returns Promise<IPage[]>
|
|
||||||
*/
|
|
||||||
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
|
||||||
try {
|
|
||||||
return await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
|
|
||||||
console.log("Response from backend 1", response);
|
|
||||||
runInAction(() => {
|
|
||||||
response.forEach((page) => {
|
|
||||||
set(this.pages, [page.id], page);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return response;
|
},
|
||||||
});
|
{ delay: 3000 }
|
||||||
} catch (error) {
|
);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const pageTitleDisposer = reaction(
|
||||||
* fetches all archived pages for a project.
|
() => this.name,
|
||||||
* @param workspaceSlug
|
(name) => {
|
||||||
* @param projectId
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
* @returns Promise<IPage[]>
|
if (!projectId || !workspaceSlug) return;
|
||||||
*/
|
this.isSubmitting = "submitting";
|
||||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
this.pageService
|
||||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
.patchPage(workspaceSlug, projectId, this.id, { name })
|
||||||
runInAction(() => {
|
.catch(() => {
|
||||||
response.forEach((page) => {
|
runInAction(() => {
|
||||||
set(this.archivedPages, [page.id], page);
|
this.name = this.oldName;
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
return response;
|
.finally(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.isSubmitting = "submitted";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ delay: 2000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.disposers.push(descriptionDisposer, pageTitleDisposer);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateName = action("updateName", async (name: string) => {
|
||||||
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.oldName = this.name;
|
||||||
|
this.name = name;
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDescription = action("updateDescription", async (description_html: string) => {
|
||||||
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.description_html = description_html;
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = action("cleanup", () => {
|
||||||
|
this.disposers.forEach((disposer) => {
|
||||||
|
disposer();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
|
||||||
|
this.isSubmitting = isSubmitting;
|
||||||
|
});
|
||||||
|
|
||||||
|
lockPage = action("lockPage", async () => {
|
||||||
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.is_locked = true;
|
||||||
|
|
||||||
|
await this.pageService.lockPage(workspaceSlug, projectId, this.id).catch(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.is_locked = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
unlockPage = action("unlockPage", async () => {
|
||||||
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.is_locked = false;
|
||||||
|
|
||||||
|
await this.pageService.unlockPage(workspaceSlug, projectId, this.id).catch(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.is_locked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Page to users favorites list
|
* Add Page to users favorites list
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
*/
|
*/
|
||||||
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
addToFavorites = action("addToFavorites", async () => {
|
||||||
try {
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.is_favorite = true;
|
||||||
|
|
||||||
|
await this.pageService.addPageToFavorites(workspaceSlug, projectId, this.id).catch(() => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(this.pages, [pageId, "is_favorite"], true);
|
this.is_favorite = false;
|
||||||
});
|
});
|
||||||
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
|
});
|
||||||
} catch (error) {
|
});
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId, "is_favorite"], false);
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove page from the users favorites list
|
* Remove page from the users favorites list
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
*/
|
*/
|
||||||
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
removeFromFavorites = action("removeFromFavorites", async () => {
|
||||||
try {
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
runInAction(() => {
|
if (!projectId || !workspaceSlug) return;
|
||||||
set(this.pages, [pageId, "is_favorite"], false);
|
|
||||||
});
|
|
||||||
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
|
|
||||||
} catch (error) {
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId, "is_favorite"], true);
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Creates a new page using the api and updated the local state in store
|
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) =>
|
|
||||||
await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => {
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [response.id], response);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
this.is_favorite = false;
|
||||||
* updates the page using the api and updates the local state in store
|
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
* @param data
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) =>
|
|
||||||
await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => {
|
|
||||||
const originalPage = this.getUnArchivedPageById(pageId);
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId], { ...originalPage, ...data });
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => {
|
||||||
* delete a page using the api and updates the local state in store
|
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
|
||||||
await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => {
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
omit(this.archivedPages, [pageId]);
|
this.is_favorite = true;
|
||||||
});
|
});
|
||||||
return response;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* make a page public
|
* make a page public
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
makePublic = action("makePublic", async () => {
|
||||||
try {
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
|
if (!projectId || !workspaceSlug) return;
|
||||||
|
|
||||||
|
this.access = 0;
|
||||||
|
|
||||||
|
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 0 }).catch(() => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(this.pages, [pageId, "access"], 0);
|
this.access = 1;
|
||||||
});
|
});
|
||||||
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
|
});
|
||||||
} catch (error) {
|
});
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId, "access"], 1);
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a page private
|
* Make a page private
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
makePrivate = action("makePrivate", async () => {
|
||||||
try {
|
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||||
runInAction(() => {
|
if (!projectId || !workspaceSlug) return;
|
||||||
set(this.pages, [pageId, "access"], 1);
|
|
||||||
});
|
|
||||||
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
|
|
||||||
} catch (error) {
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId, "access"], 0);
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
this.access = 1;
|
||||||
* Mark a page archived
|
|
||||||
* @param workspaceSlug
|
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => {
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
*/
|
|
||||||
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
|
||||||
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(this.archivedPages, [pageId], this.pages[pageId]);
|
this.access = 0;
|
||||||
set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date()));
|
|
||||||
omit(this.pages, [pageId]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore a page from archived pages to pages
|
|
||||||
* @param workspaceSlug
|
|
||||||
* @param projectId
|
|
||||||
* @param pageId
|
|
||||||
*/
|
|
||||||
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
|
||||||
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
|
|
||||||
runInAction(() => {
|
|
||||||
set(this.pages, [pageId], this.archivedPages[pageId]);
|
|
||||||
omit(this.archivedPages, [pageId]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,54 @@
|
|||||||
import { makeObservable, observable, runInAction, action } from "mobx";
|
import { makeObservable, observable, runInAction, action, computed } from "mobx";
|
||||||
import { set } from "lodash";
|
import { set } from "lodash";
|
||||||
// services
|
// services
|
||||||
import { PageService } from "services/page.service";
|
import { PageService } from "services/page.service";
|
||||||
// store
|
// store
|
||||||
import { PageStore, IPageStore } from "store/page.store";
|
import { PageStore, IPageStore } from "store/page.store";
|
||||||
// types
|
// types
|
||||||
import { IPage } from "@plane/types";
|
import { IPage, IRecentPages } from "@plane/types";
|
||||||
|
import { RootStore } from "./root.store";
|
||||||
|
import { isThisWeek, isToday, isYesterday } from "date-fns";
|
||||||
|
|
||||||
export interface IProjectPageStore {
|
export interface IProjectPageStore {
|
||||||
projectPages: Record<string, IPageStore[]>;
|
projectPageMap: Record<string, Record<string, IPageStore>>;
|
||||||
projectArchivedPages: Record<string, IPageStore[]>;
|
projectArchivedPageMap: Record<string, Record<string, IPageStore>>;
|
||||||
|
|
||||||
|
projectPageIds: string[] | undefined;
|
||||||
|
archivedPageIds: string[] | undefined;
|
||||||
|
favoriteProjectPageIds: string[] | undefined;
|
||||||
|
privateProjectPageIds: string[] | undefined;
|
||||||
|
publicProjectPageIds: string[] | undefined;
|
||||||
|
recentProjectPages: IRecentPages | undefined;
|
||||||
// fetch actions
|
// fetch actions
|
||||||
fetchProjectPages: (workspaceSlug: string, projectId: string) => void;
|
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void;
|
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
// crud actions
|
// crud actions
|
||||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => void;
|
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||||
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void;
|
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
|
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectPageStore implements IProjectPageStore {
|
export class ProjectPageStore implements IProjectPageStore {
|
||||||
projectPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
|
projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
|
||||||
projectArchivedPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
|
projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
|
||||||
|
|
||||||
|
// root store
|
||||||
|
rootStore;
|
||||||
|
|
||||||
pageService;
|
pageService;
|
||||||
|
constructor(_rootStore: RootStore) {
|
||||||
constructor() {
|
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
projectPages: observable,
|
projectPageMap: observable,
|
||||||
projectArchivedPages: observable,
|
projectArchivedPageMap: observable,
|
||||||
|
|
||||||
|
projectPageIds: computed,
|
||||||
|
archivedPageIds: computed,
|
||||||
|
favoriteProjectPageIds: computed,
|
||||||
|
privateProjectPageIds: computed,
|
||||||
|
publicProjectPageIds: computed,
|
||||||
|
recentProjectPages: computed,
|
||||||
|
|
||||||
// fetch actions
|
// fetch actions
|
||||||
fetchProjectPages: action,
|
fetchProjectPages: action,
|
||||||
fetchArchivedProjectPages: action,
|
fetchArchivedProjectPages: action,
|
||||||
@ -35,19 +56,113 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
createPage: action,
|
createPage: action,
|
||||||
deletePage: action,
|
deletePage: action,
|
||||||
});
|
});
|
||||||
|
this.rootStore = _rootStore;
|
||||||
|
|
||||||
this.pageService = new PageService();
|
this.pageService = new PageService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get projectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!projectId || !this.projectPageMap?.[projectId]) return [];
|
||||||
|
|
||||||
|
const allProjectIds = Object.keys(this.projectPageMap[projectId]);
|
||||||
|
return allProjectIds.sort((a, b) => {
|
||||||
|
const dateA = new Date(this.projectPageMap[projectId][a].created_at).getTime();
|
||||||
|
const dateB = new Date(this.projectPageMap[projectId][b].created_at).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get archivedPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!projectId || !this.projectArchivedPageMap[projectId]) return [];
|
||||||
|
const archivedPages = Object.keys(this.projectArchivedPageMap[projectId]);
|
||||||
|
return archivedPages.sort((a, b) => {
|
||||||
|
const dateA = new Date(this.projectArchivedPageMap[projectId][a].created_at).getTime();
|
||||||
|
const dateB = new Date(this.projectArchivedPageMap[projectId][b].created_at).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get favoriteProjectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!this.projectPageIds || !projectId) return [];
|
||||||
|
|
||||||
|
const favouritePages: string[] = this.projectPageIds.filter(
|
||||||
|
(page) => this.projectPageMap[projectId][page].is_favorite
|
||||||
|
);
|
||||||
|
return favouritePages;
|
||||||
|
}
|
||||||
|
|
||||||
|
get privateProjectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!this.projectPageIds || !projectId) return [];
|
||||||
|
|
||||||
|
const privatePages: string[] = this.projectPageIds.filter(
|
||||||
|
(page) => this.projectPageMap[projectId][page].access === 1
|
||||||
|
);
|
||||||
|
return privatePages;
|
||||||
|
}
|
||||||
|
|
||||||
|
get publicProjectPageIds() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
const userId = this.rootStore.user.currentUser?.id;
|
||||||
|
if (!this.projectPageIds || !projectId || !userId) return [];
|
||||||
|
|
||||||
|
const publicPages: string[] = this.projectPageIds.filter(
|
||||||
|
(page) =>
|
||||||
|
this.projectPageMap[projectId][page].access === 0 && this.projectPageMap[projectId][page].owned_by === userId
|
||||||
|
);
|
||||||
|
return publicPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
get recentProjectPages() {
|
||||||
|
const projectId = this.rootStore.app.router.projectId;
|
||||||
|
if (!this.projectPageIds || !projectId) return;
|
||||||
|
|
||||||
|
const today: string[] = this.projectPageIds.filter((page) =>
|
||||||
|
isToday(new Date(this.projectPageMap[projectId][page].updated_at))
|
||||||
|
);
|
||||||
|
|
||||||
|
const yesterday: string[] = this.projectPageIds.filter((page) =>
|
||||||
|
isYesterday(new Date(this.projectPageMap[projectId][page].updated_at))
|
||||||
|
);
|
||||||
|
|
||||||
|
const this_week: string[] = this.projectPageIds.filter((page) => {
|
||||||
|
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
|
||||||
|
return (
|
||||||
|
isThisWeek(new Date(pageUpdatedAt)) &&
|
||||||
|
!isToday(new Date(pageUpdatedAt)) &&
|
||||||
|
!isYesterday(new Date(pageUpdatedAt))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const older: string[] = this.projectPageIds.filter((page) => {
|
||||||
|
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
|
||||||
|
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { today, yesterday, this_week, older };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetching all the pages for a specific project
|
* Fetching all the pages for a specific project
|
||||||
* @param workspaceSlug
|
* @param workspaceSlug
|
||||||
* @param projectId
|
* @param projectId
|
||||||
*/
|
*/
|
||||||
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
||||||
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
|
try {
|
||||||
runInAction(() => {
|
await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
|
||||||
this.projectPages[projectId] = response?.map((page) => new PageStore(page as any));
|
runInAction(() => {
|
||||||
});
|
for (const page of response) {
|
||||||
|
set(this.projectPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -56,13 +171,20 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
* @param projectId
|
* @param projectId
|
||||||
* @returns Promise<IPage[]>
|
* @returns Promise<IPage[]>
|
||||||
*/
|
*/
|
||||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => {
|
||||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
try {
|
||||||
runInAction(() => {
|
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||||
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
|
runInAction(() => {
|
||||||
|
for (const page of response) {
|
||||||
|
set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
return response;
|
} catch (e) {
|
||||||
});
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new page using the api and updated the local state in store
|
* Creates a new page using the api and updated the local state in store
|
||||||
@ -73,7 +195,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
||||||
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)];
|
set(this.projectPageMap, [projectId, response.id], new PageStore(response, this.rootStore));
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
@ -88,11 +210,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
|
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.projectPages = set(
|
delete this.projectArchivedPageMap[projectId][pageId];
|
||||||
this.projectPages,
|
|
||||||
[projectId],
|
|
||||||
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
@ -104,14 +222,17 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
* @param pageId
|
* @param pageId
|
||||||
*/
|
*/
|
||||||
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId);
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(
|
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
|
||||||
this.projectPages,
|
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString());
|
||||||
[projectId],
|
delete this.projectPageMap[projectId][pageId];
|
||||||
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
});
|
||||||
);
|
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => {
|
||||||
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
|
runInAction(() => {
|
||||||
|
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
|
||||||
|
set(this.projectPageMap[projectId][pageId], "archived_at", null);
|
||||||
|
delete this.projectArchivedPageMap[projectId][pageId];
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
@ -122,15 +243,19 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||||||
* @param projectId
|
* @param projectId
|
||||||
* @param pageId
|
* @param pageId
|
||||||
*/
|
*/
|
||||||
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
|
const pageArchivedAt = this.projectArchivedPageMap[projectId][pageId].archived_at;
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
|
||||||
|
set(this.projectPageMap[projectId][pageId], "archived_at", null);
|
||||||
|
delete this.projectArchivedPageMap[projectId][pageId];
|
||||||
|
});
|
||||||
|
await this.pageService.restorePage(workspaceSlug, projectId, pageId).catch(() => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(
|
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
|
||||||
this.projectArchivedPages,
|
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt);
|
||||||
[projectId],
|
delete this.projectPageMap[projectId][pageId];
|
||||||
this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
|
|
||||||
);
|
|
||||||
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -92,24 +92,26 @@ export class ProjectStore implements IProjectStore {
|
|||||||
* Returns searched projects based on search query
|
* Returns searched projects based on search query
|
||||||
*/
|
*/
|
||||||
get searchedProjects() {
|
get searchedProjects() {
|
||||||
if (!this.rootStore.app.router.workspaceSlug) return [];
|
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
const projectIds = Object.keys(this.projectMap);
|
if (!workspaceDetails) return [];
|
||||||
return this.searchQuery === ""
|
const workspaceProjects = Object.values(this.projectMap).filter(
|
||||||
? projectIds
|
(p) =>
|
||||||
: projectIds?.filter((projectId) => {
|
p.workspace === workspaceDetails.id &&
|
||||||
this.projectMap[projectId].name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
|
(p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
|
||||||
this.projectMap[projectId].identifier.toLowerCase().includes(this.searchQuery.toLowerCase());
|
p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase()))
|
||||||
});
|
);
|
||||||
|
return workspaceProjects.map((p) => p.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns project IDs belong to the current workspace
|
* Returns project IDs belong to the current workspace
|
||||||
*/
|
*/
|
||||||
get workspaceProjectIds() {
|
get workspaceProjectIds() {
|
||||||
if (!this.rootStore.app.router.workspaceSlug) return null;
|
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
const projectIds = Object.keys(this.projectMap);
|
if (!workspaceDetails) return null;
|
||||||
if (!projectIds) return null;
|
const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id);
|
||||||
return projectIds;
|
const projectIds = workspaceProjects.map((p) => p.id);
|
||||||
|
return projectIds ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -9,7 +9,6 @@ import { IUserRootStore, UserRootStore } from "./user";
|
|||||||
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
|
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
|
||||||
import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
|
import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
|
||||||
import { IStateStore, StateStore } from "./state.store";
|
import { IStateStore, StateStore } from "./state.store";
|
||||||
import { IPageStore, PageStore } from "./page.store";
|
|
||||||
import { ILabelRootStore, LabelRootStore } from "./label";
|
import { ILabelRootStore, LabelRootStore } from "./label";
|
||||||
import { IMemberRootStore, MemberRootStore } from "./member";
|
import { IMemberRootStore, MemberRootStore } from "./member";
|
||||||
import { IInboxRootStore, InboxRootStore } from "./inbox";
|
import { IInboxRootStore, InboxRootStore } from "./inbox";
|
||||||
@ -33,7 +32,6 @@ export class RootStore {
|
|||||||
module: IModuleStore;
|
module: IModuleStore;
|
||||||
projectView: IProjectViewStore;
|
projectView: IProjectViewStore;
|
||||||
globalView: IGlobalViewStore;
|
globalView: IGlobalViewStore;
|
||||||
page: IPageStore;
|
|
||||||
issue: IIssueRootStore;
|
issue: IIssueRootStore;
|
||||||
state: IStateStore;
|
state: IStateStore;
|
||||||
estimate: IEstimateStore;
|
estimate: IEstimateStore;
|
||||||
@ -58,8 +56,7 @@ export class RootStore {
|
|||||||
this.state = new StateStore(this);
|
this.state = new StateStore(this);
|
||||||
this.estimate = new EstimateStore(this);
|
this.estimate = new EstimateStore(this);
|
||||||
this.mention = new MentionStore(this);
|
this.mention = new MentionStore(this);
|
||||||
|
this.projectPages = new ProjectPageStore(this);
|
||||||
this.dashboard = new DashboardStore(this);
|
this.dashboard = new DashboardStore(this);
|
||||||
this.projectPages = new ProjectPageStore();
|
|
||||||
this.page = new PageStore(this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
24
yarn.lock
24
yarn.lock
@ -6617,13 +6617,35 @@ mkdirp@^0.5.5:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimist "^1.2.6"
|
minimist "^1.2.6"
|
||||||
|
|
||||||
mobx-react-lite@^4.0.3:
|
mobx-devtools-mst@^0.9.30:
|
||||||
|
version "0.9.30"
|
||||||
|
resolved "https://registry.yarnpkg.com/mobx-devtools-mst/-/mobx-devtools-mst-0.9.30.tgz#0d1cad8b3d97e1f3f94bb9afb701cd9c8b5b164d"
|
||||||
|
integrity sha512-6fIYeFG4xT4syIeKddmK55zQbc3ZZZr/272/cCbfaAAM5YiuFdteGZGUgdsz8wxf/mGxWZbFOM3WmASAnpwrbw==
|
||||||
|
|
||||||
|
mobx-react-devtools@^6.1.1:
|
||||||
|
version "6.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-6.1.1.tgz#a462b944085cf11ff96fc937d12bf31dab4c8984"
|
||||||
|
integrity sha512-nc5IXLdEUFLn3wZal65KF3/JFEFd+mbH4KTz/IG5BOPyw7jo8z29w/8qm7+wiCyqVfUIgJ1gL4+HVKmcXIOgqA==
|
||||||
|
|
||||||
|
mobx-react-lite@^4.0.3, mobx-react-lite@^4.0.4:
|
||||||
version "4.0.5"
|
version "4.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b"
|
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b"
|
||||||
integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg==
|
integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg==
|
||||||
dependencies:
|
dependencies:
|
||||||
use-sync-external-store "^1.2.0"
|
use-sync-external-store "^1.2.0"
|
||||||
|
|
||||||
|
mobx-react@^9.1.0:
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-9.1.0.tgz#5e54919ca27ffad5f2c0d835148a1f681cebdbc1"
|
||||||
|
integrity sha512-DeDRTYw4AlgHw8xEXtiZdKKEnp+c5/jeUgTbTQXEqnAzfkrgYRWP3p3Nv3Whc2CEcM/mDycbDWGjxKokQdlffg==
|
||||||
|
dependencies:
|
||||||
|
mobx-react-lite "^4.0.4"
|
||||||
|
|
||||||
|
mobx-state-tree@^5.4.0:
|
||||||
|
version "5.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-5.4.0.tgz#d41b7fd90b8d4b063bc32526758417f1100751df"
|
||||||
|
integrity sha512-2VuUhAqFklxgGqFNqaZUXYYSQINo8C2SUEP9YfCQrwatHWHqJLlEC7Xb+5WChkev7fubzn3aVuby26Q6h+JeBg==
|
||||||
|
|
||||||
mobx@^6.10.0:
|
mobx@^6.10.0:
|
||||||
version "6.12.0"
|
version "6.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"
|
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"
|
||||||
|
Loading…
Reference in New Issue
Block a user