Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea581a6948 | ||
|
|
04b10cabc8 | ||
|
|
545717cc51 | ||
|
|
1ca0a15792 | ||
|
|
c5971f03aa | ||
|
|
902403a54d | ||
|
|
1d6ebb7c41 | ||
|
|
106914e14e | ||
|
|
8acb60baef | ||
|
|
1da97d5814 | ||
|
|
5fb2dd0b6e | ||
|
|
ff6c3ce1a0 | ||
|
|
ec51e9d8ce | ||
|
|
cc07992e47 | ||
|
|
069f8b950e | ||
|
|
5eb868e07d | ||
|
|
7c77fc1680 | ||
|
|
99a7867a5e | ||
|
|
c44bf861e0 | ||
|
|
4d38a10f8b | ||
|
|
7c3fc690e9 | ||
|
|
8cf1c2d136 | ||
|
|
fe280b2beb | ||
|
|
ad5c6ee4f5 | ||
|
|
ba0d1ba518 | ||
|
|
70ea1459cd | ||
|
|
8154a190d2 | ||
|
|
29fd1186ee | ||
|
|
68b412badf | ||
|
|
c95aa6a0f7 | ||
|
|
751cd6c862 | ||
|
|
1032bc75d7 | ||
|
|
9415a5ba00 | ||
|
|
d24a4e18a2 | ||
|
|
52f78a86af | ||
|
|
c84c37805c | ||
|
|
c2758caf95 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -2,6 +2,12 @@ name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arm64:
|
||||
description: "Build for ARM64 architecture"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -11,6 +17,8 @@ on:
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
|
||||
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
@@ -28,12 +36,13 @@ jobs:
|
||||
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
|
||||
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
|
||||
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
@@ -45,6 +54,8 @@ jobs:
|
||||
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g')
|
||||
echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
@@ -90,10 +101,11 @@ jobs:
|
||||
|
||||
branch_build_push_web:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Web Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -103,7 +115,10 @@ jobs:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }}
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-frontend:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-frontend:latest
|
||||
else
|
||||
@@ -142,10 +157,11 @@ jobs:
|
||||
|
||||
branch_build_push_admin:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -155,7 +171,10 @@ jobs:
|
||||
- name: Set Admin Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-admin:${{ github.event.release.tag_name }}
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-admin:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-admin:latest
|
||||
else
|
||||
@@ -194,10 +213,11 @@ jobs:
|
||||
|
||||
branch_build_push_space:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Space Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -207,7 +227,10 @@ jobs:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-space:${{ github.event.release.tag_name }}
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-space:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-space:latest
|
||||
else
|
||||
@@ -246,10 +269,11 @@ jobs:
|
||||
|
||||
branch_build_push_apiserver:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push API Server Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -259,7 +283,10 @@ jobs:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-backend:${{ github.event.release.tag_name }}
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-backend:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-backend:latest
|
||||
else
|
||||
@@ -298,10 +325,11 @@ jobs:
|
||||
|
||||
branch_build_push_live:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Live Collaboration Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -311,7 +339,10 @@ jobs:
|
||||
- name: Set Live Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-live:stable,makeplane/plane-live:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-live:${{ github.event.release.tag_name }}
|
||||
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-live:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-live:latest
|
||||
else
|
||||
@@ -350,10 +381,11 @@ jobs:
|
||||
|
||||
branch_build_push_proxy:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
name: Build-Push Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.flat_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
@@ -363,7 +395,10 @@ jobs:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
|
||||
TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }}
|
||||
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
|
||||
TAG=${TAG},makeplane/plane-proxy:stable
|
||||
fi
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-proxy:latest
|
||||
else
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
// components
|
||||
import { AuthenticationMethodCard } from "@/components/authentication";
|
||||
// helpers
|
||||
import { UpgradeButton } from "@/components/common/upgrade-button";
|
||||
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
|
||||
// plane admin components
|
||||
import { UpgradeButton } from "@/plane-admin/components/common";
|
||||
// images
|
||||
import OIDCLogo from "@/public/logos/oidc-logo.svg";
|
||||
import SAMLLogo from "@/public/logos/saml-logo.svg";
|
||||
@@ -27,24 +28,24 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
|
||||
const { disabled, updateConfig } = props;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-button";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
super.hydrate(initialData);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
super.resetOnSignOut();
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,3 @@ export * from "./empty-state";
|
||||
export * from "./logo-spinner";
|
||||
export * from "./page-header";
|
||||
export * from "./code-block";
|
||||
export * from "./upgrade-button";
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { isUserLoggedIn } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext } from "react";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
|
||||
let rootStore = new RootStore();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
// root store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
@@ -46,7 +46,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
// service
|
||||
instanceService;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isLoading: observable.ref,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IUserStore, UserStore } from "./user.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore {
|
||||
export abstract class CoreRootStore {
|
||||
theme: IThemeStore;
|
||||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { action, observable, makeObservable } from "mobx";
|
||||
// root store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TTheme = "dark" | "light";
|
||||
export interface IThemeStore {
|
||||
@@ -21,7 +21,7 @@ export class ThemeStore implements IThemeStore {
|
||||
isSidebarCollapsed: boolean | undefined = undefined;
|
||||
theme: string | undefined = undefined;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isNewUserPopup: observable.ref,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
// root store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
@@ -31,7 +31,7 @@ export class UserStore implements IUserStore {
|
||||
userService;
|
||||
authService;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/components/common";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "ce/store/root.store";
|
||||
@@ -1,6 +1,3 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
@@ -30,6 +27,9 @@ from .module import ModuleLiteSerializer, ModuleSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
assignees = serializers.ListField(
|
||||
@@ -315,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
|
||||
@@ -71,6 +71,16 @@ class ModuleSerializer(BaseSerializer):
|
||||
project_id = self.context["project_id"]
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if Module.objects.filter(
|
||||
name=module_name, project_id=project_id
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
module = Module.objects.create(**validated_data, project_id=project_id)
|
||||
if members is not None:
|
||||
ModuleMember.objects.bulk_create(
|
||||
@@ -93,6 +103,19 @@ class ModuleSerializer(BaseSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
members = validated_data.pop("members", None)
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if (
|
||||
Module.objects.filter(
|
||||
name=module_name, project=instance.project
|
||||
)
|
||||
.exclude(id=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
if members is not None:
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
|
||||
@@ -437,17 +437,21 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"issue",
|
||||
]
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
try:
|
||||
validate_url(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError("Invalid URL format.")
|
||||
def to_internal_value(self, data):
|
||||
# Modify the URL before validation by appending http:// if missing
|
||||
url = data.get("url", "")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
data["url"] = "http://" + url
|
||||
|
||||
# Check URL scheme
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise serializers.ValidationError("Invalid URL scheme.")
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_url(self, value):
|
||||
# Use Django's built-in URLValidator for validation
|
||||
url_validator = URLValidator()
|
||||
try:
|
||||
url_validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError({"error": "Invalid URL format."})
|
||||
|
||||
return value
|
||||
|
||||
@@ -533,7 +537,7 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
"project",
|
||||
"issue",
|
||||
"actor",
|
||||
"deleted_at"
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -552,7 +556,13 @@ class CommentReactionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"comment",
|
||||
"actor",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
@@ -5,6 +5,10 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Module,
|
||||
@@ -64,6 +68,16 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
members = validated_data.pop("member_ids", None)
|
||||
project = self.context["project"]
|
||||
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if Module.objects.filter(
|
||||
name=module_name, project=project
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
module = Module.objects.create(**validated_data, project=project)
|
||||
if members is not None:
|
||||
ModuleMember.objects.bulk_create(
|
||||
@@ -86,6 +100,19 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
members = validated_data.pop("member_ids", None)
|
||||
module_name = validated_data.get("name")
|
||||
if module_name:
|
||||
# Lookup for the module name in the module table for that project
|
||||
if (
|
||||
Module.objects.filter(
|
||||
name=module_name, project=instance.project
|
||||
)
|
||||
.exclude(id=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Module with this name already exists"}
|
||||
)
|
||||
|
||||
if members is not None:
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
@@ -155,16 +182,48 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
"module",
|
||||
]
|
||||
|
||||
# Validation if url already exists
|
||||
def to_internal_value(self, data):
|
||||
# Modify the URL before validation by appending http:// if missing
|
||||
url = data.get("url", "")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
data["url"] = "http://" + url
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_url(self, value):
|
||||
# Use Django's built-in URLValidator for validation
|
||||
url_validator = URLValidator()
|
||||
try:
|
||||
url_validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError({"error": "Invalid URL format."})
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["url"] = self.validate_url(validated_data.get("url"))
|
||||
if ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
module_id=validated_data.get("module_id"),
|
||||
).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists."})
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data["url"] = self.validate_url(validated_data.get("url"))
|
||||
if (
|
||||
ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
module_id=instance.module_id,
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return ModuleLink.objects.create(**validated_data)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ModuleSerializer(DynamicBaseSerializer):
|
||||
@@ -229,7 +288,14 @@ class ModuleDetailSerializer(ModuleSerializer):
|
||||
cancelled_estimate_points = serializers.FloatField(read_only=True)
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
|
||||
fields = ModuleSerializer.Meta.fields + [
|
||||
"link_module",
|
||||
"sub_issues",
|
||||
"backlog_estimate_points",
|
||||
"unstarted_estimate_points",
|
||||
"started_estimate_points",
|
||||
"cancelled_estimate_points",
|
||||
]
|
||||
|
||||
|
||||
class ModuleUserPropertiesSerializer(BaseSerializer):
|
||||
|
||||
@@ -40,7 +40,7 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback:
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
@@ -92,7 +92,7 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback:
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "URL resolves to a blocked IP address."}
|
||||
)
|
||||
|
||||
@@ -442,10 +442,10 @@ function backupData() {
|
||||
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
|
||||
mkdir -p "$BACKUP_FOLDER"
|
||||
|
||||
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
|
||||
volumes=$(docker volume ls -f "name=$SERVICE_FOLDER" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
|
||||
# Check if there are any matching volumes
|
||||
if [ -z "$volumes" ]; then
|
||||
echo "No volumes found starting with 'plane-app'"
|
||||
echo "No volumes found starting with '$SERVICE_FOLDER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ DEBUG=0
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
|
||||
API_BASE_URL=http://api:8000
|
||||
|
||||
#DB SETTINGS
|
||||
PGHOST=plane-db
|
||||
@@ -29,11 +30,11 @@ REDIS_PORT=6379
|
||||
REDIS_URL=
|
||||
|
||||
# RabbitMQ Settings
|
||||
RABBITMQ_HOST="plane-mq"
|
||||
RABBITMQ_PORT="5672"
|
||||
RABBITMQ_USER="plane"
|
||||
RABBITMQ_PASSWORD="plane"
|
||||
RABBITMQ_VHOST="plane"
|
||||
RABBITMQ_HOST=plane-mq
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USER=plane
|
||||
RABBITMQ_PASSWORD=plane
|
||||
RABBITMQ_VHOST=plane
|
||||
AMQP_URL=
|
||||
|
||||
# Secret Key
|
||||
|
||||
+4
-1
@@ -24,10 +24,12 @@
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/html": "^2.3.0",
|
||||
"axios": "^1.7.2",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.20.0",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"morgan": "^1.10.0",
|
||||
@@ -42,6 +44,7 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.21",
|
||||
|
||||
@@ -24,7 +24,7 @@ import { TDocumentTypes } from "@/core/types/common.js";
|
||||
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
|
||||
import { updateDocument } from "@/plane-live/lib/update-document.js";
|
||||
|
||||
export const getExtensions: () => Extension[] = () => {
|
||||
export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
const extensions: Extension[] = [
|
||||
new Logger({
|
||||
onChange: false,
|
||||
@@ -65,7 +65,7 @@ export const getExtensions: () => Extension[] = () => {
|
||||
}
|
||||
resolve(fetchedData);
|
||||
} catch (error) {
|
||||
console.error("Error in fetching document", error);
|
||||
manualLogger.error("Error in fetching document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -97,7 +97,7 @@ export const getExtensions: () => Extension[] = () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in updating document", error);
|
||||
manualLogger.error("Error in updating document:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -106,34 +106,42 @@ export const getExtensions: () => Extension[] = () => {
|
||||
|
||||
const redisUrl = getRedisUrl();
|
||||
|
||||
// Add the Redis extension only if configured
|
||||
if (redisUrl) {
|
||||
try {
|
||||
const redisClient = new Redis(redisUrl);
|
||||
redisClient.on("error", (error: any) => {
|
||||
// if auth fails or the server is down, disconnect redis
|
||||
if (
|
||||
error?.code === "ENOTFOUND" ||
|
||||
error.message.includes("WRONGPASS") ||
|
||||
error.message.includes("NOAUTH")
|
||||
) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
manualLogger.error(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data betwen multiple plane live servers)`,
|
||||
);
|
||||
manualLogger.error(error);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
redisClient.on("error", (error: any) => {
|
||||
if (
|
||||
error?.code === "ENOTFOUND" ||
|
||||
error.message.includes("WRONGPASS") ||
|
||||
error.message.includes("NOAUTH")
|
||||
) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error,
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
redisClient.on("ready", () => {
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
manualLogger.info("Redis Client connected ✅");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
redisClient.on("ready", () => {
|
||||
manualLogger.info("Redis Client connected");
|
||||
});
|
||||
if (!redisClient) {
|
||||
throw new Error("Redis client is not defined");
|
||||
}
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
} catch (error) {
|
||||
manualLogger.error("Failed to connect to Redis:", error);
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualLogger.warn(
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
|
||||
@@ -7,9 +7,32 @@ const transport = {
|
||||
},
|
||||
};
|
||||
|
||||
const hooks = {
|
||||
logMethod(inputArgs: any, method: any): any {
|
||||
if (inputArgs.length >= 2) {
|
||||
const arg1 = inputArgs.shift();
|
||||
const arg2 = inputArgs.shift();
|
||||
return method.apply(this, [arg2, arg1, ...inputArgs]);
|
||||
}
|
||||
return method.apply(this, inputArgs);
|
||||
},
|
||||
};
|
||||
|
||||
export const logger = pinoHttp({
|
||||
level: "info",
|
||||
transport: transport,
|
||||
hooks: hooks,
|
||||
serializers: {
|
||||
req(req) {
|
||||
return `${req.method} ${req.url}`;
|
||||
},
|
||||
res(res) {
|
||||
return `${res.statusCode} ${res?.statusMessage || ""}`;
|
||||
},
|
||||
responseTime(time) {
|
||||
return `${time}ms`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger = logger.logger;
|
||||
|
||||
@@ -3,33 +3,36 @@ import { Server } from "@hocuspocus/server";
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
|
||||
export const HocusPocusServer = Server.configure({
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connection,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// params
|
||||
const params = requestParameters;
|
||||
export const getHocusPocusServer = async () => {
|
||||
const extensions = await getExtensions();
|
||||
return Server.configure({
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connection,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// params
|
||||
const params = requestParameters;
|
||||
|
||||
if (!cookie) {
|
||||
throw Error("Credentials not provided");
|
||||
}
|
||||
if (!cookie) {
|
||||
throw Error("Credentials not provided");
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
connection,
|
||||
cookie,
|
||||
params,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
extensions: getExtensions(),
|
||||
});
|
||||
try {
|
||||
await handleAuthentication({
|
||||
connection,
|
||||
cookie,
|
||||
params,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { UserService } from "@/core/services/user.service.js";
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
// plane live lib
|
||||
import { authenticateUser } from "@/plane-live/lib/authentication.js";
|
||||
// core helpers
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -26,7 +28,7 @@ export const handleAuthentication = async (props: Props) => {
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current user:", error);
|
||||
manualLogger.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== token) {
|
||||
@@ -43,22 +45,27 @@ export const handleAuthentication = async (props: Props) => {
|
||||
);
|
||||
}
|
||||
// fetch current user's project membership info
|
||||
const projectMembershipInfo = await userService.getUserProjectMembership(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cookie
|
||||
);
|
||||
const projectRole = projectMembershipInfo.role;
|
||||
// make the connection read only for roles lower than a member
|
||||
if (projectRole < 15) {
|
||||
connection.readOnly = true;
|
||||
try {
|
||||
const projectMembershipInfo = await userService.getUserProjectMembership(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cookie
|
||||
);
|
||||
const projectRole = projectMembershipInfo.role;
|
||||
// make the connection read only for roles lower than a member
|
||||
if (projectRole < 15) {
|
||||
connection.readOnly = true;
|
||||
}
|
||||
} catch (error) {
|
||||
manualLogger.error("Failed to fetch project membership info:", error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
await authenticateUser({
|
||||
connection,
|
||||
cookie,
|
||||
documentType,
|
||||
params
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+24
-18
@@ -1,18 +1,22 @@
|
||||
// helpers
|
||||
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
|
||||
import {
|
||||
getAllDocumentFormatsFromBinaryData,
|
||||
getBinaryDataFromHTMLString,
|
||||
} from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
export const updatePageDescription = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
updatedDescription: Uint8Array,
|
||||
cookie: string | undefined
|
||||
cookie: string | undefined,
|
||||
) => {
|
||||
if (!(updatedDescription instanceof Uint8Array)) {
|
||||
throw new Error(
|
||||
"Invalid updatedDescription: must be an instance of Uint8Array"
|
||||
"Invalid updatedDescription: must be an instance of Uint8Array",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +24,8 @@ export const updatePageDescription = async (
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
const {
|
||||
contentBinaryEncoded,
|
||||
contentHTML,
|
||||
contentJSON
|
||||
} = getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
try {
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
@@ -37,10 +38,10 @@ export const updatePageDescription = async (
|
||||
projectId,
|
||||
pageId,
|
||||
payload,
|
||||
cookie
|
||||
cookie,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
manualLogger.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -49,7 +50,7 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
cookie: string,
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
@@ -58,12 +59,17 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
cookie,
|
||||
);
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(
|
||||
pageDetails.description_html ?? "<p></p>",
|
||||
);
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>")
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
console.error("Error while transforming from HTML to Uint8Array", error);
|
||||
manualLogger.error(
|
||||
"Error while transforming from HTML to Uint8Array",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -71,7 +77,7 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
export const fetchPageDescriptionBinary = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
cookie: string | undefined
|
||||
cookie: string | undefined,
|
||||
) => {
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
@@ -82,7 +88,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
cookie,
|
||||
);
|
||||
const binaryData = new Uint8Array(response);
|
||||
|
||||
@@ -91,7 +97,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
cookie,
|
||||
);
|
||||
if (binary) {
|
||||
return binary;
|
||||
@@ -100,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
console.error("Fetch error:", error);
|
||||
manualLogger.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+68
-4
@@ -3,12 +3,14 @@ import "@/core/config/sentry-config.js";
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import compression from "compression";
|
||||
import helmet from "helmet";
|
||||
|
||||
// cors
|
||||
import cors from "cors";
|
||||
|
||||
// core hocuspocus server
|
||||
import { HocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
|
||||
// helpers
|
||||
import { logger, manualLogger } from "@/core/helpers/logger.js";
|
||||
@@ -19,6 +21,17 @@ expressWs(app);
|
||||
|
||||
app.set("port", process.env.PORT || 3000);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// Middleware for response compression
|
||||
app.use(
|
||||
compression({
|
||||
level: 6,
|
||||
threshold: 5 * 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
// Logging middleware
|
||||
app.use(logger);
|
||||
|
||||
@@ -31,17 +44,27 @@ app.use(cors());
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
res.status(200).json({ status: "OK" });
|
||||
});
|
||||
|
||||
router.ws("/collaboration", (ws, req) => {
|
||||
HocusPocusServer.handleConnection(ws, req);
|
||||
try {
|
||||
HocusPocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
manualLogger.error("WebSocket connection error:", err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
app.use(process.env.LIVE_BASE_PATH || "/live", router);
|
||||
|
||||
app.use((_req, res, _next) => {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).send("Not Found");
|
||||
});
|
||||
|
||||
@@ -49,6 +72,47 @@ Sentry.setupExpressErrorHandler(app);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(app.get("port"), () => {
|
||||
const liveServer = app.listen(app.get("port"), () => {
|
||||
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
manualLogger.info("Starting graceful shutdown...");
|
||||
|
||||
try {
|
||||
// Close the HocusPocus server WebSocket connections
|
||||
await HocusPocusServer.destroy();
|
||||
manualLogger.info(
|
||||
"HocusPocus server WebSocket connections closed gracefully.",
|
||||
);
|
||||
|
||||
// Close the Express server
|
||||
liveServer.close(() => {
|
||||
manualLogger.info("Express server closed gracefully.");
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (err) {
|
||||
manualLogger.error("Error during shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Forcefully shut down after 10 seconds if not closed
|
||||
setTimeout(() => {
|
||||
manualLogger.error("Forcing shutdown...");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", (err: any) => {
|
||||
manualLogger.info("Unhandled Rejection: ", err);
|
||||
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", (err: any) => {
|
||||
manualLogger.info("Uncaught Exception: ", err);
|
||||
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"turbo": "^2.0.14"
|
||||
"turbo": "^2.1.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.48"
|
||||
|
||||
@@ -3,14 +3,14 @@ import React from "react";
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
import { IssueWidget } from "@/extensions";
|
||||
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const {
|
||||
|
||||
@@ -18,9 +18,9 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
|
||||
let a = event.target as HTMLElement;
|
||||
const els = [];
|
||||
|
||||
while (a.nodeName !== "DIV") {
|
||||
while (a?.nodeName !== "DIV") {
|
||||
els.push(a);
|
||||
a = a.parentNode as HTMLElement;
|
||||
a = a?.parentNode as HTMLElement;
|
||||
}
|
||||
|
||||
if (!els.find((value) => value.nodeName === "A")) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
@@ -104,4 +105,5 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
];
|
||||
|
||||
@@ -63,7 +63,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
const { selection } = editor.state;
|
||||
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
const blockType = parentNode?.type?.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
return false;
|
||||
|
||||
@@ -146,7 +146,7 @@ export const insertImageCommand = (
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".jpeg, .jpg, .png, .webp, .svg";
|
||||
input.accept = ".jpeg, .jpg, .png, .webp";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
|
||||
@@ -230,10 +230,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
documentInfo: {
|
||||
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef.current?.state),
|
||||
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
getDocumentInfo: () => {
|
||||
return {
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[editorRef, savedSelection, fileHandler.upload]
|
||||
|
||||
@@ -82,10 +82,12 @@ export const useReadOnlyEditor = ({
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
documentInfo: {
|
||||
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef.current?.state),
|
||||
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
getDocumentInfo: () => {
|
||||
return {
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ export function isFileValid(file: File): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"];
|
||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file.");
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type EditorReadOnlyRefApi = {
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
scrollSummary: (marking: IMarking) => void;
|
||||
documentInfo: {
|
||||
getDocumentInfo: () => {
|
||||
characters: number;
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
|
||||
@@ -170,13 +170,13 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
|
||||
background-color: rgba(var(--color-background-80)) !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"][checked] {
|
||||
background-color: rgba(var(--color-primary-100)) !important;
|
||||
border-color: rgba(var(--color-primary-100)) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover {
|
||||
background-color: rgba(var(--color-primary-300)) !important;
|
||||
border-color: rgba(var(--color-primary-300)) !important;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
&[checked]::before {
|
||||
transform: scale(1) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ module.exports = {
|
||||
"./pages/**/*.tsx",
|
||||
"./app/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
"../packages/ui/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/**/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
||||
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
||||
],
|
||||
},
|
||||
@@ -334,7 +334,7 @@ module.exports = {
|
||||
80: "18rem",
|
||||
96: "21.6rem",
|
||||
"page-x": "1.35rem",
|
||||
"page-y": "1.35rem"
|
||||
"page-y": "1.35rem",
|
||||
},
|
||||
margin: {
|
||||
0: "0",
|
||||
@@ -436,23 +436,26 @@ module.exports = {
|
||||
custom: ["Inter", "sans-serif"],
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography"), function({ addUtilities }) {
|
||||
const newUtilities = {
|
||||
// Mobile screens
|
||||
'.px-page-x': {
|
||||
paddingLeft: '1.25rem',
|
||||
paddingRight: '1.25rem',
|
||||
},
|
||||
// Medium screens (768px and up)
|
||||
'@media (min-width: 768px)': {
|
||||
'.px-page-x': {
|
||||
paddingLeft: '1.35rem',
|
||||
paddingRight: '1.35rem',
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography"),
|
||||
function ({ addUtilities }) {
|
||||
const newUtilities = {
|
||||
// Mobile screens
|
||||
".px-page-x": {
|
||||
paddingLeft: "1.25rem",
|
||||
paddingRight: "1.25rem",
|
||||
},
|
||||
}
|
||||
};
|
||||
// Medium screens (768px and up)
|
||||
"@media (min-width: 768px)": {
|
||||
".px-page-x": {
|
||||
paddingLeft: "1.35rem",
|
||||
paddingRight: "1.35rem",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
addUtilities(newUtilities, ['responsive']);
|
||||
},
|
||||
],
|
||||
addUtilities(newUtilities, ["responsive"]);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -109,6 +109,7 @@ export type TWidgetIssue = TIssue & {
|
||||
project_id: string;
|
||||
relation_type: TIssueRelationTypes;
|
||||
sequence_id: number;
|
||||
type_id: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ interface IInputSearch {
|
||||
inputContainerClassName?: string;
|
||||
inputClassName?: string;
|
||||
inputPlaceholder?: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export const InputSearch: FC<IInputSearch> = (props) => {
|
||||
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props;
|
||||
const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder, isMobile } =
|
||||
props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -29,10 +31,10 @@ export const InputSearch: FC<IInputSearch> = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !isMobile) {
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, isMobile]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -26,6 +26,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
value,
|
||||
renderItem,
|
||||
loader,
|
||||
isMobile = false,
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
@@ -38,6 +39,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
|
||||
+1
@@ -85,6 +85,7 @@ export interface IDropdownOptions {
|
||||
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
|
||||
options: TDropdownOption[] | undefined;
|
||||
loader?: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export interface IMultiSelectDropdownOptions extends IDropdownOptions {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface HeaderProps {
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const HeaderContext = React.createContext<THeaderVariant | null>(null);
|
||||
const Header = (props: HeaderProps) => {
|
||||
const {
|
||||
variant = EHeaderVariant.PRIMARY,
|
||||
@@ -23,13 +24,15 @@ const Header = (props: HeaderProps) => {
|
||||
|
||||
const style = getHeaderStyle(variant, setHeight, showOnMobile);
|
||||
return (
|
||||
<Row
|
||||
variant={variant === EHeaderVariant.PRIMARY ? ERowVariant.HUGGING : ERowVariant.REGULAR}
|
||||
className={cn(style, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Row>
|
||||
<HeaderContext.Provider value={variant}>
|
||||
<Row
|
||||
variant={variant === EHeaderVariant.PRIMARY ? ERowVariant.HUGGING : ERowVariant.REGULAR}
|
||||
className={cn(style, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Row>
|
||||
</HeaderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,9 +43,23 @@ const LeftItem = (props: HeaderProps) => (
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
const RightItem = (props: HeaderProps) => (
|
||||
<div className={cn("flex justify-end gap-3 w-auto items-baseline", props.className)}>{props.children}</div>
|
||||
);
|
||||
const RightItem = (props: HeaderProps) => {
|
||||
const variant = React.useContext(HeaderContext);
|
||||
if (variant === undefined) throw new Error("RightItem must be used within Header");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-end gap-3 w-auto items-start",
|
||||
{
|
||||
"items-baseline": variant === EHeaderVariant.TERNARY,
|
||||
},
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Header.LeftItem = LeftItem;
|
||||
Header.RightItem = RightItem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, FormEvent, useMemo, useState } from "react";
|
||||
import { FC, FormEvent, useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { CircleAlert, XCircle } from "lucide-react";
|
||||
@@ -22,7 +22,6 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const emailError = useMemo(
|
||||
() => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined),
|
||||
@@ -41,6 +40,9 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true)
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="mt-5 space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -52,6 +54,9 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onFocus={() => {setIsFocused(true)}}
|
||||
onBlur={() => {setIsFocused(false)}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -61,15 +66,17 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
{email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setEmail("")}
|
||||
className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,4 +92,4 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import SomethingWentWrongImage from "public/something-went-wrong.svg";
|
||||
|
||||
export const SomethingWentWrongError = () => (
|
||||
<div className="grid h-full w-full place-items-center p-6">
|
||||
<div className="grid min-h-screen w-full place-items-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
|
||||
<div className="grid h-32 w-32 place-items-center">
|
||||
|
||||
@@ -36,12 +36,12 @@ const AnalyticsPage = observer(() => {
|
||||
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Tab.List as="div" className="flex space-x-2 border-b h-[50px] border-custom-border-200">
|
||||
<Tab.List as="div" className="flex space-x-2 h-full">
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tab key={tab.key} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
className={`text-sm group relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium outline-none ${
|
||||
className={`text-sm group relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium outline-none ${
|
||||
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -48,63 +48,63 @@ export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex w-full justify-between">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={breadcrumbLabel}
|
||||
disableTooltip
|
||||
icon={<UserActivityIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className="hidden md:flex md:items-center">{showProfileIssuesFilter && <ProfileIssuesFilter />}</div>
|
||||
<div className="flex gap-4 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1.5">
|
||||
<span className="flex flex-grow justify-center text-sm text-custom-text-200">{type}</span>
|
||||
<ChevronDown className="h-4 w-4 text-custom-text-400" />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
<></>
|
||||
{tabsList.map((tab) => (
|
||||
<CustomMenu.MenuItem className="flex items-center gap-2" key={tab.route}>
|
||||
<Link
|
||||
key={tab.route}
|
||||
href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}
|
||||
className="w-full text-custom-text-300"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<button
|
||||
className="block transition-all md:hidden"
|
||||
onClick={() => {
|
||||
toggleProfileSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
|
||||
)}
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={breadcrumbLabel}
|
||||
disableTooltip
|
||||
icon={<UserActivityIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<div className="hidden md:flex md:items-center">{showProfileIssuesFilter && <ProfileIssuesFilter />}</div>
|
||||
<div className="flex gap-4 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1.5">
|
||||
<span className="flex flex-grow justify-center text-sm text-custom-text-200">{type}</span>
|
||||
<ChevronDown className="h-4 w-4 text-custom-text-400" />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
<></>
|
||||
{tabsList.map((tab) => (
|
||||
<CustomMenu.MenuItem className="flex items-center gap-2" key={tab.route}>
|
||||
<Link
|
||||
key={tab.route}
|
||||
href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}
|
||||
className="w-full text-custom-text-300"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<button
|
||||
className="block transition-all md:hidden"
|
||||
onClick={() => {
|
||||
toggleProfileSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ChevronDown } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu, Row } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
@@ -109,18 +109,18 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start border-b border-custom-border-200 py-2 md:hidden">
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 py-2 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex justify-center text-sm text-custom-text-200"
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<Row className="flex flex-start text-sm text-custom-text-200">
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
Layout
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={1} />
|
||||
</Row>
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-start text-custom-text-200 text-sm"
|
||||
customButtonClassName="flex flex-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{ISSUE_LAYOUTS.map((layout, index) => {
|
||||
@@ -139,15 +139,15 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
<div className="flex items-center justify-start border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<Row className="flex flex-start text-sm text-custom-text-200">
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={1} />
|
||||
</Row>
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
@@ -165,15 +165,15 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex items-center justify-start border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<Row className="flex flex-start text-sm text-custom-text-200">
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
Display
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={1} />
|
||||
</Row>
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
|
||||
+1
@@ -291,6 +291,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
</Button>
|
||||
{!isCompletedCycle && (
|
||||
<Button
|
||||
className="h-full self-start"
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
|
||||
|
||||
+17
-19
@@ -55,25 +55,23 @@ export const CyclesListHeader: FC = observer(() => {
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{canUserCreateCycle && currentProjectDetails ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<CyclesViewHeader projectId={currentProjectDetails.id} />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Cycles page");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="hidden sm:block">Add</div> Cycle
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
{canUserCreateCycle && currentProjectDetails ? (
|
||||
<Header.RightItem>
|
||||
<CyclesViewHeader projectId={currentProjectDetails.id} />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Cycles page");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="hidden sm:block">Add</div> Cycle
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
+1
-11
@@ -2,16 +2,13 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PanelRight } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(() => {
|
||||
@@ -20,13 +17,11 @@ export const ProjectIssueDetailsHeader = observer(() => {
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { issueDetailSidebarCollapsed, toggleIssueDetailSidebar } = useAppTheme();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
const isSidebarCollapsed = issueDetailSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
@@ -82,11 +77,6 @@ export const ProjectIssueDetailsHeader = observer(() => {
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
<button className="block md:hidden" onClick={() => toggleIssueDetailSidebar()}>
|
||||
<PanelRight
|
||||
className={cn("h-4 w-4 ", !isSidebarCollapsed ? "text-custom-primary-100" : " text-custom-text-200")}
|
||||
/>
|
||||
</button>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu, Row } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts";
|
||||
@@ -107,10 +107,10 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<Row className="flex flex-start text-sm text-custom-text-200">
|
||||
<div className="flex flex-start text-sm text-custom-text-200">
|
||||
Layout
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={1} />
|
||||
</Row>
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export const ModulesListMobileHeader = observer(() => {
|
||||
className="flex flex-grow justify-start text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={
|
||||
<Row className="flex flex-grow justify-start text-custom-text-200 text-sm gap-2">
|
||||
<Row className="flex flex-grow justify-center text-custom-text-200 text-sm gap-2">
|
||||
<span>Layout</span> <ChevronDown className="h-4 w-4 text-custom-text-200 my-auto" strokeWidth={1} />
|
||||
</Row>
|
||||
}
|
||||
|
||||
+19
-21
@@ -57,27 +57,25 @@ export const PagesListHeader = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{canUserCreatePage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Project pages page");
|
||||
toggleCreatePageModal({
|
||||
isOpen: true,
|
||||
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add page
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
{canUserCreatePage ? (
|
||||
<Header.RightItem>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Project pages page");
|
||||
toggleCreatePageModal({
|
||||
isOpen: true,
|
||||
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add page
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
+3
-3
@@ -45,9 +45,9 @@ const AutomationSettingsPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Automations</h3>
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium leading-normal">Automations</h3>
|
||||
</div>
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ const EstimatesSettingsPage = observer(() => {
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div
|
||||
className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
|
||||
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
|
||||
>
|
||||
<EstimateRoot
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ const FeaturesSettingsPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ const LabelsSettingsPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto py-2 pr-9">
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
|
||||
<ProjectSettingsLabelList />
|
||||
</div>
|
||||
</>
|
||||
|
||||
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectSettingHeader } from "./header";
|
||||
import { ProjectSettingsSidebar } from "./sidebar";
|
||||
@@ -16,16 +16,16 @@ const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectSettingHeader />} />
|
||||
<ContentWrapper>
|
||||
<div className="inset-y-0 z-20 flex flex-grow-0 h-full w-full">
|
||||
<div className="w-80 flex-shrink-0 overflow-y-hidden pt-8 sm:hidden hidden md:block lg:block">
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="w-full pl-10 sm:pl-10 md:pl-3 lg:pl-3 overflow-y-scroll vertical-scrollbar scrollbar-md">
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const MembersSettingsPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9`}>
|
||||
<section className={`w-full overflow-y-auto`}>
|
||||
<ProjectSettingsMemberDefaults />
|
||||
<ProjectMemberList />
|
||||
</section>
|
||||
|
||||
@@ -57,7 +57,7 @@ const GeneralSettingsPage = observer(() => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ export const ProjectSettingsSidebar = observer(() => {
|
||||
|
||||
if (!currentProjectRole) {
|
||||
return (
|
||||
<div className="flex w-80 flex-col gap-6 px-5">
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
@@ -41,7 +41,7 @@ export const ProjectSettingsSidebar = observer(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-80 flex-col gap-6 px-5">
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<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">
|
||||
|
||||
+5
-7
@@ -27,14 +27,12 @@ const StatesSettingsPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="py-8 pr-9">
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">States</h3>
|
||||
</div>
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
<div className="flex items-center border-b border-custom-border-100">
|
||||
<h3 className="text-xl font-medium">States</h3>
|
||||
</div>
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
+3
-3
@@ -21,7 +21,7 @@ export const ViewMobileHeader = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden flex justify-evenly border-b border-custom-border-200 py-2 z-[13] bg-custom-background-100">
|
||||
<Row className="flex items-center justify-start border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<Row className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<ViewOrderByDropdown
|
||||
sortBy={filters.sortBy}
|
||||
sortKey={filters.sortKey}
|
||||
@@ -32,7 +32,7 @@ export const ViewMobileHeader = observer(() => {
|
||||
isMobile
|
||||
/>
|
||||
</Row>
|
||||
<div className="flex flex-grow items-center justify-start border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
icon={<ListFilter className="h-3 w-3" />}
|
||||
title="Filters"
|
||||
@@ -41,7 +41,7 @@ export const ViewMobileHeader = observer(() => {
|
||||
menuButton={
|
||||
<Row className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={1} />
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,7 @@ const ApiTokensPage = observer(() => {
|
||||
<section className="w-full overflow-y-auto">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-custom-border-200 py-3.5">
|
||||
<div className="flex items-center justify-between border-b border-custom-border-200 pb-3.5">
|
||||
<h3 className="text-xl font-medium">API tokens</h3>
|
||||
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
Add API token
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// local components
|
||||
import { WorkspaceSettingHeader } from "./header";
|
||||
@@ -20,14 +19,16 @@ export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) {
|
||||
<>
|
||||
<AppHeader header={<WorkspaceSettingHeader />} />
|
||||
<MobileWorkspaceSettingsTabs />
|
||||
<ContentWrapper className="flex-row inset-y-0 gap-4">
|
||||
<div className="w-80 flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">{children}</div>
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 pb-3.5">
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start ">
|
||||
<h4 className="text-xl font-medium">Members</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
@@ -9,7 +10,7 @@ import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const MobileWorkspaceSettingsTabs = () => {
|
||||
export const MobileWorkspaceSettingsTabs = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
@@ -28,10 +29,11 @@ export const MobileWorkspaceSettingsTabs = () => {
|
||||
shouldRenderSettingLink(item.key) &&
|
||||
workspaceMemberInfo >= item.access && (
|
||||
<div
|
||||
className={`${item.highlight(pathname, `/${workspaceSlug}`)
|
||||
? "text-custom-primary-100 text-sm py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-custom-primary-200"
|
||||
: "text-custom-text-200 flex flex-grow cursor-pointer justify-around border-b border-custom-border-200 text-sm py-2 px-3 whitespace-nowrap"
|
||||
}`}
|
||||
className={`${
|
||||
item.highlight(pathname, `/${workspaceSlug}`)
|
||||
? "text-custom-primary-100 text-sm py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-custom-primary-200"
|
||||
: "text-custom-text-200 flex flex-grow cursor-pointer justify-around border-b border-custom-border-200 text-sm py-2 px-3 whitespace-nowrap"
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => router.push(`/${workspaceSlug}${item.href}`)}
|
||||
>
|
||||
@@ -41,4 +43,4 @@ export const MobileWorkspaceSettingsTabs = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export const WorkspaceSettingsSidebar = observer(() => {
|
||||
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
|
||||
|
||||
return (
|
||||
<div className="flex w-80 flex-col gap-6">
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<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">
|
||||
|
||||
@@ -90,8 +90,8 @@ const WebhookDetailsPage = observer(() => {
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
|
||||
<div className="w-full space-y-8 overflow-y-auto md:py-8 py-4 md:pr-9 pr-4">
|
||||
<div className="-m-5">
|
||||
<div className="w-full space-y-8 overflow-y-auto">
|
||||
<div className="">
|
||||
<WebhookForm onSubmit={async (data) => await handleUpdateWebhook(data)} data={currentWebhook} />
|
||||
</div>
|
||||
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
|
||||
@@ -100,4 +100,4 @@ const WebhookDetailsPage = observer(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default WebhookDetailsPage;
|
||||
export default WebhookDetailsPage;
|
||||
|
||||
@@ -63,7 +63,7 @@ const WebhooksListPage = observer(() => {
|
||||
/>
|
||||
{Object.keys(webhooks).length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 py-3.5">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
|
||||
<div className="text-xl font-medium">Webhooks</div>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
|
||||
Add webhook
|
||||
|
||||
@@ -39,7 +39,6 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
|
||||
|
||||
useEffect(() => {
|
||||
if (windowSize[0] < 768) !sidebarCollapsed && toggleSidebar();
|
||||
else sidebarCollapsed && toggleSidebar();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [windowSize]);
|
||||
|
||||
|
||||
+9
-2
@@ -8,7 +8,7 @@ import "@/styles/react-day-picker.css";
|
||||
// meta data info
|
||||
import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/meta";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||
// local
|
||||
import { AppProvider } from "./provider";
|
||||
|
||||
@@ -75,7 +75,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<body>
|
||||
<div id="context-menu-portal" />
|
||||
<AppProvider>
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"h-screen w-full overflow-hidden bg-custom-background-100 relative flex flex-col",
|
||||
"app-container"
|
||||
)}
|
||||
>
|
||||
<div className="w-full h-full overflow-hidden relative">{children}</div>
|
||||
</div>
|
||||
</AppProvider>
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (
|
||||
|
||||
@@ -81,7 +81,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
|
||||
<div className="grid h-full grid-cols-1 gap-5 pb-8 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
|
||||
<div key={item.title} className="flex min-h-32 w-full flex-col gap-2 rounded-md bg-custom-background-80 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<item.icon className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
"use client";
|
||||
import { FC } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
import { NETWORK_CHOICES } from "@/constants/project";
|
||||
import { ETabIndices } from "@/constants/tab-indices";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
|
||||
const ProjectAttributes = () => {
|
||||
type Props = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const ProjectAttributes: FC<Props> = (props) => {
|
||||
const { isMobile = false } = props;
|
||||
const { control } = useFormContext<IProject>();
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
@@ -15,7 +29,7 @@ const ProjectAttributes = () => {
|
||||
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 h-7" tabIndex={4}>
|
||||
<div className="flex-shrink-0 h-7" tabIndex={getIndex("network")}>
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@@ -35,7 +49,7 @@ const ProjectAttributes = () => {
|
||||
className="h-full"
|
||||
buttonClassName="h-full"
|
||||
noChevron
|
||||
tabIndex={4}
|
||||
tabIndex={getIndex("network")}
|
||||
>
|
||||
{NETWORK_CHOICES.map((network) => (
|
||||
<CustomSelect.Option key={network.key} value={network.key}>
|
||||
@@ -59,7 +73,7 @@ const ProjectAttributes = () => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (value === undefined || value === null || typeof value === "string")
|
||||
return (
|
||||
<div className="flex-shrink-0 h-7" tabIndex={5}>
|
||||
<div className="flex-shrink-0 h-7" tabIndex={getIndex("lead")}>
|
||||
<MemberDropdown
|
||||
value={value}
|
||||
onChange={(lead) => onChange(lead === value ? null : lead)}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const CreateProjectForm: FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<ProjectCreateHeader handleClose={handleClose} />
|
||||
<ProjectCreateHeader handleClose={handleClose} isMobile={isMobile} />
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="px-3">
|
||||
<div className="mt-9 space-y-6 pb-5">
|
||||
@@ -130,7 +130,7 @@ export const CreateProjectForm: FC<Props> = observer((props) => {
|
||||
isChangeInIdentifierRequired={isChangeInIdentifierRequired}
|
||||
setIsChangeInIdentifierRequired={setIsChangeInIdentifierRequired}
|
||||
/>
|
||||
<ProjectAttributes />
|
||||
<ProjectAttributes isMobile={isMobile} />
|
||||
</div>
|
||||
<ProjectCreateButtons handleClose={handleClose} />
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const BillingRoot = () => (
|
||||
<h3 className="text-xl font-medium">Billing and Plans</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-6">
|
||||
<div className="py-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">Current plan</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, FormEvent, useMemo, useState } from "react";
|
||||
import { FC, FormEvent, useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { CircleAlert, XCircle } from "lucide-react";
|
||||
@@ -22,7 +22,6 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const emailError = useMemo(
|
||||
() => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined),
|
||||
@@ -41,6 +40,9 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true)
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="mt-5 space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -52,6 +54,9 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onFocus={() => {setIsFocused(true)}}
|
||||
onBlur={() => {setIsFocused(false)}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -61,15 +66,17 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
{email.length > 0 && (
|
||||
<XCircle
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setEmail("")}
|
||||
className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,4 +92,4 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
|
||||
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 px-4 py-3">
|
||||
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 py-3">
|
||||
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
|
||||
<button
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-100 px-4 py-6">
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-100 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
|
||||
@@ -68,7 +68,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
|
||||
{currentProjectDetails ? (
|
||||
currentProjectDetails.archive_in !== 0 && (
|
||||
<div className="ml-12">
|
||||
<div className="mx-6">
|
||||
<div className="flex w-full items-center justify-between gap-2 rounded border border-custom-border-200 bg-custom-background-90 px-5 py-4">
|
||||
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
|
||||
<div className="w-1/2">
|
||||
@@ -104,7 +104,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="ml-12">
|
||||
<Loader className="mx-6">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
|
||||
@@ -95,7 +95,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
|
||||
{currentProjectDetails ? (
|
||||
currentProjectDetails.close_in !== 0 && (
|
||||
<div className="ml-12">
|
||||
<div className="mx-6">
|
||||
<div className="flex flex-col rounded border border-custom-border-200 bg-custom-background-90">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-5 py-4">
|
||||
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
|
||||
@@ -155,7 +155,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
|
||||
: (currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
@@ -171,7 +171,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="ml-12">
|
||||
<Loader className="mx-6">
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// fetch-keys
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
@@ -80,6 +82,8 @@ export const CommandModal: React.FC = observer(() => {
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
|
||||
// TODO: update this to mobx store
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
@@ -238,7 +242,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => setSearchTerm(e)}
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const AppHeader = (props: AppHeaderProps) => {
|
||||
|
||||
return (
|
||||
<div className="z-[15]">
|
||||
<Row className="h-[3.75rem] z-10 flex gap-2 w-full items-center border-b border-custom-border-200">
|
||||
<Row className="h-[3.75rem] z-10 flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="block bg-custom-sidebar-background-100 md:hidden">
|
||||
<SidebarHamburgerToggle />
|
||||
</div>
|
||||
|
||||
@@ -344,7 +344,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
@@ -51,6 +53,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
@@ -140,6 +143,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
placeholder="Type to search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./gpt-assistant-popover";
|
||||
export * from "./link-modal";
|
||||
export * from "./user-image-upload-modal";
|
||||
export * from "./workspace-image-upload-modal";
|
||||
export * from "./issue-search-modal-empty-state";
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect, Fragment } from "react";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { IIssueLink, ILinkDetails, ModuleLink } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: ILinkDetails | null;
|
||||
status: boolean;
|
||||
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
};
|
||||
|
||||
const defaultValues: IIssueLink | ModuleLink = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, createIssueLink, updateIssueLink, status, data } = props;
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<IIssueLink | ModuleLink>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
|
||||
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
pattern="^(https?://).*"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{`Title (optional)`}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="Enter title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating link..."
|
||||
: "Update link"
|
||||
: isSubmitting
|
||||
? "Adding link..."
|
||||
: "Add link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -144,7 +144,7 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
|
||||
@@ -150,7 +150,7 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
<input {...getInputProps()}/>
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./links-list";
|
||||
export * from "./single-progress-stats";
|
||||
export * from "./sidebar-menu-hamburger-toggle";
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useMember, useModule } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: ILinkDetails) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleDeleteLink, handleEditLink, userAuth, disabled } = props;
|
||||
// hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const currentModule = getModuleById(moduleId);
|
||||
const moduleLinks = currentModule?.link_module || undefined;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
message: "The URL has been successfully copied to your clipboard",
|
||||
});
|
||||
};
|
||||
|
||||
if (!moduleLinks) return <></>;
|
||||
return (
|
||||
<>
|
||||
{moduleLinks.map((link) => {
|
||||
const createdByDetails = getUserDetails(link.created_by);
|
||||
return (
|
||||
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
>
|
||||
{link.title && link.title !== "" ? link.title : link.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditLink(link);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeleteLink(link.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(link.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by{" "}
|
||||
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Search, X } from "lucide-react";
|
||||
import { TCycleFilters, TCycleGroups } from "@plane/types";
|
||||
// components
|
||||
import { FilterEndDate, FilterStartDate, FilterStatus } from "@/components/cycles";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
@@ -16,6 +17,8 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
const { filters, handleFiltersUpdate, isArchived = false } = props;
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
@@ -28,7 +31,7 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
placeholder="Search"
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
|
||||
@@ -8,9 +8,12 @@ import { ICycle } from "@plane/types";
|
||||
import { Button, Input, TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { DateRangeDropdown, ProjectDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
import { ETabIndices } from "@/constants/tab-indices";
|
||||
// helpers
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
|
||||
@@ -19,6 +22,7 @@ type Props = {
|
||||
projectId: string;
|
||||
setActiveProject: (projectId: string) => void;
|
||||
data?: ICycle | null;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
@@ -29,7 +33,7 @@ const defaultValues: Partial<ICycle> = {
|
||||
};
|
||||
|
||||
export const CycleForm: React.FC<Props> = (props) => {
|
||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
|
||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data, isMobile = false } = props;
|
||||
// form data
|
||||
const {
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
@@ -46,6 +50,8 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CYCLE, isMobile);
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
@@ -71,7 +77,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
tabIndex={7}
|
||||
tabIndex={getIndex("cover_image")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -101,7 +107,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
inputSize="md"
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors?.name)}
|
||||
tabIndex={1}
|
||||
tabIndex={getIndex("description")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
@@ -120,7 +126,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
hasError={Boolean(errors?.description)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tabIndex={2}
|
||||
tabIndex={getIndex("description")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -153,7 +159,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
hideIcon={{
|
||||
to: true,
|
||||
}}
|
||||
tabIndex={3}
|
||||
tabIndex={getIndex("date_range")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -163,10 +169,10 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={getIndex("submit")}>
|
||||
{data ? (isSubmitting ? "Updating" : "Update Cycle") : isSubmitting ? "Creating" : "Create Cycle"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
|
||||
|
||||
return (
|
||||
<ContentWrapper variant={ERowVariant.HUGGING}>
|
||||
<ContentWrapper variant={ERowVariant.HUGGING} className="flex-row">
|
||||
<ListLayout>
|
||||
{isArchived ? (
|
||||
<>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CYCLE_CREATED, CYCLE_UPDATED } from "@/constants/event-tracker";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useProject } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// services
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
|
||||
@@ -35,6 +36,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
const { captureCycleEvent } = useEventTracker();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { createCycle, updateCycleDetails } = useCycle();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const { setValue: setCycleTab } = useLocalStorage<TCycleTabOptions>("cycle_tab", "active");
|
||||
|
||||
@@ -186,6 +188,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
projectId={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
data={data}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
|
||||
@@ -67,12 +67,14 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: blockedByIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={blockedByIssues[0]?.id}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
<IssueIdentifier
|
||||
projectIdentifier={blockedByIssueProjectDetails?.identifier}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
issueSequenceId={blockedByIssues[0]?.sequence_id}
|
||||
issueTypeId={blockedByIssues[0]?.type_id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getRedirectionFilters } from "@/helpers/dashboard.helper";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// types
|
||||
// hooks
|
||||
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
export type WidgetIssuesListProps = {
|
||||
isLoading: boolean;
|
||||
@@ -33,11 +34,12 @@ export type WidgetIssuesListProps = {
|
||||
|
||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
issue.project_id && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
// handlers
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
|
||||
const filterParams = getRedirectionFilters(tab);
|
||||
|
||||
@@ -110,7 +112,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full grid place-items-center">
|
||||
<div className="h-full grid place-items-center my-6">
|
||||
{type === "assigned" && <AssignedIssuesEmptyState type={tab} />}
|
||||
{type === "created" && <CreatedIssuesEmptyState type={tab} />}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user