diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index db65fbc2c..38694a62e 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,61 +1,30 @@ name: Branch Build on: - pull_request: - types: - - closed + workflow_dispatch: + inputs: + branch_name: + description: "Branch Name" + required: true + default: "preview" + push: branches: - master - preview - - qa - develop - - release-* release: types: [released, prereleased] env: - TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }} jobs: branch_build_setup: - if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }} name: Build-Push Web/Space/API/Proxy Docker Image runs-on: ubuntu-20.04 - steps: - name: Check out the repo uses: actions/checkout@v3.3.0 - - - name: Uploading Proxy Source - uses: actions/upload-artifact@v3 - with: - name: proxy-src-code - path: ./nginx - - name: Uploading Backend Source - uses: actions/upload-artifact@v3 - with: - name: backend-src-code - path: ./apiserver - - name: Uploading Web Source - uses: actions/upload-artifact@v3 - with: - name: web-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./space - - name: Uploading Space Source - uses: actions/upload-artifact@v3 - with: - name: space-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./web outputs: gh_branch_name: ${{ env.TARGET_BRANCH }} @@ -63,33 +32,38 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Frontend Docker Tag + - name: Set Frontend Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable else TAG=${{ env.FRONTEND_TAG }} fi echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Web Source Code - uses: actions/download-artifact@v3 - with: - name: web-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./web/Dockerfile.web @@ -105,33 +79,39 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Space Docker Tag + - name: Set Space Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable else TAG=${{ env.SPACE_TAG }} fi echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Space Source Code - uses: actions/download-artifact@v3 - with: - name: space-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Space to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./space/Dockerfile.space @@ -147,36 +127,42 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Backend Docker Tag + - name: Set Backend Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable else TAG=${{ env.BACKEND_TAG }} fi echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Backend Source Code - uses: actions/download-artifact@v3 - with: - name: backend-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Backend to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile.api + context: ./apiserver + file: ./apiserver/Dockerfile.api platforms: linux/amd64 push: true tags: ${{ env.BACKEND_TAG }} @@ -189,37 +175,42 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Proxy Docker Tag + - name: Set Proxy Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable else TAG=${{ env.PROXY_TAG }} fi echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Proxy Source Code - uses: actions/download-artifact@v3 - with: - name: proxy-src-code + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Plane-Proxy to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile + context: ./nginx + file: ./nginx/Dockerfile platforms: linux/amd64 tags: ${{ env.PROXY_TAG }} push: true diff --git a/README.md b/README.md index 41ebdd169..b509fd6f6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose). +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). ## ⚡️ Contributors Quick Start diff --git a/apiserver/.env.example b/apiserver/.env.example index 6558078e2..42b0e32e5 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -8,11 +8,11 @@ SENTRY_DSN="" SENTRY_ENVIRONMENT="development" # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB} # Oauth variables GOOGLE_CLIENT_ID="" diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 4db0ec565..333c0c05b 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -39,7 +39,6 @@ from plane.app.serializers import ( from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, - WorkspaceUserPermission ) from plane.db.models import ( User, diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1054b6af3..d7bff43d6 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -1,5 +1,5 @@ # Python imports -from datetime import timedelta, date, datetime +from datetime import date, datetime, timedelta # Django imports from django.db import connection @@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page - # Third party imports from rest_framework import status from rest_framework.response import Response -# Module imports -from .base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - Page, - PageFavorite, - Issue, - IssueAssignee, - IssueActivity, - PageLog, - ProjectMember, -) -from plane.app.serializers import ( - PageSerializer, - PageFavoriteSerializer, - PageLogSerializer, - IssueLiteSerializer, - SubPageSerializer, -) +from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer, + PageLogSerializer, PageSerializer, + SubPageSerializer) +from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page, + PageFavorite, PageLog, ProjectMember) + +# Module imports +from .base import BaseAPIView, BaseViewSet def unarchive_archive_page_and_descendants(page_id, archived_at): @@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet): project_id=project_id, member=request.user, is_active=True, - role__gt=20, + role__gte=20, ).exists() or request.user.id != page.owned_by_id ): diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 9cedff8f4..ec878bcb0 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -41,7 +41,7 @@ from plane.app.serializers import ( ProjectMemberSerializer, WorkspaceThemeSerializer, IssueActivitySerializer, - IssueLiteSerializer, + IssueSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, ProjectMemberRoleSerializer, @@ -1339,23 +1339,10 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): project__project_projectmember__member=request.user, ) .filter(**filters) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .select_related("project", "workspace", "state", "parent") + .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .order_by("-created_at") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1370,6 +1357,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .order_by("created_at") ).distinct() # Priority Ordering @@ -1432,7 +1426,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer( + issues = IssueSerializer( issue_queryset, many=True, fields=fields if fields else None ).data return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 6c966f342..a2ac62927 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration def forgot_password(first_name, email, uidb64, token, current_site): try: relative_link = ( - f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}" + f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" ) abs_url = str(current_site) + relative_link diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 1a47aac1b..131af5e1c 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -7,19 +7,17 @@ from . import ProjectBaseModel def get_default_filters(): - return ( - { - "priority": None, - "state": None, - "state_group": None, - "assignees": None, - "created_by": None, - "labels": None, - "start_date": None, - "target_date": None, - "subscriber": None, - }, - ) + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } def get_default_display_filters(): diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx index a8d19a92b..08026bac1 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -482,19 +482,16 @@ export class TableView implements NodeView { } updateControls() { - const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( - (acc, curr) => { - if (curr.spec.hoveredCell !== undefined) { - acc["hoveredCell"] = curr.spec.hoveredCell; - } + const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } - if (curr.spec.hoveredTable !== undefined) { - acc["hoveredTable"] = curr.spec.hoveredTable; - } - return acc; - }, - {} as Record - ) as any; + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, {} as Record) as any; if (table === undefined || cell === undefined) { return this.root.classList.add("controls--disabled"); diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 21d610751..bbce330ab 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -32,6 +32,7 @@ "@plane/editor-core": "*", "@plane/editor-extensions": "*", "@plane/ui": "*", + "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.1.13", "@tiptap/extension-placeholder": "^2.1.13", "@tiptap/pm": "^2.1.13", diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index 1bda353b8..f1ef298f0 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -18,7 +18,7 @@ import { type IPageRenderer = { documentDetails: DocumentDetails; - updatePageTitle: (title: string) => Promise; + updatePageTitle: (title: string) => void; editor: Editor; onActionCompleteHandler: (action: { title: string; @@ -30,18 +30,6 @@ type IPageRenderer = { readonly: boolean; }; -const debounce = (func: (...args: any[]) => void, wait: number) => { - let timeout: NodeJS.Timeout | null = null; - return function executedFunction(...args: any[]) { - const later = () => { - if (timeout) clearTimeout(timeout); - func(...args); - }; - if (timeout) clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; - export const PageRenderer = (props: IPageRenderer) => { const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props; @@ -64,11 +52,26 @@ export const PageRenderer = (props: IPageRenderer) => { const { getFloatingProps } = useInteractions([dismiss]); - const debouncedUpdatePageTitle = debounce(updatePageTitle, 300); + const [linkViewProps, setLinkViewProps] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [coordinates, setCoordinates] = useState<{ x: number; y: number }>(); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })], + whileElementsMounted: autoUpdate, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + }); + + const { getFloatingProps } = useInteractions([dismiss]); const handlePageTitleChange = (title: string) => { setPagetitle(title); - debouncedUpdatePageTitle(title); + updatePageTitle(title); }; const [cleanup, setcleanup] = useState(() => () => {}); diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index 155245f9e..ca023f4a7 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -26,7 +26,7 @@ export const DocumentEditorExtensions = ( .focus() .insertContentAt( range, - "

#issue_

" + "

#issue_

\n" ) .run(); }, diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx index acc6213c2..35a09bcc2 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx @@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => { title: suggestion.name, priority: suggestion.priority.toString(), identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, - state: suggestion.state_detail.name, + state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo", command: ({ editor, range }) => { editor .chain() diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx index 75d977e49..96a5c1325 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx @@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({ addOptions() { return { suggestion: { + char: "#issue_", + allowSpaces: true, command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { props.command({ editor, range }); }, @@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({ addProseMirrorPlugins() { return [ Suggestion({ - char: "#issue_", pluginKey: new PluginKey("issue-embed-suggestions"), editor: this.editor, - allowSpaces: true, - ...this.options.suggestion, }), ]; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index 0a166c3e3..637afe29c 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -53,7 +53,7 @@ const IssueSuggestionList = ({ const commandListContainer = useRef(null); useEffect(() => { - let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; + const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; let totalLength = 0; sections.forEach((section) => { newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); @@ -65,8 +65,8 @@ const IssueSuggestionList = ({ }, [items]); const selectItem = useCallback( - (index: number) => { - const item = displayedItems[currentSection][index]; + (section: string, index: number) => { + const item = displayedItems[section][index]; if (item) { command(item); } @@ -87,6 +87,7 @@ const IssueSuggestionList = ({ setSelectedIndex( (selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length ); + e.stopPropagation(); return true; } if (e.key === "ArrowDown") { @@ -101,10 +102,12 @@ const IssueSuggestionList = ({ [currentSection]: [...prevItems[currentSection], ...nextItems], })); } + e.stopPropagation(); return true; } if (e.key === "Enter") { - selectItem(selectedIndex); + selectItem(currentSection, selectedIndex); + e.stopPropagation(); return true; } if (e.key === "Tab") { @@ -112,6 +115,7 @@ const IssueSuggestionList = ({ const nextSectionIndex = (currentSectionIndex + 1) % sections.length; setCurrentSection(sections[nextSectionIndex]); setSelectedIndex(0); + e.stopPropagation(); return true; } return false; @@ -172,7 +176,7 @@ const IssueSuggestionList = ({ } )} key={item.identifier} - onClick={() => selectItem(index)} + onClick={() => selectItem(section, index)} >
{item.identifier}
@@ -195,7 +199,7 @@ export const IssueListRenderer = () => { let popup: any | null = null; return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component = new ReactRenderer(IssueSuggestionList, { props, // @ts-ignore @@ -210,10 +214,10 @@ export const IssueListRenderer = () => { showOnCreate: true, interactive: true, trigger: "manual", - placement: "right", + placement: "bottom-start", }); }, - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component?.updateProps(props); popup && diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx index 78554c26d..caca2ded7 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx @@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => { setIssueDetails(issue); setLoading(0); }) - .catch((error) => { - console.log(error); + .catch(() => { setLoading(-1); }); }, []); @@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => { {loading == 0 ? (
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id} diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 34aa54c50..8d12f253b 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -16,7 +16,7 @@ interface IDocumentEditor { // document info documentDetails: DocumentDetails; value: string; - rerenderOnPropsChange: { + rerenderOnPropsChange?: { id: string; description_html: string; }; @@ -39,7 +39,7 @@ interface IDocumentEditor { setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; - updatePageTitle: (title: string) => Promise; + updatePageTitle: (title: string) => void; debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; diff --git a/packages/types/src/app.d.ts b/packages/types/src/app.d.ts index 92b304e17..06a433ddd 100644 --- a/packages/types/src/app.d.ts +++ b/packages/types/src/app.d.ts @@ -1,14 +1,14 @@ export interface IAppConfig { email_password_login: boolean; file_size_limit: number; - google_client_id: string | null; github_app_name: string | null; github_client_id: string | null; - magic_login: boolean; - slack_client_id: string | null; - posthog_api_key: string | null; - posthog_host: string | null; + google_client_id: string | null; has_openai_configured: boolean; has_unsplash_configured: boolean; is_smtp_configured: boolean; + magic_login: boolean; + posthog_api_key: string | null; + posthog_host: string | null; + slack_client_id: string | null; } diff --git a/space/components/accounts/sign-in-forms/create-password.tsx b/space/components/accounts/sign-in-forms/create-password.tsx index cb7326b75..55205e707 100644 --- a/space/components/accounts/sign-in-forms/create-password.tsx +++ b/space/components/accounts/sign-in-forms/create-password.tsx @@ -101,7 +101,7 @@ export const CreatePasswordForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/email-form.tsx b/space/components/accounts/sign-in-forms/email-form.tsx index 43fd4df31..4f8ed4294 100644 --- a/space/components/accounts/sign-in-forms/email-form.tsx +++ b/space/components/accounts/sign-in-forms/email-form.tsx @@ -100,7 +100,7 @@ export const EmailForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/optional-set-password.tsx b/space/components/accounts/sign-in-forms/optional-set-password.tsx index 686848570..219971759 100644 --- a/space/components/accounts/sign-in-forms/optional-set-password.tsx +++ b/space/components/accounts/sign-in-forms/optional-set-password.tsx @@ -61,7 +61,7 @@ export const OptionalSetPasswordForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/password.tsx b/space/components/accounts/sign-in-forms/password.tsx index d080ff639..f909f16c5 100644 --- a/space/components/accounts/sign-in-forms/password.tsx +++ b/space/components/accounts/sign-in-forms/password.tsx @@ -155,7 +155,7 @@ export const PasswordForm: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx index 6ebc05490..af1e5d68f 100644 --- a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx +++ b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx @@ -97,7 +97,7 @@ export const SelfHostedSignInForm: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/set-password-link.tsx b/space/components/accounts/sign-in-forms/set-password-link.tsx index b0e5f69d3..0b5ad21d9 100644 --- a/space/components/accounts/sign-in-forms/set-password-link.tsx +++ b/space/components/accounts/sign-in-forms/set-password-link.tsx @@ -87,7 +87,7 @@ export const SetPasswordLink: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/unique-code.tsx b/space/components/accounts/sign-in-forms/unique-code.tsx index 6b45bc429..4c61fa151 100644 --- a/space/components/accounts/sign-in-forms/unique-code.tsx +++ b/space/components/accounts/sign-in-forms/unique-code.tsx @@ -182,7 +182,7 @@ export const UniqueCodeForm: React.FC = (props) => { }} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/pages/accounts/password.tsx b/space/pages/accounts/password.tsx index a3fabdda9..85da11290 100644 --- a/space/pages/accounts/password.tsx +++ b/space/pages/accounts/password.tsx @@ -104,7 +104,7 @@ const HomePage: NextPage = () => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx deleted file mode 100644 index 8bbf859a4..000000000 --- a/web/components/account/email-signup-form.tsx +++ /dev/null @@ -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; -}; - -export const EmailSignUpForm: React.FC = (props) => { - const { onSubmit } = props; - - const { - handleSubmit, - control, - watch, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - password: "", - confirm_password: "", - medium: "email", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - return ( - <> -
-
- - /^(([^<>()[\]\\.,;:\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 } }) => ( - - )} - /> -
-
- ( - - )} - /> -
-
- { - if (watch("password") != val) { - return "Your passwords do no match"; - } - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> -
-
- - - Already have an account? Sign in. - - -
-
- -
-
- - ); -}; diff --git a/web/components/account/index.ts b/web/components/account/index.ts index 275f7ff08..0d1cffbc6 100644 --- a/web/components/account/index.ts +++ b/web/components/account/index.ts @@ -1,5 +1,4 @@ +export * from "./o-auth"; export * from "./sign-in-forms"; +export * from "./sign-up-forms"; export * from "./deactivate-account-modal"; -export * from "./github-sign-in"; -export * from "./google-sign-in"; -export * from "./email-signup-form"; diff --git a/web/components/account/github-sign-in.tsx b/web/components/account/o-auth/github-sign-in.tsx similarity index 90% rename from web/components/account/github-sign-in.tsx rename to web/components/account/o-auth/github-sign-in.tsx index 27a8bf01c..74bfd6d94 100644 --- a/web/components/account/github-sign-in.tsx +++ b/web/components/account/o-auth/github-sign-in.tsx @@ -12,10 +12,11 @@ import githubDarkModeImage from "/public/logos/github-dark.svg"; type Props = { handleSignIn: React.Dispatch; clientId: string; + type: "sign_in" | "sign_up"; }; export const GitHubSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; + const { handleSignIn, clientId, type } = props; // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -53,7 +54,7 @@ export const GitHubSignInButton: FC = (props) => { width={20} alt="GitHub Logo" /> - Sign-in with GitHub + {type === "sign_in" ? "Sign-in" : "Sign-up"} with GitHub
diff --git a/web/components/account/google-sign-in.tsx b/web/components/account/o-auth/google-sign-in.tsx similarity index 87% rename from web/components/account/google-sign-in.tsx rename to web/components/account/o-auth/google-sign-in.tsx index 48488e07e..93958bbd2 100644 --- a/web/components/account/google-sign-in.tsx +++ b/web/components/account/o-auth/google-sign-in.tsx @@ -4,10 +4,11 @@ import Script from "next/script"; type Props = { handleSignIn: React.Dispatch; clientId: string; + type: "sign_in" | "sign_up"; }; export const GoogleSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; + const { handleSignIn, clientId, type } = props; // refs const googleSignInButton = useRef(null); // states @@ -29,7 +30,7 @@ export const GoogleSignInButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - text: "signin_with", + text: type === "sign_in" ? "signin_with" : "signup_with", width: 384, } as GsiButtonConfiguration // customization attributes ); @@ -40,7 +41,7 @@ export const GoogleSignInButton: FC = (props) => { window?.google?.accounts.id.prompt(); // also display the One Tap dialog setGsiScriptLoaded(true); - }, [handleSignIn, gsiScriptLoaded, clientId]); + }, [handleSignIn, gsiScriptLoaded, clientId, type]); useEffect(() => { if (window?.google?.accounts?.id) { diff --git a/web/components/account/o-auth/index.ts b/web/components/account/o-auth/index.ts new file mode 100644 index 000000000..4cea6ce5b --- /dev/null +++ b/web/components/account/o-auth/index.ts @@ -0,0 +1,3 @@ +export * from "./github-sign-in"; +export * from "./google-sign-in"; +export * from "./o-auth-options"; diff --git a/web/components/account/sign-in-forms/o-auth-options.tsx b/web/components/account/o-auth/o-auth-options.tsx similarity index 81% rename from web/components/account/sign-in-forms/o-auth-options.tsx rename to web/components/account/o-auth/o-auth-options.tsx index 9ed4e7e5f..7c8468acb 100644 --- a/web/components/account/sign-in-forms/o-auth-options.tsx +++ b/web/components/account/o-auth/o-auth-options.tsx @@ -9,19 +9,22 @@ import { GitHubSignInButton, GoogleSignInButton } from "components/account"; type Props = { handleSignInRedirection: () => Promise; + type: "sign_in" | "sign_up"; }; // services const authService = new AuthService(); export const OAuthOptions: React.FC = observer((props) => { - const { handleSignInRedirection } = props; + const { handleSignInRedirection, type } = props; // toast alert const { setToastAlert } = useToast(); // mobx store const { config: { envConfig }, } = useApplication(); + // derived values + const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id; const handleGoogleSignIn = async ({ clientId, credential }: any) => { try { @@ -72,12 +75,14 @@ export const OAuthOptions: React.FC = observer((props) => {

Or continue with


-
+
{envConfig?.google_client_id && ( - +
+ +
)} {envConfig?.github_client_id && ( - + )}
diff --git a/web/components/account/sign-in-forms/email.tsx b/web/components/account/sign-in-forms/email.tsx new file mode 100644 index 000000000..67ef720fe --- /dev/null +++ b/web/components/account/sign-in-forms/email.tsx @@ -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 = observer((props) => { + const { onSubmit, updateEmail } = props; + // hooks + const { setToastAlert } = useToast(); + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + 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 ( + <> +

+ Welcome back, let{"'"}s get you on board +

+

+ Get back to your issues, projects and workspaces. +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+ +
+ + ); +}); diff --git a/web/components/account/sign-in-forms/forgot-password-popover.tsx b/web/components/account/sign-in-forms/forgot-password-popover.tsx new file mode 100644 index 000000000..d652e51f1 --- /dev/null +++ b/web/components/account/sign-in-forms/forgot-password-popover.tsx @@ -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(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + return ( + + + + + + {({ close }) => ( +
+ 🤥 +

+ We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link +

+ +
+ )} +
+
+ ); +}; diff --git a/web/components/account/sign-in-forms/index.ts b/web/components/account/sign-in-forms/index.ts index 1150a071c..8e44f490b 100644 --- a/web/components/account/sign-in-forms/index.ts +++ b/web/components/account/sign-in-forms/index.ts @@ -1,9 +1,6 @@ -export * from "./create-password"; -export * from "./email-form"; -export * from "./o-auth-options"; +export * from "./email"; +export * from "./forgot-password-popover"; export * from "./optional-set-password"; export * from "./password"; export * from "./root"; -export * from "./self-hosted-sign-in"; -export * from "./set-password-link"; export * from "./unique-code"; diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index ead9b9c9a..1669811cb 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -1,36 +1,76 @@ import React, { useState } from "react"; -import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/auth.service"; +// hooks +import useToast from "hooks/use-toast"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; -// constants -import { ESignInSteps } from "components/account"; type Props = { email: string; - handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; - isOnboarded: boolean; }; -export const OptionalSetPasswordForm: React.FC = (props) => { - const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; +type TCreatePasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TCreatePasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const SignInOptionalSetPasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection } = props; // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + // toast alert + const { setToastAlert } = useToast(); // form info const { control, - formState: { errors, isValid }, - } = useForm({ + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ defaultValues: { + ...defaultValues, email, }, mode: "onChange", reValidateMode: "onChange", }); + const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { + const payload = { + password: formData.password, + }; + + await authService + .setPassword(payload) + .then(async () => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Password created successfully.", + }); + await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); @@ -39,12 +79,11 @@ export const OptionalSetPasswordForm: React.FC = (props) => { return ( <> -

Set a password

-

+

Set your password

+

If you{"'"}d like to do away with codes, set a password here.

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

+ Whatever you choose now will be your account{"'"}s password until you change it. +

+
+
-

- When you click{" "} - {isOnboarded ? "Go to workspace" : "Set up workspace"} above, - you agree with our{" "} - - terms and conditions of service. - -

); diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index c2eb358f2..fd4ccbf40 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import Link from "next/link"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services @@ -7,22 +8,20 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import { useApplication } from "hooks/store"; +// components +import { ESignInSteps, ForgotPasswordPopover } from "components/account"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IPasswordSignInData } from "@plane/types"; -// constants -import { ESignInSteps } from "components/account"; -import { observer } from "mobx-react-lite"; type Props = { email: string; - updateEmail: (email: string) => void; handleStepChange: (step: ESignInSteps) => void; - handleSignInRedirection: () => Promise; handleEmailClear: () => void; + onSubmit: () => Promise; }; type TPasswordFormValues = { @@ -37,24 +36,24 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const PasswordForm: React.FC = observer((props) => { - const { email, updateEmail, handleStepChange, handleSignInRedirection, handleEmailClear } = props; +export const SignInPasswordForm: React.FC = observer((props) => { + const { email, handleStepChange, handleEmailClear, onSubmit } = props; // states const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); - const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); // toast alert const { setToastAlert } = useToast(); const { config: { envConfig }, } = useApplication(); + // derived values + const isSmtpConfigured = envConfig?.is_smtp_configured; // form info const { control, - formState: { dirtyFields, errors, isSubmitting, isValid }, + formState: { errors, isSubmitting, isValid }, getValues, handleSubmit, setError, - setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -65,8 +64,6 @@ export const PasswordForm: React.FC = observer((props) => { }); const handleFormSubmit = async (formData: TPasswordFormValues) => { - updateEmail(formData.email); - const payload: IPasswordSignInData = { email: formData.email, password: formData.password, @@ -74,7 +71,7 @@ export const PasswordForm: React.FC = observer((props) => { await authService .passwordSignIn(payload) - .then(async () => await handleSignInRedirection()) + .then(async () => await onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -84,31 +81,6 @@ export const PasswordForm: React.FC = observer((props) => { ); }; - const handleForgotPassword = async () => { - const emailFormValue = getValues("email"); - - const isEmailValid = checkEmailValidity(emailFormValue); - - if (!isEmailValid) { - setError("email", { message: "Email is invalid" }); - return; - } - - setIsSendingResetPasswordLink(true); - - authService - .sendResetPasswordLink({ email: emailFormValue }) - .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsSendingResetPasswordLink(false)); - }; - const handleSendUniqueCode = async () => { const emailFormValue = getValues("email"); @@ -134,16 +106,15 @@ export const PasswordForm: React.FC = observer((props) => { .finally(() => setIsSendingUniqueCode(false)); }; - useEffect(() => { - setFocus("password"); - }, [setFocus]); - return ( <> -

- Get on your flight deck +

+ Welcome back, let{"'"}s get you on board

-
+

+ Get back to your issues, projects and workspaces. +

+
= observer((props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" - disabled + disabled={isSmtpConfigured} /> {value.length > 0 && ( { + if (isSmtpConfigured) handleEmailClear(); + else onChange(""); + }} /> )}
@@ -180,7 +154,7 @@ export const PasswordForm: React.FC = observer((props) => { control={control} name="password" rules={{ - required: dirtyFields.email ? false : "Password is required", + required: "Password is required", }} render={({ field: { value, onChange } }) => ( = observer((props) => { hasError={Boolean(errors.password)} placeholder="Enter password" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> )} /> -
- +
+ {isSmtpConfigured ? ( + + Forgot your password? + + ) : ( + + )}
-
+
+ {envConfig && envConfig.is_smtp_configured && ( )} -
-

- When you click Go to workspace above, you agree with our{" "} - - terms and conditions of service. - -

); diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 80f46c63e..c92cd4bd4 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks import { useApplication } from "hooks/store"; @@ -6,115 +7,115 @@ import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { LatestFeatureBlock } from "components/common"; import { - EmailForm, - UniqueCodeForm, - PasswordForm, - SetPasswordLink, + SignInEmailForm, + SignInUniqueCodeForm, + SignInPasswordForm, OAuthOptions, - OptionalSetPasswordForm, - CreatePasswordForm, + SignInOptionalSetPasswordForm, } from "components/account"; export enum ESignInSteps { EMAIL = "EMAIL", PASSWORD = "PASSWORD", - SET_PASSWORD_LINK = "SET_PASSWORD_LINK", UNIQUE_CODE = "UNIQUE_CODE", OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", - CREATE_PASSWORD = "CREATE_PASSWORD", USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", } -const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; - export const SignInRoot = observer(() => { // states - const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); + const [signInStep, setSignInStep] = useState(null); const [email, setEmail] = useState(""); - const [isOnboarded, setIsOnboarded] = useState(false); // sign in redirection hook const { handleRedirection } = useSignInRedirection(); // mobx store const { config: { envConfig }, } = useApplication(); + // derived values + const isSmtpConfigured = envConfig?.is_smtp_configured; + + // step 1 submit handler- email verification + const handleEmailVerification = (isPasswordAutoset: boolean) => { + if (isSmtpConfigured && isPasswordAutoset) setSignInStep(ESignInSteps.UNIQUE_CODE); + else setSignInStep(ESignInSteps.PASSWORD); + }; + + // step 2 submit handler- unique code sign in + const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => { + if (isPasswordAutoset) setSignInStep(ESignInSteps.OPTIONAL_SET_PASSWORD); + else await handleRedirection(); + }; + + // step 3 submit handler- password sign in + const handlePasswordSignIn = async () => { + await handleRedirection(); + }; const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); + useEffect(() => { + if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL); + else setSignInStep(ESignInSteps.PASSWORD); + }, [isSmtpConfigured]); + return ( <>
<> {signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} - updateEmail={(newEmail) => setEmail(newEmail)} + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + onSubmit={handleUniqueCodeSignIn} + submitButtonText="Continue" /> )} {signInStep === ESignInSteps.PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} handleEmailClear={() => { setEmail(""); setSignInStep(ESignInSteps.EMAIL); }} - handleSignInRedirection={handleRedirection} + onSubmit={handlePasswordSignIn} + handleStepChange={(step) => setSignInStep(step)} /> )} - {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( - setEmail(newEmail)} /> - )} {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( - 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 && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - updateUserOnboardingStatus={(value) => setIsOnboarded(value)} handleEmailClear={() => { setEmail(""); setSignInStep(ESignInSteps.EMAIL); }} + onSubmit={handleUniqueCodeSignIn} + submitButtonText="Go to workspace" /> )} {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> - )} - {signInStep === ESignInSteps.CREATE_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> + )}
- {isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && ( - - )} + {isOAuthEnabled && + (signInStep === ESignInSteps.EMAIL || (!isSmtpConfigured && signInStep === ESignInSteps.PASSWORD)) && ( + <> + +

+ Don{"'"}t have an account?{" "} + + Sign up + +

+ + )} ); diff --git a/web/components/account/sign-in-forms/set-password-link.tsx b/web/components/account/sign-in-forms/set-password-link.tsx deleted file mode 100644 index 788142d80..000000000 --- a/web/components/account/sign-in-forms/set-password-link.tsx +++ /dev/null @@ -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) => { - 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 ( - <> -

- Get on your flight deck -

-

- We have sent a link to {email}, so you can set a - password -

- -
-
- checkEmailValidity(value) || "Email is invalid", - }} - render={({ field: { value, onChange } }) => ( - - )} - /> -
- -
- - ); -}; diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 3ab75831d..6e0ae3745 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState } from "react"; -import Link from "next/link"; +import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { CornerDownLeft, XCircle } from "lucide-react"; +import { XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; import { UserService } from "services/user.service"; @@ -14,18 +13,12 @@ import { Button, Input } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; -// constants -import { ESignInSteps } from "components/account"; type Props = { email: string; - updateEmail: (email: string) => void; - handleStepChange: (step: ESignInSteps) => void; - handleSignInRedirection: () => Promise; - submitButtonLabel?: string; - showTermsAndConditions?: boolean; - updateUserOnboardingStatus: (value: boolean) => void; + onSubmit: (isPasswordAutoset: boolean) => Promise; handleEmailClear: () => void; + submitButtonText: string; }; type TUniqueCodeFormValues = { @@ -42,17 +35,8 @@ const defaultValues: TUniqueCodeFormValues = { const authService = new AuthService(); const userService = new UserService(); -export const UniqueCodeForm: React.FC = (props) => { - const { - email, - updateEmail, - handleStepChange, - handleSignInRedirection, - submitButtonLabel = "Continue", - showTermsAndConditions = false, - updateUserOnboardingStatus, - handleEmailClear, - } = props; +export const SignInUniqueCodeForm: React.FC = (props) => { + const { email, onSubmit, handleEmailClear, submitButtonText } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert @@ -62,11 +46,10 @@ export const UniqueCodeForm: React.FC = (props) => { // form info const { control, - formState: { dirtyFields, errors, isSubmitting, isValid }, + formState: { errors, isSubmitting, isValid }, getValues, handleSubmit, reset, - setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -88,10 +71,7 @@ export const UniqueCodeForm: React.FC = (props) => { .then(async () => { const currentUser = await userService.currentUser(); - updateUserOnboardingStatus(currentUser.is_onboarded); - - if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); - else await handleSignInRedirection(); + await onSubmit(currentUser.is_password_autoset); }) .catch((err) => setToastAlert({ @@ -131,13 +111,6 @@ export const UniqueCodeForm: React.FC = (props) => { ); }; - const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { - updateEmail(formData.email); - - if (dirtyFields.email) await handleSendNewCode(formData); - else await handleUniqueCodeSignIn(formData); - }; - const handleRequestNewCode = async () => { setIsRequestingNewCode(true); @@ -147,21 +120,16 @@ export const UniqueCodeForm: React.FC = (props) => { }; const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; - const hasEmailChanged = dirtyFields.email; - useEffect(() => { - setFocus("token"); - }, [setFocus]); return ( <> -

- Get on your flight deck -

+

Moving to the runway

- Paste the code you got at {email} below. + Paste the code you got at +
+ {email} below.

- -
+
= (props) => { type="email" value={value} onChange={onChange} - onBlur={() => { - if (hasEmailChanged) handleSendNewCode(getValues()); - }} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" disabled /> @@ -196,21 +161,13 @@ export const UniqueCodeForm: React.FC = (props) => {
)} /> - {hasEmailChanged && ( - - )}
( = (props) => { hasError={Boolean(errors.token)} placeholder="gets-sets-flys" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> )} /> @@ -241,24 +199,9 @@ export const UniqueCodeForm: React.FC = (props) => {
- - {showTermsAndConditions && ( -

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

- )} ); diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-up-forms/email.tsx similarity index 75% rename from web/components/account/sign-in-forms/email-form.tsx rename to web/components/account/sign-up-forms/email.tsx index 7642c3b99..58165e873 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-up-forms/email.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; import { observer } from "mobx-react-lite"; @@ -13,11 +13,9 @@ import { Button, Input } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData } from "@plane/types"; -// constants -import { ESignInSteps } from "components/account"; type Props = { - handleStepChange: (step: ESignInSteps) => void; + onSubmit: () => void; updateEmail: (email: string) => void; }; @@ -27,18 +25,14 @@ type TEmailFormValues = { const authService = new AuthService(); -export const EmailForm: React.FC = observer((props) => { - const { handleStepChange, updateEmail } = props; +export const SignUpEmailForm: React.FC = observer((props) => { + const { onSubmit, updateEmail } = props; // hooks const { setToastAlert } = useToast(); - const { - config: { envConfig }, - } = useApplication(); const { control, formState: { errors, isSubmitting, isValid }, handleSubmit, - setFocus, } = useForm({ defaultValues: { email: "", @@ -57,14 +51,7 @@ export const EmailForm: React.FC = observer((props) => { await authService .emailCheck(payload) - .then((res) => { - // if the password has been auto set, send the user to magic sign-in - if (res.is_password_autoset && envConfig?.is_smtp_configured) { - handleStepChange(ESignInSteps.UNIQUE_CODE); - } - // if the password has not been auto set, send them to password sign-in - else handleStepChange(ESignInSteps.PASSWORD); - }) + .then(() => onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -74,10 +61,6 @@ export const EmailForm: React.FC = observer((props) => { ); }; - useEffect(() => { - setFocus("email"); - }, [setFocus]); - return ( <>

@@ -96,7 +79,7 @@ export const EmailForm: React.FC = observer((props) => { required: "Email is required", validate: (value) => checkEmailValidity(value) || "Email is invalid", }} - render={({ field: { value, onChange, ref } }) => ( + render={({ field: { value, onChange } }) => (
= observer((props) => { type="email" value={value} onChange={onChange} - ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> {value.length > 0 && ( = observer((props) => { />
diff --git a/web/components/account/sign-up-forms/index.ts b/web/components/account/sign-up-forms/index.ts new file mode 100644 index 000000000..f84d41abc --- /dev/null +++ b/web/components/account/sign-up-forms/index.ts @@ -0,0 +1,5 @@ +export * from "./email"; +export * from "./optional-set-password"; +export * from "./password"; +export * from "./root"; +export * from "./unique-code"; diff --git a/web/components/account/sign-in-forms/create-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx similarity index 51% rename from web/components/account/sign-in-forms/create-password.tsx rename to web/components/account/sign-up-forms/optional-set-password.tsx index cf53078be..38fdaeca1 100644 --- a/web/components/account/sign-in-forms/create-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -1,5 +1,4 @@ -import React, { useEffect } from "react"; -import Link from "next/link"; +import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; // services import { AuthService } from "services/auth.service"; @@ -10,13 +9,12 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // constants -import { ESignInSteps } from "components/account"; +import { ESignUpSteps } from "components/account"; type Props = { email: string; - handleStepChange: (step: ESignInSteps) => void; + handleStepChange: (step: ESignUpSteps) => void; handleSignInRedirection: () => Promise; - isOnboarded: boolean; }; type TCreatePasswordFormValues = { @@ -32,8 +30,10 @@ const defaultValues: TCreatePasswordFormValues = { // services const authService = new AuthService(); -export const CreatePasswordForm: React.FC = (props) => { - const { email, handleSignInRedirection, isOnboarded } = props; +export const SignUpOptionalSetPasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection } = props; + // states + const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -41,7 +41,6 @@ export const CreatePasswordForm: React.FC = (props) => { control, formState: { errors, isSubmitting, isValid }, handleSubmit, - setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -75,16 +74,21 @@ export const CreatePasswordForm: React.FC = (props) => { ); }; - useEffect(() => { - setFocus("password"); - }, [setFocus]); + const handleGoToWorkspace = async () => { + setIsGoingToWorkspace(true); + + await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + }; return ( <> -

- Get on your flight deck -

-
+

Moving to the runway

+

+ Let{"'"}s set a password so +
+ you can do away with codes. +

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

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

+
+ ( + + )} + /> +

+ This password will continue to be your account{"'"}s password. +

+
+
+ + +
); diff --git a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx b/web/components/account/sign-up-forms/password.tsx similarity index 79% rename from web/components/account/sign-in-forms/self-hosted-sign-in.tsx rename to web/components/account/sign-up-forms/password.tsx index bcecef20a..6ff6753df 100644 --- a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx +++ b/web/components/account/sign-up-forms/password.tsx @@ -1,5 +1,6 @@ -import React, { useEffect } from "react"; +import React from "react"; import Link from "next/link"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services @@ -14,9 +15,7 @@ import { checkEmailValidity } from "helpers/string.helper"; import { IPasswordSignInData } from "@plane/types"; type Props = { - email: string; - updateEmail: (email: string) => void; - handleSignInRedirection: () => Promise; + onSubmit: () => Promise; }; type TPasswordFormValues = { @@ -31,20 +30,18 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const SelfHostedSignInForm: React.FC = (props) => { - const { email, updateEmail, handleSignInRedirection } = props; +export const SignUpPasswordForm: React.FC = observer((props) => { + const { onSubmit } = props; // toast alert const { setToastAlert } = useToast(); // form info const { control, - formState: { dirtyFields, errors, isSubmitting }, + formState: { errors, isSubmitting, isValid }, handleSubmit, - setFocus, } = useForm({ defaultValues: { ...defaultValues, - email, }, mode: "onChange", reValidateMode: "onChange", @@ -56,11 +53,9 @@ export const SelfHostedSignInForm: React.FC = (props) => { password: formData.password, }; - updateEmail(formData.email); - await authService .passwordSignIn(payload) - .then(async () => await handleSignInRedirection()) + .then(async () => await onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -70,16 +65,15 @@ export const SelfHostedSignInForm: React.FC = (props) => { ); }; - useEffect(() => { - setFocus("email"); - }, [setFocus]); - return ( <> -

+

Get on your flight deck

-
+

+ Create or join a workspace. Start with your e-mail. +

+
= (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( @@ -115,7 +109,7 @@ export const SelfHostedSignInForm: React.FC = (props) => { control={control} name="password" rules={{ - required: dirtyFields.email ? false : "Password is required", + required: "Password is required", }} render={({ field: { value, onChange } }) => ( = (props) => { hasError={Boolean(errors.password)} placeholder="Enter password" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> )} /> +

+ This password will continue to be your account{"'"}s password. +

-

When you click the button above, you agree with our{" "} @@ -141,4 +139,4 @@ export const SelfHostedSignInForm: React.FC = (props) => { ); -}; +}); diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx new file mode 100644 index 000000000..da9d7d79a --- /dev/null +++ b/web/components/account/sign-up-forms/root.tsx @@ -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(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 ( + <> +

+ <> + {signInStep === ESignUpSteps.EMAIL && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignUpSteps.UNIQUE_CODE && ( + { + setEmail(""); + setSignInStep(ESignUpSteps.EMAIL); + }} + onSubmit={handleUniqueCodeSignIn} + /> + )} + {signInStep === ESignUpSteps.PASSWORD && } + {signInStep === ESignUpSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + /> + )} + +
+ {isOAuthEnabled && signInStep && OAUTH_ENABLED_STEPS.includes(signInStep) && ( + <> + +

+ Already using Plane?{" "} + + Sign in + +

+ + )} + + ); +}); diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx new file mode 100644 index 000000000..7764b627e --- /dev/null +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -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; +}; + +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) => { + 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({ + 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 ( + <> +

Moving to the runway

+

+ Paste the code you got at +
+ {email} below. +

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

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+
+ + ); +}; diff --git a/web/components/command-palette/actions/workspace-settings-actions.tsx b/web/components/command-palette/actions/workspace-settings-actions.tsx index 7503343ee..90a2ee613 100644 --- a/web/components/command-palette/actions/workspace-settings-actions.tsx +++ b/web/components/command-palette/actions/workspace-settings-actions.tsx @@ -1,8 +1,10 @@ import { useRouter } from "next/router"; import { Command } from "cmdk"; -// icons -import { SettingIcon } from "components/icons"; +// hooks +import { useUser } from "hooks/store"; import Link from "next/link"; +// constants +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace"; type Props = { closePalette: () => void; @@ -10,60 +12,35 @@ type Props = { export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) => { const { closePalette } = props; - + // router const router = useRouter(); const { workspaceSlug } = router.query; + // mobx store + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // derived values + const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; return ( <> - - -
- - General -
- -
- - -
- - Members -
- -
- - -
- - Billing and Plans -
- -
- - -
- - Integrations -
- -
- - -
- - Import -
- -
- - -
- - Export -
- -
+ {WORKSPACE_SETTINGS_LINKS.map( + (setting) => + workspaceMemberInfo >= setting.access && ( + redirect(`/${workspaceSlug}${setting.href}`)} + className="focus:outline-none" + > + +
+ + {setting.label} +
+ +
+ ) + )} ); }; diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 9a7d08f96..aedc13843 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -1,23 +1,17 @@ import { FC } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, useProject } from "hooks/store"; -// services -import { PageService } from "services/page.service"; +import { useApplication, usePage, useProject } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers import { renderEmoji } from "helpers/emoji.helper"; -// fetch-keys -import { PAGE_DETAILS } from "constants/fetch-keys"; export interface IPagesHeaderProps { showButton?: boolean; } -const pageService = new PageService(); export const PageDetailsHeader: FC = observer((props) => { const { showButton = false } = props; @@ -28,12 +22,7 @@ export const PageDetailsHeader: FC = observer((props) => { const { commandPalette: commandPaletteStore } = useApplication(); const { currentProjectDetails } = useProject(); - const { data: pageDetails } = useSWR( - workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null, - workspaceSlug && currentProjectDetails?.id - ? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string) - : null - ); + const pageDetails = usePage(pageId as string); return (
diff --git a/web/components/instance/setup-form/sign-in-form.tsx b/web/components/instance/setup-form/sign-in-form.tsx index 5a7a22987..f34d807c9 100644 --- a/web/components/instance/setup-form/sign-in-form.tsx +++ b/web/components/instance/setup-form/sign-in-form.tsx @@ -88,7 +88,7 @@ export const InstanceSetupSignInForm: FC = (props) => { type="email" value={value} onChange={onChange} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index b87bec2d0..0262788ac 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -5,19 +5,17 @@ import useSWR from "swr"; // hooks import { useGlobalView, useIssues, useUser } from "hooks/store"; // components -import { GlobalViewsAppliedFiltersRoot } from "components/issues"; +import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues"; import { SpreadsheetView } from "components/issues/issue-layouts"; import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns"; // ui import { Spinner } from "@plane/ui"; // types -import { TIssue, IIssueDisplayFilterOptions, TStaticViewTypes, TUnGroupedIssues } from "@plane/types"; +import { TIssue, IIssueDisplayFilterOptions } from "@plane/types"; import { EIssueActions } from "../types"; import { EUserProjectRoles } from "constants/project"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; - - export const AllIssueLayoutRoot: React.FC = observer(() => { // router const router = useRouter(); @@ -48,11 +46,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { if (workspaceSlug && globalViewId) { await fetchAllGlobalViews(workspaceSlug.toString()); await fetchFilters(workspaceSlug.toString(), globalViewId.toString()); - await fetchIssues( - workspaceSlug.toString(), - globalViewId.toString(), - groupedIssueIds ? "mutation" : "init-loader" - ); + await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader"); } } ); @@ -138,6 +132,9 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { )} )} + + {/* peek overview */} +
); }); diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 56ccb9b7c..5b36a1c4f 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -35,12 +35,9 @@ export type TIssuePeekOperations = { export const IssuePeekOverview: FC = observer((props) => { const { is_archived = false, onIssueUpdate } = props; // hooks - const { - project: {}, - } = useMember(); const { setToastAlert } = useToast(); const { - membership: { currentProjectRole }, + membership: { currentWorkspaceAllProjectsRole }, } = useUser(); const { issues: { removeIssue: removeArchivedIssue }, @@ -198,6 +195,7 @@ export const IssuePeekOverview: FC = observer((props) => { const issue = getIssueById(peekIssue.issueId) || undefined; + const currentProjectRole = currentWorkspaceAllProjectsRole?.[peekIssue?.projectId]; // Check if issue is editable, based on user role const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isLoading = !issue || loader ? true : false; diff --git a/web/components/page-views/signin.tsx b/web/components/page-views/signin.tsx index e4810ec2d..89bca5d62 100644 --- a/web/components/page-views/signin.tsx +++ b/web/components/page-views/signin.tsx @@ -7,7 +7,7 @@ import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { SignInRoot } from "components/account"; // ui -import { Loader, Spinner } from "@plane/ui"; +import { Spinner } from "@plane/ui"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; @@ -26,7 +26,7 @@ export const SignInView = observer(() => { handleRedirection(); }, [handleRedirection]); - if (isRedirecting || currentUser) + if (isRedirecting || currentUser || !envConfig) return (
@@ -35,32 +35,16 @@ export const SignInView = observer(() => { return (
-
+
Plane Logo Plane
-
+
- {!envConfig ? ( -
-
- - - - - - - - - -
-
- ) : ( - - )} +
diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 21142cab9..e6763acc6 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -2,132 +2,56 @@ import React, { FC } from "react"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; // hooks -import { useApplication, usePage, useWorkspace } from "hooks/store"; -import useToast from "hooks/use-toast"; +import { useApplication } from "hooks/store"; // components import { PageForm } from "./page-form"; // types import { IPage } from "@plane/types"; +import { useProjectPages } from "hooks/store/use-project-page"; +import { IPageStore } from "store/page.store"; type Props = { - data?: IPage | null; + // data?: IPage | null; + pageStore?: IPageStore; handleClose: () => void; isOpen: boolean; projectId: string; }; export const CreateUpdatePageModal: FC = (props) => { - const { isOpen, handleClose, data, projectId } = props; + const { isOpen, handleClose, projectId, pageStore } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; + + const { createPage } = useProjectPages(); // store hooks const { eventTracker: { postHogEventTracker }, } = useApplication(); - const { currentWorkspace } = useWorkspace(); - const { createPage, updatePage } = usePage(); - // toast alert - const { setToastAlert } = useToast(); - - const onClose = () => { - handleClose(); - }; const createProjectPage = async (payload: IPage) => { if (!workspaceSlug) return; - - // await createPage(workspaceSlug.toString(), projectId, payload) - // .then((res) => { - // router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`); - // onClose(); - // setToastAlert({ - // type: "success", - // title: "Success!", - // message: "Page created successfully.", - // }); - // postHogEventTracker( - // "PAGE_CREATED", - // { - // ...res, - // state: "SUCCESS", - // }, - // { - // isGrouping: true, - // groupType: "Workspace_metrics", - // groupId: currentWorkspace?.id!, - // } - // ); - // }) - // .catch((err) => { - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: err.detail ?? "Page could not be created. Please try again.", - // }); - // postHogEventTracker( - // "PAGE_CREATED", - // { - // state: "FAILED", - // }, - // { - // isGrouping: true, - // groupType: "Workspace_metrics", - // groupId: currentWorkspace?.id!, - // } - // ); - // }); - }; - - const updateProjectPage = async (payload: IPage) => { - if (!data || !workspaceSlug) return; - - // await updatePage(workspaceSlug.toString(), projectId, data.id, payload) - // .then((res) => { - // onClose(); - // setToastAlert({ - // type: "success", - // title: "Success!", - // message: "Page updated successfully.", - // }); - // postHogEventTracker( - // "PAGE_UPDATED", - // { - // ...res, - // state: "SUCCESS", - // }, - // { - // isGrouping: true, - // groupType: "Workspace_metrics", - // groupId: currentWorkspace?.id!, - // } - // ); - // }) - // .catch((err) => { - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: err.detail ?? "Page could not be updated. Please try again.", - // }); - // postHogEventTracker( - // "PAGE_UPDATED", - // { - // state: "FAILED", - // }, - // { - // isGrouping: true, - // groupType: "Workspace_metrics", - // groupId: currentWorkspace?.id!, - // } - // ); - // }); + await createPage(workspaceSlug.toString(), projectId, payload); }; const handleFormSubmit = async (formData: IPage) => { if (!workspaceSlug || !projectId) return; - - if (!data) await createProjectPage(formData); - else await updateProjectPage(formData); + try { + if (pageStore) { + if (pageStore.name !== formData.name) { + await pageStore.updateName(formData.name); + } + if (pageStore.access !== formData.access) { + formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic(); + } + } else { + await createProjectPage(formData); + } + handleClose(); + } catch (error) { + console.log(error); + } }; return ( @@ -157,7 +81,7 @@ export const CreateUpdatePageModal: FC = (props) => { leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
diff --git a/web/components/pages/delete-page-modal.tsx b/web/components/pages/delete-page-modal.tsx index fbb4667dc..736f21359 100644 --- a/web/components/pages/delete-page-modal.tsx +++ b/web/components/pages/delete-page-modal.tsx @@ -9,37 +9,45 @@ import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; // types -import type { IPage } from "@plane/types"; +import { useProjectPages } from "hooks/store/use-project-page"; type TConfirmPageDeletionProps = { - data?: IPage | null; + pageId: string; isOpen: boolean; onClose: () => void; }; export const DeletePageModal: React.FC = observer((props) => { - const { data, isOpen, onClose } = props; + const { pageId, isOpen, onClose } = props; + // states const [isDeleting, setIsDeleting] = useState(false); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { deletePage } = usePage(); + const { deletePage } = useProjectPages(); + const pageStore = usePage(pageId); + // toast alert const { setToastAlert } = useToast(); + if (!pageStore) return null; + + const { name } = pageStore; + const handleClose = () => { setIsDeleting(false); onClose(); }; const handleDelete = async () => { - if (!data || !workspaceSlug || !projectId) return; + if (!pageId || !workspaceSlug || !projectId) return; setIsDeleting(true); - await deletePage(workspaceSlug.toString(), data.project, data.id) + // Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted + await deletePage(workspaceSlug.toString(), projectId as string, pageId) .then(() => { handleClose(); setToastAlert({ @@ -99,8 +107,8 @@ export const DeletePageModal: React.FC = observer((pr

Are you sure you want to delete page-{" "} - {data?.name}? The Page - will be deleted permanently. This action cannot be undone. + {name}? The Page will be + deleted permanently. This action cannot be undone.

diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index 84ef66b59..79d378c59 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -5,11 +5,12 @@ import { Button, Input, Tooltip } from "@plane/ui"; import { IPage } from "@plane/types"; // constants import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; +import { IPageStore } from "store/page.store"; type Props = { handleFormSubmit: (values: IPage) => Promise; handleClose: () => void; - data?: IPage | null; + pageStore?: IPageStore; }; const defaultValues = { @@ -19,24 +20,24 @@ const defaultValues = { }; export const PageForm: React.FC = (props) => { - const { handleFormSubmit, handleClose, data } = props; + const { handleFormSubmit, handleClose, pageStore } = props; const { formState: { errors, isSubmitting }, handleSubmit, control, } = useForm({ - defaultValues: { ...defaultValues, ...data }, + defaultValues: pageStore + ? { name: pageStore.name, description: pageStore.description, access: pageStore.access } + : defaultValues, }); - const handleCreateUpdatePage = async (formData: IPage) => { - await handleFormSubmit(formData); - }; + const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData); return (
-

{data ? "Update" : "Create"} Page

+

{pageStore ? "Update" : "Create"} Page

= (props) => { Cancel
diff --git a/web/components/pages/pages-list/all-pages-list.tsx b/web/components/pages/pages-list/all-pages-list.tsx index 0f02efb55..4ed759a0f 100644 --- a/web/components/pages/pages-list/all-pages-list.tsx +++ b/web/components/pages/pages-list/all-pages-list.tsx @@ -1,17 +1,17 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { usePage } from "hooks/store"; -// components import { PagesListView } from "components/pages/pages-list"; // ui import { Loader } from "@plane/ui"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const AllPagesList: FC = observer(() => { - // store - const { projectPageIds } = usePage(); + const pageStores = useProjectPages(); + // subscribing to the projectPageStore + const { projectPageIds } = pageStores; - if (!projectPageIds) + if (!projectPageIds) { return ( @@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => { ); - + } return ; }); diff --git a/web/components/pages/pages-list/archived-pages-list.tsx b/web/components/pages/pages-list/archived-pages-list.tsx index b0de19241..4e679fb6d 100644 --- a/web/components/pages/pages-list/archived-pages-list.tsx +++ b/web/components/pages/pages-list/archived-pages-list.tsx @@ -3,14 +3,15 @@ import { observer } from "mobx-react-lite"; // components import { PagesListView } from "components/pages/pages-list"; // hooks -import { usePage } from "hooks/store"; // ui import { Loader } from "@plane/ui"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const ArchivedPagesList: FC = observer(() => { - const { archivedProjectPageIds } = usePage(); + const projectPageStore = useProjectPages(); + const { archivedPageIds } = projectPageStore; - if (!archivedProjectPageIds) + if (!archivedPageIds) return ( @@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => { ); - return ; + return ; }); diff --git a/web/components/pages/pages-list/favorite-pages-list.tsx b/web/components/pages/pages-list/favorite-pages-list.tsx index fc2b55cad..4ce301a68 100644 --- a/web/components/pages/pages-list/favorite-pages-list.tsx +++ b/web/components/pages/pages-list/favorite-pages-list.tsx @@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite"; // components import { PagesListView } from "components/pages/pages-list"; // hooks -import { usePage } from "hooks/store"; // ui import { Loader } from "@plane/ui"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const FavoritePagesList: FC = observer(() => { - const { favoriteProjectPageIds } = usePage(); + const projectPageStore = useProjectPages(); + const { favoriteProjectPageIds } = projectPageStore; if (!favoriteProjectPageIds) return ( diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index 7a92f3296..99b50e3c0 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -13,10 +13,6 @@ import { Star, Trash2, } from "lucide-react"; -// hooks -import { useMember, usePage, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; -// helpers import { copyUrlToClipboard } from "helpers/string.helper"; import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; // ui @@ -25,142 +21,120 @@ import { CustomMenu, Tooltip } from "@plane/ui"; import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; // constants import { EUserProjectRoles } from "constants/project"; +import { useRouter } from "next/router"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; +import { useMember, usePage, useUser } from "hooks/store"; +import { IIssueLabel } from "@plane/types"; export interface IPagesListItem { - workspaceSlug: string; - projectId: string; pageId: string; + projectId: string; } -export const PagesListItem: FC = observer((props) => { - const { workspaceSlug, projectId, pageId } = props; +export const PagesListItem: FC = observer(({ pageId, projectId }: IPagesListItem) => { + const projectPageStore = useProjectPages(); + // Now, I am observing only the projectPages, out of the projectPageStore. + const { archivePage, restorePage } = projectPageStore; + + const pageStore = usePage(pageId); + // states + const router = useRouter(); + const { workspaceSlug } = router.query; const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); + const [deletePageModal, setDeletePageModal] = useState(false); - // store hooks + const { currentUser, membership: { currentProjectRole }, } = useUser(); - const { - getArchivedPageById, - getUnArchivedPageById, - archivePage, - removeFromFavorites, - addToFavorites, - makePrivate, - makePublic, - restorePage, - } = usePage(); + const { project: { getProjectMemberDetails }, } = useMember(); - // toast alert - const { setToastAlert } = useToast(); - // derived values - const pageDetails = getUnArchivedPageById(pageId) ?? getArchivedPageById(pageId); - const handleCopyUrl = (e: any) => { + if (!pageStore) return null; + + const { + archived_at, + label_details, + access, + is_favorite, + owned_by, + name, + created_at, + updated_at, + makePublic, + makePrivate, + addToFavorites, + removeFromFavorites, + } = pageStore; + + const handleCopyUrl = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Page link copied to clipboard.", - }); - }); + await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`); }; - const handleAddToFavorites = (e: any) => { + const handleAddToFavorites = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + addToFavorites(); + }; + + const handleRemoveFromFavorites = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - addToFavorites(workspaceSlug, projectId, pageId) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Successfully added the page to favorites.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the page to favorites. Please try again.", - }); - }); + removeFromFavorites(); }; - const handleRemoveFromFavorites = (e: any) => { + const handleMakePublic = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - removeFromFavorites(workspaceSlug, projectId, pageId) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Successfully removed the page from favorites.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the page from favorites. Please try again.", - }); - }); + makePublic(); }; - const handleMakePublic = (e: any) => { + const handleMakePrivate = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - makePublic(workspaceSlug, projectId, pageId); + makePrivate(); }; - const handleMakePrivate = (e: any) => { + const handleArchivePage = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - makePrivate(workspaceSlug, projectId, pageId); + await archivePage(workspaceSlug as string, projectId as string, pageId as string); }; - const handleArchivePage = (e: any) => { + const handleRestorePage = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - archivePage(workspaceSlug, projectId, pageId); + await restorePage(workspaceSlug as string, projectId as string, pageId as string); }; - const handleRestorePage = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - - restorePage(workspaceSlug, projectId, pageId); - }; - - const handleDeletePage = (e: any) => { + const handleDeletePage = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setDeletePageModal(true); }; - const handleEditPage = (e: any) => { + const handleEditPage = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setCreateUpdatePageModal(true); }; - if (!pageDetails) return null; - - const ownerDetails = getProjectMemberDetails(pageDetails.owned_by); - const isCurrentUserOwner = pageDetails.owned_by === currentUser?.id; + const ownerDetails = getProjectMemberDetails(owned_by); + const isCurrentUserOwner = owned_by === currentUser?.id; const userCanEdit = isCurrentUserOwner || @@ -173,22 +147,21 @@ export const PagesListItem: FC = observer((props) => { return ( <> setCreateUpdatePageModal(false)} - data={pageDetails} projectId={projectId} /> - setDeletePageModal(false)} data={pageDetails} /> + setDeletePageModal(false)} pageId={pageId} />
  • - +
    -

    {pageDetails.name}

    - {/* FIXME: replace any with proper type */} - {pageDetails.label_details.length > 0 && - pageDetails.label_details.map((label: any) => ( +

    {name}

    + {label_details.length > 0 && + label_details.map((label: IIssueLabel) => (
    = observer((props) => { ))}
    - {pageDetails.archived_at ? ( + {archived_at ? ( -

    {renderFormattedTime(pageDetails.archived_at)}

    +

    {renderFormattedTime(archived_at)}

    ) : ( -

    {renderFormattedTime(pageDetails.updated_at)}

    +

    {renderFormattedTime(updated_at)}

    )} {isEditingAllowed && ( - - {pageDetails.is_favorite ? ( + + {is_favorite ? ( @@ -240,12 +213,10 @@ export const PagesListItem: FC = observer((props) => { {userCanChangeAccess && ( - {pageDetails.access ? ( + {access ? ( @@ -259,13 +230,13 @@ export const PagesListItem: FC = observer((props) => { - {pageDetails.archived_at ? ( + {archived_at ? ( <> {userCanArchive && ( diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 059a6136f..d00a641f4 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -1,11 +1,9 @@ import { FC } from "react"; import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // hooks import { useApplication, useUser } from "hooks/store"; // components -import { PagesListItem } from "./list-item"; import { NewEmptyState } from "components/common/new-empty-state"; // ui import { Loader } from "@plane/ui"; @@ -13,14 +11,17 @@ import { Loader } from "@plane/ui"; import emptyPage from "public/empty-state/empty_page.png"; // constants import { EUserProjectRoles } from "constants/project"; +import { PagesListItem } from "./list-item"; type IPagesListView = { pageIds: string[]; }; -export const PagesListView: FC = observer((props) => { - const { pageIds } = props; +export const PagesListView: FC = (props) => { + const { pageIds: projectPageIds } = props; // store hooks + // trace(true); + const { commandPalette: { toggleCreatePageModal }, } = useApplication(); @@ -31,21 +32,18 @@ export const PagesListView: FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; + // here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( <> - {pageIds && workspaceSlug && projectId ? ( + {projectPageIds && workspaceSlug && projectId ? (
    - {pageIds.length > 0 ? ( + {projectPageIds.length > 0 ? (
      - {pageIds.map((pageId) => ( - + {projectPageIds.map((pageId: string) => ( + ))}
    ) : ( @@ -77,4 +75,4 @@ export const PagesListView: FC = observer((props) => { )} ); -}); +}; diff --git a/web/components/pages/pages-list/private-page-list.tsx b/web/components/pages/pages-list/private-page-list.tsx index b19f80fdd..15a577d80 100644 --- a/web/components/pages/pages-list/private-page-list.tsx +++ b/web/components/pages/pages-list/private-page-list.tsx @@ -1,14 +1,15 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { usePage } from "hooks/store"; // components import { PagesListView } from "components/pages/pages-list"; // ui import { Loader } from "@plane/ui"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const PrivatePagesList: FC = observer(() => { - const { privateProjectPageIds } = usePage(); + const projectPageStore = useProjectPages(); + const { privateProjectPageIds } = projectPageStore; if (!privateProjectPageIds) return ( diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 2b14d22c7..77d313612 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -2,7 +2,7 @@ import React, { FC } from "react"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // hooks -import { useApplication, usePage, useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // components import { PagesListView } from "components/pages/pages-list"; import { NewEmptyState } from "components/common/new-empty-state"; @@ -14,6 +14,7 @@ import emptyPage from "public/empty-state/empty_page.png"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // constants import { EUserProjectRoles } from "constants/project"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const RecentPagesList: FC = observer(() => { // store hooks @@ -21,7 +22,7 @@ export const RecentPagesList: FC = observer(() => { const { membership: { currentProjectRole }, } = useUser(); - const { recentProjectPages } = usePage(); + const { recentProjectPages } = useProjectPages(); // FIXME: replace any with proper type const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0); diff --git a/web/components/pages/pages-list/shared-pages-list.tsx b/web/components/pages/pages-list/shared-pages-list.tsx index 8b2c56018..d20a1350e 100644 --- a/web/components/pages/pages-list/shared-pages-list.tsx +++ b/web/components/pages/pages-list/shared-pages-list.tsx @@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite"; // components import { PagesListView } from "components/pages/pages-list"; // hooks -import { usePage } from "hooks/store"; // ui import { Loader } from "@plane/ui"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; export const SharedPagesList: FC = observer(() => { - const { publicProjectPageIds } = usePage(); + const projectPageStore = useProjectPages(); + const { publicProjectPageIds } = projectPageStore; if (!publicProjectPageIds) return ( diff --git a/web/components/profile/profile-issues.tsx b/web/components/profile/profile-issues.tsx index cef250f1f..5d7575835 100644 --- a/web/components/profile/profile-issues.tsx +++ b/web/components/profile/profile-issues.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; // components import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root"; import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root"; -import { ProfileIssuesAppliedFiltersRoot } from "components/issues"; +import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues"; import { Spinner } from "@plane/ui"; // hooks import { useIssues } from "hooks/store"; @@ -34,7 +34,7 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { async () => { if (workspaceSlug && userId) { await fetchFilters(workspaceSlug, userId); - await fetchIssues(workspaceSlug, userId, groupedIssueIds ? "mutation" : "init-loader", undefined, type); + await fetchIssues(workspaceSlug, undefined, groupedIssueIds ? "mutation" : "init-loader", userId, type); } } ); @@ -57,6 +57,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { ) : null}
    + {/* peek overview */} + )} diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index 7265b7186..640318980 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -2,7 +2,6 @@ import React from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { BarChart2, Briefcase, CheckCircle, LayoutGrid, SendToBack } from "lucide-react"; // hooks import { useApplication, useUser } from "hooks/store"; // components @@ -11,34 +10,7 @@ import { NotificationPopover } from "components/notifications"; import { Tooltip } from "@plane/ui"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; - -const workspaceLinks = (workspaceSlug: string) => [ - { - Icon: LayoutGrid, - name: "Dashboard", - href: `/${workspaceSlug}`, - }, - { - Icon: BarChart2, - name: "Analytics", - href: `/${workspaceSlug}/analytics`, - }, - { - Icon: Briefcase, - name: "Projects", - href: `/${workspaceSlug}/projects`, - }, - { - Icon: CheckCircle, - name: "All Issues", - href: `/${workspaceSlug}/workspace-views/all-issues`, - }, - { - Icon: SendToBack, - name: "Active cycles", - href: `/${workspaceSlug}/active-cycles`, - }, -]; +import { SIDEBAR_MENU_ITEMS } from "constants/dashboard"; export const WorkspaceSidebarMenu = observer(() => { // store hooks @@ -50,48 +22,36 @@ export const WorkspaceSidebarMenu = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; // computed - const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; return ( -
    - {workspaceLinks(workspaceSlug as string).map((link, index) => { - const isActive = link.name === "Settings" ? router.asPath.includes(link.href) : router.asPath === link.href; - if (!isAuthorizedUser && link.name === "Analytics") return; - return ( - - - -
    + {SIDEBAR_MENU_ITEMS.map( + (link) => + workspaceMemberInfo >= link.access && ( + + + - {} - {!themeStore?.sidebarCollapsed && link.name} - {link.name === "Active Cycles" && ( - - Beta - - )} -
    -
    -
    - - ); - })} +
    + {} + {!themeStore?.sidebarCollapsed && link.label} +
    + + + + ) + )}
    ); diff --git a/web/constants/common.ts b/web/constants/common.ts index de4c0c558..3fac821fa 100644 --- a/web/constants/common.ts +++ b/web/constants/common.ts @@ -1,7 +1 @@ export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - -export const isNil = (value: any) => { - if (value === undefined || value === null) return true; - - return false; -}; diff --git a/web/constants/dashboard.ts b/web/constants/dashboard.ts index 61b6b5f1c..a563b83b0 100644 --- a/web/constants/dashboard.ts +++ b/web/constants/dashboard.ts @@ -14,6 +14,11 @@ import CompletedCreatedIssuesDark from "public/empty-state/dashboard/dark/comple import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg"; // types import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types"; +import { Props } from "components/icons/types"; +// constants +import { EUserWorkspaceRoles } from "./workspace"; +// icons +import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react"; // gradients for issues by priority widget graph bars export const PRIORITY_GRAPH_GRADIENTS = [ @@ -246,3 +251,45 @@ export const CREATED_ISSUES_EMPTY_STATES = { lightImage: CompletedCreatedIssuesLight, }, }; + +export const SIDEBAR_MENU_ITEMS: { + key: string; + label: string; + href: string; + access: EUserWorkspaceRoles; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + { + 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, + }, +]; diff --git a/web/constants/profile.ts b/web/constants/profile.ts new file mode 100644 index 000000000..3f17bb329 --- /dev/null +++ b/web/constants/profile.ts @@ -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; +}[] = [ + { + 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, + }, +]; diff --git a/web/constants/project.ts b/web/constants/project.ts index c35a6abe2..f9819c780 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -1,4 +1,8 @@ +// icons import { Globe2, Lock, LucideIcon } from "lucide-react"; +import { SettingIcon } from "components/icons"; +// types +import { Props } from "components/icons/types"; export enum EUserProjectRoles { GUEST = 5, @@ -71,3 +75,77 @@ export const PROJECT_UNSPLASH_COVERS = [ "https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", "https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", ]; + +export const PROJECT_SETTINGS_LINKS: { + key: string; + label: string; + href: string; + access: EUserProjectRoles; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + { + 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, + }, +]; diff --git a/web/constants/workspace.ts b/web/constants/workspace.ts index 90ccbae2d..1471de395 100644 --- a/web/constants/workspace.ts +++ b/web/constants/workspace.ts @@ -6,6 +6,9 @@ import ExcelLogo from "public/services/excel.svg"; import JSONLogo from "public/services/json.svg"; // types import { TStaticViewTypes } from "@plane/types"; +import { Props } from "components/icons/types"; +// icons +import { SettingIcon } from "components/icons"; export enum EUserWorkspaceRoles { GUEST = 5, @@ -115,48 +118,75 @@ export const RESTRICTED_URLS = [ ]; export const WORKSPACE_SETTINGS_LINKS: { + key: string; label: string; href: string; access: EUserWorkspaceRoles; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; }[] = [ { + key: "general", label: "General", href: `/settings`, access: EUserWorkspaceRoles.GUEST, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings`, + Icon: SettingIcon, }, { + key: "members", label: "Members", href: `/settings/members`, access: EUserWorkspaceRoles.GUEST, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members`, + Icon: SettingIcon, }, { + key: "billing-and-plans", label: "Billing and plans", href: `/settings/billing`, access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`, + Icon: SettingIcon, }, { + key: "integrations", label: "Integrations", href: `/settings/integrations`, access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`, + Icon: SettingIcon, }, { + key: "import", label: "Imports", href: `/settings/imports`, access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`, + Icon: SettingIcon, }, { + key: "export", label: "Exports", href: `/settings/exports`, access: EUserWorkspaceRoles.MEMBER, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports`, + Icon: SettingIcon, }, { + key: "webhooks", label: "Webhooks", href: `/settings/webhooks`, access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks`, + Icon: SettingIcon, }, { + key: "api-tokens", label: "API tokens", href: `/settings/api-tokens`, access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens`, + Icon: SettingIcon, }, ]; diff --git a/web/hooks/store/use-page.ts b/web/hooks/store/use-page.ts index 8cd13dcdc..8971acd22 100644 --- a/web/hooks/store/use-page.ts +++ b/web/hooks/store/use-page.ts @@ -1,11 +1,21 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "contexts/store-context"; -// types -import { IPageStore } from "store/page.store"; -export const usePage = (): IPageStore => { +export const usePage = (pageId: string) => { const context = useContext(StoreContext); if (context === undefined) throw new Error("usePage must be used within StoreProvider"); - return context.page; + + const { projectPageMap, projectArchivedPageMap } = context.projectPages; + + const { projectId, workspaceSlug } = context.app.router; + if (!projectId || !workspaceSlug) throw new Error("usePage must be used within ProjectProvider"); + + if (projectPageMap[projectId] && projectPageMap[projectId][pageId]) { + return projectPageMap[projectId][pageId]; + } else if (projectArchivedPageMap[projectId] && projectArchivedPageMap[projectId][pageId]) { + return projectArchivedPageMap[projectId][pageId]; + } else { + return; + } }; diff --git a/web/hooks/store/use-project-page.ts b/web/hooks/store/use-project-page.ts index 77d4e7d06..f7c25ea17 100644 --- a/web/hooks/store/use-project-page.ts +++ b/web/hooks/store/use-project-page.ts @@ -1,8 +1,9 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "contexts/store-context"; +import { IProjectPageStore } from "store/project-page.store"; -export const useProjectPages = () => { +export const useProjectPages = (): IProjectPageStore => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); return context.projectPages; diff --git a/web/hooks/store/use-project-specific-pages.ts b/web/hooks/store/use-project-specific-pages.ts new file mode 100644 index 000000000..325c2ef16 --- /dev/null +++ b/web/hooks/store/use-project-specific-pages.ts @@ -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; +}; diff --git a/web/hooks/use-issue-embeds.tsx b/web/hooks/use-issue-embeds.tsx new file mode 100644 index 000000000..2c8f7700b --- /dev/null +++ b/web/hooks/use-issue-embeds.tsx @@ -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, + }; +}; diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/layouts/settings-layout/profile/sidebar.tsx index b217c5f63..0a97b3364 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/layouts/settings-layout/profile/sidebar.tsx @@ -4,39 +4,14 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { useTheme } from "next-themes"; -import { Activity, ChevronLeft, CircleUser, KeyRound, LogOut, MoveLeft, Plus, Settings2, UserPlus } from "lucide-react"; +import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; // hooks import { useApplication, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Tooltip } from "@plane/ui"; - -const PROFILE_ACTION_LINKS = [ - { - key: "profile", - label: "Profile", - href: `/profile`, - Icon: CircleUser, - }, - { - key: "change-password", - label: "Change password", - href: `/profile/change-password`, - Icon: KeyRound, - }, - { - key: "activity", - label: "Activity", - href: `/profile/activity`, - Icon: Activity, - }, - { - key: "preferences", - label: "Preferences", - href: `/profile/preferences`, - Icon: Settings2, - }, -]; +// constants +import { PROFILE_ACTION_LINKS } from "constants/profile"; const WORKSPACE_ACTION_LINKS = [ { @@ -130,7 +105,7 @@ export const ProfileLayoutSidebar = observer(() => {
    { const router = useRouter(); const { workspaceSlug, projectId } = router.query; + // mobx store + const { + membership: { currentProjectRole }, + } = useUser(); + + const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST; - const projectLinks: Array<{ - label: string; - href: string; - }> = [ - { - label: "General", - href: `/${workspaceSlug}/projects/${projectId}/settings`, - }, - { - label: "Members", - href: `/${workspaceSlug}/projects/${projectId}/settings/members`, - }, - { - label: "Features", - href: `/${workspaceSlug}/projects/${projectId}/settings/features`, - }, - { - label: "States", - href: `/${workspaceSlug}/projects/${projectId}/settings/states`, - }, - { - label: "Labels", - href: `/${workspaceSlug}/projects/${projectId}/settings/labels`, - }, - { - label: "Integrations", - href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`, - }, - { - label: "Estimates", - href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`, - }, - { - label: "Automations", - href: `/${workspaceSlug}/projects/${projectId}/settings/automations`, - }, - ]; return (
    SETTINGS
    - {projectLinks.map((link) => ( - -
    - {link.label} -
    - - ))} + {PROJECT_SETTINGS_LINKS.map( + (link) => + projectMemberInfo >= link.access && ( + +
    + {link.label} +
    + + ) + )}
    diff --git a/web/layouts/settings-layout/workspace/sidebar.tsx b/web/layouts/settings-layout/workspace/sidebar.tsx index 2be9a416d..c8d4718c7 100644 --- a/web/layouts/settings-layout/workspace/sidebar.tsx +++ b/web/layouts/settings-layout/workspace/sidebar.tsx @@ -25,11 +25,11 @@ export const WorkspaceSettingsSidebar = () => { {WORKSPACE_SETTINGS_LINKS.map( (link) => workspaceMemberInfo >= link.access && ( - +
    { // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [gptModalOpen, setGptModal] = useState(false); // refs const editorRef = useRef(null); @@ -59,18 +56,82 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { // toast alert const { setToastAlert } = useToast(); + //TODO:fix reload confirmations, with mobx const { setShowAlert } = useReloadConfirmations(); const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ defaultValues: { name: "", description_html: "" }, }); - const { data: issuesResponse } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null + const { + archivePage: archivePageAction, + restorePage: restorePageAction, + createPage: createPageAction, + projectPageMap, + projectArchivedPageMap, + fetchProjectPages, + fetchArchivedProjectPages, + } = useProjectPages(); + + useSWR( + workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null, + workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string] + ? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString()) + : null + ); + // fetching archived pages from API + useSWR( + workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null, + workspaceSlug && projectId && !projectArchivedPageMap[projectId as string] && !projectPageMap[projectId as string] + ? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString()) + : null ); - const issues = Object.values(issuesResponse ?? {}); + const { issues, fetchIssue, issueWidgetClickAction } = useIssueEmbeds(); + + const pageStore = usePage(pageId as string); + + useEffect( + () => () => { + if (pageStore) { + pageStore.cleanup(); + } + }, + [pageStore] + ); + + if (!pageStore) { + return ( +
    + +
    + ); + } + + // We need to get the values of title and description from the page store but we don't have to subscribe to those values + const pageTitle = pageStore?.name; + const pageDescription = pageStore?.description_html; + const { + lockPage: lockPageAction, + unlockPage: unlockPageAction, + updateName: updateNameAction, + updateDescription: updateDescriptionAction, + id: pageIdMobx, + isSubmitting, + setIsSubmitting, + owned_by, + is_locked, + archived_at, + created_at, + created_by, + updated_at, + updated_by, + } = pageStore; + + const updatePage = async (formData: IPage) => { + if (!workspaceSlug || !projectId || !pageId) return; + await updateDescriptionAction(formData.description_html); + }; const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId || !pageId) return; @@ -78,47 +139,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const newDescription = `${watch("description_html")}

    ${response}

    `; setValue("description_html", newDescription); editorRef.current?.setEditorValue(newDescription); - - pageService - .patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { - description_html: newDescription, - }) - .then(() => { - mutatePageDetails((prevData) => ({ ...prevData, description_html: newDescription } as IPage), false); - }); - }; - - // =================== Fetching Page Details ====================== - const { - data: pageDetails, - mutate: mutatePageDetails, - error, - } = useSWR( - workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null, - workspaceSlug && projectId && pageId - ? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString()) - : null, - { - revalidateOnFocus: false, - } - ); - - const fetchIssue = async (issueId: string) => { - const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string); - return issue as TIssue; - }; - - const issueWidgetClickAction = (issueId: string) => { - const url = new URL(router.asPath, window.location.origin); - const params = new URLSearchParams(url.search); - - if (params.has("peekIssueId")) { - params.set("peekIssueId", issueId); - } else { - params.append("peekIssueId", issueId); - } - // Replace the current URL with the new one - router.replace(`${url.pathname}?${params.toString()}`, undefined, { shallow: true }); + updateDescriptionAction(newDescription); }; const actionCompleteAlert = ({ @@ -137,122 +158,14 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { }); }; - useEffect(() => { - if (isSubmitting === "submitted") { - setShowAlert(false); - setTimeout(async () => { - setIsSubmitting("saved"); - }, 2000); - } else if (isSubmitting === "submitting") { - setShowAlert(true); - } - }, [isSubmitting, setShowAlert]); - - // adding pageDetails.description_html to dependency array causes - // editor rerendering on every save - useEffect(() => { - if (pageDetails?.description_html) { - setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageDetails?.description_html]); // TODO: Verify the exhaustive-deps warning - - function createObjectFromArray(keys: string[], options: any): any { - return keys.reduce((obj, key) => { - if (options[key] !== undefined) { - obj[key] = options[key]; - } - return obj; - }, {} as { [key: string]: any }); - } - - const mutatePageDetailsHelper = ( - serverMutatorFn: Promise, - dataToMutate: Partial, - formDataValues: Array, - onErrorAction: () => void - ) => { - const commonSwrOptions: MutatorOptions = { - revalidate: false, - populateCache: false, - rollbackOnError: () => { - onErrorAction(); - return true; - }, - }; - const formData = getValues(); - const formDataMutationObject = createObjectFromArray(formDataValues, formData); - - mutatePageDetails(async () => serverMutatorFn, { - optimisticData: (prevData) => { - if (!prevData) return; - return { - ...prevData, - description_html: formData["description_html"], - ...formDataMutationObject, - ...dataToMutate, - }; - }, - ...commonSwrOptions, - }); - }; - - useEffect(() => { - mutatePageDetails(undefined, { - revalidate: true, - populateCache: true, - rollbackOnError: () => { - actionCompleteAlert({ - title: `Page could not be updated`, - message: `Sorry, page could not be updated, please try again later`, - type: "error", - }); - return true; - }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const updatePage = async (formData: IPage) => { + const updatePageTitle = (title: string) => { if (!workspaceSlug || !projectId || !pageId) return; - - formData.name = pageDetails?.name as string; - - if (!formData?.name || formData?.name.length === 0) return; - - try { - await pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be updated`, - message: `Sorry, page could not be updated, please try again later`, - type: "error", - }); - } - }; - - const updatePageTitle = async (title: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - - mutatePageDetailsHelper( - pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { name: title }), - { - name: title, - }, - [], - () => - actionCompleteAlert({ - title: `Page Title could not be updated`, - message: `Sorry, page title could not be updated, please try again later`, - type: "error", - }) - ); + updateNameAction(title); }; const createPage = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; - - await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload); + await createPageAction(workspaceSlug as string, projectId as string, payload); }; // ================ Page Menu Actions ================== @@ -260,121 +173,84 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const currentPageValues = getValues(); if (!currentPageValues?.description_html) { - currentPageValues.description_html = pageDetails?.description_html as string; + // TODO: We need to get latest data the above variable will give us stale data + currentPageValues.description_html = pageDescription as string; } const formData: Partial = { - name: "Copy of " + pageDetails?.name, + name: "Copy of " + pageTitle, description_html: currentPageValues.description_html, }; - await createPage(formData); + + try { + await createPage(formData); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be duplicated`, + message: `Sorry, page could not be duplicated, please try again later`, + type: "error", + }); + } }; const archivePage = async () => { if (!workspaceSlug || !projectId || !pageId) return; - mutatePageDetailsHelper( - pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), - { - archived_at: renderFormattedPayloadDate(new Date()), - }, - ["description_html"], - () => - actionCompleteAlert({ - title: `Page could not be Archived`, - message: `Sorry, page could not be Archived, please try again later`, - type: "error", - }) - ); + try { + await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be archived`, + message: `Sorry, page could not be archived, please try again later`, + type: "error", + }); + } }; const unArchivePage = async () => { if (!workspaceSlug || !projectId || !pageId) return; - - mutatePageDetailsHelper( - pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), - { - archived_at: null, - }, - ["description_html"], - () => - actionCompleteAlert({ - title: `Page could not be Restored`, - message: `Sorry, page could not be Restored, please try again later`, - type: "error", - }) - ); + try { + await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be restored`, + message: `Sorry, page could not be restored, please try again later`, + type: "error", + }); + } }; - // ========================= Page Lock ========================== const lockPage = async () => { if (!workspaceSlug || !projectId || !pageId) return; - mutatePageDetailsHelper( - pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), - { - is_locked: true, - }, - ["description_html"], - () => - actionCompleteAlert({ - title: `Page cannot be Locked`, - message: `Sorry, page cannot be Locked, please try again later`, - type: "error", - }) - ); + try { + await lockPageAction(); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be locked`, + message: `Sorry, page could not be locked, please try again later`, + type: "error", + }); + } }; const unlockPage = async () => { if (!workspaceSlug || !projectId || !pageId) return; - - mutatePageDetailsHelper( - pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), - { - is_locked: false, - }, - ["description_html"], - () => - actionCompleteAlert({ - title: `Page could not be Unlocked`, - message: `Sorry, page could not be Unlocked, please try again later`, - type: "error", - }) - ); + try { + await unlockPageAction(); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be unlocked`, + message: `Sorry, page could not be unlocked, please try again later`, + type: "error", + }); + } }; - const [localPageDescription, setLocalIssueDescription] = useState({ - id: pageId as string, - description_html: "", - }); - - // ADDING updatePage TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS - // TODO: Verify the exhaustive-deps warning - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFormSave = useCallback( - debounce(async () => { - handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted")); - }, 1500), - [handleSubmit, pageDetails] - ); - - if (error) - return ( - router.push(`/${workspaceSlug}/projects/${projectId}/pages`), - }} - /> - ); - const isPageReadOnly = - pageDetails?.is_locked || - pageDetails?.archived_at || + is_locked || + archived_at || (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); - const isCurrentUserOwner = pageDetails?.owned_by === currentUser?.id; + const isCurrentUserOwner = owned_by === currentUser?.id; const userCanDuplicate = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -382,144 +258,132 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const userCanLock = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return ( - <> - {pageDetails && issuesResponse ? ( -
    -
    - {isPageReadOnly ? ( - - ) : ( -
    - ( - { - setShowAlert(true); - onChange(description_html); - setIsSubmitting("submitting"); - debouncedFormSave(); - }} - duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} - pageArchiveConfig={ - userCanArchive - ? { - is_archived: pageDetails.archived_at ? true : false, - action: pageDetails.archived_at ? unArchivePage : archivePage, - } - : undefined - } - pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} - embedConfig={{ - issueEmbedConfig: { - issues: issues, - fetchIssue: fetchIssue, - clickAction: issueWidgetClickAction, - }, - }} - /> - )} + return pageIdMobx && issues ? ( +
    +
    + {isPageReadOnly ? ( + + ) : ( +
    + ( + { + setShowAlert(true); + onChange(description_html); + handleSubmit(updatePage)(); + }} + duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} + pageArchiveConfig={ + userCanArchive + ? { + is_archived: archived_at ? true : false, + action: archived_at ? unArchivePage : archivePage, + } + : undefined + } + pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} + embedConfig={{ + issueEmbedConfig: { + issues: issues, + fetchIssue: fetchIssue, + clickAction: issueWidgetClickAction, + }, + }} + /> + )} + /> + {projectId && envConfig?.has_openai_configured && ( +
    + { + 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={ + + } + className="!min-w-[38rem]" /> - {projectId && envConfig?.has_openai_configured && ( -
    - { - 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={ - - } - className="!min-w-[38rem]" - /> -
    - )}
    )}
    -
    - ) : ( -
    - -
    - )} - + )} + +
    +
    + ) : ( +
    + +
    ); }); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index dd11c4b2f..cd9699b34 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -5,7 +5,7 @@ import { Tab } from "@headlessui/react"; import useSWR from "swr"; import { observer } from "mobx-react-lite"; // hooks -import { usePage, useUser } from "hooks/store"; +import { useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; import useUserAuth from "hooks/use-user-auth"; // layouts @@ -17,6 +17,7 @@ import { PagesHeader } from "components/headers"; import { NextPageWithLayout } from "lib/types"; // constants import { PAGE_TABS_LIST } from "constants/page"; +import { useProjectPages } from "hooks/store/use-project-page"; const AllPagesList = dynamic(() => import("components/pages").then((a) => a.AllPagesList), { ssr: false, @@ -45,8 +46,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { // states const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); // store - const { fetchProjectPages, fetchArchivedProjectPages } = usePage(); const { currentUser, currentUserLoader } = useUser(); + + const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages(); // hooks const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader }); // local storage diff --git a/web/pages/accounts/forgot-password.tsx b/web/pages/accounts/forgot-password.tsx new file mode 100644 index 000000000..8d3c4cd28 --- /dev/null +++ b/web/pages/accounts/forgot-password.tsx @@ -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({ + 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 ( +
    +
    +
    + Plane Logo + Plane +
    +
    + +
    +
    +
    +

    + Get on your flight deck +

    +

    Get a link to reset your password

    + + checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + + +
    + +
    +
    +
    + ); +}; + +ForgotPasswordPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ForgotPasswordPage; diff --git a/web/pages/accounts/password.tsx b/web/pages/accounts/reset-password.tsx similarity index 58% rename from web/pages/accounts/password.tsx rename to web/pages/accounts/reset-password.tsx index 364c92711..2b893d665 100644 --- a/web/pages/accounts/password.tsx +++ b/web/pages/accounts/reset-password.tsx @@ -1,9 +1,6 @@ import { ReactElement } from "react"; import Image from "next/image"; -import Link from "next/link"; import { useRouter } from "next/router"; -import { useTheme } from "next-themes"; -import { Lightbulb } from "lucide-react"; import { Controller, useForm } from "react-hook-form"; // services import { AuthService } from "services/auth.service"; @@ -12,11 +9,12 @@ import useToast from "hooks/use-toast"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // layouts import DefaultLayout from "layouts/default-layout"; +// components +import { LatestFeatureBlock } from "components/common"; // ui import { Button, Input } from "@plane/ui"; // images import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; -import latestFeatures from "public/onboarding/onboarding-pages.svg"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // type @@ -35,12 +33,10 @@ const defaultValues: TResetPasswordFormValues = { // services const authService = new AuthService(); -const HomePage: NextPageWithLayout = () => { +const ResetPasswordPage: NextPageWithLayout = () => { // router const router = useRouter(); const { uidb64, token, email } = router.query; - // next-themes - const { resolvedTheme } = useTheme(); // toast const { setToastAlert } = useToast(); // sign in redirection hook @@ -108,35 +104,30 @@ const HomePage: NextPageWithLayout = () => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> )} /> -
    - ( - - )} - /> -

    - Whatever you choose now will be your account{"'"}s password until you change it. -

    -
    + ( + + )} + /> -

    - When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

    -
    - -

    - Try the latest features, like Tiptap editor, to write compelling responses.{" "} - - See new features - -

    -
    -
    -
    - Plane Issues -
    -
    +
    ); }; -HomePage.getLayout = function getLayout(page: ReactElement) { +ResetPasswordPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; -export default HomePage; +export default ResetPasswordPage; diff --git a/web/pages/accounts/sign-up.tsx b/web/pages/accounts/sign-up.tsx index 390d91ec9..5b5648439 100644 --- a/web/pages/accounts/sign-up.tsx +++ b/web/pages/accounts/sign-up.tsx @@ -1,97 +1,52 @@ -import React, { useEffect, ReactElement } from "react"; +import React from "react"; import Image from "next/image"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// next-themes -import { useTheme } from "next-themes"; -// services -import { AuthService } from "services/auth.service"; // hooks -import { useUser } from "hooks/store"; -import useUserAuth from "hooks/use-user-auth"; -import useToast from "hooks/use-toast"; +import { useApplication, useUser } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components -import { EmailSignUpForm } from "components/account"; -// images +import { SignUpRoot } from "components/account"; +// ui +import { Spinner } from "@plane/ui"; +// assets import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types import { NextPageWithLayout } from "lib/types"; -type EmailPasswordFormValues = { - email: string; - password?: string; - medium?: string; -}; - -// services -const authService = new AuthService(); - const SignUpPage: NextPageWithLayout = observer(() => { - // router - const router = useRouter(); - // toast alert - const { setToastAlert } = useToast(); - // next-themes - const { setTheme } = useTheme(); // store hooks - const { currentUser, fetchCurrentUser, currentUserLoader } = useUser(); - // custom hooks - const {} = useUserAuth({ routeAuth: "sign-in", user: currentUser, isLoading: currentUserLoader }); + const { + config: { envConfig }, + } = useApplication(); + const { currentUser } = useUser(); - const handleSignUp = async (formData: EmailPasswordFormValues) => { - const payload = { - email: formData.email, - password: formData.password ?? "", - }; - - await authService - .emailSignUp(payload) - .then(async (response) => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Account created successfully.", - }); - - if (response) await fetchCurrentUser(); - router.push("/onboarding"); - }) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error || "Something went wrong. Please try again later or contact the support team.", - }) - ); - }; - - useEffect(() => { - setTheme("system"); - }, [setTheme]); + if (currentUser || !envConfig) + return ( +
    + +
    + ); return ( - <> -
    -
    -
    -
    - Plane Logo -
    +
    +
    +
    + Plane Logo + Plane
    -
    -
    -

    SignUp on Plane

    - + +
    +
    +
    - +
    ); }); -SignUpPage.getLayout = function getLayout(page: ReactElement) { +SignUpPage.getLayout = function getLayout(page: React.ReactElement) { return {page}; }; diff --git a/web/store/application/app-config.store.ts b/web/store/application/app-config.store.ts index f95177f78..6faef8b69 100644 --- a/web/store/application/app-config.store.ts +++ b/web/store/application/app-config.store.ts @@ -5,8 +5,9 @@ import { IAppConfig } from "@plane/types"; import { AppConfigService } from "services/app_config.service"; export interface IAppConfigStore { + // observables envConfig: IAppConfig | null; - // action + // actions fetchAppConfig: () => Promise; } diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index 7192cc012..fe4d68642 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -148,8 +150,13 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); }); }); - - this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.archivedIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation" + ); this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, { filters: _filters.filters, }); diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index 4c640977a..e6ea3dde1 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -158,7 +160,14 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI }); }); - this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.cycleIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation", + cycleId + ); await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { filters: _filters.filters, }); diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index a303ee90e..658a2dd24 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -143,8 +145,13 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); }); }); - - this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.draftIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation" + ); this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, { filters: _filters.filters, }); diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index 352b641de..2474d6019 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -11,7 +11,6 @@ import { TStaticViewTypes, } from "@plane/types"; // constants -import { isNil } from "constants/common"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; // lib import { storage } from "lib/local-storage"; @@ -76,8 +75,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { target_date: filters?.target_date || undefined, // display filters type: displayFilters?.type || undefined, - sub_issue: isNil(displayFilters?.sub_issue) ? true : displayFilters?.sub_issue, - start_target_date: isNil(displayFilters?.start_target_date) ? true : displayFilters?.start_target_date, + sub_issue: displayFilters?.sub_issue ?? true, + start_target_date: displayFilters?.start_target_date ?? true, }; const issueFiltersParams: Partial> = {}; @@ -169,19 +168,19 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { * @returns {IIssueDisplayProperties} */ computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({ - assignee: displayProperties?.assignee || false, - start_date: displayProperties?.start_date || false, - due_date: displayProperties?.due_date || false, - labels: displayProperties?.labels || false, - priority: displayProperties?.priority || false, - state: displayProperties?.state || false, - sub_issue_count: displayProperties?.sub_issue_count || false, - attachment_count: displayProperties?.attachment_count || false, - estimate: displayProperties?.estimate || false, - link: displayProperties?.link || false, - key: displayProperties?.key || false, - created_on: displayProperties?.created_on || false, - updated_on: displayProperties?.updated_on || false, + assignee: displayProperties?.assignee ?? true, + start_date: displayProperties?.start_date ?? true, + due_date: displayProperties?.due_date ?? true, + labels: displayProperties?.labels ?? true, + priority: displayProperties?.priority ?? true, + state: displayProperties?.state ?? true, + sub_issue_count: displayProperties?.sub_issue_count ?? true, + attachment_count: displayProperties?.attachment_count ?? true, + link: displayProperties?.link ?? true, + estimate: displayProperties?.estimate ?? true, + key: displayProperties?.key ?? true, + created_on: displayProperties?.created_on ?? true, + updated_on: displayProperties?.updated_on ?? true, }); handleIssuesLocalFilters = { diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index d7d4d56da..1e00dc8c0 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -157,8 +159,14 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); }); }); - - this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.moduleIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation", + moduleId + ); await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { filters: _filters.filters, }); diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index fe30a1c58..3ec5aed6f 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -150,13 +152,16 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf }); }); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); this.rootIssueStore.profileIssues.fetchIssues( workspaceSlug, undefined, - "mutation", + isEmpty(filteredFilters) ? "init-loader" : "mutation", userId, this.rootIssueStore.profileIssues.currentView ); + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { filters: _filters.filters, }); @@ -178,10 +183,10 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf _filters.displayFilters.sub_group_by = null; updatedDisplayFilters.sub_group_by = null; } - // set group_by to state if layout is switched to kanban and group_by is null + // set group_by to priority if layout is switched to kanban and group_by is null if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { - _filters.displayFilters.group_by = "state"; - updatedDisplayFilters.group_by = "state"; + _filters.displayFilters.group_by = "priority"; + updatedDisplayFilters.group_by = "priority"; } runInAction(() => { diff --git a/web/store/issue/profile/issue.store.ts b/web/store/issue/profile/issue.store.ts index d9c727fce..e754758cc 100644 --- a/web/store/issue/profile/issue.store.ts +++ b/web/store/issue/profile/issue.store.ts @@ -97,7 +97,9 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues { const orderBy = displayFilters?.order_by; const layout = displayFilters?.layout; - const userIssueIds = this.issues[userId][currentView] ?? []; + const userIssueIds = this.issues[userId]?.[currentView]; + + if (!userIssueIds) return; const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds); if (!_issues) return undefined; diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index 610d92998..828eca29a 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -159,7 +161,14 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I }); }); - this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.projectViewIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation", + viewId + ); break; case EIssueFilterType.DISPLAY_FILTERS: const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 83a95aa6d..aaa8d81d9 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -155,7 +157,13 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.projectIssues.fetchIssues( + workspaceSlug, + projectId, + isEmpty(filteredFilters) ? "init-loader" : "mutation" + ); await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { filters: _filters.filters, }); diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index edde040c4..1f20a4151 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -123,6 +123,7 @@ export class IssueRootStore implements IIssueRootStore { moduleId: observable.ref, viewId: observable.ref, userId: observable.ref, + globalViewId: observable.ref, states: observable, stateDetails: observable, labels: observable, diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index dcacb117e..95a9a8831 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -1,6 +1,8 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; +import pickBy from "lodash/pickBy"; +import isArray from "lodash/isArray"; // base class import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers @@ -180,7 +182,13 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); }); }); - this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); + const appliedFilters = _filters.filters || {}; + const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); + this.rootIssueStore.workspaceIssues.fetchIssues( + workspaceSlug, + viewId, + isEmpty(filteredFilters) ? "init-loader" : "mutation" + ); break; case EIssueFilterType.DISPLAY_FILTERS: const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; diff --git a/web/store/page.store.ts b/web/store/page.store.ts index abb196334..fa5970e49 100644 --- a/web/store/page.store.ts +++ b/web/store/page.store.ts @@ -1,374 +1,277 @@ -import { action, computed, makeObservable, observable, runInAction } from "mobx"; -import set from "lodash/set"; -import omit from "lodash/omit"; -import isToday from "date-fns/isToday"; -import isThisWeek from "date-fns/isThisWeek"; -import isYesterday from "date-fns/isYesterday"; -// services +import { action, makeObservable, observable, reaction, runInAction } from "mobx"; + +import { IIssueLabel, IPage } from "@plane/types"; import { PageService } from "services/page.service"; -// helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -// types -import { IPage, IRecentPages } from "@plane/types"; -// store + import { RootStore } from "./root.store"; export interface IPageStore { - pages: Record; - archivedPages: Record; - // project computed - projectPageIds: string[] | null; - favoriteProjectPageIds: string[] | null; - privateProjectPageIds: string[] | null; - publicProjectPageIds: string[] | null; - archivedProjectPageIds: string[] | null; - recentProjectPages: IRecentPages | null; - // fetch page information actions - getUnArchivedPageById: (pageId: string) => IPage | null; - getArchivedPageById: (pageId: string) => IPage | null; - // fetch actions - fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise; - fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; - // favorites actions - addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // crud - createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; - updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial) => Promise; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // access control actions - makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // archive actions - archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + // Page Properties + access: number; + archived_at: string | null; + color: string; + created_at: Date; + created_by: string; + description: string; + description_html: string; + description_stripped: string | null; + id: string; + is_favorite: boolean; + label_details: IIssueLabel[]; + is_locked: boolean; + labels: string[]; + name: string; + owned_by: string; + project: string; + updated_at: Date; + updated_by: string; + workspace: string; + + // Actions + makePublic: () => Promise; + makePrivate: () => Promise; + lockPage: () => Promise; + unlockPage: () => Promise; + addToFavorites: () => Promise; + removeFromFavorites: () => Promise; + updateName: (name: string) => Promise; + updateDescription: (description: string) => Promise; + + // Reactions + disposers: Array<() => void>; + + // Helpers + oldName: string; + cleanup: () => void; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void; } export class PageStore implements IPageStore { - pages: Record = {}; - archivedPages: Record = {}; - // services + access = 0; + isSubmitting: "submitting" | "submitted" | "saved" = "saved"; + archived_at: string | null; + color: string; + created_at: Date; + created_by: string; + description: string; + description_html = ""; + description_stripped: string | null; + id: string; + is_favorite = false; + is_locked = true; + labels: string[]; + name = ""; + owned_by: string; + project: string; + updated_at: Date; + updated_by: string; + workspace: string; + oldName = ""; + label_details: IIssueLabel[] = []; + disposers: Array<() => void> = []; + pageService; - // stores + // root store rootStore; - constructor(rootStore: RootStore) { + constructor(page: IPage, _rootStore: RootStore) { makeObservable(this, { - pages: observable, - archivedPages: observable, - // computed - projectPageIds: computed, - favoriteProjectPageIds: computed, - publicProjectPageIds: computed, - privateProjectPageIds: computed, - archivedProjectPageIds: computed, - recentProjectPages: computed, - // computed actions - getUnArchivedPageById: action, - getArchivedPageById: action, - // fetch actions - fetchProjectPages: action, - fetchArchivedProjectPages: action, - // favorites actions - addToFavorites: action, - removeFromFavorites: action, - // crud - createPage: action, - updatePage: action, - deletePage: action, - // access control actions + name: observable.ref, + description_html: observable.ref, + is_favorite: observable.ref, + is_locked: observable.ref, + isSubmitting: observable.ref, + access: observable.ref, + makePublic: action, makePrivate: action, - // archive actions - archivePage: action, - restorePage: action, + addToFavorites: action, + removeFromFavorites: action, + updateName: action, + updateDescription: action, + setIsSubmitting: action, + cleanup: action, }); - // stores - this.rootStore = rootStore; - // services + this.created_by = page?.created_by || ""; + this.created_at = page?.created_at || new Date(); + this.color = page?.color || ""; + this.archived_at = page?.archived_at || null; + this.name = page?.name || ""; + this.description = page?.description || ""; + this.description_stripped = page?.description_stripped || ""; + this.description_html = page?.description_html || ""; + this.access = page?.access || 0; + this.workspace = page?.workspace || ""; + this.updated_by = page?.updated_by || ""; + this.updated_at = page?.updated_at || new Date(); + this.project = page?.project || ""; + this.owned_by = page?.owned_by || ""; + this.labels = page?.labels || []; + this.label_details = page?.label_details || []; + this.is_locked = page?.is_locked || false; + this.id = page?.id || ""; + this.is_favorite = page?.is_favorite || false; + this.oldName = page?.name || ""; + + this.rootStore = _rootStore; this.pageService = new PageService(); - } - /** - * retrieves all pages for a projectId that is available in the url. - */ - get projectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId) return null; - const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId); - return projectPageIds ?? null; - } - - /** - * retrieves all favorite pages for a projectId that is available in the url. - */ - get favoriteProjectPageIds() { - if (!this.projectPageIds) return null; - const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite); - return favoritePagesIds ?? null; - } - - /** - * retrieves all private pages for a projectId that is available in the url. - */ - get privateProjectPageIds() { - if (!this.projectPageIds) return null; - const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1); - return privatePagesIds ?? null; - } - - /** - * retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url. - */ - get publicProjectPageIds() { - if (!this.projectPageIds) return null; - const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0); - return publicPagesIds ?? null; - } - - /** - * retrieves all recent pages for a projectId that is available in the url. - * In format where today, yesterday, this_week, older are keys. - */ - get recentProjectPages() { - if (!this.projectPageIds) return null; - const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] }; - data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.updated_at))) || []; - data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.updated_at))) || []; - data.this_week = - this.projectPageIds.filter((p) => { - const pageUpdatedAt = this.pages?.[p]?.updated_at; - return ( - isThisWeek(new Date(pageUpdatedAt)) && - !isToday(new Date(pageUpdatedAt)) && - !isYesterday(new Date(pageUpdatedAt)) - ); - }) || []; - data.older = - this.projectPageIds.filter((p) => { - const pageUpdatedAt = this.pages?.[p]?.updated_at; - return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt)); - }) || []; - return data; - } - - /** - * retrieves all archived pages for a projectId that is available in the url. - */ - get archivedProjectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId) return null; - const archivedProjectPageIds = Object.keys(this.archivedPages).filter( - (pageId) => this.archivedPages?.[pageId]?.project === projectId - ); - return archivedProjectPageIds ?? null; - } - - /** - * retrieves a page from pages by id. - * @param pageId - * @returns IPage | null - */ - getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null; - - /** - * retrieves a page from archived pages by id. - * @param pageId - * @returns IPage | null - */ - getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null; - - /** - * fetches all pages for a project. - * @param workspaceSlug - * @param projectId - * @returns Promise - */ - fetchProjectPages = async (workspaceSlug: string, projectId: string) => { - try { - return await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => { - console.log("Response from backend 1", response); - runInAction(() => { - response.forEach((page) => { - set(this.pages, [page.id], page); + const descriptionDisposer = reaction( + () => this.description_html, + (description_html) => { + //TODO: Fix reaction to only run when the data is changed, not when the page is loaded + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + this.isSubmitting = "submitting"; + this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => { + runInAction(() => { + this.isSubmitting = "submitted"; }); }); - return response; - }); - } catch (error) { - throw error; - } - }; + }, + { delay: 3000 } + ); - /** - * fetches all archived pages for a project. - * @param workspaceSlug - * @param projectId - * @returns Promise - */ - fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => - await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - response.forEach((page) => { - set(this.archivedPages, [page.id], page); - }); - }); - return response; + const pageTitleDisposer = reaction( + () => this.name, + (name) => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + this.isSubmitting = "submitting"; + this.pageService + .patchPage(workspaceSlug, projectId, this.id, { name }) + .catch(() => { + runInAction(() => { + this.name = this.oldName; + }); + }) + .finally(() => { + runInAction(() => { + this.isSubmitting = "submitted"; + }); + }); + }, + { delay: 2000 } + ); + + this.disposers.push(descriptionDisposer, pageTitleDisposer); + } + + updateName = action("updateName", async (name: string) => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.oldName = this.name; + this.name = name; + }); + + updateDescription = action("updateDescription", async (description_html: string) => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.description_html = description_html; + }); + + cleanup = action("cleanup", () => { + this.disposers.forEach((disposer) => { + disposer(); }); + }); + + setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => { + this.isSubmitting = isSubmitting; + }); + + lockPage = action("lockPage", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.is_locked = true; + + await this.pageService.lockPage(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => { + this.is_locked = false; + }); + }); + }); + + unlockPage = action("unlockPage", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.is_locked = false; + + await this.pageService.unlockPage(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => { + this.is_locked = true; + }); + }); + }); /** * Add Page to users favorites list - * @param workspaceSlug - * @param projectId - * @param pageId */ - addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { - try { + addToFavorites = action("addToFavorites", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.is_favorite = true; + + await this.pageService.addPageToFavorites(workspaceSlug, projectId, this.id).catch(() => { runInAction(() => { - set(this.pages, [pageId, "is_favorite"], true); + this.is_favorite = false; }); - await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId); - } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], false); - }); - throw error; - } - }; + }); + }); /** * Remove page from the users favorites list - * @param workspaceSlug - * @param projectId - * @param pageId */ - removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { - try { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], false); - }); - await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId); - } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], true); - }); - throw error; - } - }; - /** - * Creates a new page using the api and updated the local state in store - * @param workspaceSlug - * @param projectId - * @param data - */ - createPage = async (workspaceSlug: string, projectId: string, data: Partial) => - await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => { - runInAction(() => { - set(this.pages, [response.id], response); - }); - return response; - }); + removeFromFavorites = action("removeFromFavorites", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; - /** - * updates the page using the api and updates the local state in store - * @param workspaceSlug - * @param projectId - * @param pageId - * @param data - * @returns - */ - updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial) => - await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => { - const originalPage = this.getUnArchivedPageById(pageId); - runInAction(() => { - set(this.pages, [pageId], { ...originalPage, ...data }); - }); - return response; - }); + this.is_favorite = false; - /** - * delete a page using the api and updates the local state in store - * @param workspaceSlug - * @param projectId - * @param pageId - * @returns - */ - deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => { + await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => { runInAction(() => { - omit(this.archivedPages, [pageId]); + this.is_favorite = true; }); - return response; }); + }); /** * make a page public - * @param workspaceSlug - * @param projectId - * @param pageId * @returns */ - makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => { - try { + makePublic = action("makePublic", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; + + this.access = 0; + + this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 0 }).catch(() => { runInAction(() => { - set(this.pages, [pageId, "access"], 0); + this.access = 1; }); - await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 }); - } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "access"], 1); - }); - throw error; - } - }; + }); + }); /** * Make a page private - * @param workspaceSlug - * @param projectId - * @param pageId * @returns */ - makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => { - try { - runInAction(() => { - set(this.pages, [pageId, "access"], 1); - }); - await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 }); - } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "access"], 0); - }); - throw error; - } - }; + makePrivate = action("makePrivate", async () => { + const { projectId, workspaceSlug } = this.rootStore.app.router; + if (!projectId || !workspaceSlug) return; - /** - * Mark a page archived - * @param workspaceSlug - * @param projectId - * @param pageId - */ - archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => { + this.access = 1; + + this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => { runInAction(() => { - set(this.archivedPages, [pageId], this.pages[pageId]); - set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date())); - omit(this.pages, [pageId]); - }); - }); - - /** - * Restore a page from archived pages to pages - * @param workspaceSlug - * @param projectId - * @param pageId - */ - restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => { - runInAction(() => { - set(this.pages, [pageId], this.archivedPages[pageId]); - omit(this.archivedPages, [pageId]); + this.access = 0; }); }); + }); } diff --git a/web/store/project-page.store.ts b/web/store/project-page.store.ts index 4186e65e0..f2e3f9227 100644 --- a/web/store/project-page.store.ts +++ b/web/store/project-page.store.ts @@ -1,33 +1,54 @@ -import { makeObservable, observable, runInAction, action } from "mobx"; +import { makeObservable, observable, runInAction, action, computed } from "mobx"; import { set } from "lodash"; // services import { PageService } from "services/page.service"; // store import { PageStore, IPageStore } from "store/page.store"; // types -import { IPage } from "@plane/types"; +import { IPage, IRecentPages } from "@plane/types"; +import { RootStore } from "./root.store"; +import { isThisWeek, isToday, isYesterday } from "date-fns"; export interface IProjectPageStore { - projectPages: Record; - projectArchivedPages: Record; + projectPageMap: Record>; + projectArchivedPageMap: Record>; + + projectPageIds: string[] | undefined; + archivedPageIds: string[] | undefined; + favoriteProjectPageIds: string[] | undefined; + privateProjectPageIds: string[] | undefined; + publicProjectPageIds: string[] | undefined; + recentProjectPages: IRecentPages | undefined; // fetch actions - fetchProjectPages: (workspaceSlug: string, projectId: string) => void; - fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void; + fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise; + fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; // crud actions - createPage: (workspaceSlug: string, projectId: string, data: Partial) => void; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void; + createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; } export class ProjectPageStore implements IProjectPageStore { - projectPages: Record = {}; // { projectId: [page1, page2] } - projectArchivedPages: Record = {}; // { projectId: [page1, page2] } + projectPageMap: Record> = {}; // { projectId: [page1, page2] } + projectArchivedPageMap: Record> = {}; // { projectId: [page1, page2] } + + // root store + rootStore; pageService; - - constructor() { + constructor(_rootStore: RootStore) { makeObservable(this, { - projectPages: observable, - projectArchivedPages: observable, + projectPageMap: observable, + projectArchivedPageMap: observable, + + projectPageIds: computed, + archivedPageIds: computed, + favoriteProjectPageIds: computed, + privateProjectPageIds: computed, + publicProjectPageIds: computed, + recentProjectPages: computed, + // fetch actions fetchProjectPages: action, fetchArchivedProjectPages: action, @@ -35,19 +56,113 @@ export class ProjectPageStore implements IProjectPageStore { createPage: action, deletePage: action, }); + this.rootStore = _rootStore; + this.pageService = new PageService(); } + get projectPageIds() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId || !this.projectPageMap?.[projectId]) return []; + + const allProjectIds = Object.keys(this.projectPageMap[projectId]); + return allProjectIds.sort((a, b) => { + const dateA = new Date(this.projectPageMap[projectId][a].created_at).getTime(); + const dateB = new Date(this.projectPageMap[projectId][b].created_at).getTime(); + return dateB - dateA; + }); + } + + get archivedPageIds() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId || !this.projectArchivedPageMap[projectId]) return []; + const archivedPages = Object.keys(this.projectArchivedPageMap[projectId]); + return archivedPages.sort((a, b) => { + const dateA = new Date(this.projectArchivedPageMap[projectId][a].created_at).getTime(); + const dateB = new Date(this.projectArchivedPageMap[projectId][b].created_at).getTime(); + return dateB - dateA; + }); + } + + get favoriteProjectPageIds() { + const projectId = this.rootStore.app.router.projectId; + if (!this.projectPageIds || !projectId) return []; + + const favouritePages: string[] = this.projectPageIds.filter( + (page) => this.projectPageMap[projectId][page].is_favorite + ); + return favouritePages; + } + + get privateProjectPageIds() { + const projectId = this.rootStore.app.router.projectId; + if (!this.projectPageIds || !projectId) return []; + + const privatePages: string[] = this.projectPageIds.filter( + (page) => this.projectPageMap[projectId][page].access === 1 + ); + return privatePages; + } + + get publicProjectPageIds() { + const projectId = this.rootStore.app.router.projectId; + const userId = this.rootStore.user.currentUser?.id; + if (!this.projectPageIds || !projectId || !userId) return []; + + const publicPages: string[] = this.projectPageIds.filter( + (page) => + this.projectPageMap[projectId][page].access === 0 && this.projectPageMap[projectId][page].owned_by === userId + ); + return publicPages; + } + + get recentProjectPages() { + const projectId = this.rootStore.app.router.projectId; + if (!this.projectPageIds || !projectId) return; + + const today: string[] = this.projectPageIds.filter((page) => + isToday(new Date(this.projectPageMap[projectId][page].updated_at)) + ); + + const yesterday: string[] = this.projectPageIds.filter((page) => + isYesterday(new Date(this.projectPageMap[projectId][page].updated_at)) + ); + + const this_week: string[] = this.projectPageIds.filter((page) => { + const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at; + return ( + isThisWeek(new Date(pageUpdatedAt)) && + !isToday(new Date(pageUpdatedAt)) && + !isYesterday(new Date(pageUpdatedAt)) + ); + }); + + const older: string[] = this.projectPageIds.filter((page) => { + const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at; + return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt)); + }); + + return { today, yesterday, this_week, older }; + } + /** * Fetching all the pages for a specific project * @param workspaceSlug * @param projectId */ fetchProjectPages = async (workspaceSlug: string, projectId: string) => { - const response = await this.pageService.getProjectPages(workspaceSlug, projectId); - runInAction(() => { - this.projectPages[projectId] = response?.map((page) => new PageStore(page as any)); - }); + try { + await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => { + runInAction(() => { + for (const page of response) { + set(this.projectPageMap, [projectId, page.id], new PageStore(page, this.rootStore)); + } + }); + return response; + }); + } catch (e) { + throw e; + } }; /** @@ -56,13 +171,20 @@ export class ProjectPageStore implements IProjectPageStore { * @param projectId * @returns Promise */ - fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => - await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any)); + fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => { + try { + await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { + runInAction(() => { + for (const page of response) { + set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore)); + } + }); + return response; }); - return response; - }); + } catch (e) { + throw e; + } + }; /** * Creates a new page using the api and updated the local state in store @@ -73,7 +195,7 @@ export class ProjectPageStore implements IProjectPageStore { createPage = async (workspaceSlug: string, projectId: string, data: Partial) => { const response = await this.pageService.createPage(workspaceSlug, projectId, data); runInAction(() => { - this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)]; + set(this.projectPageMap, [projectId, response.id], new PageStore(response, this.rootStore)); }); return response; }; @@ -88,11 +210,7 @@ export class ProjectPageStore implements IProjectPageStore { deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => { const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId); runInAction(() => { - this.projectPages = set( - this.projectPages, - [projectId], - this.projectPages[projectId].filter((page: any) => page.id !== pageId) - ); + delete this.projectArchivedPageMap[projectId][pageId]; }); return response; }; @@ -104,14 +222,17 @@ export class ProjectPageStore implements IProjectPageStore { * @param pageId */ archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { - const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId); runInAction(() => { - set( - this.projectPages, - [projectId], - this.projectPages[projectId].filter((page: any) => page.id !== pageId) - ); - this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]); + set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]); + set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString()); + delete this.projectPageMap[projectId][pageId]; + }); + const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => { + runInAction(() => { + set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]); + set(this.projectPageMap[projectId][pageId], "archived_at", null); + delete this.projectArchivedPageMap[projectId][pageId]; + }); }); return response; }; @@ -122,15 +243,19 @@ export class ProjectPageStore implements IProjectPageStore { * @param projectId * @param pageId */ - restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => { + restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => { + const pageArchivedAt = this.projectArchivedPageMap[projectId][pageId].archived_at; + runInAction(() => { + set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]); + set(this.projectPageMap[projectId][pageId], "archived_at", null); + delete this.projectArchivedPageMap[projectId][pageId]; + }); + await this.pageService.restorePage(workspaceSlug, projectId, pageId).catch(() => { runInAction(() => { - set( - this.projectArchivedPages, - [projectId], - this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId) - ); - set(this.projectPages, [projectId], [...this.projectPages[projectId]]); + set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]); + set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt); + delete this.projectPageMap[projectId][pageId]; }); }); + }; } diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index c59d7af1e..1d51b781c 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -92,24 +92,26 @@ export class ProjectStore implements IProjectStore { * Returns searched projects based on search query */ get searchedProjects() { - if (!this.rootStore.app.router.workspaceSlug) return []; - const projectIds = Object.keys(this.projectMap); - return this.searchQuery === "" - ? projectIds - : projectIds?.filter((projectId) => { - this.projectMap[projectId].name.toLowerCase().includes(this.searchQuery.toLowerCase()) || - this.projectMap[projectId].identifier.toLowerCase().includes(this.searchQuery.toLowerCase()); - }); + const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; + if (!workspaceDetails) return []; + const workspaceProjects = Object.values(this.projectMap).filter( + (p) => + p.workspace === workspaceDetails.id && + (p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || + p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase())) + ); + return workspaceProjects.map((p) => p.id); } /** * Returns project IDs belong to the current workspace */ get workspaceProjectIds() { - if (!this.rootStore.app.router.workspaceSlug) return null; - const projectIds = Object.keys(this.projectMap); - if (!projectIds) return null; - return projectIds; + const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; + if (!workspaceDetails) return null; + const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id); + const projectIds = workspaceProjects.map((p) => p.id); + return projectIds ?? null; } /** diff --git a/web/store/root.store.ts b/web/store/root.store.ts index ebb3779d1..bc208575a 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -9,7 +9,6 @@ import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IssueRootStore, IIssueRootStore } from "./issue/root.store"; import { IStateStore, StateStore } from "./state.store"; -import { IPageStore, PageStore } from "./page.store"; import { ILabelRootStore, LabelRootStore } from "./label"; import { IMemberRootStore, MemberRootStore } from "./member"; import { IInboxRootStore, InboxRootStore } from "./inbox"; @@ -33,7 +32,6 @@ export class RootStore { module: IModuleStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; - page: IPageStore; issue: IIssueRootStore; state: IStateStore; estimate: IEstimateStore; @@ -58,8 +56,7 @@ export class RootStore { this.state = new StateStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); + this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); - this.projectPages = new ProjectPageStore(); - this.page = new PageStore(this); } } diff --git a/yarn.lock b/yarn.lock index 7ed7e3a9e..d04bdb628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6617,13 +6617,35 @@ mkdirp@^0.5.5: dependencies: minimist "^1.2.6" -mobx-react-lite@^4.0.3: +mobx-devtools-mst@^0.9.30: + version "0.9.30" + resolved "https://registry.yarnpkg.com/mobx-devtools-mst/-/mobx-devtools-mst-0.9.30.tgz#0d1cad8b3d97e1f3f94bb9afb701cd9c8b5b164d" + integrity sha512-6fIYeFG4xT4syIeKddmK55zQbc3ZZZr/272/cCbfaAAM5YiuFdteGZGUgdsz8wxf/mGxWZbFOM3WmASAnpwrbw== + +mobx-react-devtools@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-6.1.1.tgz#a462b944085cf11ff96fc937d12bf31dab4c8984" + integrity sha512-nc5IXLdEUFLn3wZal65KF3/JFEFd+mbH4KTz/IG5BOPyw7jo8z29w/8qm7+wiCyqVfUIgJ1gL4+HVKmcXIOgqA== + +mobx-react-lite@^4.0.3, mobx-react-lite@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b" integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg== dependencies: use-sync-external-store "^1.2.0" +mobx-react@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-9.1.0.tgz#5e54919ca27ffad5f2c0d835148a1f681cebdbc1" + integrity sha512-DeDRTYw4AlgHw8xEXtiZdKKEnp+c5/jeUgTbTQXEqnAzfkrgYRWP3p3Nv3Whc2CEcM/mDycbDWGjxKokQdlffg== + dependencies: + mobx-react-lite "^4.0.4" + +mobx-state-tree@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-5.4.0.tgz#d41b7fd90b8d4b063bc32526758417f1100751df" + integrity sha512-2VuUhAqFklxgGqFNqaZUXYYSQINo8C2SUEP9YfCQrwatHWHqJLlEC7Xb+5WChkev7fubzn3aVuby26Q6h+JeBg== + mobx@^6.10.0: version "6.12.0" resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"