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

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

View File

@ -1,61 +1,30 @@
name: Branch Build
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

View File

@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
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

View File

@ -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=""

View File

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

View File

@ -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
):

View File

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

View File

@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration
def forgot_password(first_name, email, uidb64, token, current_site):
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

View File

@ -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():

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
/>

View File

@ -1,137 +0,0 @@
import React from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input } from "@plane/ui";
// types
type EmailPasswordFormValues = {
email: string;
password?: string;
confirm_password: string;
medium?: string;
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailSignUpForm: React.FC<Props> = (props) => {
const { onSubmit } = props;
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
confirm_password: "",
medium: "email",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<>
<form className="mx-auto mt-10 w-full space-y-4 sm:w-[360px]" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-1">
<Controller
control={control}
name="email"
rules={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email address..."
className="h-[46px] w-full border-custom-border-300"
/>
)}
/>
</div>
<div className="space-y-1">
<Controller
control={control}
name="password"
rules={{
required: "Password is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="password"
name="password"
type="password"
value={value ?? ""}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Enter your password..."
className="h-[46px] w-full border-custom-border-300"
/>
)}
/>
</div>
<div className="space-y-1">
<Controller
control={control}
name="confirm_password"
rules={{
required: "Password is required",
validate: (val: string) => {
if (watch("password") != val) {
return "Your passwords do no match";
}
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="confirm_password"
name="confirm_password"
type="password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.confirm_password)}
placeholder="Confirm your password..."
className="h-[46px] w-full border-custom-border-300"
/>
)}
/>
</div>
<div className="text-right text-xs">
<Link href="/">
<span className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</span>
</Link>
</div>
<div>
<Button
variant="primary"
type="submit"
className="w-full"
size="xl"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing up..." : "Sign up"}
</Button>
</div>
</form>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,110 @@
import React from "react";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
import { observer } from "mobx-react-lite";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData } from "@plane/types";
type Props = {
onSubmit: (isPasswordAutoset: boolean) => void;
updateEmail: (email: string) => void;
};
type TEmailFormValues = {
email: string;
};
const authService = new AuthService();
export const SignInEmailForm: React.FC<Props> = observer((props) => {
const { onSubmit, updateEmail } = props;
// hooks
const { setToastAlert } = useToast();
const {
control,
formState: { errors, isSubmitting, isValid },
handleSubmit,
} = useForm<TEmailFormValues>({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleFormSubmit = async (data: TEmailFormValues) => {
const payload: IEmailCheckData = {
email: data.email,
};
// update the global email state
updateEmail(data.email);
await authService
.emailCheck(payload)
.then((res) => onSubmit(res.is_password_autoset))
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
return (
<>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
Welcome back, let{"'"}s get you on board
</h1>
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
Get back to your issues, projects and workspaces.
</p>
<form onSubmit={handleSubmit(handleFormSubmit)} className="mx-auto mt-8 space-y-4 sm:w-96">
<div className="space-y-1">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange } }) => (
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
{value.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => onChange("")}
/>
)}
</div>
)}
/>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
Continue
</Button>
</form>
</>
);
});

View File

@ -0,0 +1,54 @@
import { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
import { X } from "lucide-react";
export const ForgotPasswordPopover = () => {
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
return (
<Popover className="relative">
<Popover.Button as={Fragment}>
<button
type="button"
ref={setReferenceElement}
className="text-xs font-medium text-custom-primary-100 outline-none"
>
Forgot your password?
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
{({ close }) => (
<div
className="border border-onboarding-border-300 bg-onboarding-background-100 rounded z-10 py-1 px-2 w-64 break-words flex items-start gap-3 text-left ml-3"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<span className="flex-shrink-0">🤥</span>
<p className="text-xs">
We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link
</p>
<button type="button" className="flex-shrink-0" onClick={() => close()}>
<X className="h-3 w-3 text-onboarding-text-200" />
</button>
</div>
)}
</Popover.Panel>
</Popover>
);
};

View File

@ -1,9 +1,6 @@
export * from "./create-password";
export * from "./email-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";

View File

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

View File

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

View File

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

View File

@ -1,103 +0,0 @@
import React from "react";
import { Controller, useForm } from "react-hook-form";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData } from "@plane/types";
type Props = {
email: string;
updateEmail: (email: string) => void;
};
const authService = new AuthService();
export const SetPasswordLink: React.FC<Props> = (props) => {
const { email, updateEmail } = props;
const { setToastAlert } = useToast();
const {
control,
formState: { errors, isSubmitting, isValid },
handleSubmit,
} = useForm({
defaultValues: {
email,
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleSendNewLink = async (formData: { email: string }) => {
updateEmail(formData.email);
const payload: IEmailCheckData = {
email: formData.email,
};
await authService
.sendResetPasswordLink(payload)
.then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "We have sent a new link to your email.",
})
)
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
return (
<>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
Get on your flight deck
</h1>
<p className="mt-2.5 px-20 text-center text-sm text-onboarding-text-200">
We have sent a link to <span className="font-semibold text-custom-primary-100">{email},</span> so you can set a
password
</p>
<form onSubmit={handleSubmit(handleSendNewLink)} className="mx-auto mt-5 space-y-4 sm:w-96">
<div className="space-y-1">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
hasError={Boolean(errors.email)}
placeholder="orville.wright@frstflt.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400"
disabled
/>
)}
/>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? "Sending new link" : "Get link again"}
</Button>
</form>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,97 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import {
OAuthOptions,
SignUpEmailForm,
SignUpOptionalSetPasswordForm,
SignUpPasswordForm,
SignUpUniqueCodeForm,
} from "components/account";
import Link from "next/link";
export enum ESignUpSteps {
EMAIL = "EMAIL",
UNIQUE_CODE = "UNIQUE_CODE",
PASSWORD = "PASSWORD",
OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD",
}
const OAUTH_ENABLED_STEPS = [ESignUpSteps.EMAIL];
export const SignUpRoot = observer(() => {
// states
const [signInStep, setSignInStep] = useState<ESignUpSteps | null>(null);
const [email, setEmail] = useState("");
// sign in redirection hook
const { handleRedirection } = useSignInRedirection();
// mobx store
const {
config: { envConfig },
} = useApplication();
// step 1 submit handler- email verification
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
// step 2 submit handler- unique code sign in
const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => {
if (isPasswordAutoset) setSignInStep(ESignUpSteps.OPTIONAL_SET_PASSWORD);
else await handleRedirection();
};
// step 3 submit handler- password sign in
const handlePasswordSignIn = async () => {
await handleRedirection();
};
const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id);
useEffect(() => {
if (envConfig?.is_smtp_configured) setSignInStep(ESignUpSteps.EMAIL);
else setSignInStep(ESignUpSteps.PASSWORD);
}, [envConfig?.is_smtp_configured]);
return (
<>
<div className="mx-auto flex flex-col">
<>
{signInStep === ESignUpSteps.EMAIL && (
<SignUpEmailForm onSubmit={handleEmailVerification} updateEmail={(newEmail) => setEmail(newEmail)} />
)}
{signInStep === ESignUpSteps.UNIQUE_CODE && (
<SignUpUniqueCodeForm
email={email}
handleEmailClear={() => {
setEmail("");
setSignInStep(ESignUpSteps.EMAIL);
}}
onSubmit={handleUniqueCodeSignIn}
/>
)}
{signInStep === ESignUpSteps.PASSWORD && <SignUpPasswordForm onSubmit={handlePasswordSignIn} />}
{signInStep === ESignUpSteps.OPTIONAL_SET_PASSWORD && (
<SignUpOptionalSetPasswordForm
email={email}
handleSignInRedirection={handleRedirection}
handleStepChange={(step) => setSignInStep(step)}
/>
)}
</>
</div>
{isOAuthEnabled && signInStep && OAUTH_ENABLED_STEPS.includes(signInStep) && (
<>
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Already using Plane?{" "}
<Link href="/" className="text-custom-primary-100 font-medium underline">
Sign in
</Link>
</p>
</>
)}
</>
);
});

View File

@ -0,0 +1,215 @@
import React, { useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
type Props = {
email: string;
handleEmailClear: () => void;
onSubmit: (isPasswordAutoset: boolean) => Promise<void>;
};
type TUniqueCodeFormValues = {
email: string;
token: string;
};
const defaultValues: TUniqueCodeFormValues = {
email: "",
token: "",
};
// services
const authService = new AuthService();
const userService = new UserService();
export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
const { email, handleEmailClear, onSubmit } = props;
// states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
// form info
const {
control,
formState: { errors, isSubmitting, isValid },
getValues,
handleSubmit,
reset,
} = useForm<TUniqueCodeFormValues>({
defaultValues: {
...defaultValues,
email,
},
mode: "onChange",
reValidateMode: "onChange",
});
const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => {
const payload: IMagicSignInData = {
email: formData.email,
key: `magic_${formData.email}`,
token: formData.token,
};
await authService
.magicSignIn(payload)
.then(async () => {
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
const payload: IEmailCheckData = {
email: formData.email,
};
await authService
.generateUniqueCode(payload)
.then(() => {
setResendCodeTimer(30);
setToastAlert({
type: "success",
title: "Success!",
message: "A new unique code has been sent to your email.",
});
reset({
email: formData.email,
token: "",
});
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
const handleRequestNewCode = async () => {
setIsRequestingNewCode(true);
await handleSendNewCode(getValues())
.then(() => setResendCodeTimer(30))
.finally(() => setIsRequestingNewCode(false));
};
const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0;
return (
<>
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">Moving to the runway</h1>
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">
Paste the code you got at
<br />
<span className="font-semibold text-custom-primary-100">{email}</span> below.
</p>
<form onSubmit={handleSubmit(handleUniqueCodeSignIn)} className="mx-auto mt-5 space-y-4 sm:w-96">
<div>
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
disabled
/>
{value.length > 0 && (
<XCircle
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={handleEmailClear}
/>
)}
</div>
)}
/>
</div>
<div>
<Controller
control={control}
name="token"
rules={{
required: "Code is required",
}}
render={({ field: { value, onChange } }) => (
<Input
value={value}
onChange={onChange}
hasError={Boolean(errors.token)}
placeholder="gets-sets-flys"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
)}
/>
<div className="w-full text-right">
<button
type="button"
onClick={handleRequestNewCode}
className={`text-xs ${
isRequestNewCodeDisabled
? "text-onboarding-text-300"
: "text-onboarding-text-200 hover:text-custom-primary-100"
}`}
disabled={isRequestNewCodeDisabled}
>
{resendTimerCode > 0
? `Request new code in ${resendTimerCode}s`
: isRequestingNewCode
? "Requesting new code"
: "Request new code"}
</button>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
Create account
</Button>
<p className="text-xs text-onboarding-text-200">
When you click the button above, you agree with our{" "}
<Link href="https://plane.so/terms-and-conditions" target="_blank" rel="noopener noreferrer">
<span className="font-semibold underline">terms and conditions of service.</span>
</Link>
</p>
</form>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (

View File

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

View File

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

View File

@ -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 (

View File

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

View File

@ -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 (

View File

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

View File

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

View File

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

View File

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

40
web/constants/profile.ts Normal file
View File

@ -0,0 +1,40 @@
import React from "react";
// icons
import { Activity, CircleUser, KeyRound, LucideProps, Settings2 } from "lucide-react";
export const PROFILE_ACTION_LINKS: {
key: string;
label: string;
href: string;
highlight: (pathname: string) => boolean;
Icon: React.FC<LucideProps>;
}[] = [
{
key: "profile",
label: "Profile",
href: `/profile`,
highlight: (pathname: string) => pathname === "/profile",
Icon: CircleUser,
},
{
key: "change-password",
label: "Change password",
href: `/profile/change-password`,
highlight: (pathname: string) => pathname === "/profile/change-password",
Icon: KeyRound,
},
{
key: "activity",
label: "Activity",
href: `/profile/activity`,
highlight: (pathname: string) => pathname === "/profile/activity",
Icon: Activity,
},
{
key: "preferences",
label: "Preferences",
href: `/profile/preferences`,
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
Icon: Settings2,
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
import { TIssue } from "@plane/types";
import { PROJECT_ISSUES_LIST, STATES_LIST } from "constants/fetch-keys";
import { EIssuesStoreType } from "constants/issue";
import { StoreContext } from "contexts/store-context";
import { autorun, toJS } from "mobx";
import { useContext } from "react";
import { IssueService } from "services/issue";
import useSWR from "swr";
import { useIssueDetail, useIssues, useMember, useProject, useProjectState } from "./store";
const issueService = new IssueService();
export const useIssueEmbeds = () => {
const workspaceSlug = useContext(StoreContext).app.router.workspaceSlug;
const projectId = useContext(StoreContext).app.router.projectId;
const { getProjectById } = useProject();
const { setPeekIssue } = useIssueDetail();
const { getStateById } = useProjectState();
const { getUserDetails } = useMember();
const { data: issuesResponse } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
);
const issues = Object.values(issuesResponse ?? {});
const issuesWithStateAndProject = issues.map((issue) => ({
...issue,
state_detail: toJS(getStateById(issue.state_id)),
project_detail: toJS(getProjectById(issue.project_id)),
assignee_details: issue.assignee_ids.map((assigneeid) => toJS(getUserDetails(assigneeid))),
}));
const fetchIssue = async (issueId: string) => issuesWithStateAndProject.find((issue) => issue.id === issueId);
const issueWidgetClickAction = (issueId: string) => {
if (!workspaceSlug || !projectId) return;
setPeekIssue({ workspaceSlug, projectId: projectId, issueId });
};
return {
issues: issuesWithStateAndProject,
fetchIssue,
issueWidgetClickAction,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,138 @@
import { ReactElement } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// layouts
import DefaultLayout from "layouts/default-layout";
// components
import { LatestFeatureBlock } from "components/common";
// ui
import { Button, Input } from "@plane/ui";
// images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// type
import { NextPageWithLayout } from "lib/types";
type TForgotPasswordFormValues = {
email: string;
};
const defaultValues: TForgotPasswordFormValues = {
email: "",
};
// services
const authService = new AuthService();
const ForgotPasswordPage: NextPageWithLayout = () => {
// router
const router = useRouter();
const { email } = router.query;
// toast
const { setToastAlert } = useToast();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0);
// form info
const {
control,
formState: { errors, isSubmitting, isValid },
handleSubmit,
} = useForm<TForgotPasswordFormValues>({
defaultValues: {
...defaultValues,
email: email?.toString() ?? "",
},
});
const handleForgotPassword = async (formData: TForgotPasswordFormValues) => {
await authService
.sendResetPasswordLink({
email: formData.email,
})
.then(() => {
setToastAlert({
type: "success",
title: "Email sent",
message:
"Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.",
});
setResendCodeTimer(30);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
};
return (
<div className="h-full w-full bg-onboarding-gradient-100">
<div className="flex items-center justify-between px-8 pb-4 sm:px-16 sm:py-5 lg:px-28 ">
<div className="flex items-center gap-x-2 py-10">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
</div>
</div>
<div className="mx-auto h-full rounded-t-md border-x border-t border-custom-border-200 bg-onboarding-gradient-100 px-4 pt-4 shadow-sm sm:w-4/5 md:w-2/3 ">
<div className="h-full overflow-auto rounded-t-md bg-onboarding-gradient-200 px-7 pb-56 pt-24 sm:px-0">
<div className="mx-auto flex flex-col divide-y divide-custom-border-200 sm:w-96">
<h1 className="sm:text-2.5xl text-center text-2xl font-medium text-onboarding-text-100">
Get on your flight deck
</h1>
<p className="mt-2.5 text-center text-sm text-onboarding-text-200">Get a link to reset your password</p>
<form onSubmit={handleSubmit(handleForgotPassword)} className="mx-auto mt-5 space-y-4 sm:w-96">
<Controller
control={control}
name="email"
rules={{
required: "Email is required",
validate: (value) => checkEmailValidity(value) || "Email is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="name@company.com"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
/>
)}
/>
<Button
type="submit"
variant="primary"
className="w-full"
size="xl"
disabled={!isValid}
loading={isSubmitting || resendTimerCode > 0}
>
{resendTimerCode > 0 ? `Request new link in ${resendTimerCode}s` : "Get link"}
</Button>
</form>
</div>
<LatestFeatureBlock />
</div>
</div>
</div>
);
};
ForgotPasswordPage.getLayout = function getLayout(page: ReactElement) {
return <DefaultLayout>{page}</DefaultLayout>;
};
export default ForgotPasswordPage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"