Compare commits

...
Author SHA1 Message Date
sharma01ketan ea581a6948 fix kanban view localStorage 2024-09-10 18:48:31 +05:30
sriram veeraghanta 04b10cabc8 fix: tailwind warning fixes 2024-09-10 17:57:06 +05:30
sriram veeraghanta 545717cc51 fix: security update for express pacakge 2024-09-10 17:36:32 +05:30
sriram veeraghanta 1ca0a15792 fix: upgrading tubro version 2024-09-10 17:31:10 +05:30
sriram veeraghanta c5971f03aa Merge branch 'preview' of github.com:makeplane/plane into preview 2024-09-10 17:29:34 +05:30
sriram veeraghanta 902403a54d chore: linting warning resolved 2024-09-10 17:29:19 +05:30
Akshat JainandGitHub 1d6ebb7c41 add the SERVICE_FOLDER value to install.sh script dynamically (#5553) 2024-09-10 17:29:16 +05:30
Rounak ShresthaandGitHub 106914e14e fix: Local Setup on Windows (#5539) 2024-09-10 17:28:18 +05:30
Aaryan KhandelwalandGitHub 8acb60baef [WEB-1116] fix: current version not displaying the latest content (#5573)
* fix: current version sync

* chore: update read only editor ref type
2024-09-10 16:13:20 +05:30
Manish GuptaandGitHub 1da97d5814 skipped stable tag for prerelrease, modified docker tag for branch name with special characters (#5570) 2024-09-10 15:10:10 +05:30
5fb2dd0b6e fix(webhook): allow private ip to be used as payload url (#5535)
Co-authored-by: gmajkic <gmajkic@veepee.com>
2024-09-10 14:57:30 +05:30
Akshita GoyalandGitHub ff6c3ce1a0 fix: settings page scrollbar (#5572) 2024-09-10 14:44:32 +05:30
Anmol Singh BhatiaandGitHub ec51e9d8ce fix-header-theme (#5564) 2024-09-10 14:42:24 +05:30
Aaryan KhandelwalandGitHub cc07992e47 [WEB-2424] fix: add optional chaining for parent node (#5571)
* fix: add optional chaining for parent node

* chore: revert yarn lock changes
2024-09-10 14:41:48 +05:30
M. PalanikannanandGitHub 069f8b950e fix: svg not supported in image uploads in the editor (#5558)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed
2024-09-10 14:27:27 +05:30
Akshita GoyalandGitHub 5eb868e07d [WEB 2418] Fix minor UI inconsistencies (#5568)
* fix: project features modal padding

* fix: minor ui inconsistencies

* fix: lint issue
2024-09-10 14:24:07 +05:30
Aaryan KhandelwalandGitHub 7c77fc1680 fix: task list not getting synced (#5566) 2024-09-09 21:35:31 +05:30
Anmol Singh BhatiaandGitHub 99a7867a5e [WEB-2228] fix: dashboard peek overview issue stats #5442 (#5560)
* fix: dashboard issue stats

* chore: code refactor
2024-09-09 20:37:46 +05:30
Ketan SharmaandGitHub c44bf861e0 [WEB-2415] fix:remove input type to fix image upload (#5563)
* remove input type to fix things

* made the same changes in all locations
2024-09-09 20:12:15 +05:30
M. PalanikannanandGitHub 4d38a10f8b fix: character count to work properly on editor rerenders and read only mode (#5554)
* fix: character count to work properly on editor rerenders and read only mode

* fix: desctructing properly at the start
2024-09-09 19:59:07 +05:30
Akshita GoyalandGitHub 7c3fc690e9 fix: project features modal padding (#5562) 2024-09-09 19:22:47 +05:30
Prateek ShouryaandGitHub 8cf1c2d136 [WEB-2413] chore: admin application restructuring. (#5557) 2024-09-09 17:43:56 +05:30
Ketan SharmaandGitHub fe280b2beb [WEB-2106] fix: add date and state change functionalities to list and grid view (#5533)
* added functionality to list and grid

* fixed logic for archived module

* fixed logic for list view

* improved logic and fixed linting issues

* improved variable names
2024-09-09 16:50:56 +05:30
Ketan SharmaandGitHub ad5c6ee4f5 [WEB-2201] fix: clear email button on login screen (#5546)
* fixed the logic

* made required css changes

* replicated same for space component

* fixed variable name

* replicated for space

* better variable name

* improved the css

* replicated for space
2024-09-09 14:58:06 +05:30
MihirandGitHub ba0d1ba518 Update sidebar (#5549)
Removed else statement which was expanding it whenever windowSize changed or webapp was hard refreshed.
2024-09-09 14:57:05 +05:30
M. PalanikannanandGitHub 70ea1459cd fix: async loading of the redis extension (#5537)
* fix: async loading of the redis extension

* fix: initialize redis connection and hocuspocusserver only during server start

* fix: removed console logs

* fix: remove async

* fix: error handling and shutting down gracefully in unhandled errors

* feat: added compression library

* fix: added helmet for security headers
2024-09-07 14:24:20 +05:30
Aaryan KhandelwalandGitHub 8154a190d2 [WEB-1116] fix: editor info badges occupying multiple lines (#5548) 2024-09-07 09:01:01 +05:30
29fd1186ee [WEB-2129] fix: module creation and updation toast error (#5550)
* chore: added error message for module name

* used the backend message

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-07 08:58:28 +05:30
68b412badf [WEB-1933] refactor: link create/update for issues and modules (#5543)
* chore: added module and issue link validation

* refactor: issues and modules link moda;

* chore: changed the url validation logic

* chore: code cleanup

* refactor: modules link logic

* chore: removed the validator function

* fix: url validation regex

* chore: removed unwanted imports

* chore: reverted the external api changes

* refactor: link modals

* refactor: reset modal logic

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-06 22:52:29 +05:30
Akshita GoyalandGitHub c95aa6a0f7 [WEB-2273] Fix: page alignments (#5541)
* chore: headers + common containers

* fix: filters code splitting

* fix: home header

* fix: header changes

* chore: page alignments fixed

* fix: uncommented filters

* fix: used enums

* fix: cards + filters

* fix: enum changes

* fix: reverted package changes

* fix: reverted package changes

* fix: Card + tags seperated + naming fixed

* fix: card + tags seperated + naming fixed

* fix: mobile headers fixed partially

* fix: build errors + minor css

* fix: checkbox spacing

* fix: review changes

* fix: lint errors

* fix: minor review changes

* fix: header-alignments

* fix: tabs

* fix: settings page

* fix: subgroup page

* fix: mobile headers

* fix: settings mobile header made observable

* fix: lint error + edge case handling
2024-09-06 18:38:53 +05:30
rahulrameshaandGitHub 751cd6c862 [WEB-2365] fix: Minor UI in-consistencies cause by tooltip changes (#5545)
* Fix minor in-consistencies caused by tooltip on hover changes

* fix linting
2024-09-06 18:37:57 +05:30
Prateek ShouryaandGitHub 1032bc75d7 [WEB-2332] chore: layout structure improvement. (#5538)
* [WEB-2332] chore: layout structure improvement.

* chore: improve layout.
2024-09-06 16:46:42 +05:30
Ketan SharmaandGitHub 9415a5ba00 made required changes in css (#5542) 2024-09-06 16:22:59 +05:30
Akshat JainandGitHub d24a4e18a2 add: API_BASE_URL env to selfhost envs (#5523)
* add: API_BASE_URL env to selfhost envs

* Update variables.env
2024-09-06 16:22:16 +05:30
Anmol Singh BhatiaandGitHub 52f78a86af [PWA-26] chore: pwa input focus improvement (#5507)
* chore: pwa dropdown input focus improvement

* chore: tab indices helper function updated and code refactor

* chore: modal tab index refactoring

* fix: PWA filters input autofocus

* chore: intake tab index updated and code refactor

* chore: code refactor
2024-09-06 16:21:14 +05:30
Anmol Singh BhatiaandGitHub c84c37805c [PWA-22] chore: pwa issue redirection (#5544)
* chore: issue peek overview redirection hook added

* chore: handleIssuePeekOverview function updated
2024-09-06 15:36:06 +05:30
Anmol Singh BhatiaandGitHub c2758caf95 chore: pwa issue detail improvement (#5540) 2024-09-06 15:23:48 +05:30
211 changed files with 2825 additions and 2169 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+48 -13
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export * from "./upgrade-button";
+19
View File
@@ -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();
}
}
-1
View File
@@ -8,4 +8,3 @@ export * from "./empty-state";
export * from "./logo-spinner";
export * from "./page-header";
export * from "./code-block";
export * from "./upgrade-button";
+1
View File
@@ -18,6 +18,7 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
const { children } = props;
// router
const router = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+1
View File
@@ -0,0 +1 @@
export * from "ce/components/common";
+1
View File
@@ -0,0 +1 @@
export * from "ce/store/root.store";
+4 -4
View File
@@ -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()
+23
View File
@@ -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()
+22 -12
View File
@@ -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):
+69 -3
View File
@@ -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):
+2 -2
View File
@@ -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."}
)
+2 -2
View File
@@ -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
+6 -5
View File
@@ -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
View File
@@ -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",
+33 -25
View File
@@ -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;
+23
View File
@@ -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;
+31 -28
View File
@@ -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,
});
};
+18 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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];
+6 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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"]);
},
],
};
+1
View File
@@ -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
View File
@@ -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 {
+27 -10
View File
@@ -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
@@ -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);
@@ -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>
);
});
@@ -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>
);
@@ -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
@@ -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>
}
@@ -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>
);
});
@@ -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} />
@@ -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()}
@@ -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()}
@@ -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>
</>
@@ -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>
</>
);
};
@@ -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}
@@ -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">
@@ -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()} />
)}
</>
);
});
@@ -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
View File
@@ -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)}
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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("")}>
+13 -7
View File
@@ -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>
+1 -1
View File
@@ -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 ? (
<>
+3
View File
@@ -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