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
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_name:
|
||||
description: "Branch Name"
|
||||
required: true
|
||||
default: "preview"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- preview
|
||||
- qa
|
||||
- develop
|
||||
- release-*
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
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:
|
||||
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
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
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:
|
||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
@ -63,33 +32,38 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
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:
|
||||
- name: Set Frontend Docker Tag
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
fi
|
||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v2.1.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Downloading Web Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: web-src-code
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
file: ./web/Dockerfile.web
|
||||
@ -105,33 +79,39 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
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:
|
||||
- name: Set Space Docker Tag
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
fi
|
||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v2.1.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Downloading Space Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: space-src-code
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
file: ./space/Dockerfile.space
|
||||
@ -147,36 +127,42 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
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:
|
||||
- name: Set Backend Docker Tag
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v2.1.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Downloading Backend Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: backend-src-code
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
file: ./Dockerfile.api
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.BACKEND_TAG }}
|
||||
@ -189,37 +175,42 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
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:
|
||||
- name: Set Proxy Docker Tag
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
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
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v2.1.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Downloading Proxy Source Code
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: proxy-src-code
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.PROXY_TAG }}
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
@ -8,11 +8,11 @@ SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
POSTGRES_USER="plane"
|
||||
POSTGRES_PASSWORD="plane"
|
||||
POSTGRES_HOST="plane-db"
|
||||
POSTGRES_DB="plane"
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||
|
||||
# Oauth variables
|
||||
GOOGLE_CLIENT_ID=""
|
||||
|
@ -39,7 +39,6 @@ from plane.app.serializers import (
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission
|
||||
)
|
||||
from plane.db.models import (
|
||||
User,
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
from datetime import timedelta, date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
# Django imports
|
||||
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.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageFavorite,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueActivity,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
PageSerializer,
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
IssueLiteSerializer,
|
||||
SubPageSerializer,
|
||||
)
|
||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||
PageLogSerializer, PageSerializer,
|
||||
SubPageSerializer)
|
||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||
PageFavorite, PageLog, ProjectMember)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
role__gt=20,
|
||||
role__gte=20,
|
||||
).exists()
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
|
@ -41,7 +41,7 @@ from plane.app.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
@ -1339,23 +1339,10 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
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")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@ -1370,6 +1357,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by("created_at")
|
||||
).distinct()
|
||||
|
||||
# Priority Ordering
|
||||
@ -1432,7 +1426,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueLiteSerializer(
|
||||
issues = IssueSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
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):
|
||||
try:
|
||||
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
|
||||
|
||||
|
@ -7,19 +7,17 @@ from . import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return (
|
||||
{
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
|
@ -482,19 +482,16 @@ export class TableView implements NodeView {
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HTMLElement>
|
||||
) as any;
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, HTMLElement>) as any;
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled");
|
||||
|
@ -32,6 +32,7 @@
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/editor-extensions": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
|
||||
type IPageRenderer = {
|
||||
documentDetails: DocumentDetails;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
updatePageTitle: (title: string) => void;
|
||||
editor: Editor;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
@ -30,18 +30,6 @@ type IPageRenderer = {
|
||||
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) => {
|
||||
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
|
||||
|
||||
@ -64,11 +52,26 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
|
||||
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) => {
|
||||
setPagetitle(title);
|
||||
debouncedUpdatePageTitle(title);
|
||||
updatePageTitle(title);
|
||||
};
|
||||
|
||||
const [cleanup, setcleanup] = useState(() => () => {});
|
||||
|
@ -26,7 +26,7 @@ export const DocumentEditorExtensions = (
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
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();
|
||||
},
|
||||
|
@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
|
||||
title: suggestion.name,
|
||||
priority: suggestion.priority.toString(),
|
||||
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 }) => {
|
||||
editor
|
||||
.chain()
|
||||
|
@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "#issue_",
|
||||
allowSpaces: true,
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
char: "#issue_",
|
||||
pluginKey: new PluginKey("issue-embed-suggestions"),
|
||||
editor: this.editor,
|
||||
allowSpaces: true,
|
||||
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
|
@ -53,7 +53,7 @@ const IssueSuggestionList = ({
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
let totalLength = 0;
|
||||
sections.forEach((section) => {
|
||||
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
|
||||
@ -65,8 +65,8 @@ const IssueSuggestionList = ({
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = displayedItems[currentSection][index];
|
||||
(section: string, index: number) => {
|
||||
const item = displayedItems[section][index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
@ -87,6 +87,7 @@ const IssueSuggestionList = ({
|
||||
setSelectedIndex(
|
||||
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
|
||||
);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
@ -101,10 +102,12 @@ const IssueSuggestionList = ({
|
||||
[currentSection]: [...prevItems[currentSection], ...nextItems],
|
||||
}));
|
||||
}
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
selectItem(currentSection, selectedIndex);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
@ -112,6 +115,7 @@ const IssueSuggestionList = ({
|
||||
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
|
||||
setCurrentSection(sections[nextSectionIndex]);
|
||||
setSelectedIndex(0);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -172,7 +176,7 @@ const IssueSuggestionList = ({
|
||||
}
|
||||
)}
|
||||
key={item.identifier}
|
||||
onClick={() => selectItem(index)}
|
||||
onClick={() => selectItem(section, index)}
|
||||
>
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
||||
<PriorityIcon priority={item.priority} />
|
||||
@ -195,7 +199,7 @@ export const IssueListRenderer = () => {
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(IssueSuggestionList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
@ -210,10 +214,10 @@ export const IssueListRenderer = () => {
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "right",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
|
@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => {
|
||||
setIssueDetails(issue);
|
||||
setLoading(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
.catch(() => {
|
||||
setLoading(-1);
|
||||
});
|
||||
}, []);
|
||||
@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => {
|
||||
{loading == 0 ? (
|
||||
<div
|
||||
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">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
|
@ -16,7 +16,7 @@ interface IDocumentEditor {
|
||||
// document info
|
||||
documentDetails: DocumentDetails;
|
||||
value: string;
|
||||
rerenderOnPropsChange: {
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
@ -39,7 +39,7 @@ interface IDocumentEditor {
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
updatePageTitle: (title: string) => void;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
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 {
|
||||
email_password_login: boolean;
|
||||
file_size_limit: number;
|
||||
google_client_id: string | null;
|
||||
github_app_name: string | null;
|
||||
github_client_id: string | null;
|
||||
magic_login: boolean;
|
||||
slack_client_id: string | null;
|
||||
posthog_api_key: string | null;
|
||||
posthog_host: string | null;
|
||||
google_client_id: string | null;
|
||||
has_openai_configured: boolean;
|
||||
has_unsplash_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}
|
||||
ref={ref}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
|
@ -100,7 +100,7 @@ export const EmailForm: React.FC<Props> = (props) => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
|
@ -61,7 +61,7 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
|
@ -155,7 +155,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
|
@ -97,7 +97,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
|
@ -87,7 +87,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
|
@ -182,7 +182,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
}}
|
||||
ref={ref}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
|
@ -104,7 +104,7 @@ const HomePage: NextPage = () => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
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-up-forms";
|
||||
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 = {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
clientId: string;
|
||||
type: "sign_in" | "sign_up";
|
||||
};
|
||||
|
||||
export const GitHubSignInButton: FC<Props> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
const { handleSignIn, clientId, type } = props;
|
||||
// states
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
@ -53,7 +54,7 @@ export const GitHubSignInButton: FC<Props> = (props) => {
|
||||
width={20}
|
||||
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>
|
||||
</Link>
|
||||
</div>
|
@ -4,10 +4,11 @@ import Script from "next/script";
|
||||
type Props = {
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
clientId: string;
|
||||
type: "sign_in" | "sign_up";
|
||||
};
|
||||
|
||||
export const GoogleSignInButton: FC<Props> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
const { handleSignIn, clientId, type } = props;
|
||||
// refs
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
@ -29,7 +30,7 @@ export const GoogleSignInButton: FC<Props> = (props) => {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
text: "signin_with",
|
||||
text: type === "sign_in" ? "signin_with" : "signup_with",
|
||||
width: 384,
|
||||
} 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
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded, clientId]);
|
||||
}, [handleSignIn, gsiScriptLoaded, clientId, type]);
|
||||
|
||||
useEffect(() => {
|
||||
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 = {
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
type: "sign_in" | "sign_up";
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const OAuthOptions: React.FC<Props> = observer((props) => {
|
||||
const { handleSignInRedirection } = props;
|
||||
const { handleSignInRedirection, type } = props;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// mobx store
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
// derived values
|
||||
const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id;
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
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>
|
||||
<hr className="w-full border-onboarding-border-100" />
|
||||
</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 && (
|
||||
<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 && (
|
||||
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} />
|
||||
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} type={type} />
|
||||
)}
|
||||
</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-form";
|
||||
export * from "./o-auth-options";
|
||||
export * from "./email";
|
||||
export * from "./forgot-password-popover";
|
||||
export * from "./optional-set-password";
|
||||
export * from "./password";
|
||||
export * from "./root";
|
||||
export * from "./self-hosted-sign-in";
|
||||
export * from "./set-password-link";
|
||||
export * from "./unique-code";
|
||||
|
@ -1,36 +1,76 @@
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
// constants
|
||||
import { ESignInSteps } from "components/account";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
|
||||
export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props;
|
||||
type TCreatePasswordFormValues = {
|
||||
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
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
} = useForm({
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<TCreatePasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "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 () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
@ -39,12 +79,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set a password</h1>
|
||||
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
|
||||
<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 text-center text-sm text-onboarding-text-200">
|
||||
If you{"'"}d like to do away with codes, set a password here.
|
||||
</p>
|
||||
|
||||
<form className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
@ -61,22 +100,47 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder="orville.wright@frstflt.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400"
|
||||
placeholder="name@company.com"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
|
||||
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
|
||||
type="button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
onClick={() => handleStepChange(ESignInSteps.CREATE_PASSWORD)}
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Create password
|
||||
Set password
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@ -84,20 +148,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
className="w-full"
|
||||
size="xl"
|
||||
onClick={handleGoToWorkspace}
|
||||
disabled={!isValid}
|
||||
loading={isGoingToWorkspace}
|
||||
>
|
||||
{isOnboarded ? "Go to workspace" : "Set up workspace"}
|
||||
Skip to workspace
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
@ -7,22 +8,20 @@ import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication } from "hooks/store";
|
||||
// components
|
||||
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IPasswordSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { ESignInSteps } from "components/account";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
handleEmailClear: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
@ -37,24 +36,24 @@ const defaultValues: TPasswordFormValues = {
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
const { email, updateEmail, handleStepChange, handleSignInRedirection, handleEmailClear } = props;
|
||||
export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
const { email, handleStepChange, handleEmailClear, onSubmit } = props;
|
||||
// states
|
||||
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
|
||||
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
// derived values
|
||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
} = useForm<TPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
@ -65,8 +64,6 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (formData: TPasswordFormValues) => {
|
||||
updateEmail(formData.email);
|
||||
|
||||
const payload: IPasswordSignInData = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
@ -74,7 +71,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await handleSignInRedirection())
|
||||
.then(async () => await onSubmit())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
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 emailFormValue = getValues("email");
|
||||
|
||||
@ -134,16 +106,15 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
.finally(() => setIsSendingUniqueCode(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("password");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
<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>
|
||||
<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>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -161,14 +132,17 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
disabled
|
||||
disabled={isSmtpConfigured}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={handleEmailClear}
|
||||
onClick={() => {
|
||||
if (isSmtpConfigured) handleEmailClear();
|
||||
else onChange("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -180,7 +154,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: dirtyFields.email ? false : "Password is required",
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
@ -190,23 +164,34 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className={`text-xs font-medium ${
|
||||
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100"
|
||||
}`}
|
||||
disabled={isSendingResetPasswordLink}
|
||||
>
|
||||
{isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"}
|
||||
</button>
|
||||
<div className="w-full text-right mt-2 pb-3">
|
||||
{isSmtpConfigured ? (
|
||||
<Link
|
||||
href={`/accounts/forgot-password?email=${email}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
) : (
|
||||
<ForgotPasswordPopover />
|
||||
)}
|
||||
</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 && (
|
||||
<Button
|
||||
type="button"
|
||||
@ -216,26 +201,10 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
|
||||
size="xl"
|
||||
loading={isSendingUniqueCode}
|
||||
>
|
||||
{isSendingUniqueCode ? "Sending code" : "Use unique code"}
|
||||
{isSendingUniqueCode ? "Sending code" : "Sign in with unique code"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
@ -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";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
@ -6,115 +7,115 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import { LatestFeatureBlock } from "components/common";
|
||||
import {
|
||||
EmailForm,
|
||||
UniqueCodeForm,
|
||||
PasswordForm,
|
||||
SetPasswordLink,
|
||||
SignInEmailForm,
|
||||
SignInUniqueCodeForm,
|
||||
SignInPasswordForm,
|
||||
OAuthOptions,
|
||||
OptionalSetPasswordForm,
|
||||
CreatePasswordForm,
|
||||
SignInOptionalSetPasswordForm,
|
||||
} from "components/account";
|
||||
|
||||
export enum ESignInSteps {
|
||||
EMAIL = "EMAIL",
|
||||
PASSWORD = "PASSWORD",
|
||||
SET_PASSWORD_LINK = "SET_PASSWORD_LINK",
|
||||
UNIQUE_CODE = "UNIQUE_CODE",
|
||||
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
|
||||
CREATE_PASSWORD = "CREATE_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(() => {
|
||||
// states
|
||||
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
|
||||
const [signInStep, setSignInStep] = useState<ESignInSteps | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [isOnboarded, setIsOnboarded] = useState(false);
|
||||
// sign in redirection hook
|
||||
const { handleRedirection } = useSignInRedirection();
|
||||
// mobx store
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL);
|
||||
else setSignInStep(ESignInSteps.PASSWORD);
|
||||
}, [isSmtpConfigured]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex flex-col">
|
||||
<>
|
||||
{signInStep === ESignInSteps.EMAIL && (
|
||||
<EmailForm
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
<SignInEmailForm onSubmit={handleEmailVerification} updateEmail={(newEmail) => setEmail(newEmail)} />
|
||||
)}
|
||||
{signInStep === ESignInSteps.UNIQUE_CODE && (
|
||||
<SignInUniqueCodeForm
|
||||
email={email}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
setSignInStep(ESignInSteps.EMAIL);
|
||||
}}
|
||||
onSubmit={handleUniqueCodeSignIn}
|
||||
submitButtonText="Continue"
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.PASSWORD && (
|
||||
<PasswordForm
|
||||
<SignInPasswordForm
|
||||
email={email}
|
||||
updateEmail={(newEmail) => setEmail(newEmail)}
|
||||
handleStepChange={(step) => setSignInStep(step)}
|
||||
handleEmailClear={() => {
|
||||
setEmail("");
|
||||
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 && (
|
||||
<UniqueCodeForm
|
||||
<SignInUniqueCodeForm
|
||||
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={() => {
|
||||
setEmail("");
|
||||
setSignInStep(ESignInSteps.EMAIL);
|
||||
}}
|
||||
onSubmit={handleUniqueCodeSignIn}
|
||||
submitButtonText="Go to workspace"
|
||||
/>
|
||||
)}
|
||||
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
|
||||
<OptionalSetPasswordForm
|
||||
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}
|
||||
/>
|
||||
<SignInOptionalSetPasswordForm email={email} handleSignInRedirection={handleRedirection} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
{isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && (
|
||||
<OAuthOptions handleSignInRedirection={handleRedirection} />
|
||||
)}
|
||||
{isOAuthEnabled &&
|
||||
(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 />
|
||||
</>
|
||||
);
|
||||
|
@ -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 Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { CornerDownLeft, XCircle } from "lucide-react";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
import { UserService } from "services/user.service";
|
||||
@ -14,18 +13,12 @@ import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { ESignInSteps } from "components/account";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
submitButtonLabel?: string;
|
||||
showTermsAndConditions?: boolean;
|
||||
updateUserOnboardingStatus: (value: boolean) => void;
|
||||
onSubmit: (isPasswordAutoset: boolean) => Promise<void>;
|
||||
handleEmailClear: () => void;
|
||||
submitButtonText: string;
|
||||
};
|
||||
|
||||
type TUniqueCodeFormValues = {
|
||||
@ -42,17 +35,8 @@ const defaultValues: TUniqueCodeFormValues = {
|
||||
const authService = new AuthService();
|
||||
const userService = new UserService();
|
||||
|
||||
export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const {
|
||||
email,
|
||||
updateEmail,
|
||||
handleStepChange,
|
||||
handleSignInRedirection,
|
||||
submitButtonLabel = "Continue",
|
||||
showTermsAndConditions = false,
|
||||
updateUserOnboardingStatus,
|
||||
handleEmailClear,
|
||||
} = props;
|
||||
export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const { email, onSubmit, handleEmailClear, submitButtonText } = props;
|
||||
// states
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// toast alert
|
||||
@ -62,11 +46,10 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting, isValid },
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setFocus,
|
||||
} = useForm<TUniqueCodeFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
@ -88,10 +71,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
.then(async () => {
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
updateUserOnboardingStatus(currentUser.is_onboarded);
|
||||
|
||||
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
|
||||
else await handleSignInRedirection();
|
||||
await onSubmit(currentUser.is_password_autoset);
|
||||
})
|
||||
.catch((err) =>
|
||||
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 () => {
|
||||
setIsRequestingNewCode(true);
|
||||
|
||||
@ -147,21 +120,16 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
|
||||
const hasEmailChanged = dirtyFields.email;
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("token");
|
||||
}, [setFocus]);
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<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 <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>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<form onSubmit={handleSubmit(handleUniqueCodeSignIn)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -178,12 +146,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={() => {
|
||||
if (hasEmailChanged) handleSendNewCode(getValues());
|
||||
}}
|
||||
ref={ref}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
@ -196,21 +161,13 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
</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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="token"
|
||||
rules={{
|
||||
required: hasEmailChanged ? false : "Code is required",
|
||||
required: "Code is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
@ -219,6 +176,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -241,24 +199,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
size="xl"
|
||||
disabled={!isValid || hasEmailChanged}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{submitButtonLabel}
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@ -13,11 +13,9 @@ import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData } from "@plane/types";
|
||||
// constants
|
||||
import { ESignInSteps } from "components/account";
|
||||
|
||||
type Props = {
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
onSubmit: () => void;
|
||||
updateEmail: (email: string) => void;
|
||||
};
|
||||
|
||||
@ -27,18 +25,14 @@ type TEmailFormValues = {
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const EmailForm: React.FC<Props> = observer((props) => {
|
||||
const { handleStepChange, updateEmail } = props;
|
||||
export const SignUpEmailForm: React.FC<Props> = observer((props) => {
|
||||
const { onSubmit, updateEmail } = props;
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<TEmailFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
@ -57,14 +51,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
||||
|
||||
await authService
|
||||
.emailCheck(payload)
|
||||
.then((res) => {
|
||||
// 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);
|
||||
})
|
||||
.then(() => onSubmit())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@ -74,10 +61,6 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("email");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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",
|
||||
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">
|
||||
<Input
|
||||
id="email"
|
||||
@ -104,10 +87,10 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<XCircle
|
||||
@ -120,7 +103,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
Continue
|
||||
Verify
|
||||
</Button>
|
||||
</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 Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
@ -10,13 +9,12 @@ import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// constants
|
||||
import { ESignInSteps } from "components/account";
|
||||
import { ESignUpSteps } from "components/account";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
handleStepChange: (step: ESignInSteps) => void;
|
||||
handleStepChange: (step: ESignUpSteps) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
|
||||
type TCreatePasswordFormValues = {
|
||||
@ -32,8 +30,10 @@ const defaultValues: TCreatePasswordFormValues = {
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
export const CreatePasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleSignInRedirection, isOnboarded } = props;
|
||||
export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleSignInRedirection } = props;
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@ -41,7 +41,6 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<TCreatePasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
@ -75,16 +74,21 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("password");
|
||||
}, [setFocus]);
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
|
||||
Get on your flight deck
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-11 space-y-4 sm:w-96">
|
||||
<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">
|
||||
Let{"'"}s set a password so
|
||||
<br />
|
||||
you can do away with codes.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
@ -101,40 +105,58 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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="Choose password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
|
||||
{isOnboarded ? "Go to workspace" : "Set up workspace"}
|
||||
</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>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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">
|
||||
This password will continue to be your account{"'"}s password.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<Button
|
||||
type="submit"
|
||||
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>
|
||||
</>
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
// services
|
||||
@ -14,9 +15,7 @@ import { checkEmailValidity } from "helpers/string.helper";
|
||||
import { IPasswordSignInData } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
updateEmail: (email: string) => void;
|
||||
handleSignInRedirection: () => Promise<void>;
|
||||
onSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
type TPasswordFormValues = {
|
||||
@ -31,20 +30,18 @@ const defaultValues: TPasswordFormValues = {
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
const { email, updateEmail, handleSignInRedirection } = props;
|
||||
export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
|
||||
const { onSubmit } = props;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { dirtyFields, errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
} = useForm<TPasswordFormValues>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
email,
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
@ -56,11 +53,9 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
updateEmail(formData.email);
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await handleSignInRedirection())
|
||||
.then(async () => await onSubmit())
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@ -70,16 +65,15 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("email");
|
||||
}, [setFocus]);
|
||||
|
||||
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
|
||||
</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>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -97,7 +91,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
@ -115,7 +109,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: dirtyFields.email ? false : "Password is required",
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
@ -125,12 +119,16 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
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"
|
||||
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>
|
||||
<Button type="submit" variant="primary" className="w-full" size="xl" loading={isSubmitting}>
|
||||
Continue
|
||||
<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{" "}
|
||||
@ -141,4 +139,4 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
|
||||
</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 { Command } from "cmdk";
|
||||
// icons
|
||||
import { SettingIcon } from "components/icons";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import Link from "next/link";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
@ -10,60 +12,35 @@ type Props = {
|
||||
|
||||
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// mobx store
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
// derived values
|
||||
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
||||
<Link href={`/${workspaceSlug}/settings`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
General
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={closePalette} className="focus:outline-none">
|
||||
<Link href={`/${workspaceSlug}/settings/members`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Members
|
||||
</div>
|
||||
</Link>
|
||||
</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>
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(setting) =>
|
||||
workspaceMemberInfo >= setting.access && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}${setting.href}`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<setting.Icon className="h-4 w-4 text-custom-text-200" />
|
||||
{setting.label}
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,23 +1,17 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useProject } from "hooks/store";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { useApplication, usePage, useProject } from "hooks/store";
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// fetch-keys
|
||||
import { PAGE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
}
|
||||
const pageService = new PageService();
|
||||
|
||||
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
const { showButton = false } = props;
|
||||
@ -28,12 +22,7 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { data: pageDetails } = useSWR(
|
||||
workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null,
|
||||
workspaceSlug && currentProjectDetails?.id
|
||||
? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string)
|
||||
: null
|
||||
);
|
||||
const pageDetails = usePage(pageId as string);
|
||||
|
||||
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">
|
||||
|
@ -88,7 +88,7 @@ export const InstanceSetupSignInForm: FC<IInstanceSetupEmailForm> = (props) => {
|
||||
type="email"
|
||||
value={value}
|
||||
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"
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
|
@ -5,19 +5,17 @@ import useSWR from "swr";
|
||||
// hooks
|
||||
import { useGlobalView, useIssues, useUser } from "hooks/store";
|
||||
// components
|
||||
import { GlobalViewsAppliedFiltersRoot } from "components/issues";
|
||||
import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues";
|
||||
import { SpreadsheetView } from "components/issues/issue-layouts";
|
||||
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue, IIssueDisplayFilterOptions, TStaticViewTypes, TUnGroupedIssues } from "@plane/types";
|
||||
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
|
||||
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -48,11 +46,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
if (workspaceSlug && globalViewId) {
|
||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
globalViewId.toString(),
|
||||
groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -138,6 +132,9 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -35,12 +35,9 @@ export type TIssuePeekOperations = {
|
||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const { is_archived = false, onIssueUpdate } = props;
|
||||
// hooks
|
||||
const {
|
||||
project: {},
|
||||
} = useMember();
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
} = useUser();
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
@ -198,6 +195,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
|
||||
const issue = getIssueById(peekIssue.issueId) || undefined;
|
||||
|
||||
const currentProjectRole = currentWorkspaceAllProjectsRole?.[peekIssue?.projectId];
|
||||
// Check if issue is editable, based on user role
|
||||
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
const isLoading = !issue || loader ? true : false;
|
||||
|
@ -7,7 +7,7 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import { SignInRoot } from "components/account";
|
||||
// ui
|
||||
import { Loader, Spinner } from "@plane/ui";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// images
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
|
||||
@ -26,7 +26,7 @@ export const SignInView = observer(() => {
|
||||
handleRedirection();
|
||||
}, [handleRedirection]);
|
||||
|
||||
if (isRedirecting || currentUser)
|
||||
if (isRedirecting || currentUser || !envConfig)
|
||||
return (
|
||||
<div className="grid h-screen place-items-center">
|
||||
<Spinner />
|
||||
@ -35,32 +35,16 @@ export const SignInView = observer(() => {
|
||||
|
||||
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 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="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">
|
||||
{!envConfig ? (
|
||||
<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 />
|
||||
)}
|
||||
<SignInRoot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,132 +2,56 @@ import React, { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useApplication, usePage, useWorkspace } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication } from "hooks/store";
|
||||
// components
|
||||
import { PageForm } from "./page-form";
|
||||
// types
|
||||
import { IPage } from "@plane/types";
|
||||
import { useProjectPages } from "hooks/store/use-project-page";
|
||||
import { IPageStore } from "store/page.store";
|
||||
|
||||
type Props = {
|
||||
data?: IPage | null;
|
||||
// data?: IPage | null;
|
||||
pageStore?: IPageStore;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, data, projectId } = props;
|
||||
const { isOpen, handleClose, projectId, pageStore } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { createPage } = useProjectPages();
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createPage, updatePage } = usePage();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const createProjectPage = async (payload: IPage) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
// 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!,
|
||||
// }
|
||||
// );
|
||||
// });
|
||||
await createPage(workspaceSlug.toString(), projectId, payload);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (!data) await createProjectPage(formData);
|
||||
else await updateProjectPage(formData);
|
||||
try {
|
||||
if (pageStore) {
|
||||
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 (
|
||||
@ -157,7 +81,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
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">
|
||||
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
|
||||
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} pageStore={pageStore} />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -9,37 +9,45 @@ import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// types
|
||||
import type { IPage } from "@plane/types";
|
||||
import { useProjectPages } from "hooks/store/use-project-page";
|
||||
|
||||
type TConfirmPageDeletionProps = {
|
||||
data?: IPage | null;
|
||||
pageId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
|
||||
const { data, isOpen, onClose } = props;
|
||||
const { pageId, isOpen, onClose } = props;
|
||||
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { deletePage } = usePage();
|
||||
const { deletePage } = useProjectPages();
|
||||
const pageStore = usePage(pageId);
|
||||
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
if (!pageStore) return null;
|
||||
|
||||
const { name } = pageStore;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeleting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!data || !workspaceSlug || !projectId) return;
|
||||
if (!pageId || !workspaceSlug || !projectId) return;
|
||||
|
||||
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(() => {
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
@ -99,8 +107,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to delete page-{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
|
||||
will be deleted permanently. This action cannot be undone.
|
||||
<span className="break-words font-medium text-custom-text-100">{name}</span>? The Page will be
|
||||
deleted permanently. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,11 +5,12 @@ import { Button, Input, Tooltip } from "@plane/ui";
|
||||
import { IPage } from "@plane/types";
|
||||
// constants
|
||||
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
|
||||
import { IPageStore } from "store/page.store";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: IPage) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
data?: IPage | null;
|
||||
pageStore?: IPageStore;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
@ -19,24 +20,24 @@ const defaultValues = {
|
||||
};
|
||||
|
||||
export const PageForm: React.FC<Props> = (props) => {
|
||||
const { handleFormSubmit, handleClose, data } = props;
|
||||
const { handleFormSubmit, handleClose, pageStore } = props;
|
||||
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
} = useForm<IPage>({
|
||||
defaultValues: { ...defaultValues, ...data },
|
||||
defaultValues: pageStore
|
||||
? { name: pageStore.name, description: pageStore.description, access: pageStore.access }
|
||||
: defaultValues,
|
||||
});
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IPage) => {
|
||||
await handleFormSubmit(formData);
|
||||
};
|
||||
const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<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>
|
||||
<Controller
|
||||
@ -104,7 +105,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
||||
Cancel
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const AllPagesList: FC = observer(() => {
|
||||
// store
|
||||
const { projectPageIds } = usePage();
|
||||
const pageStores = useProjectPages();
|
||||
// subscribing to the projectPageStore
|
||||
const { projectPageIds } = pageStores;
|
||||
|
||||
if (!projectPageIds)
|
||||
if (!projectPageIds) {
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => {
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
}
|
||||
return <PagesListView pageIds={projectPageIds} />;
|
||||
});
|
||||
|
@ -3,14 +3,15 @@ import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const ArchivedPagesList: FC = observer(() => {
|
||||
const { archivedProjectPageIds } = usePage();
|
||||
const projectPageStore = useProjectPages();
|
||||
const { archivedPageIds } = projectPageStore;
|
||||
|
||||
if (!archivedProjectPageIds)
|
||||
if (!archivedPageIds)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => {
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return <PagesListView pageIds={archivedProjectPageIds} />;
|
||||
return <PagesListView pageIds={archivedPageIds} />;
|
||||
});
|
||||
|
@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const FavoritePagesList: FC = observer(() => {
|
||||
const { favoriteProjectPageIds } = usePage();
|
||||
const projectPageStore = useProjectPages();
|
||||
const { favoriteProjectPageIds } = projectPageStore;
|
||||
|
||||
if (!favoriteProjectPageIds)
|
||||
return (
|
||||
|
@ -13,10 +13,6 @@ import {
|
||||
Star,
|
||||
Trash2,
|
||||
} 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 { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
|
||||
// ui
|
||||
@ -25,142 +21,120 @@ import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
|
||||
// constants
|
||||
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 {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
const { workspaceSlug, projectId, pageId } = props;
|
||||
export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId }: IPagesListItem) => {
|
||||
const projectPageStore = useProjectPages();
|
||||
// Now, I am observing only the projectPages, out of the projectPageStore.
|
||||
const { archivePage, restorePage } = projectPageStore;
|
||||
|
||||
const pageStore = usePage(pageId);
|
||||
|
||||
// states
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
// store hooks
|
||||
|
||||
const {
|
||||
currentUser,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const {
|
||||
getArchivedPageById,
|
||||
getUnArchivedPageById,
|
||||
archivePage,
|
||||
removeFromFavorites,
|
||||
addToFavorites,
|
||||
makePrivate,
|
||||
makePublic,
|
||||
restorePage,
|
||||
} = usePage();
|
||||
|
||||
const {
|
||||
project: { getProjectMemberDetails },
|
||||
} = 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.stopPropagation();
|
||||
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`);
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (e: any) => {
|
||||
const handleAddToFavorites = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addToFavorites();
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
addToFavorites(workspaceSlug, projectId, pageId)
|
||||
.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.",
|
||||
});
|
||||
});
|
||||
removeFromFavorites();
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: any) => {
|
||||
const handleMakePublic = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
removeFromFavorites(workspaceSlug, projectId, pageId)
|
||||
.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.",
|
||||
});
|
||||
});
|
||||
makePublic();
|
||||
};
|
||||
|
||||
const handleMakePublic = (e: any) => {
|
||||
const handleMakePrivate = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
makePublic(workspaceSlug, projectId, pageId);
|
||||
makePrivate();
|
||||
};
|
||||
|
||||
const handleMakePrivate = (e: any) => {
|
||||
const handleArchivePage = async (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
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.stopPropagation();
|
||||
|
||||
archivePage(workspaceSlug, projectId, pageId);
|
||||
await restorePage(workspaceSlug as string, projectId as string, pageId as string);
|
||||
};
|
||||
|
||||
const handleRestorePage = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
restorePage(workspaceSlug, projectId, pageId);
|
||||
};
|
||||
|
||||
const handleDeletePage = (e: any) => {
|
||||
const handleDeletePage = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDeletePageModal(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (e: any) => {
|
||||
const handleEditPage = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
if (!pageDetails) return null;
|
||||
|
||||
const ownerDetails = getProjectMemberDetails(pageDetails.owned_by);
|
||||
const isCurrentUserOwner = pageDetails.owned_by === currentUser?.id;
|
||||
const ownerDetails = getProjectMemberDetails(owned_by);
|
||||
const isCurrentUserOwner = owned_by === currentUser?.id;
|
||||
|
||||
const userCanEdit =
|
||||
isCurrentUserOwner ||
|
||||
@ -173,22 +147,21 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
return (
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
pageStore={pageStore}
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
data={pageDetails}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={pageDetails} />
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={pageId} />
|
||||
<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="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<p className="mr-2 truncate text-sm text-custom-text-100">{pageDetails.name}</p>
|
||||
{/* FIXME: replace any with proper type */}
|
||||
{pageDetails.label_details.length > 0 &&
|
||||
pageDetails.label_details.map((label: any) => (
|
||||
<p className="mr-2 truncate text-sm text-custom-text-100">{name}</p>
|
||||
{label_details.length > 0 &&
|
||||
label_details.map((label: IIssueLabel) => (
|
||||
<div
|
||||
key={label.id}
|
||||
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 className="flex items-center gap-2.5">
|
||||
{pageDetails.archived_at ? (
|
||||
{archived_at ? (
|
||||
<Tooltip
|
||||
tooltipContent={`Archived at ${renderFormattedTime(
|
||||
pageDetails.archived_at
|
||||
)} on ${renderFormattedDate(pageDetails.archived_at)}`}
|
||||
tooltipContent={`Archived at ${renderFormattedTime(archived_at)} on ${renderFormattedDate(
|
||||
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
|
||||
tooltipContent={`Last updated at ${renderFormattedTime(
|
||||
pageDetails.updated_at
|
||||
)} on ${renderFormattedDate(pageDetails.updated_at)}`}
|
||||
tooltipContent={`Last updated at ${renderFormattedTime(updated_at)} on ${renderFormattedDate(
|
||||
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>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<Tooltip tooltipContent={`${pageDetails.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
||||
{pageDetails.is_favorite ? (
|
||||
<Tooltip tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
||||
{is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" />
|
||||
</button>
|
||||
@ -240,12 +213,10 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
{userCanChangeAccess && (
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
pageDetails.access
|
||||
? "This page is only visible to you"
|
||||
: "This page can be viewed by anyone in the project"
|
||||
access ? "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}>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -259,13 +230,13 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate(
|
||||
pageDetails.created_at
|
||||
created_at
|
||||
)}`}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||
{pageDetails.archived_at ? (
|
||||
{archived_at ? (
|
||||
<>
|
||||
{userCanArchive && (
|
||||
<CustomMenu.MenuItem onClick={handleRestorePage}>
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// components
|
||||
import { PagesListItem } from "./list-item";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
@ -13,14 +11,17 @@ import { Loader } from "@plane/ui";
|
||||
import emptyPage from "public/empty-state/empty_page.png";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { PagesListItem } from "./list-item";
|
||||
|
||||
type IPagesListView = {
|
||||
pageIds: string[];
|
||||
};
|
||||
|
||||
export const PagesListView: FC<IPagesListView> = observer((props) => {
|
||||
const { pageIds } = props;
|
||||
export const PagesListView: FC<IPagesListView> = (props) => {
|
||||
const { pageIds: projectPageIds } = props;
|
||||
// store hooks
|
||||
// trace(true);
|
||||
|
||||
const {
|
||||
commandPalette: { toggleCreatePageModal },
|
||||
} = useApplication();
|
||||
@ -31,21 +32,18 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
|
||||
const router = useRouter();
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageIds && workspaceSlug && projectId ? (
|
||||
{projectPageIds && workspaceSlug && projectId ? (
|
||||
<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">
|
||||
{pageIds.map((pageId) => (
|
||||
<PagesListItem
|
||||
key={pageId}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
pageId={pageId}
|
||||
/>
|
||||
{projectPageIds.map((pageId: string) => (
|
||||
<PagesListItem key={pageId} pageId={pageId} projectId={projectId.toString()} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
@ -77,4 +75,4 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const PrivatePagesList: FC = observer(() => {
|
||||
const { privateProjectPageIds } = usePage();
|
||||
const projectPageStore = useProjectPages();
|
||||
const { privateProjectPageIds } = projectPageStore;
|
||||
|
||||
if (!privateProjectPageIds)
|
||||
return (
|
||||
|
@ -2,7 +2,7 @@ import React, { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, usePage, useUser } from "hooks/store";
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
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";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const RecentPagesList: FC = observer(() => {
|
||||
// store hooks
|
||||
@ -21,7 +22,7 @@ export const RecentPagesList: FC = observer(() => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { recentProjectPages } = usePage();
|
||||
const { recentProjectPages } = useProjectPages();
|
||||
|
||||
// FIXME: replace any with proper type
|
||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);
|
||||
|
@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { usePage } from "hooks/store";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { useProjectPages } from "hooks/store/use-project-specific-pages";
|
||||
|
||||
export const SharedPagesList: FC = observer(() => {
|
||||
const { publicProjectPageIds } = usePage();
|
||||
const projectPageStore = useProjectPages();
|
||||
const { publicProjectPageIds } = projectPageStore;
|
||||
|
||||
if (!publicProjectPageIds)
|
||||
return (
|
||||
|
@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
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 { ProfileIssuesAppliedFiltersRoot } from "components/issues";
|
||||
import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssues } from "hooks/store";
|
||||
@ -34,7 +34,7 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
|
||||
async () => {
|
||||
if (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 />
|
||||
) : null}
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -2,7 +2,6 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// components
|
||||
@ -11,34 +10,7 @@ import { NotificationPopover } from "components/notifications";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
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`,
|
||||
},
|
||||
];
|
||||
import { SIDEBAR_MENU_ITEMS } from "constants/dashboard";
|
||||
|
||||
export const WorkspaceSidebarMenu = observer(() => {
|
||||
// store hooks
|
||||
@ -50,48 +22,36 @@ export const WorkspaceSidebarMenu = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// computed
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
|
||||
|
||||
return (
|
||||
<div className="w-full cursor-pointer space-y-1 p-4">
|
||||
{workspaceLinks(workspaceSlug as string).map((link, index) => {
|
||||
const isActive = link.name === "Settings" ? router.asPath.includes(link.href) : router.asPath === link.href;
|
||||
if (!isAuthorizedUser && link.name === "Analytics") return;
|
||||
return (
|
||||
<Link key={index} href={link.href}>
|
||||
<span className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={link.name}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
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" : ""}`}
|
||||
<div className="w-full cursor-pointer space-y-2 p-4">
|
||||
{SIDEBAR_MENU_ITEMS.map(
|
||||
(link) =>
|
||||
workspaceMemberInfo >= link.access && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<span className="block w-full my-1">
|
||||
<Tooltip
|
||||
tooltipContent={link.label}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!themeStore?.sidebarCollapsed}
|
||||
>
|
||||
{<link.Icon className="h-4 w-4" />}
|
||||
{!themeStore?.sidebarCollapsed && link.name}
|
||||
{link.name === "Active Cycles" && (
|
||||
<span
|
||||
className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl"
|
||||
style={{
|
||||
color: "#F59E0B",
|
||||
backgroundColor: "#F59E0B20",
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
link.highlight(router.asPath, `/${workspaceSlug}`)
|
||||
? "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" />}
|
||||
{!themeStore?.sidebarCollapsed && link.label}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<NotificationPopover />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,7 +1 @@
|
||||
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";
|
||||
// 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
|
||||
export const PRIORITY_GRAPH_GRADIENTS = [
|
||||
@ -246,3 +251,45 @@ export const CREATED_ISSUES_EMPTY_STATES = {
|
||||
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 { SettingIcon } from "components/icons";
|
||||
// types
|
||||
import { Props } from "components/icons/types";
|
||||
|
||||
export enum EUserProjectRoles {
|
||||
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-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";
|
||||
// types
|
||||
import { TStaticViewTypes } from "@plane/types";
|
||||
import { Props } from "components/icons/types";
|
||||
// icons
|
||||
import { SettingIcon } from "components/icons";
|
||||
|
||||
export enum EUserWorkspaceRoles {
|
||||
GUEST = 5,
|
||||
@ -115,48 +118,75 @@ export const RESTRICTED_URLS = [
|
||||
];
|
||||
|
||||
export const WORKSPACE_SETTINGS_LINKS: {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
access: EUserWorkspaceRoles;
|
||||
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||
Icon: React.FC<Props>;
|
||||
}[] = [
|
||||
{
|
||||
key: "general",
|
||||
label: "General",
|
||||
href: `/settings`,
|
||||
access: EUserWorkspaceRoles.GUEST,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
href: `/settings/members`,
|
||||
access: EUserWorkspaceRoles.GUEST,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "billing-and-plans",
|
||||
label: "Billing and plans",
|
||||
href: `/settings/billing`,
|
||||
access: EUserWorkspaceRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
label: "Integrations",
|
||||
href: `/settings/integrations`,
|
||||
access: EUserWorkspaceRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "import",
|
||||
label: "Imports",
|
||||
href: `/settings/imports`,
|
||||
access: EUserWorkspaceRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
label: "Exports",
|
||||
href: `/settings/exports`,
|
||||
access: EUserWorkspaceRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
label: "Webhooks",
|
||||
href: `/settings/webhooks`,
|
||||
access: EUserWorkspaceRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "api-tokens",
|
||||
label: "API tokens",
|
||||
href: `/settings/api-tokens`,
|
||||
access: EUserWorkspaceRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
];
|
||||
|
@ -1,11 +1,21 @@
|
||||
import { useContext } from "react";
|
||||
// mobx store
|
||||
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);
|
||||
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";
|
||||
// mobx store
|
||||
import { StoreContext } from "contexts/store-context";
|
||||
import { IProjectPageStore } from "store/project-page.store";
|
||||
|
||||
export const useProjectPages = () => {
|
||||
export const useProjectPages = (): IProjectPageStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
|
||||
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 { observer } from "mobx-react-lite";
|
||||
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
|
||||
import { useApplication, useUser, useWorkspace } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
|
||||
const PROFILE_ACTION_LINKS = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
// constants
|
||||
import { PROFILE_ACTION_LINKS } from "constants/profile";
|
||||
|
||||
const WORKSPACE_ACTION_LINKS = [
|
||||
{
|
||||
@ -130,7 +105,7 @@ export const ProfileLayoutSidebar = observer(() => {
|
||||
<Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}>
|
||||
<div
|
||||
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"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapsed ? "justify-center" : ""}`}
|
||||
|
@ -1,66 +1,42 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
// constants
|
||||
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "constants/project";
|
||||
|
||||
export const ProjectSettingsSidebar = () => {
|
||||
const router = useRouter();
|
||||
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 (
|
||||
<div className="flex w-80 flex-col gap-6 px-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{projectLinks.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<div
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href)
|
||||
? "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>
|
||||
))}
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
projectMemberInfo >= link.access && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||
<div
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -25,11 +25,11 @@ export const WorkspaceSettingsSidebar = () => {
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
workspaceMemberInfo >= link.access && (
|
||||
<Link key={link.href} href={`/${workspaceSlug}${link.href}`}>
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<span>
|
||||
<div
|
||||
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"
|
||||
: "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/lite-text-editor": "*",
|
||||
"@plane/rich-text-editor": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/nextjs": "^7.85.0",
|
||||
"axios": "^1.1.3",
|
||||
@ -39,7 +39,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.294.0",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"mobx-react": "^9.1.0",
|
||||
"next": "^14.0.3",
|
||||
"next-pwa": "^5.6.0",
|
||||
"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 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
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication, useIssues, usePage, useUser } from "hooks/store";
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { FileService } from "services/file.service";
|
||||
import { IssueService } from "services/issue";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { GptAssistantPopover } from "components/core";
|
||||
import { PageDetailsHeader } from "components/headers/page-details";
|
||||
import { EmptyState } from "components/common";
|
||||
// ui
|
||||
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// assets
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage } from "@plane/types";
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
import { IPage, TIssue } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
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
|
||||
const fileService = new FileService();
|
||||
const pageService = new PageService();
|
||||
const issueService = new IssueService();
|
||||
|
||||
const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
const [gptModalOpen, setGptModal] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<any>(null);
|
||||
@ -59,18 +56,82 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
//TODO:fix reload confirmations, with mobx
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
|
||||
defaultValues: { name: "", description_html: "" },
|
||||
});
|
||||
|
||||
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 {
|
||||
archivePage: archivePageAction,
|
||||
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) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
@ -78,47 +139,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
const newDescription = `${watch("description_html")}<p>${response}</p>`;
|
||||
setValue("description_html", newDescription);
|
||||
editorRef.current?.setEditorValue(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 });
|
||||
updateDescriptionAction(newDescription);
|
||||
};
|
||||
|
||||
const actionCompleteAlert = ({
|
||||
@ -137,122 +158,14 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
const updatePageTitle = (title: string) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
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",
|
||||
})
|
||||
);
|
||||
updateNameAction(title);
|
||||
};
|
||||
|
||||
const createPage = async (payload: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
|
||||
await createPageAction(workspaceSlug as string, projectId as string, payload);
|
||||
};
|
||||
|
||||
// ================ Page Menu Actions ==================
|
||||
@ -260,121 +173,84 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
const currentPageValues = getValues();
|
||||
|
||||
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> = {
|
||||
name: "Copy of " + pageDetails?.name,
|
||||
name: "Copy of " + pageTitle,
|
||||
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 () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
mutatePageDetailsHelper(
|
||||
pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
||||
{
|
||||
archived_at: renderFormattedPayloadDate(new Date()),
|
||||
},
|
||||
["description_html"],
|
||||
() =>
|
||||
actionCompleteAlert({
|
||||
title: `Page could not be Archived`,
|
||||
message: `Sorry, page could not be Archived, please try again later`,
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
try {
|
||||
await archivePageAction(workspaceSlug as string, projectId as string, pageId as string);
|
||||
} catch (error) {
|
||||
actionCompleteAlert({
|
||||
title: `Page could not be archived`,
|
||||
message: `Sorry, page could not be archived, please try again later`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const unArchivePage = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutatePageDetailsHelper(
|
||||
pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
||||
{
|
||||
archived_at: null,
|
||||
},
|
||||
["description_html"],
|
||||
() =>
|
||||
actionCompleteAlert({
|
||||
title: `Page could not be Restored`,
|
||||
message: `Sorry, page could not be Restored, please try again later`,
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
try {
|
||||
await restorePageAction(workspaceSlug as string, projectId as string, pageId as string);
|
||||
} catch (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 () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
mutatePageDetailsHelper(
|
||||
pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
||||
{
|
||||
is_locked: true,
|
||||
},
|
||||
["description_html"],
|
||||
() =>
|
||||
actionCompleteAlert({
|
||||
title: `Page cannot be Locked`,
|
||||
message: `Sorry, page cannot be Locked, please try again later`,
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
try {
|
||||
await lockPageAction();
|
||||
} catch (error) {
|
||||
actionCompleteAlert({
|
||||
title: `Page could not be locked`,
|
||||
message: `Sorry, page could not be locked, please try again later`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const unlockPage = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutatePageDetailsHelper(
|
||||
pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
|
||||
{
|
||||
is_locked: false,
|
||||
},
|
||||
["description_html"],
|
||||
() =>
|
||||
actionCompleteAlert({
|
||||
title: `Page could not be Unlocked`,
|
||||
message: `Sorry, page could not be Unlocked, please try again later`,
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
try {
|
||||
await unlockPageAction();
|
||||
} catch (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 =
|
||||
pageDetails?.is_locked ||
|
||||
pageDetails?.archived_at ||
|
||||
is_locked ||
|
||||
archived_at ||
|
||||
(currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole));
|
||||
|
||||
const isCurrentUserOwner = pageDetails?.owned_by === currentUser?.id;
|
||||
const isCurrentUserOwner = owned_by === currentUser?.id;
|
||||
|
||||
const userCanDuplicate =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
@ -382,144 +258,132 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
const userCanLock =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageDetails && issuesResponse ? (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{isPageReadOnly ? (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
onActionCompleteHandler={actionCompleteAlert}
|
||||
ref={editorRef}
|
||||
value={localPageDescription.description_html}
|
||||
rerenderOnPropsChange={localPageDescription}
|
||||
customClassName={"tracking-tight w-full px-0"}
|
||||
borderOnFocus={false}
|
||||
noBorder
|
||||
documentDetails={{
|
||||
title: pageDetails.name,
|
||||
created_by: pageDetails.created_by,
|
||||
created_on: pageDetails.created_at,
|
||||
last_updated_at: pageDetails.updated_at,
|
||||
last_updated_by: pageDetails.updated_by,
|
||||
}}
|
||||
pageLockConfig={
|
||||
userCanLock && !pageDetails.archived_at
|
||||
? { action: unlockPage, is_locked: pageDetails.is_locked }
|
||||
: undefined
|
||||
}
|
||||
pageDuplicationConfig={
|
||||
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined
|
||||
}
|
||||
pageArchiveConfig={
|
||||
userCanArchive
|
||||
? {
|
||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||
is_archived: pageDetails.archived_at ? true : false,
|
||||
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
embedConfig={{
|
||||
issueEmbedConfig: {
|
||||
issues: issues,
|
||||
fetchIssue: fetchIssue,
|
||||
clickAction: issueWidgetClickAction,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<DocumentEditorWithRef
|
||||
isSubmitting={isSubmitting}
|
||||
documentDetails={{
|
||||
title: pageDetails.name,
|
||||
created_by: pageDetails.created_by,
|
||||
created_on: pageDetails.created_at,
|
||||
last_updated_at: pageDetails.updated_at,
|
||||
last_updated_by: pageDetails.updated_by,
|
||||
}}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
deleteFile={fileService.deleteImage}
|
||||
restoreFile={fileService.restoreImage}
|
||||
cancelUploadImage={fileService.cancelUpload}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
updatePageTitle={updatePageTitle}
|
||||
value={localPageDescription.description_html}
|
||||
rerenderOnPropsChange={localPageDescription}
|
||||
onActionCompleteHandler={actionCompleteAlert}
|
||||
customClassName="tracking-tight self-center px-0 h-full w-full"
|
||||
onChange={(_description_json: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
onChange(description_html);
|
||||
setIsSubmitting("submitting");
|
||||
debouncedFormSave();
|
||||
}}
|
||||
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
|
||||
pageArchiveConfig={
|
||||
userCanArchive
|
||||
? {
|
||||
is_archived: pageDetails.archived_at ? true : false,
|
||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
|
||||
embedConfig={{
|
||||
issueEmbedConfig: {
|
||||
issues: issues,
|
||||
fetchIssue: fetchIssue,
|
||||
clickAction: issueWidgetClickAction,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return pageIdMobx && issues ? (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{isPageReadOnly ? (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
onActionCompleteHandler={actionCompleteAlert}
|
||||
ref={editorRef}
|
||||
value={pageDescription}
|
||||
customClassName={"tracking-tight w-full px-0"}
|
||||
borderOnFocus={false}
|
||||
noBorder
|
||||
documentDetails={{
|
||||
title: pageTitle,
|
||||
created_by: created_by,
|
||||
created_on: created_at,
|
||||
last_updated_at: updated_at,
|
||||
last_updated_by: updated_by,
|
||||
}}
|
||||
pageLockConfig={userCanLock && !archived_at ? { action: unlockPage, is_locked: is_locked } : undefined}
|
||||
pageDuplicationConfig={userCanDuplicate && !archived_at ? { action: duplicate_page } : undefined}
|
||||
pageArchiveConfig={
|
||||
userCanArchive
|
||||
? {
|
||||
action: archived_at ? unArchivePage : archivePage,
|
||||
is_archived: archived_at ? true : false,
|
||||
archived_at: archived_at ? new Date(archived_at) : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
embedConfig={{
|
||||
issueEmbedConfig: {
|
||||
issues: issues,
|
||||
fetchIssue: fetchIssue,
|
||||
clickAction: issueWidgetClickAction,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<DocumentEditorWithRef
|
||||
isSubmitting={isSubmitting}
|
||||
documentDetails={{
|
||||
title: pageTitle,
|
||||
created_by: created_by,
|
||||
created_on: created_at,
|
||||
last_updated_at: updated_at,
|
||||
last_updated_by: updated_by,
|
||||
}}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
value={pageDescription}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
deleteFile={fileService.deleteImage}
|
||||
restoreFile={fileService.restoreImage}
|
||||
cancelUploadImage={fileService.cancelUpload}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
updatePageTitle={updatePageTitle}
|
||||
onActionCompleteHandler={actionCompleteAlert}
|
||||
customClassName="tracking-tight self-center px-0 h-full w-full"
|
||||
onChange={(_description_json: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
onChange(description_html);
|
||||
handleSubmit(updatePage)();
|
||||
}}
|
||||
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
|
||||
pageArchiveConfig={
|
||||
userCanArchive
|
||||
? {
|
||||
is_archived: archived_at ? true : false,
|
||||
action: archived_at ? unArchivePage : archivePage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
|
||||
embedConfig={{
|
||||
issueEmbedConfig: {
|
||||
issues: issues,
|
||||
fetchIssue: fetchIssue,
|
||||
clickAction: issueWidgetClickAction,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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]"
|
||||
/>
|
||||
{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 className="grid h-full w-full place-items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<IssuePeekOverview />
|
||||
</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 { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { usePage, useUser } from "hooks/store";
|
||||
import { useUser } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// layouts
|
||||
@ -17,6 +17,7 @@ import { PagesHeader } from "components/headers";
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
// constants
|
||||
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), {
|
||||
ssr: false,
|
||||
@ -45,8 +46,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
// states
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
// store
|
||||
const { fetchProjectPages, fetchArchivedProjectPages } = usePage();
|
||||
const { currentUser, currentUserLoader } = useUser();
|
||||
|
||||
const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages();
|
||||
// hooks
|
||||
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
|
||||
// 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 Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
@ -12,11 +9,12 @@ import useToast from "hooks/use-toast";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// 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";
|
||||
import latestFeatures from "public/onboarding/onboarding-pages.svg";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// type
|
||||
@ -35,12 +33,10 @@ const defaultValues: TResetPasswordFormValues = {
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
const HomePage: NextPageWithLayout = () => {
|
||||
const ResetPasswordPage: NextPageWithLayout = () => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { uidb64, token, email } = router.query;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// sign in redirection hook
|
||||
@ -108,35 +104,30 @@ const HomePage: NextPageWithLayout = () => {
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
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"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.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"
|
||||
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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@ -145,44 +136,19 @@ const HomePage: NextPageWithLayout = () => {
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Go to workspace"}
|
||||
Set password
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
<LatestFeatureBlock />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
ResetPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||
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 { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
// components
|
||||
import { EmailSignUpForm } from "components/account";
|
||||
// images
|
||||
import { SignUpRoot } from "components/account";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// assets
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
// types
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
|
||||
type EmailPasswordFormValues = {
|
||||
email: string;
|
||||
password?: string;
|
||||
medium?: string;
|
||||
};
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
|
||||
const SignUpPage: NextPageWithLayout = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// next-themes
|
||||
const { setTheme } = useTheme();
|
||||
// store hooks
|
||||
const { currentUser, fetchCurrentUser, currentUserLoader } = useUser();
|
||||
// custom hooks
|
||||
const {} = useUserAuth({ routeAuth: "sign-in", user: currentUser, isLoading: currentUserLoader });
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const handleSignUp = async (formData: EmailPasswordFormValues) => {
|
||||
const payload = {
|
||||
email: formData.email,
|
||||
password: formData.password ?? "",
|
||||
};
|
||||
|
||||
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]);
|
||||
if (currentUser || !envConfig)
|
||||
return (
|
||||
<div className="grid h-screen place-items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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="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="grid place-items-center bg-custom-background-100">
|
||||
<div className="h-[30px] w-[30px]">
|
||||
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
|
||||
</div>
|
||||
<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="grid h-full w-full place-items-center overflow-y-auto px-7 py-5">
|
||||
<div>
|
||||
<h1 className="font- text-center text-2xl">SignUp on Plane</h1>
|
||||
<EmailSignUpForm onSubmit={handleSignUp} />
|
||||
|
||||
<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">
|
||||
<SignUpRoot />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SignUpPage.getLayout = function getLayout(page: ReactElement) {
|
||||
SignUpPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||
return <DefaultLayout>{page}</DefaultLayout>;
|
||||
};
|
||||
|
||||
|
@ -5,8 +5,9 @@ import { IAppConfig } from "@plane/types";
|
||||
import { AppConfigService } from "services/app_config.service";
|
||||
|
||||
export interface IAppConfigStore {
|
||||
// observables
|
||||
envConfig: IAppConfig | null;
|
||||
// action
|
||||
// actions
|
||||
fetchAppConfig: () => Promise<any>;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
@ -148,8 +150,13 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
const appliedFilters = _filters.filters || {};
|
||||
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, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// 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, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
@ -143,8 +145,13 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
const appliedFilters = _filters.filters || {};
|
||||
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, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
|
@ -11,7 +11,6 @@ import {
|
||||
TStaticViewTypes,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { isNil } from "constants/common";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
// lib
|
||||
import { storage } from "lib/local-storage";
|
||||
@ -76,8 +75,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
target_date: filters?.target_date || undefined,
|
||||
// display filters
|
||||
type: displayFilters?.type || undefined,
|
||||
sub_issue: isNil(displayFilters?.sub_issue) ? true : displayFilters?.sub_issue,
|
||||
start_target_date: isNil(displayFilters?.start_target_date) ? true : displayFilters?.start_target_date,
|
||||
sub_issue: displayFilters?.sub_issue ?? true,
|
||||
start_target_date: displayFilters?.start_target_date ?? true,
|
||||
};
|
||||
|
||||
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
|
||||
@ -169,19 +168,19 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
* @returns {IIssueDisplayProperties}
|
||||
*/
|
||||
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({
|
||||
assignee: displayProperties?.assignee || false,
|
||||
start_date: displayProperties?.start_date || false,
|
||||
due_date: displayProperties?.due_date || false,
|
||||
labels: displayProperties?.labels || false,
|
||||
priority: displayProperties?.priority || false,
|
||||
state: displayProperties?.state || false,
|
||||
sub_issue_count: displayProperties?.sub_issue_count || false,
|
||||
attachment_count: displayProperties?.attachment_count || false,
|
||||
estimate: displayProperties?.estimate || false,
|
||||
link: displayProperties?.link || false,
|
||||
key: displayProperties?.key || false,
|
||||
created_on: displayProperties?.created_on || false,
|
||||
updated_on: displayProperties?.updated_on || false,
|
||||
assignee: displayProperties?.assignee ?? true,
|
||||
start_date: displayProperties?.start_date ?? true,
|
||||
due_date: displayProperties?.due_date ?? true,
|
||||
labels: displayProperties?.labels ?? true,
|
||||
priority: displayProperties?.priority ?? true,
|
||||
state: displayProperties?.state ?? true,
|
||||
sub_issue_count: displayProperties?.sub_issue_count ?? true,
|
||||
attachment_count: displayProperties?.attachment_count ?? true,
|
||||
link: displayProperties?.link ?? true,
|
||||
estimate: displayProperties?.estimate ?? true,
|
||||
key: displayProperties?.key ?? true,
|
||||
created_on: displayProperties?.created_on ?? true,
|
||||
updated_on: displayProperties?.updated_on ?? true,
|
||||
});
|
||||
|
||||
handleIssuesLocalFilters = {
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
@ -157,8 +159,14 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul
|
||||
set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId);
|
||||
const appliedFilters = _filters.filters || {};
|
||||
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, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// 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(
|
||||
workspaceSlug,
|
||||
undefined,
|
||||
"mutation",
|
||||
isEmpty(filteredFilters) ? "init-loader" : "mutation",
|
||||
userId,
|
||||
this.rootIssueStore.profileIssues.currentView
|
||||
);
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
@ -178,10 +183,10 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
|
||||
_filters.displayFilters.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) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
_filters.displayFilters.group_by = "priority";
|
||||
updatedDisplayFilters.group_by = "priority";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
|
@ -97,7 +97,9 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
|
||||
const orderBy = displayFilters?.order_by;
|
||||
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);
|
||||
if (!_issues) return undefined;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// 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;
|
||||
case EIssueFilterType.DISPLAY_FILTERS:
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// 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, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
|
@ -123,6 +123,7 @@ export class IssueRootStore implements IIssueRootStore {
|
||||
moduleId: observable.ref,
|
||||
viewId: observable.ref,
|
||||
userId: observable.ref,
|
||||
globalViewId: observable.ref,
|
||||
states: observable,
|
||||
stateDetails: observable,
|
||||
labels: observable,
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import pickBy from "lodash/pickBy";
|
||||
import isArray from "lodash/isArray";
|
||||
// base class
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
@ -180,7 +182,13 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
|
||||
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;
|
||||
case EIssueFilterType.DISPLAY_FILTERS:
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
|
@ -1,374 +1,277 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import set from "lodash/set";
|
||||
import omit from "lodash/omit";
|
||||
import isToday from "date-fns/isToday";
|
||||
import isThisWeek from "date-fns/isThisWeek";
|
||||
import isYesterday from "date-fns/isYesterday";
|
||||
// services
|
||||
import { action, makeObservable, observable, reaction, runInAction } from "mobx";
|
||||
|
||||
import { IIssueLabel, IPage } from "@plane/types";
|
||||
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";
|
||||
|
||||
export interface IPageStore {
|
||||
pages: Record<string, IPage>;
|
||||
archivedPages: Record<string, IPage>;
|
||||
// project computed
|
||||
projectPageIds: string[] | null;
|
||||
favoriteProjectPageIds: string[] | null;
|
||||
privateProjectPageIds: string[] | null;
|
||||
publicProjectPageIds: string[] | null;
|
||||
archivedProjectPageIds: string[] | null;
|
||||
recentProjectPages: IRecentPages | null;
|
||||
// fetch page information actions
|
||||
getUnArchivedPageById: (pageId: string) => IPage | null;
|
||||
getArchivedPageById: (pageId: string) => IPage | null;
|
||||
// fetch actions
|
||||
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||
// favorites actions
|
||||
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
// crud
|
||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
// access control actions
|
||||
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
// archive actions
|
||||
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
// Page Properties
|
||||
access: number;
|
||||
archived_at: string | null;
|
||||
color: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
description: string;
|
||||
description_html: string;
|
||||
description_stripped: string | null;
|
||||
id: string;
|
||||
is_favorite: boolean;
|
||||
label_details: IIssueLabel[];
|
||||
is_locked: boolean;
|
||||
labels: string[];
|
||||
name: string;
|
||||
owned_by: string;
|
||||
project: string;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
|
||||
// Actions
|
||||
makePublic: () => Promise<void>;
|
||||
makePrivate: () => Promise<void>;
|
||||
lockPage: () => Promise<void>;
|
||||
unlockPage: () => Promise<void>;
|
||||
addToFavorites: () => 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 {
|
||||
pages: Record<string, IPage> = {};
|
||||
archivedPages: Record<string, IPage> = {};
|
||||
// services
|
||||
access = 0;
|
||||
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
|
||||
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;
|
||||
// stores
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
constructor(page: IPage, _rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
pages: observable,
|
||||
archivedPages: observable,
|
||||
// computed
|
||||
projectPageIds: computed,
|
||||
favoriteProjectPageIds: computed,
|
||||
publicProjectPageIds: computed,
|
||||
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
|
||||
name: observable.ref,
|
||||
description_html: observable.ref,
|
||||
is_favorite: observable.ref,
|
||||
is_locked: observable.ref,
|
||||
isSubmitting: observable.ref,
|
||||
access: observable.ref,
|
||||
|
||||
makePublic: action,
|
||||
makePrivate: action,
|
||||
// archive actions
|
||||
archivePage: action,
|
||||
restorePage: action,
|
||||
addToFavorites: action,
|
||||
removeFromFavorites: action,
|
||||
updateName: action,
|
||||
updateDescription: action,
|
||||
setIsSubmitting: action,
|
||||
cleanup: action,
|
||||
});
|
||||
// stores
|
||||
this.rootStore = rootStore;
|
||||
// services
|
||||
this.created_by = page?.created_by || "";
|
||||
this.created_at = page?.created_at || new Date();
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves all pages for a projectId that is available in the url.
|
||||
*/
|
||||
get projectPageIds() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId) return null;
|
||||
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId);
|
||||
return projectPageIds ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const descriptionDisposer = reaction(
|
||||
() => this.description_html,
|
||||
(description_html) => {
|
||||
//TODO: Fix reaction to only run when the data is changed, not when the page is loaded
|
||||
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||
if (!projectId || !workspaceSlug) return;
|
||||
this.isSubmitting = "submitting";
|
||||
this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => {
|
||||
runInAction(() => {
|
||||
this.isSubmitting = "submitted";
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
{ delay: 3000 }
|
||||
);
|
||||
|
||||
/**
|
||||
* fetches all archived pages for a project.
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<IPage[]>
|
||||
*/
|
||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((page) => {
|
||||
set(this.archivedPages, [page.id], page);
|
||||
});
|
||||
});
|
||||
return response;
|
||||
const pageTitleDisposer = reaction(
|
||||
() => this.name,
|
||||
(name) => {
|
||||
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||
if (!projectId || !workspaceSlug) return;
|
||||
this.isSubmitting = "submitting";
|
||||
this.pageService
|
||||
.patchPage(workspaceSlug, projectId, this.id, { name })
|
||||
.catch(() => {
|
||||
runInAction(() => {
|
||||
this.name = this.oldName;
|
||||
});
|
||||
})
|
||||
.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
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
*/
|
||||
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
addToFavorites = action("addToFavorites", async () => {
|
||||
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(() => {
|
||||
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
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
*/
|
||||
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
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;
|
||||
});
|
||||
removeFromFavorites = action("removeFromFavorites", async () => {
|
||||
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||
if (!projectId || !workspaceSlug) return;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
this.is_favorite = false;
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => {
|
||||
runInAction(() => {
|
||||
omit(this.archivedPages, [pageId]);
|
||||
this.is_favorite = true;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* make a page public
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
* @returns
|
||||
*/
|
||||
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
makePublic = action("makePublic", async () => {
|
||||
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(() => {
|
||||
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
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
* @returns
|
||||
*/
|
||||
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
makePrivate = action("makePrivate", async () => {
|
||||
const { projectId, workspaceSlug } = this.rootStore.app.router;
|
||||
if (!projectId || !workspaceSlug) return;
|
||||
|
||||
/**
|
||||
* Mark a page archived
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
*/
|
||||
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
||||
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
|
||||
this.access = 1;
|
||||
|
||||
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => {
|
||||
runInAction(() => {
|
||||
set(this.archivedPages, [pageId], this.pages[pageId]);
|
||||
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]);
|
||||
this.access = 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -1,33 +1,54 @@
|
||||
import { makeObservable, observable, runInAction, action } from "mobx";
|
||||
import { makeObservable, observable, runInAction, action, computed } from "mobx";
|
||||
import { set } from "lodash";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// store
|
||||
import { PageStore, IPageStore } from "store/page.store";
|
||||
// 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 {
|
||||
projectPages: Record<string, IPageStore[]>;
|
||||
projectArchivedPages: Record<string, IPageStore[]>;
|
||||
projectPageMap: Record<string, 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
|
||||
fetchProjectPages: (workspaceSlug: string, projectId: string) => void;
|
||||
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void;
|
||||
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
// crud actions
|
||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => void;
|
||||
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void;
|
||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||
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 {
|
||||
projectPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
|
||||
projectArchivedPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
|
||||
projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
|
||||
projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
|
||||
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
pageService;
|
||||
|
||||
constructor() {
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
projectPages: observable,
|
||||
projectArchivedPages: observable,
|
||||
projectPageMap: observable,
|
||||
projectArchivedPageMap: observable,
|
||||
|
||||
projectPageIds: computed,
|
||||
archivedPageIds: computed,
|
||||
favoriteProjectPageIds: computed,
|
||||
privateProjectPageIds: computed,
|
||||
publicProjectPageIds: computed,
|
||||
recentProjectPages: computed,
|
||||
|
||||
// fetch actions
|
||||
fetchProjectPages: action,
|
||||
fetchArchivedProjectPages: action,
|
||||
@ -35,19 +56,113 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
createPage: action,
|
||||
deletePage: action,
|
||||
});
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
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
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
|
||||
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
this.projectPages[projectId] = response?.map((page) => new PageStore(page as any));
|
||||
});
|
||||
try {
|
||||
await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
|
||||
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
|
||||
* @returns Promise<IPage[]>
|
||||
*/
|
||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
|
||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
|
||||
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
|
||||
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
|
||||
@ -73,7 +195,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
||||
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
||||
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;
|
||||
};
|
||||
@ -88,11 +210,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
|
||||
runInAction(() => {
|
||||
this.projectPages = set(
|
||||
this.projectPages,
|
||||
[projectId],
|
||||
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
||||
);
|
||||
delete this.projectArchivedPageMap[projectId][pageId];
|
||||
});
|
||||
return response;
|
||||
};
|
||||
@ -104,14 +222,17 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
* @param pageId
|
||||
*/
|
||||
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId);
|
||||
runInAction(() => {
|
||||
set(
|
||||
this.projectPages,
|
||||
[projectId],
|
||||
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
|
||||
);
|
||||
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
|
||||
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
|
||||
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString());
|
||||
delete this.projectPageMap[projectId][pageId];
|
||||
});
|
||||
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => {
|
||||
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;
|
||||
};
|
||||
@ -122,15 +243,19 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
* @param projectId
|
||||
* @param pageId
|
||||
*/
|
||||
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
|
||||
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
|
||||
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
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(() => {
|
||||
set(
|
||||
this.projectArchivedPages,
|
||||
[projectId],
|
||||
this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
|
||||
);
|
||||
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
|
||||
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
|
||||
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt);
|
||||
delete this.projectPageMap[projectId][pageId];
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -92,24 +92,26 @@ export class ProjectStore implements IProjectStore {
|
||||
* Returns searched projects based on search query
|
||||
*/
|
||||
get searchedProjects() {
|
||||
if (!this.rootStore.app.router.workspaceSlug) return [];
|
||||
const projectIds = Object.keys(this.projectMap);
|
||||
return this.searchQuery === ""
|
||||
? projectIds
|
||||
: projectIds?.filter((projectId) => {
|
||||
this.projectMap[projectId].name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
|
||||
this.projectMap[projectId].identifier.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||
});
|
||||
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!workspaceDetails) return [];
|
||||
const workspaceProjects = Object.values(this.projectMap).filter(
|
||||
(p) =>
|
||||
p.workspace === workspaceDetails.id &&
|
||||
(p.name.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
|
||||
*/
|
||||
get workspaceProjectIds() {
|
||||
if (!this.rootStore.app.router.workspaceSlug) return null;
|
||||
const projectIds = Object.keys(this.projectMap);
|
||||
if (!projectIds) return null;
|
||||
return projectIds;
|
||||
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!workspaceDetails) return null;
|
||||
const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id);
|
||||
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 { IssueRootStore, IIssueRootStore } from "./issue/root.store";
|
||||
import { IStateStore, StateStore } from "./state.store";
|
||||
import { IPageStore, PageStore } from "./page.store";
|
||||
import { ILabelRootStore, LabelRootStore } from "./label";
|
||||
import { IMemberRootStore, MemberRootStore } from "./member";
|
||||
import { IInboxRootStore, InboxRootStore } from "./inbox";
|
||||
@ -33,7 +32,6 @@ export class RootStore {
|
||||
module: IModuleStore;
|
||||
projectView: IProjectViewStore;
|
||||
globalView: IGlobalViewStore;
|
||||
page: IPageStore;
|
||||
issue: IIssueRootStore;
|
||||
state: IStateStore;
|
||||
estimate: IEstimateStore;
|
||||
@ -58,8 +56,7 @@ export class RootStore {
|
||||
this.state = new StateStore(this);
|
||||
this.estimate = new EstimateStore(this);
|
||||
this.mention = new MentionStore(this);
|
||||
this.projectPages = new ProjectPageStore(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:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b"
|
||||
integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg==
|
||||
dependencies:
|
||||
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:
|
||||
version "6.12.0"
|
||||
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"
|
||||
|
Loading…
Reference in New Issue
Block a user