Merge branch 'develop' into fix/table-colors-row-col-add

This commit is contained in:
PalanikannanM 2024-01-22 11:47:23 +00:00
commit 2ca8448350
100 changed files with 2518 additions and 2262 deletions

View File

@ -1,61 +1,30 @@
name: Branch Build name: Branch Build
on: on:
pull_request: workflow_dispatch:
types: inputs:
- closed branch_name:
description: "Branch Name"
required: true
default: "preview"
push:
branches: branches:
- master - master
- preview - preview
- qa
- develop - develop
- release-*
release: release:
types: [released, prereleased] types: [released, prereleased]
env: env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }} TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
jobs: jobs:
branch_build_setup: branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
name: Build-Push Web/Space/API/Proxy Docker Image name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v3.3.0 uses: actions/checkout@v3.3.0
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
name: proxy-src-code
path: ./nginx
- name: Uploading Backend Source
uses: actions/upload-artifact@v3
with:
name: backend-src-code
path: ./apiserver
- name: Uploading Web Source
uses: actions/upload-artifact@v3
with:
name: web-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
name: space-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./web
outputs: outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }} gh_branch_name: ${{ env.TARGET_BRANCH }}
@ -63,33 +32,38 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps: steps:
- name: Set Frontend Docker Tag - name: Set Frontend Docker Tag
run: | run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else else
TAG=${{ env.FRONTEND_TAG }} TAG=${{ env.FRONTEND_TAG }}
fi fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2.1.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Web Source Code
uses: actions/download-artifact@v3 - name: Check out the repo
with: uses: actions/checkout@v4.1.1
name: web-src-code
- name: Build and Push Frontend to Docker Container Registry - name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v5.1.0
with: with:
context: . context: .
file: ./web/Dockerfile.web file: ./web/Dockerfile.web
@ -105,33 +79,39 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps: steps:
- name: Set Space Docker Tag - name: Set Space Docker Tag
run: | run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else else
TAG=${{ env.SPACE_TAG }} TAG=${{ env.SPACE_TAG }}
fi fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2.1.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Space Source Code
uses: actions/download-artifact@v3 - name: Check out the repo
with: uses: actions/checkout@v4.1.1
name: space-src-code
- name: Build and Push Space to Docker Hub - name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v5.1.0
with: with:
context: . context: .
file: ./space/Dockerfile.space file: ./space/Dockerfile.space
@ -147,36 +127,42 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps: steps:
- name: Set Backend Docker Tag - name: Set Backend Docker Tag
run: | run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else else
TAG=${{ env.BACKEND_TAG }} TAG=${{ env.BACKEND_TAG }}
fi fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2.1.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Backend Source Code
uses: actions/download-artifact@v3 - name: Check out the repo
with: uses: actions/checkout@v4.1.1
name: backend-src-code
- name: Build and Push Backend to Docker Hub - name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v5.1.0
with: with:
context: . context: ./apiserver
file: ./Dockerfile.api file: ./apiserver/Dockerfile.api
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: ${{ env.BACKEND_TAG }} tags: ${{ env.BACKEND_TAG }}
@ -189,37 +175,42 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps: steps:
- name: Set Proxy Docker Tag - name: Set Proxy Docker Tag
run: | run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else else
TAG=${{ env.PROXY_TAG }} TAG=${{ env.PROXY_TAG }}
fi fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2.1.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Proxy Source Code - name: Check out the repo
uses: actions/download-artifact@v3 uses: actions/checkout@v4.1.1
with:
name: proxy-src-code
- name: Build and Push Plane-Proxy to Docker Hub - name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v5.1.0
with: with:
context: . context: ./nginx
file: ./Dockerfile file: ./nginx/Dockerfile
platforms: linux/amd64 platforms: linux/amd64
tags: ${{ env.PROXY_TAG }} tags: ${{ env.PROXY_TAG }}
push: true push: true

View File

@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose). The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
## ⚡️ Contributors Quick Start ## ⚡️ Contributors Quick Start

View File

@ -8,11 +8,11 @@ SENTRY_DSN=""
SENTRY_ENVIRONMENT="development" SENTRY_ENVIRONMENT="development"
# Database Settings # Database Settings
PGUSER="plane" POSTGRES_USER="plane"
PGPASSWORD="plane" POSTGRES_PASSWORD="plane"
PGHOST="plane-db" POSTGRES_HOST="plane-db"
PGDATABASE="plane" POSTGRES_DB="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
# Oauth variables # Oauth variables
GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_ID=""

View File

@ -39,7 +39,6 @@ from plane.app.serializers import (
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
ProjectLitePermission, ProjectLitePermission,
WorkspaceUserPermission
) )
from plane.db.models import ( from plane.db.models import (
User, User,

View File

@ -1,5 +1,5 @@
# Python imports # Python imports
from datetime import timedelta, date, datetime from datetime import date, datetime, timedelta
# Django imports # Django imports
from django.db import connection from django.db import connection
@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.db.models import ( from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
Page, PageLogSerializer, PageSerializer,
PageFavorite, SubPageSerializer)
Issue, from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
IssueAssignee, PageFavorite, PageLog, ProjectMember)
IssueActivity,
PageLog, # Module imports
ProjectMember, from .base import BaseAPIView, BaseViewSet
)
from plane.app.serializers import (
PageSerializer,
PageFavoriteSerializer,
PageLogSerializer,
IssueLiteSerializer,
SubPageSerializer,
)
def unarchive_archive_page_and_descendants(page_id, archived_at): def unarchive_archive_page_and_descendants(page_id, archived_at):
@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet):
project_id=project_id, project_id=project_id,
member=request.user, member=request.user,
is_active=True, is_active=True,
role__gt=20, role__gte=20,
).exists() ).exists()
or request.user.id != page.owned_by_id or request.user.id != page.owned_by_id
): ):

View File

@ -41,7 +41,7 @@ from plane.app.serializers import (
ProjectMemberSerializer, ProjectMemberSerializer,
WorkspaceThemeSerializer, WorkspaceThemeSerializer,
IssueActivitySerializer, IssueActivitySerializer,
IssueLiteSerializer, IssueSerializer,
WorkspaceMemberAdminSerializer, WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer, WorkspaceMemberMeSerializer,
ProjectMemberRoleSerializer, ProjectMemberRoleSerializer,
@ -1339,23 +1339,10 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
) )
.filter(**filters) .filter(**filters)
.annotate( .select_related("workspace", "project", "state", "parent")
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels") .prefetch_related("assignees", "labels")
.prefetch_related( .annotate(cycle_id=F("issue_cycle__cycle_id"))
Prefetch( .annotate(module_id=F("issue_module__module_id"))
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -1370,6 +1357,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
).distinct() ).distinct()
# Priority Ordering # Priority Ordering
@ -1432,7 +1426,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer( issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None issue_queryset, many=True, fields=fields if fields else None
).data ).data
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)

View File

@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
def forgot_password(first_name, email, uidb64, token, current_site): def forgot_password(first_name, email, uidb64, token, current_site):
try: try:
relative_link = ( relative_link = (
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}" f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
) )
abs_url = str(current_site) + relative_link abs_url = str(current_site) + relative_link

View File

@ -7,19 +7,17 @@ from . import ProjectBaseModel
def get_default_filters(): def get_default_filters():
return ( return {
{ "priority": None,
"priority": None, "state": None,
"state": None, "state_group": None,
"state_group": None, "assignees": None,
"assignees": None, "created_by": None,
"created_by": None, "labels": None,
"labels": None, "start_date": None,
"start_date": None, "target_date": None,
"target_date": None, "subscriber": None,
"subscriber": None, }
},
)
def get_default_display_filters(): def get_default_display_filters():

View File

@ -482,19 +482,16 @@ export class TableView implements NodeView {
} }
updateControls() { updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
(acc, curr) => { if (curr.spec.hoveredCell !== undefined) {
if (curr.spec.hoveredCell !== undefined) { acc["hoveredCell"] = curr.spec.hoveredCell;
acc["hoveredCell"] = curr.spec.hoveredCell; }
}
if (curr.spec.hoveredTable !== undefined) { if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable; acc["hoveredTable"] = curr.spec.hoveredTable;
} }
return acc; return acc;
}, }, {} as Record<string, HTMLElement>) as any;
{} as Record<string, HTMLElement>
) as any;
if (table === undefined || cell === undefined) { if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled"); return this.root.classList.add("controls--disabled");

View File

@ -32,6 +32,7 @@
"@plane/editor-core": "*", "@plane/editor-core": "*",
"@plane/editor-extensions": "*", "@plane/editor-extensions": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@tippyjs/react": "^4.2.6",
"@tiptap/core": "^2.1.13", "@tiptap/core": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13", "@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/pm": "^2.1.13", "@tiptap/pm": "^2.1.13",

View File

@ -18,7 +18,7 @@ import {
type IPageRenderer = { type IPageRenderer = {
documentDetails: DocumentDetails; documentDetails: DocumentDetails;
updatePageTitle: (title: string) => Promise<void>; updatePageTitle: (title: string) => void;
editor: Editor; editor: Editor;
onActionCompleteHandler: (action: { onActionCompleteHandler: (action: {
title: string; title: string;
@ -30,18 +30,6 @@ type IPageRenderer = {
readonly: boolean; readonly: boolean;
}; };
const debounce = (func: (...args: any[]) => void, wait: number) => {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: any[]) {
const later = () => {
if (timeout) clearTimeout(timeout);
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export const PageRenderer = (props: IPageRenderer) => { export const PageRenderer = (props: IPageRenderer) => {
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props; const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
@ -64,11 +52,26 @@ export const PageRenderer = (props: IPageRenderer) => {
const { getFloatingProps } = useInteractions([dismiss]); const { getFloatingProps } = useInteractions([dismiss]);
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300); const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
whileElementsMounted: autoUpdate,
});
const dismiss = useDismiss(context, {
ancestorScroll: true,
});
const { getFloatingProps } = useInteractions([dismiss]);
const handlePageTitleChange = (title: string) => { const handlePageTitleChange = (title: string) => {
setPagetitle(title); setPagetitle(title);
debouncedUpdatePageTitle(title); updatePageTitle(title);
}; };
const [cleanup, setcleanup] = useState(() => () => {}); const [cleanup, setcleanup] = useState(() => () => {});

View File

@ -26,7 +26,7 @@ export const DocumentEditorExtensions = (
.focus() .focus()
.insertContentAt( .insertContentAt(
range, range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>" "<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>\n"
) )
.run(); .run();
}, },

View File

@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
title: suggestion.name, title: suggestion.name,
priority: suggestion.priority.toString(), priority: suggestion.priority.toString(),
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
state: suggestion.state_detail.name, state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor editor
.chain() .chain()

View File

@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({
addOptions() { addOptions() {
return { return {
suggestion: { suggestion: {
char: "#issue_",
allowSpaces: true,
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range }); props.command({ editor, range });
}, },
@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
Suggestion({ Suggestion({
char: "#issue_",
pluginKey: new PluginKey("issue-embed-suggestions"), pluginKey: new PluginKey("issue-embed-suggestions"),
editor: this.editor, editor: this.editor,
allowSpaces: true,
...this.options.suggestion, ...this.options.suggestion,
}), }),
]; ];

View File

@ -53,7 +53,7 @@ const IssueSuggestionList = ({
const commandListContainer = useRef<HTMLDivElement>(null); const commandListContainer = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let totalLength = 0; let totalLength = 0;
sections.forEach((section) => { sections.forEach((section) => {
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
@ -65,8 +65,8 @@ const IssueSuggestionList = ({
}, [items]); }, [items]);
const selectItem = useCallback( const selectItem = useCallback(
(index: number) => { (section: string, index: number) => {
const item = displayedItems[currentSection][index]; const item = displayedItems[section][index];
if (item) { if (item) {
command(item); command(item);
} }
@ -87,6 +87,7 @@ const IssueSuggestionList = ({
setSelectedIndex( setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length (selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
); );
e.stopPropagation();
return true; return true;
} }
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
@ -101,10 +102,12 @@ const IssueSuggestionList = ({
[currentSection]: [...prevItems[currentSection], ...nextItems], [currentSection]: [...prevItems[currentSection], ...nextItems],
})); }));
} }
e.stopPropagation();
return true; return true;
} }
if (e.key === "Enter") { if (e.key === "Enter") {
selectItem(selectedIndex); selectItem(currentSection, selectedIndex);
e.stopPropagation();
return true; return true;
} }
if (e.key === "Tab") { if (e.key === "Tab") {
@ -112,6 +115,7 @@ const IssueSuggestionList = ({
const nextSectionIndex = (currentSectionIndex + 1) % sections.length; const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]); setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0); setSelectedIndex(0);
e.stopPropagation();
return true; return true;
} }
return false; return false;
@ -172,7 +176,7 @@ const IssueSuggestionList = ({
} }
)} )}
key={item.identifier} key={item.identifier}
onClick={() => selectItem(index)} onClick={() => selectItem(section, index)}
> >
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5> <h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} /> <PriorityIcon priority={item.priority} />
@ -195,7 +199,7 @@ export const IssueListRenderer = () => {
let popup: any | null = null; let popup: any | null = null;
return { return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(IssueSuggestionList, { component = new ReactRenderer(IssueSuggestionList, {
props, props,
// @ts-ignore // @ts-ignore
@ -210,10 +214,10 @@ export const IssueListRenderer = () => {
showOnCreate: true, showOnCreate: true,
interactive: true, interactive: true,
trigger: "manual", trigger: "manual",
placement: "right", placement: "bottom-start",
}); });
}, },
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props); component?.updateProps(props);
popup && popup &&

View File

@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => {
setIssueDetails(issue); setIssueDetails(issue);
setLoading(0); setLoading(0);
}) })
.catch((error) => { .catch(() => {
console.log(error);
setLoading(-1); setLoading(-1);
}); });
}, []); }, []);
@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => {
{loading == 0 ? ( {loading == 0 ? (
<div <div
onClick={completeIssueEmbedAction} onClick={completeIssueEmbedAction}
className="w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs" className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs`}
> >
<h5 className="text-xs text-custom-text-300"> <h5 className="text-xs text-custom-text-300">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id} {issueDetails.project_detail.identifier}-{issueDetails.sequence_id}

View File

@ -16,7 +16,7 @@ interface IDocumentEditor {
// document info // document info
documentDetails: DocumentDetails; documentDetails: DocumentDetails;
value: string; value: string;
rerenderOnPropsChange: { rerenderOnPropsChange?: {
id: string; id: string;
description_html: string; description_html: string;
}; };
@ -39,7 +39,7 @@ interface IDocumentEditor {
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void; setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any; forwardedRef?: any;
updatePageTitle: (title: string) => Promise<void>; updatePageTitle: (title: string) => void;
debouncedUpdatesEnabled?: boolean; debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved"; isSubmitting: "submitting" | "submitted" | "saved";

View File

@ -1,14 +1,14 @@
export interface IAppConfig { export interface IAppConfig {
email_password_login: boolean; email_password_login: boolean;
file_size_limit: number; file_size_limit: number;
google_client_id: string | null;
github_app_name: string | null; github_app_name: string | null;
github_client_id: string | null; github_client_id: string | null;
magic_login: boolean; google_client_id: string | null;
slack_client_id: string | null;
posthog_api_key: string | null;
posthog_host: string | null;
has_openai_configured: boolean; has_openai_configured: boolean;
has_unsplash_configured: boolean; has_unsplash_configured: boolean;
is_smtp_configured: boolean; is_smtp_configured: boolean;
magic_login: boolean;
posthog_api_key: string | null;
posthog_host: string | null;
slack_client_id: string | null;
} }

View File

@ -101,7 +101,7 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />

View File

@ -100,7 +100,7 @@ export const EmailForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (

View File

@ -61,7 +61,7 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400"
disabled disabled
/> />

View File

@ -155,7 +155,7 @@ export const PasswordForm: React.FC<Props> = (props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (

View File

@ -97,7 +97,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (

View File

@ -87,7 +87,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />

View File

@ -182,7 +182,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (

View File

@ -104,7 +104,7 @@ const HomePage: NextPage = () => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />

View File

@ -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>
</>
);
};

View File

@ -1,5 +1,4 @@
export * from "./o-auth";
export * from "./sign-in-forms"; export * from "./sign-in-forms";
export * from "./sign-up-forms";
export * from "./deactivate-account-modal"; export * from "./deactivate-account-modal";
export * from "./github-sign-in";
export * from "./google-sign-in";
export * from "./email-signup-form";

View File

@ -12,10 +12,11 @@ import githubDarkModeImage from "/public/logos/github-dark.svg";
type Props = { type Props = {
handleSignIn: React.Dispatch<string>; handleSignIn: React.Dispatch<string>;
clientId: string; clientId: string;
type: "sign_in" | "sign_up";
}; };
export const GitHubSignInButton: FC<Props> = (props) => { export const GitHubSignInButton: FC<Props> = (props) => {
const { handleSignIn, clientId } = props; const { handleSignIn, clientId, type } = props;
// states // states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null); const [gitCode, setGitCode] = useState<null | string>(null);
@ -53,7 +54,7 @@ export const GitHubSignInButton: FC<Props> = (props) => {
width={20} width={20}
alt="GitHub Logo" alt="GitHub Logo"
/> />
<span className="text-onboarding-text-200">Sign-in with GitHub</span> <span className="text-onboarding-text-200">{type === "sign_in" ? "Sign-in" : "Sign-up"} with GitHub</span>
</button> </button>
</Link> </Link>
</div> </div>

View File

@ -4,10 +4,11 @@ import Script from "next/script";
type Props = { type Props = {
handleSignIn: React.Dispatch<any>; handleSignIn: React.Dispatch<any>;
clientId: string; clientId: string;
type: "sign_in" | "sign_up";
}; };
export const GoogleSignInButton: FC<Props> = (props) => { export const GoogleSignInButton: FC<Props> = (props) => {
const { handleSignIn, clientId } = props; const { handleSignIn, clientId, type } = props;
// refs // refs
const googleSignInButton = useRef<HTMLDivElement>(null); const googleSignInButton = useRef<HTMLDivElement>(null);
// states // states
@ -29,7 +30,7 @@ export const GoogleSignInButton: FC<Props> = (props) => {
theme: "outline", theme: "outline",
size: "large", size: "large",
logo_alignment: "center", logo_alignment: "center",
text: "signin_with", text: type === "sign_in" ? "signin_with" : "signup_with",
width: 384, width: 384,
} as GsiButtonConfiguration // customization attributes } as GsiButtonConfiguration // customization attributes
); );
@ -40,7 +41,7 @@ export const GoogleSignInButton: FC<Props> = (props) => {
window?.google?.accounts.id.prompt(); // also display the One Tap dialog window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true); setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded, clientId]); }, [handleSignIn, gsiScriptLoaded, clientId, type]);
useEffect(() => { useEffect(() => {
if (window?.google?.accounts?.id) { if (window?.google?.accounts?.id) {

View File

@ -0,0 +1,3 @@
export * from "./github-sign-in";
export * from "./google-sign-in";
export * from "./o-auth-options";

View File

@ -9,19 +9,22 @@ import { GitHubSignInButton, GoogleSignInButton } from "components/account";
type Props = { type Props = {
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
type: "sign_in" | "sign_up";
}; };
// services // services
const authService = new AuthService(); const authService = new AuthService();
export const OAuthOptions: React.FC<Props> = observer((props) => { export const OAuthOptions: React.FC<Props> = observer((props) => {
const { handleSignInRedirection } = props; const { handleSignInRedirection, type } = props;
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// mobx store // mobx store
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
// derived values
const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id;
const handleGoogleSignIn = async ({ clientId, credential }: any) => { const handleGoogleSignIn = async ({ clientId, credential }: any) => {
try { try {
@ -72,12 +75,14 @@ export const OAuthOptions: React.FC<Props> = observer((props) => {
<p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">Or continue with</p> <p className="mx-3 flex-shrink-0 text-center text-sm text-onboarding-text-400">Or continue with</p>
<hr className="w-full border-onboarding-border-100" /> <hr className="w-full border-onboarding-border-100" />
</div> </div>
<div className="mx-auto mt-7 space-y-4 overflow-hidden sm:w-96"> <div className={`mx-auto mt-7 grid gap-4 overflow-hidden sm:w-96 ${areBothOAuthEnabled ? "grid-cols-2" : ""}`}>
{envConfig?.google_client_id && ( {envConfig?.google_client_id && (
<GoogleSignInButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} /> <div className="h-[42px] flex items-center !overflow-hidden">
<GoogleSignInButton clientId={envConfig?.google_client_id} handleSignIn={handleGoogleSignIn} type={type} />
</div>
)} )}
{envConfig?.github_client_id && ( {envConfig?.github_client_id && (
<GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} /> <GitHubSignInButton clientId={envConfig?.github_client_id} handleSignIn={handleGitHubSignIn} type={type} />
)} )}
</div> </div>
</> </>

View 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>
</>
);
});

View File

@ -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>
);
};

View File

@ -1,9 +1,6 @@
export * from "./create-password"; export * from "./email";
export * from "./email-form"; export * from "./forgot-password-popover";
export * from "./o-auth-options";
export * from "./optional-set-password"; export * from "./optional-set-password";
export * from "./password"; export * from "./password";
export * from "./root"; export * from "./root";
export * from "./self-hosted-sign-in";
export * from "./set-password-link";
export * from "./unique-code"; export * from "./unique-code";

View File

@ -1,36 +1,76 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignInSteps } from "components/account";
type Props = { type Props = {
email: string; email: string;
handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
isOnboarded: boolean;
}; };
export const OptionalSetPasswordForm: React.FC<Props> = (props) => { type TCreatePasswordFormValues = {
const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; email: string;
password: string;
};
const defaultValues: TCreatePasswordFormValues = {
email: "",
password: "",
};
// services
const authService = new AuthService();
export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection } = props;
// states // states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// form info // form info
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isSubmitting, isValid },
} = useForm({ handleSubmit,
} = useForm<TCreatePasswordFormValues>({
defaultValues: { defaultValues: {
...defaultValues,
email, email,
}, },
mode: "onChange", mode: "onChange",
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const handleCreatePassword = async (formData: TCreatePasswordFormValues) => {
const payload = {
password: formData.password,
};
await authService
.setPassword(payload)
.then(async () => {
setToastAlert({
type: "success",
title: "Success!",
message: "Password created successfully.",
});
await handleSignInRedirection();
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const handleGoToWorkspace = async () => { const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true); setIsGoingToWorkspace(true);
@ -39,12 +79,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set a password</h1> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Set your password</h1>
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200"> <p className="mt-2.5 text-center text-sm text-onboarding-text-200">
If you{"'"}d like to do away with codes, set a password here. If you{"'"}d like to do away with codes, set a password here.
</p> </p>
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
<form className="mx-auto mt-5 space-y-4 sm:w-96">
<Controller <Controller
control={control} control={control}
name="email" name="email"
@ -61,22 +100,47 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />
)} )}
/> />
<div className="grid grid-cols-2 gap-2.5"> <div>
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
type="password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8}
autoFocus
/>
)}
/>
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
Whatever you choose now will be your account{"'"}s password until you change it.
</p>
</div>
<div className="space-y-2.5">
<Button <Button
type="button" type="submit"
variant="primary" variant="primary"
onClick={() => handleStepChange(ESignInSteps.CREATE_PASSWORD)}
className="w-full" className="w-full"
size="xl" size="xl"
disabled={!isValid} disabled={!isValid}
loading={isSubmitting}
> >
Create password Set password
</Button> </Button>
<Button <Button
type="button" type="button"
@ -84,20 +148,11 @@ export const OptionalSetPasswordForm: React.FC<Props> = (props) => {
className="w-full" className="w-full"
size="xl" size="xl"
onClick={handleGoToWorkspace} onClick={handleGoToWorkspace}
disabled={!isValid}
loading={isGoingToWorkspace} loading={isGoingToWorkspace}
> >
{isOnboarded ? "Go to workspace" : "Set up workspace"} Skip to workspace
</Button> </Button>
</div> </div>
<p className="text-xs text-onboarding-text-200">
When you click{" "}
<span className="text-custom-primary-100">{isOnboarded ? "Go to workspace" : "Set up workspace"}</span> above,
you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form> </form>
</> </>
); );

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
// services // services
@ -7,22 +8,20 @@ import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store"; import { useApplication } from "hooks/store";
// components
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IPasswordSignInData } from "@plane/types"; import { IPasswordSignInData } from "@plane/types";
// constants
import { ESignInSteps } from "components/account";
import { observer } from "mobx-react-lite";
type Props = { type Props = {
email: string; email: string;
updateEmail: (email: string) => void;
handleStepChange: (step: ESignInSteps) => void; handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>;
handleEmailClear: () => void; handleEmailClear: () => void;
onSubmit: () => Promise<void>;
}; };
type TPasswordFormValues = { type TPasswordFormValues = {
@ -37,24 +36,24 @@ const defaultValues: TPasswordFormValues = {
const authService = new AuthService(); const authService = new AuthService();
export const PasswordForm: React.FC<Props> = observer((props) => { export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection, handleEmailClear } = props; const { email, handleStepChange, handleEmailClear, onSubmit } = props;
// states // states
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info // form info
const { const {
control, control,
formState: { dirtyFields, errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
getValues, getValues,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
} = useForm<TPasswordFormValues>({ } = useForm<TPasswordFormValues>({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
@ -65,8 +64,6 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
}); });
const handleFormSubmit = async (formData: TPasswordFormValues) => { const handleFormSubmit = async (formData: TPasswordFormValues) => {
updateEmail(formData.email);
const payload: IPasswordSignInData = { const payload: IPasswordSignInData = {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
@ -74,7 +71,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
await authService await authService
.passwordSignIn(payload) .passwordSignIn(payload)
.then(async () => await handleSignInRedirection()) .then(async () => await onSubmit())
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -84,31 +81,6 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
); );
}; };
const handleForgotPassword = async () => {
const emailFormValue = getValues("email");
const isEmailValid = checkEmailValidity(emailFormValue);
if (!isEmailValid) {
setError("email", { message: "Email is invalid" });
return;
}
setIsSendingResetPasswordLink(true);
authService
.sendResetPasswordLink({ email: emailFormValue })
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsSendingResetPasswordLink(false));
};
const handleSendUniqueCode = async () => { const handleSendUniqueCode = async () => {
const emailFormValue = getValues("email"); const emailFormValue = getValues("email");
@ -134,16 +106,15 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
.finally(() => setIsSendingUniqueCode(false)); .finally(() => setIsSendingUniqueCode(false));
}; };
useEffect(() => {
setFocus("password");
}, [setFocus]);
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100"> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
Get on your flight deck Welcome back, let{"'"}s get you on board
</h1> </h1>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96"> <p className="mt-2.5 text-center text-sm text-onboarding-text-200">
Get back to your issues, projects and workspaces.
</p>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
<div> <div>
<Controller <Controller
control={control} control={control}
@ -161,14 +132,17 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
disabled disabled={isSmtpConfigured}
/> />
{value.length > 0 && ( {value.length > 0 && (
<XCircle <XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer" className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear} onClick={() => {
if (isSmtpConfigured) handleEmailClear();
else onChange("");
}}
/> />
)} )}
</div> </div>
@ -180,7 +154,7 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
control={control} control={control}
name="password" name="password"
rules={{ rules={{
required: dirtyFields.email ? false : "Password is required", required: "Password is required",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
@ -190,23 +164,34 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Enter password" placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/> />
)} )}
/> />
<div className="w-full text-right"> <div className="w-full text-right mt-2 pb-3">
<button {isSmtpConfigured ? (
type="button" <Link
onClick={handleForgotPassword} href={`/accounts/forgot-password?email=${email}`}
className={`text-xs font-medium ${ className="text-xs font-medium text-custom-primary-100"
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100" >
}`} Forgot your password?
disabled={isSendingResetPasswordLink} </Link>
> ) : (
{isSendingResetPasswordLink ? "Sending link" : "Forgot your password?"} <ForgotPasswordPopover />
</button> )}
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="space-y-2.5">
<Button
type="submit"
variant="primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSubmitting}
>
{envConfig?.is_smtp_configured ? "Continue" : "Go to workspace"}
</Button>
{envConfig && envConfig.is_smtp_configured && ( {envConfig && envConfig.is_smtp_configured && (
<Button <Button
type="button" type="button"
@ -216,26 +201,10 @@ export const PasswordForm: React.FC<Props> = observer((props) => {
size="xl" size="xl"
loading={isSendingUniqueCode} loading={isSendingUniqueCode}
> >
{isSendingUniqueCode ? "Sending code" : "Use unique code"} {isSendingUniqueCode ? "Sending code" : "Sign in with unique code"}
</Button> </Button>
)} )}
<Button
type="submit"
variant="primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSubmitting}
>
Continue
</Button>
</div> </div>
<p className="text-xs text-onboarding-text-200">
When you click <span className="text-custom-primary-100">Go to workspace</span> above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form> </form>
</> </>
); );

View File

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useApplication } from "hooks/store"; import { useApplication } from "hooks/store";
@ -6,115 +7,115 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { LatestFeatureBlock } from "components/common"; import { LatestFeatureBlock } from "components/common";
import { import {
EmailForm, SignInEmailForm,
UniqueCodeForm, SignInUniqueCodeForm,
PasswordForm, SignInPasswordForm,
SetPasswordLink,
OAuthOptions, OAuthOptions,
OptionalSetPasswordForm, SignInOptionalSetPasswordForm,
CreatePasswordForm,
} from "components/account"; } from "components/account";
export enum ESignInSteps { export enum ESignInSteps {
EMAIL = "EMAIL", EMAIL = "EMAIL",
PASSWORD = "PASSWORD", PASSWORD = "PASSWORD",
SET_PASSWORD_LINK = "SET_PASSWORD_LINK",
UNIQUE_CODE = "UNIQUE_CODE", UNIQUE_CODE = "UNIQUE_CODE",
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
CREATE_PASSWORD = "CREATE_PASSWORD",
USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD",
} }
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
export const SignInRoot = observer(() => { export const SignInRoot = observer(() => {
// states // states
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL); const [signInStep, setSignInStep] = useState<ESignInSteps | null>(null);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [isOnboarded, setIsOnboarded] = useState(false);
// sign in redirection hook // sign in redirection hook
const { handleRedirection } = useSignInRedirection(); const { handleRedirection } = useSignInRedirection();
// mobx store // mobx store
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
// step 1 submit handler- email verification
const handleEmailVerification = (isPasswordAutoset: boolean) => {
if (isSmtpConfigured && isPasswordAutoset) setSignInStep(ESignInSteps.UNIQUE_CODE);
else setSignInStep(ESignInSteps.PASSWORD);
};
// step 2 submit handler- unique code sign in
const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => {
if (isPasswordAutoset) setSignInStep(ESignInSteps.OPTIONAL_SET_PASSWORD);
else await handleRedirection();
};
// step 3 submit handler- password sign in
const handlePasswordSignIn = async () => {
await handleRedirection();
};
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
useEffect(() => {
if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL);
else setSignInStep(ESignInSteps.PASSWORD);
}, [isSmtpConfigured]);
return ( return (
<> <>
<div className="mx-auto flex flex-col"> <div className="mx-auto flex flex-col">
<> <>
{signInStep === ESignInSteps.EMAIL && ( {signInStep === ESignInSteps.EMAIL && (
<EmailForm <SignInEmailForm onSubmit={handleEmailVerification} updateEmail={(newEmail) => setEmail(newEmail)} />
handleStepChange={(step) => setSignInStep(step)} )}
updateEmail={(newEmail) => setEmail(newEmail)} {signInStep === ESignInSteps.UNIQUE_CODE && (
<SignInUniqueCodeForm
email={email}
handleEmailClear={() => {
setEmail("");
setSignInStep(ESignInSteps.EMAIL);
}}
onSubmit={handleUniqueCodeSignIn}
submitButtonText="Continue"
/> />
)} )}
{signInStep === ESignInSteps.PASSWORD && ( {signInStep === ESignInSteps.PASSWORD && (
<PasswordForm <SignInPasswordForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)}
handleEmailClear={() => { handleEmailClear={() => {
setEmail(""); setEmail("");
setSignInStep(ESignInSteps.EMAIL); setSignInStep(ESignInSteps.EMAIL);
}} }}
handleSignInRedirection={handleRedirection} onSubmit={handlePasswordSignIn}
handleStepChange={(step) => setSignInStep(step)}
/> />
)} )}
{signInStep === ESignInSteps.SET_PASSWORD_LINK && (
<SetPasswordLink email={email} updateEmail={(newEmail) => setEmail(newEmail)} />
)}
{signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && (
<UniqueCodeForm <SignInUniqueCodeForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
submitButtonLabel="Go to workspace"
showTermsAndConditions
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
handleEmailClear={() => {
setEmail("");
setSignInStep(ESignInSteps.EMAIL);
}}
/>
)}
{signInStep === ESignInSteps.UNIQUE_CODE && (
<UniqueCodeForm
email={email}
updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
updateUserOnboardingStatus={(value) => setIsOnboarded(value)}
handleEmailClear={() => { handleEmailClear={() => {
setEmail(""); setEmail("");
setSignInStep(ESignInSteps.EMAIL); setSignInStep(ESignInSteps.EMAIL);
}} }}
onSubmit={handleUniqueCodeSignIn}
submitButtonText="Go to workspace"
/> />
)} )}
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
<OptionalSetPasswordForm <SignInOptionalSetPasswordForm email={email} handleSignInRedirection={handleRedirection} />
email={email}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
isOnboarded={isOnboarded}
/>
)}
{signInStep === ESignInSteps.CREATE_PASSWORD && (
<CreatePasswordForm
email={email}
handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleRedirection}
isOnboarded={isOnboarded}
/>
)} )}
</> </>
</div> </div>
{isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && ( {isOAuthEnabled &&
<OAuthOptions handleSignInRedirection={handleRedirection} /> (signInStep === ESignInSteps.EMAIL || (!isSmtpConfigured && signInStep === ESignInSteps.PASSWORD)) && (
)} <>
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "}
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
Sign up
</Link>
</p>
</>
)}
<LatestFeatureBlock /> <LatestFeatureBlock />
</> </>
); );

View File

@ -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>
</>
);
};

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { CornerDownLeft, XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
// services // services
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
import { UserService } from "services/user.service"; import { UserService } from "services/user.service";
@ -14,18 +13,12 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData, IMagicSignInData } from "@plane/types"; import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { ESignInSteps } from "components/account";
type Props = { type Props = {
email: string; email: string;
updateEmail: (email: string) => void; onSubmit: (isPasswordAutoset: boolean) => Promise<void>;
handleStepChange: (step: ESignInSteps) => void;
handleSignInRedirection: () => Promise<void>;
submitButtonLabel?: string;
showTermsAndConditions?: boolean;
updateUserOnboardingStatus: (value: boolean) => void;
handleEmailClear: () => void; handleEmailClear: () => void;
submitButtonText: string;
}; };
type TUniqueCodeFormValues = { type TUniqueCodeFormValues = {
@ -42,17 +35,8 @@ const defaultValues: TUniqueCodeFormValues = {
const authService = new AuthService(); const authService = new AuthService();
const userService = new UserService(); const userService = new UserService();
export const UniqueCodeForm: React.FC<Props> = (props) => { export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
const { const { email, onSubmit, handleEmailClear, submitButtonText } = props;
email,
updateEmail,
handleStepChange,
handleSignInRedirection,
submitButtonLabel = "Continue",
showTermsAndConditions = false,
updateUserOnboardingStatus,
handleEmailClear,
} = props;
// states // states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert // toast alert
@ -62,11 +46,10 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
// form info // form info
const { const {
control, control,
formState: { dirtyFields, errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
getValues, getValues,
handleSubmit, handleSubmit,
reset, reset,
setFocus,
} = useForm<TUniqueCodeFormValues>({ } = useForm<TUniqueCodeFormValues>({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
@ -88,10 +71,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
.then(async () => { .then(async () => {
const currentUser = await userService.currentUser(); const currentUser = await userService.currentUser();
updateUserOnboardingStatus(currentUser.is_onboarded); await onSubmit(currentUser.is_password_autoset);
if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD);
else await handleSignInRedirection();
}) })
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
@ -131,13 +111,6 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
); );
}; };
const handleFormSubmit = async (formData: TUniqueCodeFormValues) => {
updateEmail(formData.email);
if (dirtyFields.email) await handleSendNewCode(formData);
else await handleUniqueCodeSignIn(formData);
};
const handleRequestNewCode = async () => { const handleRequestNewCode = async () => {
setIsRequestingNewCode(true); setIsRequestingNewCode(true);
@ -147,21 +120,16 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
}; };
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
const hasEmailChanged = dirtyFields.email;
useEffect(() => {
setFocus("token");
}, [setFocus]);
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100"> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
Get on your flight deck
</h1>
<p className="mt-2.5 text-center text-sm text-onboarding-text-200"> <p className="mt-2.5 text-center text-sm text-onboarding-text-200">
Paste the code you got at <span className="font-semibold text-custom-primary-100">{email}</span> below. Paste the code you got at
<br />
<span className="font-semibold text-custom-primary-100">{email}</span> below.
</p> </p>
<form onSubmit={handleSubmit(handleUniqueCodeSignIn)} className="mx-auto mt-5 space-y-4 sm:w-96">
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
<div> <div>
<Controller <Controller
control={control} control={control}
@ -178,12 +146,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
type="email" type="email"
value={value} value={value}
onChange={onChange} onChange={onChange}
onBlur={() => {
if (hasEmailChanged) handleSendNewCode(getValues());
}}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
disabled disabled
/> />
@ -196,21 +161,13 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
</div> </div>
)} )}
/> />
{hasEmailChanged && (
<button
type="submit"
className="mt-1.5 flex items-center gap-1 border-none bg-transparent text-xs text-onboarding-text-300 outline-none"
>
Hit <CornerDownLeft className="h-2.5 w-2.5" /> or <span className="italic">Tab</span> to get a new code
</button>
)}
</div> </div>
<div> <div>
<Controller <Controller
control={control} control={control}
name="token" name="token"
rules={{ rules={{
required: hasEmailChanged ? false : "Code is required", required: "Code is required",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
@ -219,6 +176,7 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
hasError={Boolean(errors.token)} hasError={Boolean(errors.token)}
placeholder="gets-sets-flys" placeholder="gets-sets-flys"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/> />
)} )}
/> />
@ -241,24 +199,9 @@ export const UniqueCodeForm: React.FC<Props> = (props) => {
</button> </button>
</div> </div>
</div> </div>
<Button <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
type="submit" {submitButtonText}
variant="primary"
className="w-full"
size="xl"
disabled={!isValid || hasEmailChanged}
loading={isSubmitting}
>
{submitButtonLabel}
</Button> </Button>
{showTermsAndConditions && (
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
)}
</form> </form>
</> </>
); );

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -13,11 +13,9 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData } from "@plane/types"; import { IEmailCheckData } from "@plane/types";
// constants
import { ESignInSteps } from "components/account";
type Props = { type Props = {
handleStepChange: (step: ESignInSteps) => void; onSubmit: () => void;
updateEmail: (email: string) => void; updateEmail: (email: string) => void;
}; };
@ -27,18 +25,14 @@ type TEmailFormValues = {
const authService = new AuthService(); const authService = new AuthService();
export const EmailForm: React.FC<Props> = observer((props) => { export const SignUpEmailForm: React.FC<Props> = observer((props) => {
const { handleStepChange, updateEmail } = props; const { onSubmit, updateEmail } = props;
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const {
config: { envConfig },
} = useApplication();
const { const {
control, control,
formState: { errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
handleSubmit, handleSubmit,
setFocus,
} = useForm<TEmailFormValues>({ } = useForm<TEmailFormValues>({
defaultValues: { defaultValues: {
email: "", email: "",
@ -57,14 +51,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
await authService await authService
.emailCheck(payload) .emailCheck(payload)
.then((res) => { .then(() => onSubmit())
// if the password has been auto set, send the user to magic sign-in
if (res.is_password_autoset && envConfig?.is_smtp_configured) {
handleStepChange(ESignInSteps.UNIQUE_CODE);
}
// if the password has not been auto set, send them to password sign-in
else handleStepChange(ESignInSteps.PASSWORD);
})
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -74,10 +61,6 @@ export const EmailForm: React.FC<Props> = observer((props) => {
); );
}; };
useEffect(() => {
setFocus("email");
}, [setFocus]);
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100"> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
@ -96,7 +79,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
required: "Email is required", required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid", validate: (value) => checkEmailValidity(value) || "Email is invalid",
}} }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange } }) => (
<div className="relative flex items-center rounded-md bg-onboarding-background-200"> <div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input <Input
id="email" id="email"
@ -104,10 +87,10 @@ export const EmailForm: React.FC<Props> = observer((props) => {
type="email" type="email"
value={value} value={value}
onChange={onChange} onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/> />
{value.length > 0 && ( {value.length > 0 && (
<XCircle <XCircle
@ -120,7 +103,7 @@ export const EmailForm: React.FC<Props> = observer((props) => {
/> />
</div> </div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}> <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
Continue Verify
</Button> </Button>
</form> </form>
</> </>

View File

@ -0,0 +1,5 @@
export * from "./email";
export * from "./optional-set-password";
export * from "./password";
export * from "./root";
export * from "./unique-code";

View File

@ -1,5 +1,4 @@
import React, { useEffect } from "react"; import React, { useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services // services
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
@ -10,13 +9,12 @@ import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// constants // constants
import { ESignInSteps } from "components/account"; import { ESignUpSteps } from "components/account";
type Props = { type Props = {
email: string; email: string;
handleStepChange: (step: ESignInSteps) => void; handleStepChange: (step: ESignUpSteps) => void;
handleSignInRedirection: () => Promise<void>; handleSignInRedirection: () => Promise<void>;
isOnboarded: boolean;
}; };
type TCreatePasswordFormValues = { type TCreatePasswordFormValues = {
@ -32,8 +30,10 @@ const defaultValues: TCreatePasswordFormValues = {
// services // services
const authService = new AuthService(); const authService = new AuthService();
export const CreatePasswordForm: React.FC<Props> = (props) => { export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection, isOnboarded } = props; const { email, handleSignInRedirection } = props;
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -41,7 +41,6 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
control, control,
formState: { errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
handleSubmit, handleSubmit,
setFocus,
} = useForm<TCreatePasswordFormValues>({ } = useForm<TCreatePasswordFormValues>({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
@ -75,16 +74,21 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
); );
}; };
useEffect(() => { const handleGoToWorkspace = async () => {
setFocus("password"); setIsGoingToWorkspace(true);
}, [setFocus]);
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
};
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100"> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
Get on your flight deck <p className="mt-2.5 text-center text-sm text-onboarding-text-200">
</h1> Let{"'"}s set a password so
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-11 space-y-4 sm:w-96"> <br />
you can do away with codes.
</p>
<form onSubmit={handleSubmit(handleCreatePassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
<Controller <Controller
control={control} control={control}
name="email" name="email"
@ -101,40 +105,58 @@ export const CreatePasswordForm: React.FC<Props> = (props) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />
)} )}
/> />
<Controller <div>
control={control} <Controller
name="password" control={control}
rules={{ name="password"
required: "Password is required", rules={{
}} required: "Password is required",
render={({ field: { value, onChange, ref } }) => ( }}
<Input render={({ field: { value, onChange } }) => (
type="password" <Input
value={value} type="password"
onChange={onChange} value={value}
ref={ref} onChange={onChange}
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Choose password" placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8} minLength={8}
/> autoFocus
)} />
/> )}
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}> />
{isOnboarded ? "Go to workspace" : "Set up workspace"} <p className="text-onboarding-text-200 text-xs mt-2 pb-3">
</Button> This password will continue to be your account{"'"}s password.
<p className="text-xs text-onboarding-text-200"> </p>
When you click the button above, you agree with our{" "} </div>
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer"> <div className="space-y-2.5">
<span className="font-semibold underline">terms and conditions of service.</span> <Button
</Link> type="submit"
</p> variant="primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSubmitting}
>
Set password
</Button>
<Button
type="button"
variant="outline-primary"
className="w-full"
size="xl"
onClick={handleGoToWorkspace}
loading={isGoingToWorkspace}
>
Skip to setup
</Button>
</div>
</form> </form>
</> </>
); );

View File

@ -1,5 +1,6 @@
import React, { useEffect } from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
// services // services
@ -14,9 +15,7 @@ import { checkEmailValidity } from "helpers/string.helper";
import { IPasswordSignInData } from "@plane/types"; import { IPasswordSignInData } from "@plane/types";
type Props = { type Props = {
email: string; onSubmit: () => Promise<void>;
updateEmail: (email: string) => void;
handleSignInRedirection: () => Promise<void>;
}; };
type TPasswordFormValues = { type TPasswordFormValues = {
@ -31,20 +30,18 @@ const defaultValues: TPasswordFormValues = {
const authService = new AuthService(); const authService = new AuthService();
export const SelfHostedSignInForm: React.FC<Props> = (props) => { export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
const { email, updateEmail, handleSignInRedirection } = props; const { onSubmit } = props;
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
const { const {
control, control,
formState: { dirtyFields, errors, isSubmitting }, formState: { errors, isSubmitting, isValid },
handleSubmit, handleSubmit,
setFocus,
} = useForm<TPasswordFormValues>({ } = useForm<TPasswordFormValues>({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
email,
}, },
mode: "onChange", mode: "onChange",
reValidateMode: "onChange", reValidateMode: "onChange",
@ -56,11 +53,9 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
password: formData.password, password: formData.password,
}; };
updateEmail(formData.email);
await authService await authService
.passwordSignIn(payload) .passwordSignIn(payload)
.then(async () => await handleSignInRedirection()) .then(async () => await onSubmit())
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -70,16 +65,15 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
); );
}; };
useEffect(() => {
setFocus("email");
}, [setFocus]);
return ( return (
<> <>
<h1 className="sm:text-2.5xl text-center text-2xl font-semibold text-onboarding-text-100"> <h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
Get on your flight deck Get on your flight deck
</h1> </h1>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-11 space-y-4 sm:w-96"> <p className="mt-2.5 text-center text-sm text-onboarding-text-200">
Create or join a workspace. Start with your e-mail.
</p>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-5 space-y-4 sm:w-96">
<div> <div>
<Controller <Controller
control={control} control={control}
@ -97,7 +91,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (
@ -115,7 +109,7 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
control={control} control={control}
name="password" name="password"
rules={{ rules={{
required: dirtyFields.email ? false : "Password is required", required: "Password is required",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
@ -125,12 +119,16 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
hasError={Boolean(errors.password)} hasError={Boolean(errors.password)}
placeholder="Enter password" placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/> />
)} )}
/> />
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
This password will continue to be your account{"'"}s password.
</p>
</div> </div>
<Button type="submit" variant="primary" className="w-full" size="xl" loading={isSubmitting}> <Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
Continue Create account
</Button> </Button>
<p className="text-xs text-onboarding-text-200"> <p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "} When you click the button above, you agree with our{" "}
@ -141,4 +139,4 @@ export const SelfHostedSignInForm: React.FC<Props> = (props) => {
</form> </form>
</> </>
); );
}; });

View 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>
</>
)}
</>
);
});

View 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>
</>
);
};

View File

@ -1,8 +1,10 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Command } from "cmdk"; import { Command } from "cmdk";
// icons // hooks
import { SettingIcon } from "components/icons"; import { useUser } from "hooks/store";
import Link from "next/link"; import Link from "next/link";
// constants
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace";
type Props = { type Props = {
closePalette: () => void; closePalette: () => void;
@ -10,60 +12,35 @@ type Props = {
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => { export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props; const { closePalette } = props;
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// mobx store
const {
membership: { currentWorkspaceRole },
} = useUser();
// derived values
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
return ( return (
<> <>
<Command.Item onSelect={closePalette} className="focus:outline-none"> {WORKSPACE_SETTINGS_LINKS.map(
<Link href={`/${workspaceSlug}/settings`}> (setting) =>
<div className="flex items-center gap-2 text-custom-text-200"> workspaceMemberInfo >= setting.access && (
<SettingIcon className="h-4 w-4 text-custom-text-200" /> <Command.Item
General key={setting.key}
</div> onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
</Link> className="focus:outline-none"
</Command.Item> >
<Command.Item onSelect={closePalette} className="focus:outline-none"> <Link href={`/${workspaceSlug}${setting.href}`}>
<Link href={`/${workspaceSlug}/settings/members`}> <div className="flex items-center gap-2 text-custom-text-200">
<div className="flex items-center gap-2 text-custom-text-200"> <setting.Icon className="h-4 w-4 text-custom-text-200" />
<SettingIcon className="h-4 w-4 text-custom-text-200" /> {setting.label}
Members </div>
</div> </Link>
</Link> </Command.Item>
</Command.Item> )
<Command.Item onSelect={closePalette} className="focus:outline-none"> )}
<Link href={`/${workspaceSlug}/settings/billing`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/integrations`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/imports`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/exports`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Link>
</Command.Item>
</> </>
); );
}; };

View File

@ -1,23 +1,17 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { FileText, Plus } from "lucide-react"; import { FileText, Plus } from "lucide-react";
// hooks // hooks
import { useApplication, useProject } from "hooks/store"; import { useApplication, usePage, useProject } from "hooks/store";
// services
import { PageService } from "services/page.service";
// ui // ui
import { Breadcrumbs, Button } from "@plane/ui"; import { Breadcrumbs, Button } from "@plane/ui";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
// fetch-keys
import { PAGE_DETAILS } from "constants/fetch-keys";
export interface IPagesHeaderProps { export interface IPagesHeaderProps {
showButton?: boolean; showButton?: boolean;
} }
const pageService = new PageService();
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => { export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { showButton = false } = props; const { showButton = false } = props;
@ -28,12 +22,7 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { data: pageDetails } = useSWR( const pageDetails = usePage(pageId as string);
workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && currentProjectDetails?.id
? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string)
: null
);
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">

View File

@ -88,7 +88,7 @@ export const InstanceSetupSignInForm: FC<IInstanceSetupEmailForm> = (props) => {
type="email" type="email"
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
/> />
{value.length > 0 && ( {value.length > 0 && (

View File

@ -5,19 +5,17 @@ import useSWR from "swr";
// hooks // hooks
import { useGlobalView, useIssues, useUser } from "hooks/store"; import { useGlobalView, useIssues, useUser } from "hooks/store";
// components // components
import { GlobalViewsAppliedFiltersRoot } from "components/issues"; import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues";
import { SpreadsheetView } from "components/issues/issue-layouts"; import { SpreadsheetView } from "components/issues/issue-layouts";
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns"; import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
// ui // ui
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// types // types
import { TIssue, IIssueDisplayFilterOptions, TStaticViewTypes, TUnGroupedIssues } from "@plane/types"; import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
export const AllIssueLayoutRoot: React.FC = observer(() => { export const AllIssueLayoutRoot: React.FC = observer(() => {
// router // router
const router = useRouter(); const router = useRouter();
@ -48,11 +46,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
if (workspaceSlug && globalViewId) { if (workspaceSlug && globalViewId) {
await fetchAllGlobalViews(workspaceSlug.toString()); await fetchAllGlobalViews(workspaceSlug.toString());
await fetchFilters(workspaceSlug.toString(), globalViewId.toString()); await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
await fetchIssues( await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
workspaceSlug.toString(),
globalViewId.toString(),
groupedIssueIds ? "mutation" : "init-loader"
);
} }
} }
); );
@ -138,6 +132,9 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
)} )}
</> </>
)} )}
{/* peek overview */}
<IssuePeekOverview />
</div> </div>
); );
}); });

View File

@ -35,12 +35,9 @@ export type TIssuePeekOperations = {
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => { export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const { is_archived = false, onIssueUpdate } = props; const { is_archived = false, onIssueUpdate } = props;
// hooks // hooks
const {
project: {},
} = useMember();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
membership: { currentProjectRole }, membership: { currentWorkspaceAllProjectsRole },
} = useUser(); } = useUser();
const { const {
issues: { removeIssue: removeArchivedIssue }, issues: { removeIssue: removeArchivedIssue },
@ -198,6 +195,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const issue = getIssueById(peekIssue.issueId) || undefined; const issue = getIssueById(peekIssue.issueId) || undefined;
const currentProjectRole = currentWorkspaceAllProjectsRole?.[peekIssue?.projectId];
// Check if issue is editable, based on user role // Check if issue is editable, based on user role
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const isLoading = !issue || loader ? true : false; const isLoading = !issue || loader ? true : false;

View File

@ -7,7 +7,7 @@ import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { SignInRoot } from "components/account"; import { SignInRoot } from "components/account";
// ui // ui
import { Loader, Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
@ -26,7 +26,7 @@ export const SignInView = observer(() => {
handleRedirection(); handleRedirection();
}, [handleRedirection]); }, [handleRedirection]);
if (isRedirecting || currentUser) if (isRedirecting || currentUser || !envConfig)
return ( return (
<div className="grid h-screen place-items-center"> <div className="grid h-screen place-items-center">
<Spinner /> <Spinner />
@ -35,32 +35,16 @@ export const SignInView = observer(() => {
return ( return (
<div className="h-full w-full bg-onboarding-gradient-100"> <div className="h-full w-full bg-onboarding-gradient-100">
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 "> <div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
<div className="flex items-center gap-x-2 py-10"> <div className="flex items-center gap-x-2 py-10">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" /> <Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<span className="text-2xl font-semibold sm:text-3xl">Plane</span> <span className="text-2xl font-semibold sm:text-3xl">Plane</span>
</div> </div>
</div> </div>
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 "> <div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0"> <div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
{!envConfig ? ( <SignInRoot />
<div className="mx-auto flex justify-center pt-10">
<div>
<Loader className="mx-auto w-full space-y-4 pb-4">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
<Loader className="mx-auto w-full space-y-4 pt-4">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
</div>
</div>
) : (
<SignInRoot />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,132 +2,56 @@ import React, { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// hooks // hooks
import { useApplication, usePage, useWorkspace } from "hooks/store"; import { useApplication } from "hooks/store";
import useToast from "hooks/use-toast";
// components // components
import { PageForm } from "./page-form"; import { PageForm } from "./page-form";
// types // types
import { IPage } from "@plane/types"; import { IPage } from "@plane/types";
import { useProjectPages } from "hooks/store/use-project-page";
import { IPageStore } from "store/page.store";
type Props = { type Props = {
data?: IPage | null; // data?: IPage | null;
pageStore?: IPageStore;
handleClose: () => void; handleClose: () => void;
isOpen: boolean; isOpen: boolean;
projectId: string; projectId: string;
}; };
export const CreateUpdatePageModal: FC<Props> = (props) => { export const CreateUpdatePageModal: FC<Props> = (props) => {
const { isOpen, handleClose, data, projectId } = props; const { isOpen, handleClose, projectId, pageStore } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { createPage } = useProjectPages();
// store hooks // store hooks
const { const {
eventTracker: { postHogEventTracker }, eventTracker: { postHogEventTracker },
} = useApplication(); } = useApplication();
const { currentWorkspace } = useWorkspace();
const { createPage, updatePage } = usePage();
// toast alert
const { setToastAlert } = useToast();
const onClose = () => {
handleClose();
};
const createProjectPage = async (payload: IPage) => { const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
await createPage(workspaceSlug.toString(), projectId, payload);
// await createPage(workspaceSlug.toString(), projectId, payload)
// .then((res) => {
// router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
// onClose();
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Page created successfully.",
// });
// postHogEventTracker(
// "PAGE_CREATED",
// {
// ...res,
// state: "SUCCESS",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// })
// .catch((err) => {
// setToastAlert({
// type: "error",
// title: "Error!",
// message: err.detail ?? "Page could not be created. Please try again.",
// });
// postHogEventTracker(
// "PAGE_CREATED",
// {
// state: "FAILED",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// });
};
const updateProjectPage = async (payload: IPage) => {
if (!data || !workspaceSlug) return;
// await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
// .then((res) => {
// onClose();
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Page updated successfully.",
// });
// postHogEventTracker(
// "PAGE_UPDATED",
// {
// ...res,
// state: "SUCCESS",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// })
// .catch((err) => {
// setToastAlert({
// type: "error",
// title: "Error!",
// message: err.detail ?? "Page could not be updated. Please try again.",
// });
// postHogEventTracker(
// "PAGE_UPDATED",
// {
// state: "FAILED",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// });
}; };
const handleFormSubmit = async (formData: IPage) => { const handleFormSubmit = async (formData: IPage) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
try {
if (!data) await createProjectPage(formData); if (pageStore) {
else await updateProjectPage(formData); if (pageStore.name !== formData.name) {
await pageStore.updateName(formData.name);
}
if (pageStore.access !== formData.access) {
formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic();
}
} else {
await createProjectPage(formData);
}
handleClose();
} catch (error) {
console.log(error);
}
}; };
return ( return (
@ -157,7 +81,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl"> <Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} /> <PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} pageStore={pageStore} />
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -9,37 +9,45 @@ import useToast from "hooks/use-toast";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// types // types
import type { IPage } from "@plane/types"; import { useProjectPages } from "hooks/store/use-project-page";
type TConfirmPageDeletionProps = { type TConfirmPageDeletionProps = {
data?: IPage | null; pageId: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}; };
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => { export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { data, isOpen, onClose } = props; const { pageId, isOpen, onClose } = props;
// states // states
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { deletePage } = usePage(); const { deletePage } = useProjectPages();
const pageStore = usePage(pageId);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
if (!pageStore) return null;
const { name } = pageStore;
const handleClose = () => { const handleClose = () => {
setIsDeleting(false); setIsDeleting(false);
onClose(); onClose();
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!data || !workspaceSlug || !projectId) return; if (!pageId || !workspaceSlug || !projectId) return;
setIsDeleting(true); setIsDeleting(true);
await deletePage(workspaceSlug.toString(), data.project, data.id) // Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted
await deletePage(workspaceSlug.toString(), projectId as string, pageId)
.then(() => { .then(() => {
handleClose(); handleClose();
setToastAlert({ setToastAlert({
@ -99,8 +107,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200">
Are you sure you want to delete page-{" "} Are you sure you want to delete page-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page <span className="break-words font-medium text-custom-text-100">{name}</span>? The Page will be
will be deleted permanently. This action cannot be undone. deleted permanently. This action cannot be undone.
</p> </p>
</div> </div>
</div> </div>

View File

@ -5,11 +5,12 @@ import { Button, Input, Tooltip } from "@plane/ui";
import { IPage } from "@plane/types"; import { IPage } from "@plane/types";
// constants // constants
import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
import { IPageStore } from "store/page.store";
type Props = { type Props = {
handleFormSubmit: (values: IPage) => Promise<void>; handleFormSubmit: (values: IPage) => Promise<void>;
handleClose: () => void; handleClose: () => void;
data?: IPage | null; pageStore?: IPageStore;
}; };
const defaultValues = { const defaultValues = {
@ -19,24 +20,24 @@ const defaultValues = {
}; };
export const PageForm: React.FC<Props> = (props) => { export const PageForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, data } = props; const { handleFormSubmit, handleClose, pageStore } = props;
const { const {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
control, control,
} = useForm<IPage>({ } = useForm<IPage>({
defaultValues: { ...defaultValues, ...data }, defaultValues: pageStore
? { name: pageStore.name, description: pageStore.description, access: pageStore.access }
: defaultValues,
}); });
const handleCreateUpdatePage = async (formData: IPage) => { const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData);
await handleFormSubmit(formData);
};
return ( return (
<form onSubmit={handleSubmit(handleCreateUpdatePage)}> <form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3> <h3 className="text-lg font-medium leading-6 text-custom-text-100">{pageStore ? "Update" : "Create"} Page</h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Controller <Controller
@ -104,7 +105,7 @@ export const PageForm: React.FC<Props> = (props) => {
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"} {pageStore ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,17 +1,17 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { usePage } from "hooks/store";
// components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const AllPagesList: FC = observer(() => { export const AllPagesList: FC = observer(() => {
// store const pageStores = useProjectPages();
const { projectPageIds } = usePage(); // subscribing to the projectPageStore
const { projectPageIds } = pageStores;
if (!projectPageIds) if (!projectPageIds) {
return ( return (
<Loader className="space-y-4"> <Loader className="space-y-4">
<Loader.Item height="40px" /> <Loader.Item height="40px" />
@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => {
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
); );
}
return <PagesListView pageIds={projectPageIds} />; return <PagesListView pageIds={projectPageIds} />;
}); });

View File

@ -3,14 +3,15 @@ import { observer } from "mobx-react-lite";
// components // components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
// hooks // hooks
import { usePage } from "hooks/store";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const ArchivedPagesList: FC = observer(() => { export const ArchivedPagesList: FC = observer(() => {
const { archivedProjectPageIds } = usePage(); const projectPageStore = useProjectPages();
const { archivedPageIds } = projectPageStore;
if (!archivedProjectPageIds) if (!archivedPageIds)
return ( return (
<Loader className="space-y-4"> <Loader className="space-y-4">
<Loader.Item height="40px" /> <Loader.Item height="40px" />
@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => {
</Loader> </Loader>
); );
return <PagesListView pageIds={archivedProjectPageIds} />; return <PagesListView pageIds={archivedPageIds} />;
}); });

View File

@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
// components // components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
// hooks // hooks
import { usePage } from "hooks/store";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const FavoritePagesList: FC = observer(() => { export const FavoritePagesList: FC = observer(() => {
const { favoriteProjectPageIds } = usePage(); const projectPageStore = useProjectPages();
const { favoriteProjectPageIds } = projectPageStore;
if (!favoriteProjectPageIds) if (!favoriteProjectPageIds)
return ( return (

View File

@ -13,10 +13,6 @@ import {
Star, Star,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
// hooks
import { useMember, usePage, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
// ui // ui
@ -25,142 +21,120 @@ import { CustomMenu, Tooltip } from "@plane/ui";
import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { useRouter } from "next/router";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
import { useMember, usePage, useUser } from "hooks/store";
import { IIssueLabel } from "@plane/types";
export interface IPagesListItem { export interface IPagesListItem {
workspaceSlug: string;
projectId: string;
pageId: string; pageId: string;
projectId: string;
} }
export const PagesListItem: FC<IPagesListItem> = observer((props) => { export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId }: IPagesListItem) => {
const { workspaceSlug, projectId, pageId } = props; const projectPageStore = useProjectPages();
// Now, I am observing only the projectPages, out of the projectPageStore.
const { archivePage, restorePage } = projectPageStore;
const pageStore = usePage(pageId);
// states // states
const router = useRouter();
const { workspaceSlug } = router.query;
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [deletePageModal, setDeletePageModal] = useState(false); const [deletePageModal, setDeletePageModal] = useState(false);
// store hooks
const { const {
currentUser, currentUser,
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const {
getArchivedPageById,
getUnArchivedPageById,
archivePage,
removeFromFavorites,
addToFavorites,
makePrivate,
makePublic,
restorePage,
} = usePage();
const { const {
project: { getProjectMemberDetails }, project: { getProjectMemberDetails },
} = useMember(); } = useMember();
// toast alert
const { setToastAlert } = useToast();
// derived values
const pageDetails = getUnArchivedPageById(pageId) ?? getArchivedPageById(pageId);
const handleCopyUrl = (e: any) => { if (!pageStore) return null;
const {
archived_at,
label_details,
access,
is_favorite,
owned_by,
name,
created_at,
updated_at,
makePublic,
makePrivate,
addToFavorites,
removeFromFavorites,
} = pageStore;
const handleCopyUrl = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => { await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`);
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
}; };
const handleAddToFavorites = (e: any) => { const handleAddToFavorites = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
addToFavorites();
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
addToFavorites(workspaceSlug, projectId, pageId) removeFromFavorites();
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
}; };
const handleRemoveFromFavorites = (e: any) => { const handleMakePublic = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
removeFromFavorites(workspaceSlug, projectId, pageId) makePublic();
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
}; };
const handleMakePublic = (e: any) => { const handleMakePrivate = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
makePublic(workspaceSlug, projectId, pageId); makePrivate();
}; };
const handleMakePrivate = (e: any) => { const handleArchivePage = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
makePrivate(workspaceSlug, projectId, pageId); await archivePage(workspaceSlug as string, projectId as string, pageId as string);
}; };
const handleArchivePage = (e: any) => { const handleRestorePage = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
archivePage(workspaceSlug, projectId, pageId); await restorePage(workspaceSlug as string, projectId as string, pageId as string);
}; };
const handleRestorePage = (e: any) => { const handleDeletePage = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
restorePage(workspaceSlug, projectId, pageId);
};
const handleDeletePage = (e: any) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDeletePageModal(true); setDeletePageModal(true);
}; };
const handleEditPage = (e: any) => { const handleEditPage = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setCreateUpdatePageModal(true); setCreateUpdatePageModal(true);
}; };
if (!pageDetails) return null; const ownerDetails = getProjectMemberDetails(owned_by);
const isCurrentUserOwner = owned_by === currentUser?.id;
const ownerDetails = getProjectMemberDetails(pageDetails.owned_by);
const isCurrentUserOwner = pageDetails.owned_by === currentUser?.id;
const userCanEdit = const userCanEdit =
isCurrentUserOwner || isCurrentUserOwner ||
@ -173,22 +147,21 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
return ( return (
<> <>
<CreateUpdatePageModal <CreateUpdatePageModal
pageStore={pageStore}
isOpen={createUpdatePageModal} isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)} handleClose={() => setCreateUpdatePageModal(false)}
data={pageDetails}
projectId={projectId} projectId={projectId}
/> />
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={pageDetails} /> <DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={pageId} />
<li> <li>
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}>
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80"> <div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<FileText className="h-4 w-4 shrink-0" /> <FileText className="h-4 w-4 shrink-0" />
<p className="mr-2 truncate text-sm text-custom-text-100">{pageDetails.name}</p> <p className="mr-2 truncate text-sm text-custom-text-100">{name}</p>
{/* FIXME: replace any with proper type */} {label_details.length > 0 &&
{pageDetails.label_details.length > 0 && label_details.map((label: IIssueLabel) => (
pageDetails.label_details.map((label: any) => (
<div <div
key={label.id} key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs" className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
@ -207,26 +180,26 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
))} ))}
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{pageDetails.archived_at ? ( {archived_at ? (
<Tooltip <Tooltip
tooltipContent={`Archived at ${renderFormattedTime( tooltipContent={`Archived at ${renderFormattedTime(archived_at)} on ${renderFormattedDate(
pageDetails.archived_at archived_at
)} on ${renderFormattedDate(pageDetails.archived_at)}`} )}`}
> >
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.archived_at)}</p> <p className="text-sm text-custom-text-200">{renderFormattedTime(archived_at)}</p>
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip <Tooltip
tooltipContent={`Last updated at ${renderFormattedTime( tooltipContent={`Last updated at ${renderFormattedTime(updated_at)} on ${renderFormattedDate(
pageDetails.updated_at updated_at
)} on ${renderFormattedDate(pageDetails.updated_at)}`} )}`}
> >
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.updated_at)}</p> <p className="text-sm text-custom-text-200">{renderFormattedTime(updated_at)}</p>
</Tooltip> </Tooltip>
)} )}
{isEditingAllowed && ( {isEditingAllowed && (
<Tooltip tooltipContent={`${pageDetails.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}> <Tooltip tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
{pageDetails.is_favorite ? ( {is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}> <button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" /> <Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" />
</button> </button>
@ -240,12 +213,10 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
{userCanChangeAccess && ( {userCanChangeAccess && (
<Tooltip <Tooltip
tooltipContent={`${ tooltipContent={`${
pageDetails.access access ? "This page is only visible to you" : "This page can be viewed by anyone in the project"
? "This page is only visible to you"
: "This page can be viewed by anyone in the project"
}`} }`}
> >
{pageDetails.access ? ( {access ? (
<button type="button" onClick={handleMakePublic}> <button type="button" onClick={handleMakePublic}>
<Lock className="h-3.5 w-3.5" /> <Lock className="h-3.5 w-3.5" />
</button> </button>
@ -259,13 +230,13 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
<Tooltip <Tooltip
position="top-right" position="top-right"
tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate( tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate(
pageDetails.created_at created_at
)}`} )}`}
> >
<AlertCircle className="h-3.5 w-3.5" /> <AlertCircle className="h-3.5 w-3.5" />
</Tooltip> </Tooltip>
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis> <CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
{pageDetails.archived_at ? ( {archived_at ? (
<> <>
{userCanArchive && ( {userCanArchive && (
<CustomMenu.MenuItem onClick={handleRestorePage}> <CustomMenu.MenuItem onClick={handleRestorePage}>

View File

@ -1,11 +1,9 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
// hooks // hooks
import { useApplication, useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
// components // components
import { PagesListItem } from "./list-item";
import { NewEmptyState } from "components/common/new-empty-state"; import { NewEmptyState } from "components/common/new-empty-state";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
@ -13,14 +11,17 @@ import { Loader } from "@plane/ui";
import emptyPage from "public/empty-state/empty_page.png"; import emptyPage from "public/empty-state/empty_page.png";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { PagesListItem } from "./list-item";
type IPagesListView = { type IPagesListView = {
pageIds: string[]; pageIds: string[];
}; };
export const PagesListView: FC<IPagesListView> = observer((props) => { export const PagesListView: FC<IPagesListView> = (props) => {
const { pageIds } = props; const { pageIds: projectPageIds } = props;
// store hooks // store hooks
// trace(true);
const { const {
commandPalette: { toggleCreatePageModal }, commandPalette: { toggleCreatePageModal },
} = useApplication(); } = useApplication();
@ -31,21 +32,18 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return ( return (
<> <>
{pageIds && workspaceSlug && projectId ? ( {projectPageIds && workspaceSlug && projectId ? (
<div className="h-full space-y-4 overflow-y-auto"> <div className="h-full space-y-4 overflow-y-auto">
{pageIds.length > 0 ? ( {projectPageIds.length > 0 ? (
<ul role="list" className="divide-y divide-custom-border-200"> <ul role="list" className="divide-y divide-custom-border-200">
{pageIds.map((pageId) => ( {projectPageIds.map((pageId: string) => (
<PagesListItem <PagesListItem key={pageId} pageId={pageId} projectId={projectId.toString()} />
key={pageId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
pageId={pageId}
/>
))} ))}
</ul> </ul>
) : ( ) : (
@ -77,4 +75,4 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
)} )}
</> </>
); );
}); };

View File

@ -1,14 +1,15 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { usePage } from "hooks/store";
// components // components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const PrivatePagesList: FC = observer(() => { export const PrivatePagesList: FC = observer(() => {
const { privateProjectPageIds } = usePage(); const projectPageStore = useProjectPages();
const { privateProjectPageIds } = projectPageStore;
if (!privateProjectPageIds) if (!privateProjectPageIds)
return ( return (

View File

@ -2,7 +2,7 @@ import React, { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
// hooks // hooks
import { useApplication, usePage, useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
// components // components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
import { NewEmptyState } from "components/common/new-empty-state"; import { NewEmptyState } from "components/common/new-empty-state";
@ -14,6 +14,7 @@ import emptyPage from "public/empty-state/empty_page.png";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const RecentPagesList: FC = observer(() => { export const RecentPagesList: FC = observer(() => {
// store hooks // store hooks
@ -21,7 +22,7 @@ export const RecentPagesList: FC = observer(() => {
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { recentProjectPages } = usePage(); const { recentProjectPages } = useProjectPages();
// FIXME: replace any with proper type // FIXME: replace any with proper type
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0); const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);

View File

@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
// components // components
import { PagesListView } from "components/pages/pages-list"; import { PagesListView } from "components/pages/pages-list";
// hooks // hooks
import { usePage } from "hooks/store";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const SharedPagesList: FC = observer(() => { export const SharedPagesList: FC = observer(() => {
const { publicProjectPageIds } = usePage(); const projectPageStore = useProjectPages();
const { publicProjectPageIds } = projectPageStore;
if (!publicProjectPageIds) if (!publicProjectPageIds)
return ( return (

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
// components // components
import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root"; import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root";
import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root"; import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root";
import { ProfileIssuesAppliedFiltersRoot } from "components/issues"; import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// hooks // hooks
import { useIssues } from "hooks/store"; import { useIssues } from "hooks/store";
@ -34,7 +34,7 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
async () => { async () => {
if (workspaceSlug && userId) { if (workspaceSlug && userId) {
await fetchFilters(workspaceSlug, userId); await fetchFilters(workspaceSlug, userId);
await fetchIssues(workspaceSlug, userId, groupedIssueIds ? "mutation" : "init-loader", undefined, type); await fetchIssues(workspaceSlug, undefined, groupedIssueIds ? "mutation" : "init-loader", userId, type);
} }
} }
); );
@ -57,6 +57,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
<ProfileIssuesKanBanLayout /> <ProfileIssuesKanBanLayout />
) : null} ) : null}
</div> </div>
{/* peek overview */}
<IssuePeekOverview />
</> </>
)} )}
</> </>

View File

@ -2,7 +2,6 @@ import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react";
// hooks // hooks
import { useApplication, useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
// components // components
@ -11,34 +10,7 @@ import { NotificationPopover } from "components/notifications";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// constants // constants
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { SIDEBAR_MENU_ITEMS } from "constants/dashboard";
const workspaceLinks = (workspaceSlug: string) => [
{
Icon: LayoutGrid,
name: "Dashboard",
href: `/${workspaceSlug}`,
},
{
Icon: BarChart2,
name: "Analytics",
href: `/${workspaceSlug}/analytics`,
},
{
Icon: Briefcase,
name: "Projects",
href: `/${workspaceSlug}/projects`,
},
{
Icon: CheckCircle,
name: "All Issues",
href: `/${workspaceSlug}/workspace-views/all-issues`,
},
{
Icon: SendToBack,
name: "Active cycles",
href: `/${workspaceSlug}/active-cycles`,
},
];
export const WorkspaceSidebarMenu = observer(() => { export const WorkspaceSidebarMenu = observer(() => {
// store hooks // store hooks
@ -50,48 +22,36 @@ export const WorkspaceSidebarMenu = observer(() => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// computed // computed
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
return ( return (
<div className="w-full cursor-pointer space-y-1 p-4"> <div className="w-full cursor-pointer space-y-2 p-4">
{workspaceLinks(workspaceSlug as string).map((link, index) => { {SIDEBAR_MENU_ITEMS.map(
const isActive = link.name === "Settings" ? router.asPath.includes(link.href) : router.asPath === link.href; (link) =>
if (!isAuthorizedUser && link.name === "Analytics") return; workspaceMemberInfo >= link.access && (
return ( <Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
<Link key={index} href={link.href}> <span className="block w-full my-1">
<span className="block w-full"> <Tooltip
<Tooltip tooltipContent={link.label}
tooltipContent={link.name} position="right"
position="right" className="ml-2"
className="ml-2" disabled={!themeStore?.sidebarCollapsed}
disabled={!themeStore?.sidebarCollapsed}
>
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
> >
{<link.Icon className="h-4 w-4" />} <div
{!themeStore?.sidebarCollapsed && link.name} className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
{link.name === "Active Cycles" && ( link.highlight(router.asPath, `/${workspaceSlug}`)
<span ? "bg-custom-primary-100/10 text-custom-primary-100"
className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
style={{ } ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
color: "#F59E0B", >
backgroundColor: "#F59E0B20", {<link.Icon className="h-4 w-4" />}
}} {!themeStore?.sidebarCollapsed && link.label}
> </div>
Beta </Tooltip>
</span> </span>
)} </Link>
</div> )
</Tooltip> )}
</span>
</Link>
);
})}
<NotificationPopover /> <NotificationPopover />
</div> </div>
); );

View File

@ -1,7 +1 @@
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export const isNil = (value: any) => {
if (value === undefined || value === null) return true;
return false;
};

View File

@ -14,6 +14,11 @@ import CompletedCreatedIssuesDark from "public/empty-state/dashboard/dark/comple
import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg"; import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg";
// types // types
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types"; import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
import { Props } from "components/icons/types";
// constants
import { EUserWorkspaceRoles } from "./workspace";
// icons
import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react";
// gradients for issues by priority widget graph bars // gradients for issues by priority widget graph bars
export const PRIORITY_GRAPH_GRADIENTS = [ export const PRIORITY_GRAPH_GRADIENTS = [
@ -246,3 +251,45 @@ export const CREATED_ISSUES_EMPTY_STATES = {
lightImage: CompletedCreatedIssuesLight, lightImage: CompletedCreatedIssuesLight,
}, },
}; };
export const SIDEBAR_MENU_ITEMS: {
key: string;
label: string;
href: string;
access: EUserWorkspaceRoles;
highlight: (pathname: string, baseUrl: string) => boolean;
Icon: React.FC<Props>;
}[] = [
{
key: "dashboard",
label: "Dashboard",
href: ``,
access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}`,
Icon: LayoutGrid,
},
{
key: "analytics",
label: "Analytics",
href: `/analytics`,
access: EUserWorkspaceRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/analytics`,
Icon: BarChart2,
},
{
key: "projects",
label: "Projects",
href: `/projects`,
access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/projects`,
Icon: Briefcase,
},
{
key: "all-issues",
label: "All Issues",
href: `/workspace-views/all-issues`,
access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/workspace-views/all-issues`,
Icon: CheckCircle,
},
];

40
web/constants/profile.ts Normal file
View 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,
},
];

View File

@ -1,4 +1,8 @@
// icons
import { Globe2, Lock, LucideIcon } from "lucide-react"; import { Globe2, Lock, LucideIcon } from "lucide-react";
import { SettingIcon } from "components/icons";
// types
import { Props } from "components/icons/types";
export enum EUserProjectRoles { export enum EUserProjectRoles {
GUEST = 5, GUEST = 5,
@ -71,3 +75,77 @@ export const PROJECT_UNSPLASH_COVERS = [
"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", "https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", "https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
]; ];
export const PROJECT_SETTINGS_LINKS: {
key: string;
label: string;
href: string;
access: EUserProjectRoles;
highlight: (pathname: string, baseUrl: string) => boolean;
Icon: React.FC<Props>;
}[] = [
{
key: "general",
label: "General",
href: `/settings`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`,
Icon: SettingIcon,
},
{
key: "members",
label: "Members",
href: `/settings/members`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`,
Icon: SettingIcon,
},
{
key: "features",
label: "Features",
href: `/settings/features`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features`,
Icon: SettingIcon,
},
{
key: "states",
label: "States",
href: `/settings/states`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states`,
Icon: SettingIcon,
},
{
key: "labels",
label: "Labels",
href: `/settings/labels`,
access: EUserProjectRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`,
Icon: SettingIcon,
},
{
key: "integrations",
label: "Integrations",
href: `/settings/integrations`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
},
{
key: "estimates",
label: "Estimates",
href: `/settings/estimates`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates`,
Icon: SettingIcon,
},
{
key: "automations",
label: "Automations",
href: `/settings/automations`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations`,
Icon: SettingIcon,
},
];

View File

@ -6,6 +6,9 @@ import ExcelLogo from "public/services/excel.svg";
import JSONLogo from "public/services/json.svg"; import JSONLogo from "public/services/json.svg";
// types // types
import { TStaticViewTypes } from "@plane/types"; import { TStaticViewTypes } from "@plane/types";
import { Props } from "components/icons/types";
// icons
import { SettingIcon } from "components/icons";
export enum EUserWorkspaceRoles { export enum EUserWorkspaceRoles {
GUEST = 5, GUEST = 5,
@ -115,48 +118,75 @@ export const RESTRICTED_URLS = [
]; ];
export const WORKSPACE_SETTINGS_LINKS: { export const WORKSPACE_SETTINGS_LINKS: {
key: string;
label: string; label: string;
href: string; href: string;
access: EUserWorkspaceRoles; access: EUserWorkspaceRoles;
highlight: (pathname: string, baseUrl: string) => boolean;
Icon: React.FC<Props>;
}[] = [ }[] = [
{ {
key: "general",
label: "General", label: "General",
href: `/settings`, href: `/settings`,
access: EUserWorkspaceRoles.GUEST, access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`,
Icon: SettingIcon,
}, },
{ {
key: "members",
label: "Members", label: "Members",
href: `/settings/members`, href: `/settings/members`,
access: EUserWorkspaceRoles.GUEST, access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`,
Icon: SettingIcon,
}, },
{ {
key: "billing-and-plans",
label: "Billing and plans", label: "Billing and plans",
href: `/settings/billing`, href: `/settings/billing`,
access: EUserWorkspaceRoles.ADMIN, access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`,
Icon: SettingIcon,
}, },
{ {
key: "integrations",
label: "Integrations", label: "Integrations",
href: `/settings/integrations`, href: `/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN, access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
}, },
{ {
key: "import",
label: "Imports", label: "Imports",
href: `/settings/imports`, href: `/settings/imports`,
access: EUserWorkspaceRoles.ADMIN, access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`,
Icon: SettingIcon,
}, },
{ {
key: "export",
label: "Exports", label: "Exports",
href: `/settings/exports`, href: `/settings/exports`,
access: EUserWorkspaceRoles.MEMBER, access: EUserWorkspaceRoles.MEMBER,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports`,
Icon: SettingIcon,
}, },
{ {
key: "webhooks",
label: "Webhooks", label: "Webhooks",
href: `/settings/webhooks`, href: `/settings/webhooks`,
access: EUserWorkspaceRoles.ADMIN, access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks`,
Icon: SettingIcon,
}, },
{ {
key: "api-tokens",
label: "API tokens", label: "API tokens",
href: `/settings/api-tokens`, href: `/settings/api-tokens`,
access: EUserWorkspaceRoles.ADMIN, access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens`,
Icon: SettingIcon,
}, },
]; ];

View File

@ -1,11 +1,21 @@
import { useContext } from "react"; import { useContext } from "react";
// mobx store // mobx store
import { StoreContext } from "contexts/store-context"; import { StoreContext } from "contexts/store-context";
// types
import { IPageStore } from "store/page.store";
export const usePage = (): IPageStore => { export const usePage = (pageId: string) => {
const context = useContext(StoreContext); const context = useContext(StoreContext);
if (context === undefined) throw new Error("usePage must be used within StoreProvider"); if (context === undefined) throw new Error("usePage must be used within StoreProvider");
return context.page;
const { projectPageMap, projectArchivedPageMap } = context.projectPages;
const { projectId, workspaceSlug } = context.app.router;
if (!projectId || !workspaceSlug) throw new Error("usePage must be used within ProjectProvider");
if (projectPageMap[projectId] && projectPageMap[projectId][pageId]) {
return projectPageMap[projectId][pageId];
} else if (projectArchivedPageMap[projectId] && projectArchivedPageMap[projectId][pageId]) {
return projectArchivedPageMap[projectId][pageId];
} else {
return;
}
}; };

View File

@ -1,8 +1,9 @@
import { useContext } from "react"; import { useContext } from "react";
// mobx store // mobx store
import { StoreContext } from "contexts/store-context"; import { StoreContext } from "contexts/store-context";
import { IProjectPageStore } from "store/project-page.store";
export const useProjectPages = () => { export const useProjectPages = (): IProjectPageStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
return context.projectPages; return context.projectPages;

View 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;
};

View 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,
};
};

View File

@ -4,39 +4,14 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Activity, ChevronLeft, CircleUser, KeyRound, LogOut, MoveLeft, Plus, Settings2, UserPlus } from "lucide-react"; import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react";
// hooks // hooks
import { useApplication, useUser, useWorkspace } from "hooks/store"; import { useApplication, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// constants
const PROFILE_ACTION_LINKS = [ import { PROFILE_ACTION_LINKS } from "constants/profile";
{
key: "profile",
label: "Profile",
href: `/profile`,
Icon: CircleUser,
},
{
key: "change-password",
label: "Change password",
href: `/profile/change-password`,
Icon: KeyRound,
},
{
key: "activity",
label: "Activity",
href: `/profile/activity`,
Icon: Activity,
},
{
key: "preferences",
label: "Preferences",
href: `/profile/preferences`,
Icon: Settings2,
},
];
const WORKSPACE_ACTION_LINKS = [ const WORKSPACE_ACTION_LINKS = [
{ {
@ -130,7 +105,7 @@ export const ProfileLayoutSidebar = observer(() => {
<Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}> <Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}>
<div <div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
router.pathname === link.href link.highlight(router.pathname)
? "bg-custom-primary-100/10 text-custom-primary-100" ? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
} ${sidebarCollapsed ? "justify-center" : ""}`} } ${sidebarCollapsed ? "justify-center" : ""}`}

View File

@ -1,66 +1,42 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// hooks
import { useUser } from "hooks/store";
// constants
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "constants/project";
export const ProjectSettingsSidebar = () => { export const ProjectSettingsSidebar = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// mobx store
const {
membership: { currentProjectRole },
} = useUser();
const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST;
const projectLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
},
{
label: "Members",
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
},
{
label: "Features",
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
},
{
label: "States",
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
},
{
label: "Labels",
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
},
{
label: "Estimates",
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
},
{
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
];
return ( return (
<div className="flex w-80 flex-col gap-6 px-5"> <div className="flex w-80 flex-col gap-6 px-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span> <span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
{projectLinks.map((link) => ( {PROJECT_SETTINGS_LINKS.map(
<Link key={link.href} href={link.href}> (link) =>
<div projectMemberInfo >= link.access && (
className={`rounded-md px-4 py-2 text-sm font-medium ${ <Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href) <div
? "bg-custom-primary-100/10 text-custom-primary-100" className={`rounded-md px-4 py-2 text-sm font-medium ${
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" link.highlight(router.asPath, `/${workspaceSlug}/projects/${projectId}`)
}`} ? "bg-custom-primary-100/10 text-custom-primary-100"
> : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
{link.label} }`}
</div> >
</Link> {link.label}
))} </div>
</Link>
)
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -25,11 +25,11 @@ export const WorkspaceSettingsSidebar = () => {
{WORKSPACE_SETTINGS_LINKS.map( {WORKSPACE_SETTINGS_LINKS.map(
(link) => (link) =>
workspaceMemberInfo >= link.access && ( workspaceMemberInfo >= link.access && (
<Link key={link.href} href={`/${workspaceSlug}${link.href}`}> <Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
<span> <span>
<div <div
className={`rounded-md px-4 py-2 text-sm font-medium ${ className={`rounded-md px-4 py-2 text-sm font-medium ${
router.pathname.split("/")?.[3] === link.href.split("/")?.[2] link.highlight(router.asPath, `/${workspaceSlug}`)
? "bg-custom-primary-100/10 text-custom-primary-100" ? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`} }`}

View File

@ -26,8 +26,8 @@
"@plane/document-editor": "*", "@plane/document-editor": "*",
"@plane/lite-text-editor": "*", "@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*", "@plane/rich-text-editor": "*",
"@plane/ui": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@sentry/nextjs": "^7.85.0", "@sentry/nextjs": "^7.85.0",
"axios": "^1.1.3", "axios": "^1.1.3",
@ -39,7 +39,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"mobx": "^6.10.0", "mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3", "mobx-react": "^9.1.0",
"next": "^14.0.3", "next": "^14.0.3",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",

View File

@ -1,47 +1,44 @@
import React, { useEffect, useRef, useState, ReactElement, useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR, { MutatorOptions } from "swr";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react"; import { Sparkle } from "lucide-react";
import debounce from "lodash/debounce"; import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useRouter } from "next/router";
import { ReactElement, useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks // hooks
import { useApplication, useUser } from "hooks/store"; import { useApplication, useIssues, usePage, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
import useReloadConfirmations from "hooks/use-reload-confirmation"; import useReloadConfirmations from "hooks/use-reload-confirmation";
import useToast from "hooks/use-toast";
// services // services
import { PageService } from "services/page.service";
import { FileService } from "services/file.service"; import { FileService } from "services/file.service";
import { IssueService } from "services/issue";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// components // components
import { GptAssistantPopover } from "components/core"; import { GptAssistantPopover } from "components/core";
import { PageDetailsHeader } from "components/headers/page-details"; import { PageDetailsHeader } from "components/headers/page-details";
import { EmptyState } from "components/common";
// ui // ui
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// assets // assets
import emptyPage from "public/empty-state/page.svg";
// helpers // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types // types
import { IPage } from "@plane/types";
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
import { IPage, TIssue } from "@plane/types";
// fetch-keys // fetch-keys
import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
import { useIssueEmbeds } from "hooks/use-issue-embeds";
import { IssuePeekOverview } from "components/issues";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { IssueService } from "services/issue";
import { EIssuesStoreType } from "constants/issue";
// services // services
const fileService = new FileService(); const fileService = new FileService();
const pageService = new PageService();
const issueService = new IssueService(); const issueService = new IssueService();
const PageDetailsPage: NextPageWithLayout = observer(() => { const PageDetailsPage: NextPageWithLayout = observer(() => {
// states // states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [gptModalOpen, setGptModal] = useState(false); const [gptModalOpen, setGptModal] = useState(false);
// refs // refs
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
@ -59,18 +56,82 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
//TODO:fix reload confirmations, with mobx
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({ const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
defaultValues: { name: "", description_html: "" }, defaultValues: { name: "", description_html: "" },
}); });
const { data: issuesResponse } = useSWR( const {
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, archivePage: archivePageAction,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null restorePage: restorePageAction,
createPage: createPageAction,
projectPageMap,
projectArchivedPageMap,
fetchProjectPages,
fetchArchivedProjectPages,
} = useProjectPages();
useSWR(
workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string]
? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString())
: null
);
// fetching archived pages from API
useSWR(
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId && !projectArchivedPageMap[projectId as string] && !projectPageMap[projectId as string]
? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString())
: null
); );
const issues = Object.values(issuesResponse ?? {}); const { issues, fetchIssue, issueWidgetClickAction } = useIssueEmbeds();
const pageStore = usePage(pageId as string);
useEffect(
() => () => {
if (pageStore) {
pageStore.cleanup();
}
},
[pageStore]
);
if (!pageStore) {
return (
<div className="grid h-full w-full place-items-center">
<Spinner />
</div>
);
}
// We need to get the values of title and description from the page store but we don't have to subscribe to those values
const pageTitle = pageStore?.name;
const pageDescription = pageStore?.description_html;
const {
lockPage: lockPageAction,
unlockPage: unlockPageAction,
updateName: updateNameAction,
updateDescription: updateDescriptionAction,
id: pageIdMobx,
isSubmitting,
setIsSubmitting,
owned_by,
is_locked,
archived_at,
created_at,
created_by,
updated_at,
updated_by,
} = pageStore;
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
await updateDescriptionAction(formData.description_html);
};
const handleAiAssistance = async (response: string) => { const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
@ -78,47 +139,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const newDescription = `${watch("description_html")}<p>${response}</p>`; const newDescription = `${watch("description_html")}<p>${response}</p>`;
setValue("description_html", newDescription); setValue("description_html", newDescription);
editorRef.current?.setEditorValue(newDescription); editorRef.current?.setEditorValue(newDescription);
updateDescriptionAction(newDescription);
pageService
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), {
description_html: newDescription,
})
.then(() => {
mutatePageDetails((prevData) => ({ ...prevData, description_html: newDescription } as IPage), false);
});
};
// =================== Fetching Page Details ======================
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null,
{
revalidateOnFocus: false,
}
);
const fetchIssue = async (issueId: string) => {
const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string);
return issue as TIssue;
};
const issueWidgetClickAction = (issueId: string) => {
const url = new URL(router.asPath, window.location.origin);
const params = new URLSearchParams(url.search);
if (params.has("peekIssueId")) {
params.set("peekIssueId", issueId);
} else {
params.append("peekIssueId", issueId);
}
// Replace the current URL with the new one
router.replace(`${url.pathname}?${params.toString()}`, undefined, { shallow: true });
}; };
const actionCompleteAlert = ({ const actionCompleteAlert = ({
@ -137,122 +158,14 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
}); });
}; };
useEffect(() => { const updatePageTitle = (title: string) => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
// adding pageDetails.description_html to dependency array causes
// editor rerendering on every save
useEffect(() => {
if (pageDetails?.description_html) {
setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageDetails?.description_html]); // TODO: Verify the exhaustive-deps warning
function createObjectFromArray(keys: string[], options: any): any {
return keys.reduce((obj, key) => {
if (options[key] !== undefined) {
obj[key] = options[key];
}
return obj;
}, {} as { [key: string]: any });
}
const mutatePageDetailsHelper = (
serverMutatorFn: Promise<any>,
dataToMutate: Partial<IPage>,
formDataValues: Array<keyof IPage>,
onErrorAction: () => void
) => {
const commonSwrOptions: MutatorOptions = {
revalidate: false,
populateCache: false,
rollbackOnError: () => {
onErrorAction();
return true;
},
};
const formData = getValues();
const formDataMutationObject = createObjectFromArray(formDataValues, formData);
mutatePageDetails(async () => serverMutatorFn, {
optimisticData: (prevData) => {
if (!prevData) return;
return {
...prevData,
description_html: formData["description_html"],
...formDataMutationObject,
...dataToMutate,
};
},
...commonSwrOptions,
});
};
useEffect(() => {
mutatePageDetails(undefined, {
revalidate: true,
populateCache: true,
rollbackOnError: () => {
actionCompleteAlert({
title: `Page could not be updated`,
message: `Sorry, page could not be updated, please try again later`,
type: "error",
});
return true;
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
updateNameAction(title);
formData.name = pageDetails?.name as string;
if (!formData?.name || formData?.name.length === 0) return;
try {
await pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData);
} catch (error) {
actionCompleteAlert({
title: `Page could not be updated`,
message: `Sorry, page could not be updated, please try again later`,
type: "error",
});
}
};
const updatePageTitle = async (title: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { name: title }),
{
name: title,
},
[],
() =>
actionCompleteAlert({
title: `Page Title could not be updated`,
message: `Sorry, page title could not be updated, please try again later`,
type: "error",
})
);
}; };
const createPage = async (payload: Partial<IPage>) => { const createPage = async (payload: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
await createPageAction(workspaceSlug as string, projectId as string, payload);
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
}; };
// ================ Page Menu Actions ================== // ================ Page Menu Actions ==================
@ -260,121 +173,84 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const currentPageValues = getValues(); const currentPageValues = getValues();
if (!currentPageValues?.description_html) { if (!currentPageValues?.description_html) {
currentPageValues.description_html = pageDetails?.description_html as string; // TODO: We need to get latest data the above variable will give us stale data
currentPageValues.description_html = pageDescription as string;
} }
const formData: Partial<IPage> = { const formData: Partial<IPage> = {
name: "Copy of " + pageDetails?.name, name: "Copy of " + pageTitle,
description_html: currentPageValues.description_html, description_html: currentPageValues.description_html,
}; };
await createPage(formData);
try {
await createPage(formData);
} catch (error) {
actionCompleteAlert({
title: `Page could not be duplicated`,
message: `Sorry, page could not be duplicated, please try again later`,
type: "error",
});
}
}; };
const archivePage = async () => { const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper( try {
pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), await archivePageAction(workspaceSlug as string, projectId as string, pageId as string);
{ } catch (error) {
archived_at: renderFormattedPayloadDate(new Date()), actionCompleteAlert({
}, title: `Page could not be archived`,
["description_html"], message: `Sorry, page could not be archived, please try again later`,
() => type: "error",
actionCompleteAlert({ });
title: `Page could not be Archived`, }
message: `Sorry, page could not be Archived, please try again later`,
type: "error",
})
);
}; };
const unArchivePage = async () => { const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetailsHelper( await restorePageAction(workspaceSlug as string, projectId as string, pageId as string);
pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), } catch (error) {
{ actionCompleteAlert({
archived_at: null, title: `Page could not be restored`,
}, message: `Sorry, page could not be restored, please try again later`,
["description_html"], type: "error",
() => });
actionCompleteAlert({ }
title: `Page could not be Restored`,
message: `Sorry, page could not be Restored, please try again later`,
type: "error",
})
);
}; };
// ========================= Page Lock ==========================
const lockPage = async () => { const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper( try {
pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), await lockPageAction();
{ } catch (error) {
is_locked: true, actionCompleteAlert({
}, title: `Page could not be locked`,
["description_html"], message: `Sorry, page could not be locked, please try again later`,
() => type: "error",
actionCompleteAlert({ });
title: `Page cannot be Locked`, }
message: `Sorry, page cannot be Locked, please try again later`,
type: "error",
})
);
}; };
const unlockPage = async () => { const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return; if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetailsHelper( await unlockPageAction();
pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), } catch (error) {
{ actionCompleteAlert({
is_locked: false, title: `Page could not be unlocked`,
}, message: `Sorry, page could not be unlocked, please try again later`,
["description_html"], type: "error",
() => });
actionCompleteAlert({ }
title: `Page could not be Unlocked`,
message: `Sorry, page could not be Unlocked, please try again later`,
type: "error",
})
);
}; };
const [localPageDescription, setLocalIssueDescription] = useState({
id: pageId as string,
description_html: "",
});
// ADDING updatePage TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
}, 1500),
[handleSubmit, pageDetails]
);
if (error)
return (
<EmptyState
image={emptyPage}
title="Page does not exist"
description="The page you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other pages",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/pages`),
}}
/>
);
const isPageReadOnly = const isPageReadOnly =
pageDetails?.is_locked || is_locked ||
pageDetails?.archived_at || archived_at ||
(currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole));
const isCurrentUserOwner = pageDetails?.owned_by === currentUser?.id; const isCurrentUserOwner = owned_by === currentUser?.id;
const userCanDuplicate = const userCanDuplicate =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
@ -382,144 +258,132 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const userCanLock = const userCanLock =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return pageIdMobx && issues ? (
<> <div className="flex h-full flex-col justify-between">
{pageDetails && issuesResponse ? ( <div className="h-full w-full overflow-hidden">
<div className="flex h-full flex-col justify-between"> {isPageReadOnly ? (
<div className="h-full w-full overflow-hidden"> <DocumentReadOnlyEditorWithRef
{isPageReadOnly ? ( onActionCompleteHandler={actionCompleteAlert}
<DocumentReadOnlyEditorWithRef ref={editorRef}
onActionCompleteHandler={actionCompleteAlert} value={pageDescription}
ref={editorRef} customClassName={"tracking-tight w-full px-0"}
value={localPageDescription.description_html} borderOnFocus={false}
rerenderOnPropsChange={localPageDescription} noBorder
customClassName={"tracking-tight w-full px-0"} documentDetails={{
borderOnFocus={false} title: pageTitle,
noBorder created_by: created_by,
documentDetails={{ created_on: created_at,
title: pageDetails.name, last_updated_at: updated_at,
created_by: pageDetails.created_by, last_updated_by: updated_by,
created_on: pageDetails.created_at, }}
last_updated_at: pageDetails.updated_at, pageLockConfig={userCanLock && !archived_at ? { action: unlockPage, is_locked: is_locked } : undefined}
last_updated_by: pageDetails.updated_by, pageDuplicationConfig={userCanDuplicate && !archived_at ? { action: duplicate_page } : undefined}
}} pageArchiveConfig={
pageLockConfig={ userCanArchive
userCanLock && !pageDetails.archived_at ? {
? { action: unlockPage, is_locked: pageDetails.is_locked } action: archived_at ? unArchivePage : archivePage,
: undefined is_archived: archived_at ? true : false,
} archived_at: archived_at ? new Date(archived_at) : undefined,
pageDuplicationConfig={ }
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined : undefined
} }
pageArchiveConfig={ embedConfig={{
userCanArchive issueEmbedConfig: {
? { issues: issues,
action: pageDetails.archived_at ? unArchivePage : archivePage, fetchIssue: fetchIssue,
is_archived: pageDetails.archived_at ? true : false, clickAction: issueWidgetClickAction,
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined, },
} }}
: undefined />
} ) : (
embedConfig={{ <div className="relative h-full w-full overflow-hidden">
issueEmbedConfig: { <Controller
issues: issues, name="description_html"
fetchIssue: fetchIssue, control={control}
clickAction: issueWidgetClickAction, render={({ field: { onChange } }) => (
}, <DocumentEditorWithRef
}} isSubmitting={isSubmitting}
/> documentDetails={{
) : ( title: pageTitle,
<div className="relative h-full w-full overflow-hidden"> created_by: created_by,
<Controller created_on: created_at,
name="description_html" last_updated_at: updated_at,
control={control} last_updated_by: updated_by,
render={({ field: { onChange } }) => ( }}
<DocumentEditorWithRef uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
isSubmitting={isSubmitting} value={pageDescription}
documentDetails={{ setShouldShowAlert={setShowAlert}
title: pageDetails.name, deleteFile={fileService.deleteImage}
created_by: pageDetails.created_by, restoreFile={fileService.restoreImage}
created_on: pageDetails.created_at, cancelUploadImage={fileService.cancelUpload}
last_updated_at: pageDetails.updated_at, ref={editorRef}
last_updated_by: pageDetails.updated_by, debouncedUpdatesEnabled={false}
}} setIsSubmitting={setIsSubmitting}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} updatePageTitle={updatePageTitle}
setShouldShowAlert={setShowAlert} onActionCompleteHandler={actionCompleteAlert}
deleteFile={fileService.deleteImage} customClassName="tracking-tight self-center px-0 h-full w-full"
restoreFile={fileService.restoreImage} onChange={(_description_json: Object, description_html: string) => {
cancelUploadImage={fileService.cancelUpload} setShowAlert(true);
ref={editorRef} onChange(description_html);
debouncedUpdatesEnabled={false} handleSubmit(updatePage)();
setIsSubmitting={setIsSubmitting} }}
updatePageTitle={updatePageTitle} duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
value={localPageDescription.description_html} pageArchiveConfig={
rerenderOnPropsChange={localPageDescription} userCanArchive
onActionCompleteHandler={actionCompleteAlert} ? {
customClassName="tracking-tight self-center px-0 h-full w-full" is_archived: archived_at ? true : false,
onChange={(_description_json: Object, description_html: string) => { action: archived_at ? unArchivePage : archivePage,
setShowAlert(true); }
onChange(description_html); : undefined
setIsSubmitting("submitting"); }
debouncedFormSave(); pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
}} embedConfig={{
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} issueEmbedConfig: {
pageArchiveConfig={ issues: issues,
userCanArchive fetchIssue: fetchIssue,
? { clickAction: issueWidgetClickAction,
is_archived: pageDetails.archived_at ? true : false, },
action: pageDetails.archived_at ? unArchivePage : archivePage, }}
} />
: undefined )}
} />
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} {projectId && envConfig?.has_openai_configured && (
embedConfig={{ <div className="absolute right-[68px] top-2.5">
issueEmbedConfig: { <GptAssistantPopover
issues: issues, isOpen={gptModalOpen}
fetchIssue: fetchIssue, projectId={projectId.toString()}
clickAction: issueWidgetClickAction, handleClose={() => {
}, setGptModal((prevData) => !prevData);
}} // this is done so that the title do not reset after gpt popover closed
/> reset(getValues());
)} }}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
/> />
{projectId && envConfig?.has_openai_configured && (
<div className="absolute right-[68px] top-2.5">
<GptAssistantPopover
isOpen={gptModalOpen}
projectId={projectId.toString()}
handleClose={() => {
setGptModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
/>
</div>
)}
</div> </div>
)} )}
</div> </div>
</div> )}
) : ( <IssuePeekOverview />
<div className="grid h-full w-full place-items-center"> </div>
<Spinner /> </div>
</div> ) : (
)} <div className="grid h-full w-full place-items-center">
</> <Spinner />
</div>
); );
}); });

View File

@ -5,7 +5,7 @@ import { Tab } from "@headlessui/react";
import useSWR from "swr"; import useSWR from "swr";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { usePage, useUser } from "hooks/store"; import { useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage"; import useLocalStorage from "hooks/use-local-storage";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
@ -17,6 +17,7 @@ import { PagesHeader } from "components/headers";
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
// constants // constants
import { PAGE_TABS_LIST } from "constants/page"; import { PAGE_TABS_LIST } from "constants/page";
import { useProjectPages } from "hooks/store/use-project-page";
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), { const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
ssr: false, ssr: false,
@ -45,8 +46,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
// states // states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
// store // store
const { fetchProjectPages, fetchArchivedProjectPages } = usePage();
const { currentUser, currentUserLoader } = useUser(); const { currentUser, currentUserLoader } = useUser();
const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages();
// hooks // hooks
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader }); const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
// local storage // local storage

View 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;

View File

@ -1,9 +1,6 @@
import { ReactElement } from "react"; import { ReactElement } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTheme } from "next-themes";
import { Lightbulb } from "lucide-react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services // services
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
@ -12,11 +9,12 @@ import useToast from "hooks/use-toast";
import useSignInRedirection from "hooks/use-sign-in-redirection"; import useSignInRedirection from "hooks/use-sign-in-redirection";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
// components
import { LatestFeatureBlock } from "components/common";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import latestFeatures from "public/onboarding/onboarding-pages.svg";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// type // type
@ -35,12 +33,10 @@ const defaultValues: TResetPasswordFormValues = {
// services // services
const authService = new AuthService(); const authService = new AuthService();
const HomePage: NextPageWithLayout = () => { const ResetPasswordPage: NextPageWithLayout = () => {
// router // router
const router = useRouter(); const router = useRouter();
const { uidb64, token, email } = router.query; const { uidb64, token, email } = router.query;
// next-themes
const { resolvedTheme } = useTheme();
// toast // toast
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// sign in redirection hook // sign in redirection hook
@ -108,35 +104,30 @@ const HomePage: NextPageWithLayout = () => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com" placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled disabled
/> />
)} )}
/> />
<div> <Controller
<Controller control={control}
control={control} name="password"
name="password" rules={{
rules={{ required: "Password is required",
required: "Password is required", }}
}} render={({ field: { value, onChange } }) => (
render={({ field: { value, onChange } }) => ( <Input
<Input type="password"
type="password" value={value}
value={value} onChange={onChange}
onChange={onChange} hasError={Boolean(errors.password)}
hasError={Boolean(errors.password)} placeholder="Enter password"
placeholder="Choose password" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" minLength={8}
minLength={8} />
/> )}
)} />
/>
<p className="mt-3 text-xs text-onboarding-text-200">
Whatever you choose now will be your account{"'"}s password until you change it.
</p>
</div>
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
@ -145,44 +136,19 @@ const HomePage: NextPageWithLayout = () => {
disabled={!isValid} disabled={!isValid}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Signing in..." : "Go to workspace"} Set password
</Button> </Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form> </form>
</div> </div>
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-onboarding-border-200 bg-onboarding-background-100 py-2 sm:w-96"> <LatestFeatureBlock />
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
<p className="text-left text-sm text-onboarding-text-100">
Try the latest features, like Tiptap editor, to write compelling responses.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">See new features</span>
</Link>
</p>
</div>
<div className="mx-auto mt-8 overflow-hidden rounded-md border border-onboarding-border-200 bg-onboarding-background-100 object-cover sm:h-52 sm:w-96">
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`-mt-2 ml-8 h-full rounded-md ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
HomePage.getLayout = function getLayout(page: ReactElement) { ResetPasswordPage.getLayout = function getLayout(page: ReactElement) {
return <DefaultLayout>{page}</DefaultLayout>; return <DefaultLayout>{page}</DefaultLayout>;
}; };
export default HomePage; export default ResetPasswordPage;

View File

@ -1,97 +1,52 @@
import React, { useEffect, ReactElement } from "react"; import React from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// next-themes
import { useTheme } from "next-themes";
// services
import { AuthService } from "services/auth.service";
// hooks // hooks
import { useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
// components // components
import { EmailSignUpForm } from "components/account"; import { SignUpRoot } from "components/account";
// images // ui
import { Spinner } from "@plane/ui";
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// types // types
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
type EmailPasswordFormValues = {
email: string;
password?: string;
medium?: string;
};
// services
const authService = new AuthService();
const SignUpPage: NextPageWithLayout = observer(() => { const SignUpPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
// toast alert
const { setToastAlert } = useToast();
// next-themes
const { setTheme } = useTheme();
// store hooks // store hooks
const { currentUser, fetchCurrentUser, currentUserLoader } = useUser(); const {
// custom hooks config: { envConfig },
const {} = useUserAuth({ routeAuth: "sign-in", user: currentUser, isLoading: currentUserLoader }); } = useApplication();
const { currentUser } = useUser();
const handleSignUp = async (formData: EmailPasswordFormValues) => { if (currentUser || !envConfig)
const payload = { return (
email: formData.email, <div className="grid h-screen place-items-center">
password: formData.password ?? "", <Spinner />
}; </div>
);
await authService
.emailSignUp(payload)
.then(async (response) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Account created successfully.",
});
if (response) await fetchCurrentUser();
router.push("/onboarding");
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error || "Something went wrong. Please try again later or contact the support team.",
})
);
};
useEffect(() => {
setTheme("system");
}, [setTheme]);
return ( return (
<> <div className="h-full w-full bg-onboarding-gradient-100">
<div className="left-20 top-0 hidden h-screen w-[0.5px] border-r-[0.5px] border-custom-border-200 sm:fixed sm:block lg:left-32" /> <div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28">
<div className="fixed left-7 top-11 grid place-items-center bg-custom-background-100 sm:left-16 sm:top-12 sm:py-5 lg:left-28"> <div className="flex items-center gap-x-2 py-10">
<div className="grid place-items-center bg-custom-background-100"> <Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<div className="h-[30px] w-[30px]"> <span className="text-2xl font-semibold sm:text-3xl">Plane</span>
<Image src={BluePlaneLogoWithoutText} alt="Plane Logo" />
</div>
</div> </div>
</div> </div>
<div className="grid h-full w-full place-items-center overflow-y-auto px-7 py-5">
<div> <div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3">
<h1 className="font- text-center text-2xl">SignUp on Plane</h1> <div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
<EmailSignUpForm onSubmit={handleSignUp} /> <SignUpRoot />
</div> </div>
</div> </div>
</> </div>
); );
}); });
SignUpPage.getLayout = function getLayout(page: ReactElement) { SignUpPage.getLayout = function getLayout(page: React.ReactElement) {
return <DefaultLayout>{page}</DefaultLayout>; return <DefaultLayout>{page}</DefaultLayout>;
}; };

View File

@ -5,8 +5,9 @@ import { IAppConfig } from "@plane/types";
import { AppConfigService } from "services/app_config.service"; import { AppConfigService } from "services/app_config.service";
export interface IAppConfigStore { export interface IAppConfigStore {
// observables
envConfig: IAppConfig | null; envConfig: IAppConfig | null;
// action // actions
fetchAppConfig: () => Promise<any>; fetchAppConfig: () => Promise<any>;
} }

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -148,8 +150,13 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
}); });
}); });
const appliedFilters = _filters.filters || {};
this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation"); const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.archivedIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation"
);
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, { this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
filters: _filters.filters, filters: _filters.filters,
}); });

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -158,7 +160,14 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
}); });
}); });
this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); const appliedFilters = _filters.filters || {};
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.cycleIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation",
cycleId
);
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
filters: _filters.filters, filters: _filters.filters,
}); });

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -143,8 +145,13 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
}); });
}); });
const appliedFilters = _filters.filters || {};
this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation"); const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.draftIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation"
);
this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, { this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, {
filters: _filters.filters, filters: _filters.filters,
}); });

View File

@ -11,7 +11,6 @@ import {
TStaticViewTypes, TStaticViewTypes,
} from "@plane/types"; } from "@plane/types";
// constants // constants
import { isNil } from "constants/common";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
// lib // lib
import { storage } from "lib/local-storage"; import { storage } from "lib/local-storage";
@ -76,8 +75,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
target_date: filters?.target_date || undefined, target_date: filters?.target_date || undefined,
// display filters // display filters
type: displayFilters?.type || undefined, type: displayFilters?.type || undefined,
sub_issue: isNil(displayFilters?.sub_issue) ? true : displayFilters?.sub_issue, sub_issue: displayFilters?.sub_issue ?? true,
start_target_date: isNil(displayFilters?.start_target_date) ? true : displayFilters?.start_target_date, start_target_date: displayFilters?.start_target_date ?? true,
}; };
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {}; const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
@ -169,19 +168,19 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
* @returns {IIssueDisplayProperties} * @returns {IIssueDisplayProperties}
*/ */
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({ computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({
assignee: displayProperties?.assignee || false, assignee: displayProperties?.assignee ?? true,
start_date: displayProperties?.start_date || false, start_date: displayProperties?.start_date ?? true,
due_date: displayProperties?.due_date || false, due_date: displayProperties?.due_date ?? true,
labels: displayProperties?.labels || false, labels: displayProperties?.labels ?? true,
priority: displayProperties?.priority || false, priority: displayProperties?.priority ?? true,
state: displayProperties?.state || false, state: displayProperties?.state ?? true,
sub_issue_count: displayProperties?.sub_issue_count || false, sub_issue_count: displayProperties?.sub_issue_count ?? true,
attachment_count: displayProperties?.attachment_count || false, attachment_count: displayProperties?.attachment_count ?? true,
estimate: displayProperties?.estimate || false, link: displayProperties?.link ?? true,
link: displayProperties?.link || false, estimate: displayProperties?.estimate ?? true,
key: displayProperties?.key || false, key: displayProperties?.key ?? true,
created_on: displayProperties?.created_on || false, created_on: displayProperties?.created_on ?? true,
updated_on: displayProperties?.updated_on || false, updated_on: displayProperties?.updated_on ?? true,
}); });
handleIssuesLocalFilters = { handleIssuesLocalFilters = {

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -157,8 +159,14 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul
set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
}); });
}); });
const appliedFilters = _filters.filters || {};
this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.moduleIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation",
moduleId
);
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
filters: _filters.filters, filters: _filters.filters,
}); });

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -150,13 +152,16 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
}); });
}); });
const appliedFilters = _filters.filters || {};
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.profileIssues.fetchIssues( this.rootIssueStore.profileIssues.fetchIssues(
workspaceSlug, workspaceSlug,
undefined, undefined,
"mutation", isEmpty(filteredFilters) ? "init-loader" : "mutation",
userId, userId,
this.rootIssueStore.profileIssues.currentView this.rootIssueStore.profileIssues.currentView
); );
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
filters: _filters.filters, filters: _filters.filters,
}); });
@ -178,10 +183,10 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
_filters.displayFilters.sub_group_by = null; _filters.displayFilters.sub_group_by = null;
updatedDisplayFilters.sub_group_by = null; updatedDisplayFilters.sub_group_by = null;
} }
// set group_by to state if layout is switched to kanban and group_by is null // set group_by to priority if layout is switched to kanban and group_by is null
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
_filters.displayFilters.group_by = "state"; _filters.displayFilters.group_by = "priority";
updatedDisplayFilters.group_by = "state"; updatedDisplayFilters.group_by = "priority";
} }
runInAction(() => { runInAction(() => {

View File

@ -97,7 +97,9 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
const orderBy = displayFilters?.order_by; const orderBy = displayFilters?.order_by;
const layout = displayFilters?.layout; const layout = displayFilters?.layout;
const userIssueIds = this.issues[userId][currentView] ?? []; const userIssueIds = this.issues[userId]?.[currentView];
if (!userIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds); const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds);
if (!_issues) return undefined; if (!_issues) return undefined;

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -159,7 +161,14 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I
}); });
}); });
this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId); const appliedFilters = _filters.filters || {};
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.projectViewIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation",
viewId
);
break; break;
case EIssueFilterType.DISPLAY_FILTERS: case EIssueFilterType.DISPLAY_FILTERS:
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -155,7 +157,13 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj
}); });
}); });
this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); const appliedFilters = _filters.filters || {};
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.projectIssues.fetchIssues(
workspaceSlug,
projectId,
isEmpty(filteredFilters) ? "init-loader" : "mutation"
);
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
filters: _filters.filters, filters: _filters.filters,
}); });

View File

@ -123,6 +123,7 @@ export class IssueRootStore implements IIssueRootStore {
moduleId: observable.ref, moduleId: observable.ref,
viewId: observable.ref, viewId: observable.ref,
userId: observable.ref, userId: observable.ref,
globalViewId: observable.ref,
states: observable, states: observable,
stateDetails: observable, stateDetails: observable,
labels: observable, labels: observable,

View File

@ -1,6 +1,8 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import set from "lodash/set"; import set from "lodash/set";
import pickBy from "lodash/pickBy";
import isArray from "lodash/isArray";
// base class // base class
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers // helpers
@ -180,7 +182,13 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]);
}); });
}); });
this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); const appliedFilters = _filters.filters || {};
const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0);
this.rootIssueStore.workspaceIssues.fetchIssues(
workspaceSlug,
viewId,
isEmpty(filteredFilters) ? "init-loader" : "mutation"
);
break; break;
case EIssueFilterType.DISPLAY_FILTERS: case EIssueFilterType.DISPLAY_FILTERS:
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;

View File

@ -1,374 +1,277 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, makeObservable, observable, reaction, runInAction } from "mobx";
import set from "lodash/set";
import omit from "lodash/omit"; import { IIssueLabel, IPage } from "@plane/types";
import isToday from "date-fns/isToday";
import isThisWeek from "date-fns/isThisWeek";
import isYesterday from "date-fns/isYesterday";
// services
import { PageService } from "services/page.service"; import { PageService } from "services/page.service";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IPage, IRecentPages } from "@plane/types";
// store
import { RootStore } from "./root.store"; import { RootStore } from "./root.store";
export interface IPageStore { export interface IPageStore {
pages: Record<string, IPage>; // Page Properties
archivedPages: Record<string, IPage>; access: number;
// project computed archived_at: string | null;
projectPageIds: string[] | null; color: string;
favoriteProjectPageIds: string[] | null; created_at: Date;
privateProjectPageIds: string[] | null; created_by: string;
publicProjectPageIds: string[] | null; description: string;
archivedProjectPageIds: string[] | null; description_html: string;
recentProjectPages: IRecentPages | null; description_stripped: string | null;
// fetch page information actions id: string;
getUnArchivedPageById: (pageId: string) => IPage | null; is_favorite: boolean;
getArchivedPageById: (pageId: string) => IPage | null; label_details: IIssueLabel[];
// fetch actions is_locked: boolean;
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>; labels: string[];
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>; name: string;
// favorites actions owned_by: string;
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; project: string;
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; updated_at: Date;
// crud updated_by: string;
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>; workspace: string;
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; // Actions
// access control actions makePublic: () => Promise<void>;
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; makePrivate: () => Promise<void>;
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; lockPage: () => Promise<void>;
// archive actions unlockPage: () => Promise<void>;
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; addToFavorites: () => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>; removeFromFavorites: () => Promise<void>;
updateName: (name: string) => Promise<void>;
updateDescription: (description: string) => Promise<void>;
// Reactions
disposers: Array<() => void>;
// Helpers
oldName: string;
cleanup: () => void;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
} }
export class PageStore implements IPageStore { export class PageStore implements IPageStore {
pages: Record<string, IPage> = {}; access = 0;
archivedPages: Record<string, IPage> = {}; isSubmitting: "submitting" | "submitted" | "saved" = "saved";
// services archived_at: string | null;
color: string;
created_at: Date;
created_by: string;
description: string;
description_html = "";
description_stripped: string | null;
id: string;
is_favorite = false;
is_locked = true;
labels: string[];
name = "";
owned_by: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
oldName = "";
label_details: IIssueLabel[] = [];
disposers: Array<() => void> = [];
pageService; pageService;
// stores // root store
rootStore; rootStore;
constructor(rootStore: RootStore) { constructor(page: IPage, _rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
pages: observable, name: observable.ref,
archivedPages: observable, description_html: observable.ref,
// computed is_favorite: observable.ref,
projectPageIds: computed, is_locked: observable.ref,
favoriteProjectPageIds: computed, isSubmitting: observable.ref,
publicProjectPageIds: computed, access: observable.ref,
privateProjectPageIds: computed,
archivedProjectPageIds: computed,
recentProjectPages: computed,
// computed actions
getUnArchivedPageById: action,
getArchivedPageById: action,
// fetch actions
fetchProjectPages: action,
fetchArchivedProjectPages: action,
// favorites actions
addToFavorites: action,
removeFromFavorites: action,
// crud
createPage: action,
updatePage: action,
deletePage: action,
// access control actions
makePublic: action, makePublic: action,
makePrivate: action, makePrivate: action,
// archive actions addToFavorites: action,
archivePage: action, removeFromFavorites: action,
restorePage: action, updateName: action,
updateDescription: action,
setIsSubmitting: action,
cleanup: action,
}); });
// stores this.created_by = page?.created_by || "";
this.rootStore = rootStore; this.created_at = page?.created_at || new Date();
// services this.color = page?.color || "";
this.archived_at = page?.archived_at || null;
this.name = page?.name || "";
this.description = page?.description || "";
this.description_stripped = page?.description_stripped || "";
this.description_html = page?.description_html || "";
this.access = page?.access || 0;
this.workspace = page?.workspace || "";
this.updated_by = page?.updated_by || "";
this.updated_at = page?.updated_at || new Date();
this.project = page?.project || "";
this.owned_by = page?.owned_by || "";
this.labels = page?.labels || [];
this.label_details = page?.label_details || [];
this.is_locked = page?.is_locked || false;
this.id = page?.id || "";
this.is_favorite = page?.is_favorite || false;
this.oldName = page?.name || "";
this.rootStore = _rootStore;
this.pageService = new PageService(); this.pageService = new PageService();
}
/** const descriptionDisposer = reaction(
* retrieves all pages for a projectId that is available in the url. () => this.description_html,
*/ (description_html) => {
get projectPageIds() { //TODO: Fix reaction to only run when the data is changed, not when the page is loaded
const projectId = this.rootStore.app.router.projectId; const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId) return null; if (!projectId || !workspaceSlug) return;
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId); this.isSubmitting = "submitting";
return projectPageIds ?? null; this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => {
} runInAction(() => {
this.isSubmitting = "submitted";
/**
* retrieves all favorite pages for a projectId that is available in the url.
*/
get favoriteProjectPageIds() {
if (!this.projectPageIds) return null;
const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite);
return favoritePagesIds ?? null;
}
/**
* retrieves all private pages for a projectId that is available in the url.
*/
get privateProjectPageIds() {
if (!this.projectPageIds) return null;
const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1);
return privatePagesIds ?? null;
}
/**
* retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url.
*/
get publicProjectPageIds() {
if (!this.projectPageIds) return null;
const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0);
return publicPagesIds ?? null;
}
/**
* retrieves all recent pages for a projectId that is available in the url.
* In format where today, yesterday, this_week, older are keys.
*/
get recentProjectPages() {
if (!this.projectPageIds) return null;
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.updated_at))) || [];
data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.updated_at))) || [];
data.this_week =
this.projectPageIds.filter((p) => {
const pageUpdatedAt = this.pages?.[p]?.updated_at;
return (
isThisWeek(new Date(pageUpdatedAt)) &&
!isToday(new Date(pageUpdatedAt)) &&
!isYesterday(new Date(pageUpdatedAt))
);
}) || [];
data.older =
this.projectPageIds.filter((p) => {
const pageUpdatedAt = this.pages?.[p]?.updated_at;
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
}) || [];
return data;
}
/**
* retrieves all archived pages for a projectId that is available in the url.
*/
get archivedProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null;
const archivedProjectPageIds = Object.keys(this.archivedPages).filter(
(pageId) => this.archivedPages?.[pageId]?.project === projectId
);
return archivedProjectPageIds ?? null;
}
/**
* retrieves a page from pages by id.
* @param pageId
* @returns IPage | null
*/
getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null;
/**
* retrieves a page from archived pages by id.
* @param pageId
* @returns IPage | null
*/
getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null;
/**
* fetches all pages for a project.
* @param workspaceSlug
* @param projectId
* @returns Promise<IPage[]>
*/
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
try {
return await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
console.log("Response from backend 1", response);
runInAction(() => {
response.forEach((page) => {
set(this.pages, [page.id], page);
}); });
}); });
return response; },
}); { delay: 3000 }
} catch (error) { );
throw error;
}
};
/** const pageTitleDisposer = reaction(
* fetches all archived pages for a project. () => this.name,
* @param workspaceSlug (name) => {
* @param projectId const { projectId, workspaceSlug } = this.rootStore.app.router;
* @returns Promise<IPage[]> if (!projectId || !workspaceSlug) return;
*/ this.isSubmitting = "submitting";
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => this.pageService
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { .patchPage(workspaceSlug, projectId, this.id, { name })
runInAction(() => { .catch(() => {
response.forEach((page) => { runInAction(() => {
set(this.archivedPages, [page.id], page); this.name = this.oldName;
}); });
}); })
return response; .finally(() => {
runInAction(() => {
this.isSubmitting = "submitted";
});
});
},
{ delay: 2000 }
);
this.disposers.push(descriptionDisposer, pageTitleDisposer);
}
updateName = action("updateName", async (name: string) => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.oldName = this.name;
this.name = name;
});
updateDescription = action("updateDescription", async (description_html: string) => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.description_html = description_html;
});
cleanup = action("cleanup", () => {
this.disposers.forEach((disposer) => {
disposer();
}); });
});
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
this.isSubmitting = isSubmitting;
});
lockPage = action("lockPage", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_locked = true;
await this.pageService.lockPage(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
this.is_locked = false;
});
});
});
unlockPage = action("unlockPage", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_locked = false;
await this.pageService.unlockPage(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
this.is_locked = true;
});
});
});
/** /**
* Add Page to users favorites list * Add Page to users favorites list
* @param workspaceSlug
* @param projectId
* @param pageId
*/ */
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { addToFavorites = action("addToFavorites", async () => {
try { const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_favorite = true;
await this.pageService.addPageToFavorites(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => { runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true); this.is_favorite = false;
}); });
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId); });
} catch (error) { });
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
throw error;
}
};
/** /**
* Remove page from the users favorites list * Remove page from the users favorites list
* @param workspaceSlug
* @param projectId
* @param pageId
*/ */
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { removeFromFavorites = action("removeFromFavorites", async () => {
try { const { projectId, workspaceSlug } = this.rootStore.app.router;
runInAction(() => { if (!projectId || !workspaceSlug) return;
set(this.pages, [pageId, "is_favorite"], false);
});
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true);
});
throw error;
}
};
/**
* Creates a new page using the api and updated the local state in store
* @param workspaceSlug
* @param projectId
* @param data
*/
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) =>
await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.pages, [response.id], response);
});
return response;
});
/** this.is_favorite = false;
* updates the page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @param data
* @returns
*/
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) =>
await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => {
const originalPage = this.getUnArchivedPageById(pageId);
runInAction(() => {
set(this.pages, [pageId], { ...originalPage, ...data });
});
return response;
});
/** await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => {
* delete a page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns
*/
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => {
runInAction(() => { runInAction(() => {
omit(this.archivedPages, [pageId]); this.is_favorite = true;
}); });
return response;
}); });
});
/** /**
* make a page public * make a page public
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns * @returns
*/ */
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => { makePublic = action("makePublic", async () => {
try { const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.access = 0;
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 0 }).catch(() => {
runInAction(() => { runInAction(() => {
set(this.pages, [pageId, "access"], 0); this.access = 1;
}); });
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 }); });
} catch (error) { });
runInAction(() => {
set(this.pages, [pageId, "access"], 1);
});
throw error;
}
};
/** /**
* Make a page private * Make a page private
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns * @returns
*/ */
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => { makePrivate = action("makePrivate", async () => {
try { const { projectId, workspaceSlug } = this.rootStore.app.router;
runInAction(() => { if (!projectId || !workspaceSlug) return;
set(this.pages, [pageId, "access"], 1);
});
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "access"], 0);
});
throw error;
}
};
/** this.access = 1;
* Mark a page archived
* @param workspaceSlug this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => {
* @param projectId
* @param pageId
*/
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
runInAction(() => { runInAction(() => {
set(this.archivedPages, [pageId], this.pages[pageId]); this.access = 0;
set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date()));
omit(this.pages, [pageId]);
});
});
/**
* Restore a page from archived pages to pages
* @param workspaceSlug
* @param projectId
* @param pageId
*/
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
runInAction(() => {
set(this.pages, [pageId], this.archivedPages[pageId]);
omit(this.archivedPages, [pageId]);
}); });
}); });
});
} }

View File

@ -1,33 +1,54 @@
import { makeObservable, observable, runInAction, action } from "mobx"; import { makeObservable, observable, runInAction, action, computed } from "mobx";
import { set } from "lodash"; import { set } from "lodash";
// services // services
import { PageService } from "services/page.service"; import { PageService } from "services/page.service";
// store // store
import { PageStore, IPageStore } from "store/page.store"; import { PageStore, IPageStore } from "store/page.store";
// types // types
import { IPage } from "@plane/types"; import { IPage, IRecentPages } from "@plane/types";
import { RootStore } from "./root.store";
import { isThisWeek, isToday, isYesterday } from "date-fns";
export interface IProjectPageStore { export interface IProjectPageStore {
projectPages: Record<string, IPageStore[]>; projectPageMap: Record<string, Record<string, IPageStore>>;
projectArchivedPages: Record<string, IPageStore[]>; projectArchivedPageMap: Record<string, Record<string, IPageStore>>;
projectPageIds: string[] | undefined;
archivedPageIds: string[] | undefined;
favoriteProjectPageIds: string[] | undefined;
privateProjectPageIds: string[] | undefined;
publicProjectPageIds: string[] | undefined;
recentProjectPages: IRecentPages | undefined;
// fetch actions // fetch actions
fetchProjectPages: (workspaceSlug: string, projectId: string) => void; fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void; fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
// crud actions // crud actions
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => void; createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void; deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
} }
export class ProjectPageStore implements IProjectPageStore { export class ProjectPageStore implements IProjectPageStore {
projectPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] } projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
projectArchivedPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] } projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
// root store
rootStore;
pageService; pageService;
constructor(_rootStore: RootStore) {
constructor() {
makeObservable(this, { makeObservable(this, {
projectPages: observable, projectPageMap: observable,
projectArchivedPages: observable, projectArchivedPageMap: observable,
projectPageIds: computed,
archivedPageIds: computed,
favoriteProjectPageIds: computed,
privateProjectPageIds: computed,
publicProjectPageIds: computed,
recentProjectPages: computed,
// fetch actions // fetch actions
fetchProjectPages: action, fetchProjectPages: action,
fetchArchivedProjectPages: action, fetchArchivedProjectPages: action,
@ -35,19 +56,113 @@ export class ProjectPageStore implements IProjectPageStore {
createPage: action, createPage: action,
deletePage: action, deletePage: action,
}); });
this.rootStore = _rootStore;
this.pageService = new PageService(); this.pageService = new PageService();
} }
get projectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.projectPageMap?.[projectId]) return [];
const allProjectIds = Object.keys(this.projectPageMap[projectId]);
return allProjectIds.sort((a, b) => {
const dateA = new Date(this.projectPageMap[projectId][a].created_at).getTime();
const dateB = new Date(this.projectPageMap[projectId][b].created_at).getTime();
return dateB - dateA;
});
}
get archivedPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.projectArchivedPageMap[projectId]) return [];
const archivedPages = Object.keys(this.projectArchivedPageMap[projectId]);
return archivedPages.sort((a, b) => {
const dateA = new Date(this.projectArchivedPageMap[projectId][a].created_at).getTime();
const dateB = new Date(this.projectArchivedPageMap[projectId][b].created_at).getTime();
return dateB - dateA;
});
}
get favoriteProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return [];
const favouritePages: string[] = this.projectPageIds.filter(
(page) => this.projectPageMap[projectId][page].is_favorite
);
return favouritePages;
}
get privateProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return [];
const privatePages: string[] = this.projectPageIds.filter(
(page) => this.projectPageMap[projectId][page].access === 1
);
return privatePages;
}
get publicProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
const userId = this.rootStore.user.currentUser?.id;
if (!this.projectPageIds || !projectId || !userId) return [];
const publicPages: string[] = this.projectPageIds.filter(
(page) =>
this.projectPageMap[projectId][page].access === 0 && this.projectPageMap[projectId][page].owned_by === userId
);
return publicPages;
}
get recentProjectPages() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return;
const today: string[] = this.projectPageIds.filter((page) =>
isToday(new Date(this.projectPageMap[projectId][page].updated_at))
);
const yesterday: string[] = this.projectPageIds.filter((page) =>
isYesterday(new Date(this.projectPageMap[projectId][page].updated_at))
);
const this_week: string[] = this.projectPageIds.filter((page) => {
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
return (
isThisWeek(new Date(pageUpdatedAt)) &&
!isToday(new Date(pageUpdatedAt)) &&
!isYesterday(new Date(pageUpdatedAt))
);
});
const older: string[] = this.projectPageIds.filter((page) => {
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
});
return { today, yesterday, this_week, older };
}
/** /**
* Fetching all the pages for a specific project * Fetching all the pages for a specific project
* @param workspaceSlug * @param workspaceSlug
* @param projectId * @param projectId
*/ */
fetchProjectPages = async (workspaceSlug: string, projectId: string) => { fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
const response = await this.pageService.getProjectPages(workspaceSlug, projectId); try {
runInAction(() => { await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
this.projectPages[projectId] = response?.map((page) => new PageStore(page as any)); runInAction(() => {
}); for (const page of response) {
set(this.projectPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
}
});
return response;
});
} catch (e) {
throw e;
}
}; };
/** /**
@ -56,13 +171,20 @@ export class ProjectPageStore implements IProjectPageStore {
* @param projectId * @param projectId
* @returns Promise<IPage[]> * @returns Promise<IPage[]>
*/ */
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => {
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { try {
runInAction(() => { await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any)); runInAction(() => {
for (const page of response) {
set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
}
});
return response;
}); });
return response; } catch (e) {
}); throw e;
}
};
/** /**
* Creates a new page using the api and updated the local state in store * Creates a new page using the api and updated the local state in store
@ -73,7 +195,7 @@ export class ProjectPageStore implements IProjectPageStore {
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => { createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
const response = await this.pageService.createPage(workspaceSlug, projectId, data); const response = await this.pageService.createPage(workspaceSlug, projectId, data);
runInAction(() => { runInAction(() => {
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)]; set(this.projectPageMap, [projectId, response.id], new PageStore(response, this.rootStore));
}); });
return response; return response;
}; };
@ -88,11 +210,7 @@ export class ProjectPageStore implements IProjectPageStore {
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => { deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId); const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
runInAction(() => { runInAction(() => {
this.projectPages = set( delete this.projectArchivedPageMap[projectId][pageId];
this.projectPages,
[projectId],
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
);
}); });
return response; return response;
}; };
@ -104,14 +222,17 @@ export class ProjectPageStore implements IProjectPageStore {
* @param pageId * @param pageId
*/ */
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId);
runInAction(() => { runInAction(() => {
set( set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
this.projectPages, set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString());
[projectId], delete this.projectPageMap[projectId][pageId];
this.projectPages[projectId].filter((page: any) => page.id !== pageId) });
); const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => {
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]); runInAction(() => {
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
set(this.projectPageMap[projectId][pageId], "archived_at", null);
delete this.projectArchivedPageMap[projectId][pageId];
});
}); });
return response; return response;
}; };
@ -122,15 +243,19 @@ export class ProjectPageStore implements IProjectPageStore {
* @param projectId * @param projectId
* @param pageId * @param pageId
*/ */
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => { const pageArchivedAt = this.projectArchivedPageMap[projectId][pageId].archived_at;
runInAction(() => {
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
set(this.projectPageMap[projectId][pageId], "archived_at", null);
delete this.projectArchivedPageMap[projectId][pageId];
});
await this.pageService.restorePage(workspaceSlug, projectId, pageId).catch(() => {
runInAction(() => { runInAction(() => {
set( set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
this.projectArchivedPages, set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt);
[projectId], delete this.projectPageMap[projectId][pageId];
this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
);
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
}); });
}); });
};
} }

View File

@ -92,24 +92,26 @@ export class ProjectStore implements IProjectStore {
* Returns searched projects based on search query * Returns searched projects based on search query
*/ */
get searchedProjects() { get searchedProjects() {
if (!this.rootStore.app.router.workspaceSlug) return []; const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
const projectIds = Object.keys(this.projectMap); if (!workspaceDetails) return [];
return this.searchQuery === "" const workspaceProjects = Object.values(this.projectMap).filter(
? projectIds (p) =>
: projectIds?.filter((projectId) => { p.workspace === workspaceDetails.id &&
this.projectMap[projectId].name.toLowerCase().includes(this.searchQuery.toLowerCase()) || (p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
this.projectMap[projectId].identifier.toLowerCase().includes(this.searchQuery.toLowerCase()); p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase()))
}); );
return workspaceProjects.map((p) => p.id);
} }
/** /**
* Returns project IDs belong to the current workspace * Returns project IDs belong to the current workspace
*/ */
get workspaceProjectIds() { get workspaceProjectIds() {
if (!this.rootStore.app.router.workspaceSlug) return null; const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
const projectIds = Object.keys(this.projectMap); if (!workspaceDetails) return null;
if (!projectIds) return null; const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id);
return projectIds; const projectIds = workspaceProjects.map((p) => p.id);
return projectIds ?? null;
} }
/** /**

View File

@ -9,7 +9,6 @@ import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IssueRootStore, IIssueRootStore } from "./issue/root.store"; import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
import { IStateStore, StateStore } from "./state.store"; import { IStateStore, StateStore } from "./state.store";
import { IPageStore, PageStore } from "./page.store";
import { ILabelRootStore, LabelRootStore } from "./label"; import { ILabelRootStore, LabelRootStore } from "./label";
import { IMemberRootStore, MemberRootStore } from "./member"; import { IMemberRootStore, MemberRootStore } from "./member";
import { IInboxRootStore, InboxRootStore } from "./inbox"; import { IInboxRootStore, InboxRootStore } from "./inbox";
@ -33,7 +32,6 @@ export class RootStore {
module: IModuleStore; module: IModuleStore;
projectView: IProjectViewStore; projectView: IProjectViewStore;
globalView: IGlobalViewStore; globalView: IGlobalViewStore;
page: IPageStore;
issue: IIssueRootStore; issue: IIssueRootStore;
state: IStateStore; state: IStateStore;
estimate: IEstimateStore; estimate: IEstimateStore;
@ -58,8 +56,7 @@ export class RootStore {
this.state = new StateStore(this); this.state = new StateStore(this);
this.estimate = new EstimateStore(this); this.estimate = new EstimateStore(this);
this.mention = new MentionStore(this); this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.projectPages = new ProjectPageStore();
this.page = new PageStore(this);
} }
} }

View File

@ -6617,13 +6617,35 @@ mkdirp@^0.5.5:
dependencies: dependencies:
minimist "^1.2.6" minimist "^1.2.6"
mobx-react-lite@^4.0.3: mobx-devtools-mst@^0.9.30:
version "0.9.30"
resolved "https://registry.yarnpkg.com/mobx-devtools-mst/-/mobx-devtools-mst-0.9.30.tgz#0d1cad8b3d97e1f3f94bb9afb701cd9c8b5b164d"
integrity sha512-6fIYeFG4xT4syIeKddmK55zQbc3ZZZr/272/cCbfaAAM5YiuFdteGZGUgdsz8wxf/mGxWZbFOM3WmASAnpwrbw==
mobx-react-devtools@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-6.1.1.tgz#a462b944085cf11ff96fc937d12bf31dab4c8984"
integrity sha512-nc5IXLdEUFLn3wZal65KF3/JFEFd+mbH4KTz/IG5BOPyw7jo8z29w/8qm7+wiCyqVfUIgJ1gL4+HVKmcXIOgqA==
mobx-react-lite@^4.0.3, mobx-react-lite@^4.0.4:
version "4.0.5" version "4.0.5"
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b" resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b"
integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg== integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg==
dependencies: dependencies:
use-sync-external-store "^1.2.0" use-sync-external-store "^1.2.0"
mobx-react@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-9.1.0.tgz#5e54919ca27ffad5f2c0d835148a1f681cebdbc1"
integrity sha512-DeDRTYw4AlgHw8xEXtiZdKKEnp+c5/jeUgTbTQXEqnAzfkrgYRWP3p3Nv3Whc2CEcM/mDycbDWGjxKokQdlffg==
dependencies:
mobx-react-lite "^4.0.4"
mobx-state-tree@^5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-5.4.0.tgz#d41b7fd90b8d4b063bc32526758417f1100751df"
integrity sha512-2VuUhAqFklxgGqFNqaZUXYYSQINo8C2SUEP9YfCQrwatHWHqJLlEC7Xb+5WChkev7fubzn3aVuby26Q6h+JeBg==
mobx@^6.10.0: mobx@^6.10.0:
version "6.12.0" version "6.12.0"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135" resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"