Compare commits

...

39 Commits

Author SHA1 Message Date
LAKHAN BAHETI
2448a60952 added module events 2024-06-03 17:06:55 +05:30
Nikhil
5322c0e57b fix: instance register script (#4681)
* fix: instance register script

* dev: remove api key and add latest version and current version in types
2024-06-03 12:44:40 +05:30
sriram veeraghanta
81dfc15d1f fix: instance serializer 2024-06-01 01:04:19 +05:30
Nikhil
6a00fcc253 [WEB - 1505] chore: alter instance id field (#4676)
* chore: instance id

* dev: update to max length
2024-06-01 00:09:27 +05:30
Nikhil
f96e76dbbc [WEB - 1500] chore: add extra fields on instance and create changelog table to store release change logs (#4673)
* chore: add extra fields on instance and create changelog table to store release change logs

* dev: rename new_version to latest_version
2024-05-31 23:39:13 +05:30
Aaryan Khandelwal
de7dad59f0 fix: ai buttons overlapping issue (#4621) 2024-05-31 20:28:28 +05:30
Anmol Singh Bhatia
1c901446ab fix: checkbox ui component (#4675) 2024-05-31 20:21:00 +05:30
Prateek Shourya
e7d6e7d575 [WEB-1440] chore: update cycle empty state to use project level access. (#4672) 2024-05-31 18:30:57 +05:30
Prateek Shourya
a2cdbd52dc [WEB-1436] chore: pages improvement. (#4657)
* add empty state if no pages are available.
* set access to private in create page modal when the modal is open form private tab.
2024-05-31 18:30:38 +05:30
Aaryan Khandelwal
608e193c36 chore: added primary variant to the alert modal (#4664) 2024-05-31 17:40:21 +05:30
Aaryan Khandelwal
830f0861c1 chore: created a new constant for archivable state groups (#4668) 2024-05-31 17:39:23 +05:30
Aaryan Khandelwal
98ebe88c86 [WEB-1501] dev: multiple select core components (#4667)
* dev: multiple select core components

* chore: added export statement
2024-05-31 17:37:24 +05:30
Aaryan Khandelwal
c8c86a33f8 chore: added a prop to render default state conditionally (#4669) 2024-05-31 17:36:12 +05:30
Aaryan Khandelwal
ba4798deb9 chore: created new constants for marketing website page links (#4670) 2024-05-31 17:30:50 +05:30
Aaryan Khandelwal
463d0732e9 chore: added buttonClassName prop to label dropdown (#4671) 2024-05-31 17:30:06 +05:30
Aaryan Khandelwal
a8184c366a chore: priority dropdown accepts undefined (#4666) 2024-05-31 15:14:13 +05:30
Prateek Shourya
0a105a1c21 [WEB-1325] chore: refactor inbox issue store to avoid data loss. (#4640)
* [WEB-1325] chore: refactor inbox issue store to avoid data loss.

* chore: inbox store improvement.
2024-05-31 15:10:38 +05:30
Aaryan Khandelwal
bf4f97d7f6 refactor: checkbox ui component (#4665) 2024-05-31 15:05:28 +05:30
Aaryan Khandelwal
a9d9cbcb72 refactor: drag handle component (#4663) 2024-05-31 14:59:49 +05:30
sriram veeraghanta
092e65b43d [WEB-1424] chore: page and view logo implementation, and emoji/icon (#4662)
* [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583)

* chore: added logo_props

* chore: logo props in cycles, views and modules

* chore: emoji icon picker types updated

* chore: info icon added to plane ui package

* chore: icon color adjust helper function added

* style: icon picker ui improvement and default color options updated

* chore: update page logo action added in store

* chore: emoji code to unicode helper function added

* chore: common logo renderer component added

* chore: app header project logo updated

* chore: project logo updated across platform

* chore: page logo picker added

* chore: control link component improvement

* chore: list item improvement

* chore: emoji picker component updated

* chore: space app and package logo prop type updated

* chore: migration

* chore: logo added to project view

* chore: page logo picker added in create modal and breadcrumbs

* chore: view logo picker added in create modal and updated breadcrumbs

* fix: build error

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: merge conflict

* chore: lucide react added to planu ui package

* chore: new emoji picker component added with lucid icon and code refactor

* chore: logo component updated

* chore: emoji picker updated for pages and views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: build error

---------

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-05-31 14:27:52 +05:30
Anmol Singh Bhatia
fc4ba5a170 [WEB-1235] chore: module and cycle sidebar graph improvement (#4650)
* chore: module and cycle sidebar graph improvement

* chore: code refactor
2024-05-31 13:57:46 +05:30
sriram veeraghanta
9143e5abc8 fix: build errors 2024-05-31 13:32:21 +05:30
Prateek Shourya
1cb26fa863 [WEB-1216] chore: increase module empty state for consistency. (#4658) 2024-05-31 12:42:36 +05:30
Prateek Shourya
9ff3c22089 [WEB-1419] chore: enable module creation with dates older than today. (#4659) 2024-05-31 12:38:45 +05:30
Anmol Singh Bhatia
653b1a7b30 fix: project state setting state name remove camel case logic (#4652) 2024-05-31 12:27:25 +05:30
Anmol Singh Bhatia
d27590cd49 [WEB-1493] chore: product tour asset and app sidebar quick action hover (#4655)
* chore: product tour asset updated

* fix: app sidebar quick action hover
2024-05-31 12:24:16 +05:30
Anmol Singh Bhatia
3cbc1dcf10 fix: email notification preferences (#4656) 2024-05-31 12:18:57 +05:30
M. Palanikannan
4d9cd0c318 fix: negate check while trying to discard (#4653) 2024-05-30 17:53:49 +05:30
Nikhil
87de913c76 [WEB - 1482] fix: uploads when using block storages other than s3 and minio (#4647)
* fix: minio storage and redirection

* dev: disconnect web url and app base url configuration.
2024-05-30 16:22:47 +05:30
Prateek Shourya
b016e1d1b5 [WEB-1467] chore: run the API's required to bootstrap the application in parallel. (#4642) 2024-05-30 16:20:58 +05:30
Aaryan Khandelwal
67bd14ceb5 chore: remove enter key extension (#4648) 2024-05-30 15:37:25 +05:30
Anmol Singh Bhatia
4091e61953 fix: notification mark all as read (#4643) 2024-05-30 12:00:55 +05:30
M. Palanikannan
ade6eded69 [WEB-1244] fix: add better image insertion and replacement logic in the editor (#4508)
* fix: add better image insertion and replacement logic

* refactor: image handling in editor

* chore: remove passing uploadKey around

* refactor: remove unused code

* fix: redundant files removed

* fix: add is editor ready to discard api to control behvaiours from our app

* fix: focus issues and image insertion position when not using slash command

* fix: import order fixed
2024-05-29 18:25:03 +05:30
Prateek Shourya
061a447734 [WEB-1445] fix: issue creation on sub groups when cycle/ module grouping is applied. (#4636) 2024-05-29 18:22:08 +05:30
Prateek Shourya
10ef4e657f [WEB-1465] fix: theme fluctuation on initial load. (#4638) 2024-05-29 18:21:33 +05:30
Aaryan Khandelwal
8a30c2c484 [WEB-1480] fix: preserve page access when making a copy (#4568) 2024-05-29 18:19:50 +05:30
rahulramesha
6636a64817 [WEB-1374] fix: clear changes made on modal close (#4555) 2024-05-29 18:18:47 +05:30
Nikhil
571a3d1239 fix: remove issue duplicated when adding multiple modules (#4637) 2024-05-29 13:31:32 +05:30
Manish Gupta
49e65fbcb3 modified the actions to build images correctly (#4635) 2024-05-29 12:40:08 +05:30
188 changed files with 3378 additions and 1134 deletions

View File

@@ -45,7 +45,7 @@ jobs:
runs-on: ubuntu-latest
needs: [base_build_setup]
env:
BASE_IMG_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }}
BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
@@ -58,7 +58,7 @@ jobs:
- name: Set Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-aio-base:latest
TAG=makeplane/plane-aio-base:latest
else
TAG=${{ env.BASE_IMG_TAG }}
fi

View File

@@ -14,7 +14,7 @@ env:
jobs:
branch_build_setup:
name: Build-Push Web/Space/API/Proxy Docker Image
name: Build Setup
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_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 }}
@@ -95,9 +95,9 @@ jobs:
- name: Set Frontend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
TAG=makeplane/plane-frontend:latest
else
TAG=${{ env.FRONTEND_TAG }}
fi
@@ -137,7 +137,7 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_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 }}
@@ -147,9 +147,9 @@ jobs:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }}
TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest
TAG=makeplane/plane-admin:latest
else
TAG=${{ env.ADMIN_TAG }}
fi
@@ -189,7 +189,7 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_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 }}
@@ -199,9 +199,9 @@ jobs:
- name: Set Space Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
TAG=makeplane/plane-space:latest
else
TAG=${{ env.SPACE_TAG }}
fi
@@ -241,7 +241,7 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_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 }}
@@ -251,9 +251,9 @@ jobs:
- name: Set Backend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
TAG=makeplane/plane-backend:latest
else
TAG=${{ env.BACKEND_TAG }}
fi
@@ -293,7 +293,7 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_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 }}
@@ -303,9 +303,9 @@ jobs:
- name: Set Proxy Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
TAG=makeplane/plane-proxy:latest
else
TAG=${{ env.PROXY_TAG }}
fi

View File

@@ -182,7 +182,6 @@ class IssueAPIEndpoint(BaseAPIView):
issue_queryset = (
self.get_queryset()
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()

View File

@@ -66,6 +66,7 @@ class CycleSerializer(BaseSerializer):
"external_source",
"external_id",
"progress_snapshot",
"logo_props",
# meta fields
"is_favorite",
"total_issues",

View File

@@ -199,6 +199,7 @@ class ModuleSerializer(DynamicBaseSerializer):
"sort_order",
"external_source",
"external_id",
"logo_props",
# computed fields
"is_favorite",
"total_issues",

View File

@@ -39,6 +39,7 @@ class PageSerializer(BaseSerializer):
"created_by",
"updated_by",
"view_props",
"logo_props",
]
read_only_fields = [
"workspace",

View File

@@ -231,6 +231,7 @@ class CycleViewSet(BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"logo_props",
# meta fields
"is_favorite",
"total_issues",
@@ -356,6 +357,7 @@ class CycleViewSet(BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"logo_props",
# meta fields
"is_favorite",
"total_issues",
@@ -403,6 +405,7 @@ class CycleViewSet(BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"logo_props",
# meta fields
"is_favorite",
"cancelled_issues",
@@ -496,6 +499,7 @@ class CycleViewSet(BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"logo_props",
# meta fields
"is_favorite",
"total_issues",
@@ -556,6 +560,7 @@ class CycleViewSet(BaseViewSet):
"external_id",
"progress_snapshot",
"sub_issues",
"logo_props",
# meta fields
"is_favorite",
"total_issues",

View File

@@ -225,6 +225,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order",
"external_source",
"external_id",
"logo_props",
# computed fields
"is_favorite",
"cancelled_issues",
@@ -281,6 +282,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order",
"external_source",
"external_id",
"logo_props",
# computed fields
"total_issues",
"is_favorite",
@@ -465,6 +467,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order",
"external_source",
"external_id",
"logo_props",
# computed fields
"is_favorite",
"cancelled_issues",

View File

@@ -13,12 +13,9 @@ class InstanceSerializer(BaseSerializer):
model = Instance
exclude = [
"license_key",
"api_key",
"version",
]
read_only_fields = [
"id",
"instance_id",
"email",
"last_checked_at",
"is_setup_done",

View File

@@ -49,8 +49,8 @@ class Command(BaseCommand):
instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
version=payload.get("version"),
current_version=payload.get("version"),
latest_version=payload.get("version"),
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
)

View File

@@ -0,0 +1,106 @@
# Generated by Django 4.2.11 on 2024-05-31 10:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("license", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="instance",
name="instance_id",
field=models.CharField(max_length=255, unique=True),
),
migrations.RenameField(
model_name="instance",
old_name="version",
new_name="current_version",
),
migrations.RemoveField(
model_name="instance",
name="api_key",
),
migrations.AddField(
model_name="instance",
name="domain",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AddField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=50),
),
migrations.CreateModel(
name="ChangeLog",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("title", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("version", models.CharField(max_length=100)),
("tags", models.JSONField(default=list)),
("release_date", models.DateTimeField(null=True)),
("is_release_candidate", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
],
options={
"verbose_name": "Change Log",
"verbose_name_plural": "Change Logs",
"db_table": "changelogs",
"ordering": ("-created_at",),
},
),
]

View File

@@ -1,3 +1,6 @@
# Python imports
from enum import Enum
# Django imports
from django.db import models
from django.conf import settings
@@ -8,15 +11,23 @@ from plane.db.models import BaseModel
ROLE_CHOICES = ((20, "Admin"),)
class ProductTypes(Enum):
PLANE_CE = "plane-ce"
class Instance(BaseModel):
# General informations
# General information
instance_name = models.CharField(max_length=255)
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=25, unique=True)
instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True)
api_key = models.CharField(max_length=16)
version = models.CharField(max_length=10)
# Instnace specifics
current_version = models.CharField(max_length=10)
latest_version = models.CharField(max_length=10, null=True, blank=True)
product = models.CharField(
max_length=50, default=ProductTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=50, blank=True, null=True)
# telemetry and support
@@ -70,3 +81,20 @@ class InstanceConfiguration(BaseModel):
verbose_name_plural = "Instance Configurations"
db_table = "instance_configurations"
ordering = ("-created_at",)
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
title = models.CharField(max_length=100)
description = models.TextField(blank=True)
version = models.CharField(max_length=100)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
class Meta:
verbose_name = "Change Log"
verbose_name_plural = "Change Logs"
db_table = "changelogs"
ordering = ("-created_at",)

View File

@@ -225,6 +225,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Storage Settings
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
@@ -243,7 +246,7 @@ AWS_S3_FILE_OVERWRITE = False
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", None
) or os.environ.get("MINIO_ENDPOINT_URL", None)
if AWS_S3_ENDPOINT_URL:
if AWS_S3_ENDPOINT_URL and USE_MINIO:
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
@@ -307,8 +310,6 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
@@ -350,4 +351,4 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
# Base URLs
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL")
APP_BASE_URL = os.environ.get("APP_BASE_URL")

View File

@@ -147,7 +147,7 @@ export const useEditor = ({
const item = getEditorMenuItem(itemName);
if (item) {
if (item.key === "image") {
item.command(savedSelection);
item.command(savedSelectionRef.current);
} else {
item.command();
}
@@ -186,6 +186,7 @@ export const useEditor = ({
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");

View File

@@ -9,7 +9,7 @@ export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell
// utils
export * from "src/lib/utils";
export * from "src/ui/extensions/table/table";
export { startImageUpload } from "src/ui/plugins/upload-image";
export { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
// components
export { EditorContainer } from "src/ui/components/editor-container";

View File

@@ -1,5 +1,5 @@
import { Editor, Range } from "@tiptap/core";
import { startImageUpload } from "src/ui/plugins/upload-image";
import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
@@ -194,7 +194,7 @@ export const insertImageCommand = (
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.accept = ".jpeg, .jpg, .png, .webp, .svg";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];

View File

@@ -15,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
}

View File

@@ -1,7 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
import { UploadImage } from "src/types/upload-image";
import { startImageUpload } from "../plugins/upload-image";
import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
export const DropHandlerExtension = (uploadFile: UploadImage) =>
Extension.create({

View File

@@ -1,25 +1,16 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { UploadImagesPlugin } from "src/ui/plugins/upload-image";
import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image";
import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image";
import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants";
import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend({
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend<any, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
@@ -29,77 +20,8 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
addProseMirrorPlugins() {
return [
UploadImagesPlugin(this.editor, cancelUploadImage),
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
// transaction could be a selection
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode, oldPos) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
this.storage.images.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
}),
new Plugin({
key: new PluginKey("imageRestoration"),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
oldImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = this.storage.images.get(image.attrs.src);
if (wasDeleted === undefined) {
this.storage.images.set(image.attrs.src, false);
} else if (wasDeleted === true) {
await onNodeRestored(image.attrs.src, restoreFile);
}
});
});
return null;
},
}),
TrackImageDeletionPlugin(this.editor, deleteImage),
TrackImageRestorationPlugin(this.editor, restoreImage),
];
},
@@ -113,7 +35,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreFile(assetUrlWithWorkspaceId);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
@@ -123,7 +45,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
return {
images: new Map<string, boolean>(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
};
},

View File

@@ -141,8 +141,11 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return "";
if (placeholder) {

View File

@@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
];
}
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
? U extends { key: infer N }
? N
: never
: never;
export type EditorMenuItemNames =
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;

View File

@@ -1,73 +0,0 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
});
export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
}
export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
}

View File

@@ -0,0 +1,7 @@
import { PluginKey } from "@tiptap/pm/state";
export const uploadKey = new PluginKey("upload-image");
export const deleteKey = new PluginKey("delete-image");
export const restoreKey = new PluginKey("restore-image");
export const IMAGE_NODE_TYPE = "image";

View File

@@ -0,0 +1,54 @@
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { DeleteImage } from "src/types/delete-image";
import { Editor } from "@tiptap/core";
import { type ImageNode } from "src/ui/plugins/image/types/image-node";
import { deleteKey, IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants";
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
// transaction could be a selection
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage.image.deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
});
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@@ -0,0 +1,114 @@
import { type UploadImage } from "src/types/upload-image";
// utilities
import { v4 as uuidv4 } from "uuid";
// types
import { isFileValid } from "src/ui/plugins/image/utils/validate-file";
import { Editor } from "@tiptap/core";
import { EditorView } from "@tiptap/pm/view";
import { uploadKey } from "./constants";
import { removePlaceholder, findPlaceholder } from "./utils/placeholder";
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number | null,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!isFileValid(file)) {
editor.storage.image.uploadInProgress = false;
return;
}
const id = uuidv4();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
try {
view.focus();
const src = await uploadAndValidateImage(file, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
}
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
if (pos < 0 || pos > view.state.doc.content.size) {
throw new Error("Invalid position to insert the image node.");
}
// insert the image node at the position of the placeholder and remove the placeholder
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
editor.storage.image.uploadInProgress = false;
} catch (error) {
console.error("Error in uploading and inserting image: ", error);
removePlaceholder(editor, view, id);
}
}
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
try {
const imageUrl = await uploadFile(file);
if (imageUrl == null) {
throw new Error("Image URL is undefined.");
}
await new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve();
};
image.onerror = (error) => {
console.error("Error in loading image: ", error);
reject(error);
};
});
return imageUrl;
} catch (error) {
console.error("Error in uploading image: ", error);
// throw error to remove the placeholder
throw error;
}
}

View File

@@ -0,0 +1,57 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { RestoreImage } from "src/types/restore-image";
import { restoreKey, IMAGE_NODE_TYPE } from "./constants";
import { type ImageNode } from "./types/image-node";
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin =>
new Plugin({
key: restoreKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
oldImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src);
if (wasDeleted === undefined) {
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
} else if (wasDeleted === true) {
try {
await onNodeRestored(image.attrs.src, restoreImage);
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
} catch (error) {
console.error("Error restoring image: ", error);
}
}
});
});
return null;
},
});
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
throw error;
}
}

View File

@@ -0,0 +1,13 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
export interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
uploadInProgress: boolean;
};

View File

@@ -0,0 +1,91 @@
import { Editor } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
// utils
import { removePlaceholder } from "src/ui/plugins/image/utils/placeholder";
// constants
import { uploadKey } from "src/ui/plugins/image/constants";
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
let currentView: EditorView | null = null;
const createPlaceholder = (src: string): HTMLElement => {
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
return placeholder;
};
const createCancelButton = (id: string): HTMLButtonElement => {
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
if (currentView) {
cancelUploadImage?.();
removePlaceholder(editor, currentView, id);
}
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
cancelButton.appendChild(svgElement);
return cancelButton;
};
return new Plugin({
key: uploadKey,
view(editorView) {
currentView = editorView;
return {
destroy() {
currentView = null;
},
};
},
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = createPlaceholder(src);
const cancelButton = createCancelButton(id);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
};

View File

@@ -0,0 +1,16 @@
import { Editor } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import { DecorationSet, EditorView } from "@tiptap/pm/view";
import { uploadKey } from "src/ui/plugins/image/constants";
export function findPlaceholder(state: EditorState, id: string): number | null {
const decos = uploadKey.getState(state) as DecorationSet;
const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id);
return found.length ? found[0].from : null;
}
export function removePlaceholder(editor: Editor, view: EditorView, id: string) {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } });
view.dispatch(removePlaceholderTr);
editor.storage.image.uploadInProgress = false;
}

View File

@@ -0,0 +1,19 @@
export function isFileValid(file: File): boolean {
if (!file) {
alert("No file selected. Please select a file to upload.");
return false;
}
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"];
if (!allowedTypes.includes(file.type)) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file.");
return false;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
return false;
}
return true;
}

View File

@@ -1,189 +0,0 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { UploadImage } from "src/types/upload-image";
const uploadKey = new PluginKey("upload-image");
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
let currentView: EditorView | null = null;
return new Plugin({
key: uploadKey,
view(editorView) {
currentView = editorView;
return {
destroy() {
currentView = null;
},
};
},
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
// Create cancel button
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
if (currentView) {
cancelUploadImage?.();
removePlaceholder(editor, currentView, id);
}
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
cancelButton.appendChild(svgElement);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
};
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id);
return found.length ? found[0].from : null;
}
const removePlaceholder = (editor: Editor, view: EditorView, id: {}) => {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, {
remove: { id },
});
view.dispatch(removePlaceholderTr);
editor.storage.image.uploadInProgress = false;
};
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!file) {
alert("No file selected. Please select a file to upload.");
editor.storage.image.uploadInProgress = false;
return;
}
if (!file.type.includes("image/")) {
alert("Invalid file type. Please select an image file.");
editor.storage.image.uploadInProgress = false;
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
editor.storage.image.uploadInProgress = false;
return;
}
const id = {};
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
// setIsSubmitting?.("submitting");
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
if (view.hasFocus()) view.focus();
editor.storage.image.uploadInProgress = false;
} catch (error) {
removePlaceholder(editor, view, id);
}
}
const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise<string> => {
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
reject(error);
}
});
} catch (error) {
return Promise.reject(error);
}
};

View File

@@ -17,5 +17,6 @@ export const RichTextEditorExtensions = ({
}: TArguments) => [
SlashCommand(uploadFile),
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
EnterKeyExtension(onEnterKeyPress),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
];

View File

@@ -9,3 +9,15 @@ export type TPaginationInfo = {
per_page?: number;
total_results: number;
};
export type TLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};

View File

@@ -19,8 +19,8 @@ export interface IInstance {
whitelist_emails: string | undefined;
instance_id: string | undefined;
license_key: string | undefined;
api_key: string | undefined;
version: string | undefined;
current_version: string | undefined;
latest_version: string | undefined;
last_checked_at: string | undefined;
namespace: string | undefined;
is_telemetry_enabled: boolean;

View File

@@ -57,7 +57,7 @@ export interface INotificationIssueLite {
state_group: string;
}
export type NotificationType = "created" | "assigned" | "watching" | null;
export type NotificationType = "created" | "assigned" | "watching" | "all";
export interface INotificationParams {
snoozed?: boolean;

View File

@@ -1,3 +1,4 @@
import { TLogoProps } from "./common";
import { EPageAccess } from "./enums";
export type TPage = {
@@ -17,6 +18,7 @@ export type TPage = {
updated_at: Date | undefined;
updated_by: string | undefined;
workspace: string | undefined;
logo_props: TLogoProps | undefined;
};
// page filters

View File

@@ -6,21 +6,10 @@ import type {
IUserMemberLite,
IWorkspace,
IWorkspaceLite,
TLogoProps,
TStateGroups,
} from "..";
export type TProjectLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};
export interface IProject {
archive_in: number;
archived_at: string | null;
@@ -46,7 +35,7 @@ export interface IProject {
is_deployed: boolean;
is_favorite: boolean;
is_member: boolean;
logo_props: TProjectLogoProps;
logo_props: TLogoProps;
member_role: EUserProjectRoles | null;
members: IProjectMemberLite[];
name: string;

View File

@@ -1,3 +1,4 @@
import { TLogoProps } from "./common";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
@@ -21,4 +22,5 @@ export interface IProjectView {
query_data: IIssueFilterOptions;
project: string;
workspace: string;
logo_props: TLogoProps | undefined;
}

View File

@@ -26,6 +26,7 @@
"@popperjs/core": "^2.11.8",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lucide-react": "^0.379.0",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
"react-popper": "^2.3.0",

View File

@@ -2,7 +2,7 @@ import * as React from "react";
export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
onClick: () => void;
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
children: React.ReactNode;
target?: string;
disabled?: boolean;
@@ -17,7 +17,7 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE;
if (!clickCondition) {
event.preventDefault();
onClick();
onClick(event);
}
};

View File

@@ -1,14 +1,15 @@
import React from "react";
import { forwardRef } from "react";
import React, { forwardRef } from "react";
import { MoreVertical } from "lucide-react";
// helpers
import { cn } from "../helpers";
interface IDragHandle {
isDragging: boolean;
className?: string;
disabled?: boolean;
}
export const DragHandle = forwardRef<HTMLButtonElement | null, IDragHandle>((props, ref) => {
const { isDragging, disabled = false } = props;
const { className, disabled = false } = props;
if (disabled) {
return <div className="w-[14px] h-[18px]" />;
@@ -17,9 +18,10 @@ export const DragHandle = forwardRef<HTMLButtonElement | null, IDragHandle>((pro
return (
<button
type="button"
className={` p-[2px] flex flex-shrink-0 rounded bg-custom-background-90 text-custom-sidebar-text-200 group-hover:opacity-100 cursor-grab ${
isDragging ? "opacity-100" : "opacity-0"
}`}
className={cn(
"p-0.5 flex flex-shrink-0 rounded bg-custom-background-90 text-custom-sidebar-text-200 cursor-grab",
className
)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -0,0 +1,100 @@
import { Placement } from "@popperjs/core";
import { EmojiClickData, Theme } from "emoji-picker-react";
export enum EmojiIconPickerTypes {
EMOJI = "emoji",
ICON = "icon",
}
export const TABS_LIST = [
{
key: EmojiIconPickerTypes.EMOJI,
title: "Emojis",
},
{
key: EmojiIconPickerTypes.ICON,
title: "Icons",
},
];
export type TChangeHandlerProps =
| {
type: EmojiIconPickerTypes.EMOJI;
value: EmojiClickData;
}
| {
type: EmojiIconPickerTypes.ICON;
value: {
name: string;
color: string;
};
};
export type TCustomEmojiPicker = {
isOpen: boolean;
handleToggle: (value: boolean) => void;
buttonClassName?: string;
className?: string;
closeOnSelect?: boolean;
defaultIconColor?: string;
defaultOpen?: EmojiIconPickerTypes;
disabled?: boolean;
dropdownClassName?: string;
label: React.ReactNode;
onChange: (value: TChangeHandlerProps) => void;
placement?: Placement;
searchPlaceholder?: string;
theme?: Theme;
iconType?: "material" | "lucide";
};
export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b55c", "#f2be02", "#e57a00", "#f38e82"];
export type TIconsListProps = {
defaultColor: string;
onChange: (val: { name: string; color: string }) => void;
};
/**
* Adjusts the given hex color to ensure it has enough contrast.
* @param {string} hex - The hex color code input by the user.
* @returns {string} - The adjusted hex color code.
*/
export const adjustColorForContrast = (hex: string): string => {
// Ensure hex color is valid
if (!/^#([0-9A-F]{3}){1,2}$/i.test(hex)) {
throw new Error("Invalid hex color code");
}
// Convert hex to RGB
let r = 0,
g = 0,
b = 0;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else if (hex.length === 7) {
r = parseInt(hex[1] + hex[2], 16);
g = parseInt(hex[3] + hex[4], 16);
b = parseInt(hex[5] + hex[6], 16);
}
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// If the color is too light, darken it
if (luminance > 0.5) {
r = Math.max(0, r - 50);
g = Math.max(0, g - 50);
b = Math.max(0, b - 50);
}
// Convert RGB back to hex
const toHex = (value: number): string => {
const hex = value.toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};

View File

@@ -0,0 +1,135 @@
import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Tab } from "@headlessui/react";
import EmojiPicker from "emoji-picker-react";
// helpers
import { cn } from "../../helpers";
// hooks
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
import { LucideIconsList } from "./lucide-icons-list";
// helpers
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
export const EmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
const {
isOpen,
handleToggle,
buttonClassName,
className,
closeOnSelect = true,
defaultIconColor = "#6d7b8a",
defaultOpen = EmojiIconPickerTypes.EMOJI,
disabled = false,
dropdownClassName,
label,
onChange,
placement = "bottom-start",
searchPlaceholder = "Search",
theme,
} = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: "preventOverflow",
options: {
padding: 20,
},
},
],
});
// close dropdown on outside click
useOutsideClickDetector(containerRef, () => handleToggle(false));
return (
<Popover as="div" className={cn("relative", className)}>
<>
<Popover.Button as={React.Fragment}>
<button
type="button"
ref={setReferenceElement}
className={cn("outline-none", buttonClassName)}
disabled={disabled}
onClick={() => handleToggle(!isOpen)}
>
{label}
</button>
</Popover.Button>
{isOpen && (
<Popover.Panel className="fixed z-10" static>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className={cn(
"w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
dropdownClassName
)}
>
<Tab.Group
ref={containerRef}
as="div"
className="h-full w-full flex flex-col overflow-hidden"
defaultIndex={TABS_LIST.findIndex((tab) => tab.key === defaultOpen)}
>
<Tab.List as="div" className="grid grid-cols-2 gap-1 p-2">
{TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
cn("py-1 text-sm rounded border border-custom-border-200", {
"bg-custom-background-80": selected,
"hover:bg-custom-background-90 focus:bg-custom-background-90": !selected,
})
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels as="div" className="h-full w-full overflow-y-auto">
<Tab.Panel>
<EmojiPicker
onEmojiClick={(val) => {
onChange({
type: EmojiIconPickerTypes.EMOJI,
value: val,
});
if (closeOnSelect) close();
}}
height="20rem"
width="100%"
theme={theme}
searchPlaceholder={searchPlaceholder}
previewConfig={{
showPreview: false,
}}
/>
</Tab.Panel>
<Tab.Panel className="h-80 w-full">
<LucideIconsList
defaultColor={defaultIconColor}
onChange={(val) => {
onChange({
type: EmojiIconPickerTypes.ICON,
value: val,
});
if (closeOnSelect) close();
}}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Popover.Panel>
)}
</>
</Popover>
);
};

View File

@@ -1,63 +1,23 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react";
import EmojiPicker from "emoji-picker-react";
import { Popover, Tab } from "@headlessui/react";
import { Placement } from "@popperjs/core";
// components
import { IconsList } from "./icons-list";
// helpers
import { cn } from "../../helpers";
export enum EmojiIconPickerTypes {
EMOJI = "emoji",
ICON = "icon",
}
type TChangeHandlerProps =
| {
type: EmojiIconPickerTypes.EMOJI;
value: EmojiClickData;
}
| {
type: EmojiIconPickerTypes.ICON;
value: {
name: string;
color: string;
};
};
export type TCustomEmojiPicker = {
buttonClassName?: string;
className?: string;
closeOnSelect?: boolean;
defaultIconColor?: string;
defaultOpen?: EmojiIconPickerTypes;
disabled?: boolean;
dropdownClassName?: string;
label: React.ReactNode;
onChange: (value: TChangeHandlerProps) => void;
placement?: Placement;
searchPlaceholder?: string;
theme?: Theme;
};
const TABS_LIST = [
{
key: EmojiIconPickerTypes.EMOJI,
title: "Emojis",
},
{
key: EmojiIconPickerTypes.ICON,
title: "Icons",
},
];
// hooks
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
const {
isOpen,
handleToggle,
buttonClassName,
className,
closeOnSelect = true,
defaultIconColor = "#5f5f5f",
defaultIconColor = "#6d7b8a",
defaultOpen = EmojiIconPickerTypes.EMOJI,
disabled = false,
dropdownClassName,
@@ -68,6 +28,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
theme,
} = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js
@@ -83,21 +44,25 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
],
});
// close dropdown on outside click
useOutsideClickDetector(containerRef, () => handleToggle(false));
return (
<Popover as="div" className={cn("relative", className)}>
{({ close }) => (
<>
<Popover.Button as={React.Fragment}>
<button
type="button"
ref={setReferenceElement}
className={cn("outline-none", buttonClassName)}
disabled={disabled}
>
{label}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<>
<Popover.Button as={React.Fragment}>
<button
type="button"
ref={setReferenceElement}
className={cn("outline-none", buttonClassName)}
disabled={disabled}
onClick={() => handleToggle(!isOpen)}
>
{label}
</button>
</Popover.Button>
{isOpen && (
<Popover.Panel className="fixed z-10" static>
<div
ref={setPopperElement}
style={styles.popper}
@@ -108,6 +73,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
)}
>
<Tab.Group
ref={containerRef}
as="div"
className="h-full w-full flex flex-col overflow-hidden"
defaultIndex={TABS_LIST.findIndex((tab) => tab.key === defaultOpen)}
@@ -162,8 +128,8 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
</Tab.Group>
</div>
</Popover.Panel>
</>
)}
)}
</>
</Popover>
);
};

View File

@@ -3,15 +3,11 @@ import React, { useEffect, useState } from "react";
import { Input } from "../form-fields";
// helpers
import { cn } from "../../helpers";
// constants
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
// icons
import { MATERIAL_ICONS_LIST } from "./icons";
type TIconsListProps = {
defaultColor: string;
onChange: (val: { name: string; color: string }) => void;
};
const DEFAULT_COLORS = ["#ff6b00", "#8cc1ff", "#fcbe1d", "#18904f", "#adf672", "#05c3ff", "#5f5f5f"];
import { InfoIcon } from "../icons";
import { Search } from "lucide-react";
export const IconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange } = props;
@@ -19,6 +15,8 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
const [activeColor, setActiveColor] = useState(defaultColor);
const [showHexInput, setShowHexInput] = useState(false);
const [hexValue, setHexValue] = useState("");
const [isInputFocused, setIsInputFocused] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
if (DEFAULT_COLORS.includes(defaultColor.toLowerCase())) setShowHexInput(false);
@@ -28,11 +26,28 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
}
}, [defaultColor]);
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<div className="grid grid-cols-8 gap-2 items-center justify-items-center px-2.5 h-9">
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
</div>
</div>
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
{showHexInput ? (
<div className="col-span-7 flex items-center gap-1 justify-self-stretch ml-2">
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">
<span
className="h-4 w-4 flex-shrink-0 rounded-full mr-1"
style={{
@@ -47,7 +62,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
onChange={(e) => {
const value = e.target.value;
setHexValue(value);
if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(`#${value}`);
if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`));
}}
className="flex-grow pl-0 text-xs text-custom-text-200"
mode="true-transparent"
@@ -59,7 +74,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
<button
key={curCol}
type="button"
className="grid place-items-center"
className="grid place-items-center size-5"
onClick={() => {
setActiveColor(curCol);
setHexValue(curCol.slice(1, 7));
@@ -86,12 +101,16 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
)}
</button>
</div>
<div className="grid grid-cols-8 gap-2 px-2.5 justify-items-center mt-2">
{MATERIAL_ICONS_LIST.map((icon) => (
<div className="flex items-center gap-2 w-full pl-4 pr-3 py-1 h-6">
<InfoIcon className="h-3 w-3" />
<p className="text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
</div>
<div className="grid grid-cols-8 gap-1 px-2.5 justify-items-center mt-2">
{filteredArray.map((icon) => (
<button
key={icon.name}
type="button"
className="h-6 w-6 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
onClick={() => {
onChange({
name: icon.name,
@@ -99,7 +118,10 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
});
}}
>
<span style={{ color: activeColor }} className="material-symbols-rounded text-base">
<span
style={{ color: activeColor }}
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
>
{icon.name}
</span>
</button>

View File

@@ -1,3 +1,156 @@
import {
Activity,
Airplay,
AlertCircle,
AlertOctagon,
AlertTriangle,
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Anchor,
Aperture,
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
AtSign,
Award,
BarChart,
BarChart2,
Battery,
BatteryCharging,
Bell,
BellOff,
Book,
Bookmark,
BookOpen,
Box,
Briefcase,
Calendar,
Camera,
CameraOff,
Cast,
Check,
CheckCircle,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clipboard,
Clock,
Cloud,
CloudDrizzle,
CloudLightning,
CloudOff,
CloudRain,
CloudSnow,
Code,
Codepen,
Codesandbox,
Coffee,
Columns,
Command,
Compass,
Copy,
CornerDownLeft,
CornerDownRight,
CornerLeftDown,
CornerLeftUp,
CornerRightDown,
CornerRightUp,
CornerUpLeft,
CornerUpRight,
Cpu,
CreditCard,
Crop,
Crosshair,
Database,
Delete,
Disc,
Divide,
DivideCircle,
DivideSquare,
DollarSign,
Download,
DownloadCloud,
Dribbble,
Droplet,
Edit,
Edit2,
Edit3,
ExternalLink,
Eye,
EyeOff,
Facebook,
FastForward,
Feather,
Figma,
File,
FileMinus,
FilePlus,
FileText,
Film,
Filter,
Flag,
Folder,
FolderMinus,
FolderPlus,
Framer,
Frown,
Gift,
GitBranch,
GitCommit,
GitMerge,
GitPullRequest,
Github,
Gitlab,
Globe,
Grid,
HardDrive,
Hash,
Headphones,
Heart,
HelpCircle,
Hexagon,
Home,
Image,
Inbox,
Info,
Instagram,
Italic,
Key,
Layers,
Layout,
LifeBuoy,
Link,
Link2,
Linkedin,
List,
Loader,
Lock,
LogIn,
LogOut,
Mail,
Map,
MapPin,
Maximize,
Maximize2,
Meh,
Menu,
MessageCircle,
MessageSquare,
Mic,
MicOff,
Minimize,
Minimize2,
Minus,
MinusCircle,
MinusSquare,
} from "lucide-react";
export const MATERIAL_ICONS_LIST = [
{
name: "search",
@@ -603,3 +756,156 @@ export const MATERIAL_ICONS_LIST = [
name: "skull",
},
];
export const LUCIDE_ICONS_LIST = [
{ name: "Activity", element: Activity },
{ name: "Airplay", element: Airplay },
{ name: "AlertCircle", element: AlertCircle },
{ name: "AlertOctagon", element: AlertOctagon },
{ name: "AlertTriangle", element: AlertTriangle },
{ name: "AlignCenter", element: AlignCenter },
{ name: "AlignJustify", element: AlignJustify },
{ name: "AlignLeft", element: AlignLeft },
{ name: "AlignRight", element: AlignRight },
{ name: "Anchor", element: Anchor },
{ name: "Aperture", element: Aperture },
{ name: "Archive", element: Archive },
{ name: "ArrowDown", element: ArrowDown },
{ name: "ArrowLeft", element: ArrowLeft },
{ name: "ArrowRight", element: ArrowRight },
{ name: "ArrowUp", element: ArrowUp },
{ name: "AtSign", element: AtSign },
{ name: "Award", element: Award },
{ name: "BarChart", element: BarChart },
{ name: "BarChart2", element: BarChart2 },
{ name: "Battery", element: Battery },
{ name: "BatteryCharging", element: BatteryCharging },
{ name: "Bell", element: Bell },
{ name: "BellOff", element: BellOff },
{ name: "Book", element: Book },
{ name: "Bookmark", element: Bookmark },
{ name: "BookOpen", element: BookOpen },
{ name: "Box", element: Box },
{ name: "Briefcase", element: Briefcase },
{ name: "Calendar", element: Calendar },
{ name: "Camera", element: Camera },
{ name: "CameraOff", element: CameraOff },
{ name: "Cast", element: Cast },
{ name: "Check", element: Check },
{ name: "CheckCircle", element: CheckCircle },
{ name: "CheckSquare", element: CheckSquare },
{ name: "ChevronDown", element: ChevronDown },
{ name: "ChevronLeft", element: ChevronLeft },
{ name: "ChevronRight", element: ChevronRight },
{ name: "ChevronUp", element: ChevronUp },
{ name: "Clipboard", element: Clipboard },
{ name: "Clock", element: Clock },
{ name: "Cloud", element: Cloud },
{ name: "CloudDrizzle", element: CloudDrizzle },
{ name: "CloudLightning", element: CloudLightning },
{ name: "CloudOff", element: CloudOff },
{ name: "CloudRain", element: CloudRain },
{ name: "CloudSnow", element: CloudSnow },
{ name: "Code", element: Code },
{ name: "Codepen", element: Codepen },
{ name: "Codesandbox", element: Codesandbox },
{ name: "Coffee", element: Coffee },
{ name: "Columns", element: Columns },
{ name: "Command", element: Command },
{ name: "Compass", element: Compass },
{ name: "Copy", element: Copy },
{ name: "CornerDownLeft", element: CornerDownLeft },
{ name: "CornerDownRight", element: CornerDownRight },
{ name: "CornerLeftDown", element: CornerLeftDown },
{ name: "CornerLeftUp", element: CornerLeftUp },
{ name: "CornerRightDown", element: CornerRightDown },
{ name: "CornerRightUp", element: CornerRightUp },
{ name: "CornerUpLeft", element: CornerUpLeft },
{ name: "CornerUpRight", element: CornerUpRight },
{ name: "Cpu", element: Cpu },
{ name: "CreditCard", element: CreditCard },
{ name: "Crop", element: Crop },
{ name: "Crosshair", element: Crosshair },
{ name: "Database", element: Database },
{ name: "Delete", element: Delete },
{ name: "Disc", element: Disc },
{ name: "Divide", element: Divide },
{ name: "DivideCircle", element: DivideCircle },
{ name: "DivideSquare", element: DivideSquare },
{ name: "DollarSign", element: DollarSign },
{ name: "Download", element: Download },
{ name: "DownloadCloud", element: DownloadCloud },
{ name: "Dribbble", element: Dribbble },
{ name: "Droplet", element: Droplet },
{ name: "Edit", element: Edit },
{ name: "Edit2", element: Edit2 },
{ name: "Edit3", element: Edit3 },
{ name: "ExternalLink", element: ExternalLink },
{ name: "Eye", element: Eye },
{ name: "EyeOff", element: EyeOff },
{ name: "Facebook", element: Facebook },
{ name: "FastForward", element: FastForward },
{ name: "Feather", element: Feather },
{ name: "Figma", element: Figma },
{ name: "File", element: File },
{ name: "FileMinus", element: FileMinus },
{ name: "FilePlus", element: FilePlus },
{ name: "FileText", element: FileText },
{ name: "Film", element: Film },
{ name: "Filter", element: Filter },
{ name: "Flag", element: Flag },
{ name: "Folder", element: Folder },
{ name: "FolderMinus", element: FolderMinus },
{ name: "FolderPlus", element: FolderPlus },
{ name: "Framer", element: Framer },
{ name: "Frown", element: Frown },
{ name: "Gift", element: Gift },
{ name: "GitBranch", element: GitBranch },
{ name: "GitCommit", element: GitCommit },
{ name: "GitMerge", element: GitMerge },
{ name: "GitPullRequest", element: GitPullRequest },
{ name: "Github", element: Github },
{ name: "Gitlab", element: Gitlab },
{ name: "Globe", element: Globe },
{ name: "Grid", element: Grid },
{ name: "HardDrive", element: HardDrive },
{ name: "Hash", element: Hash },
{ name: "Headphones", element: Headphones },
{ name: "Heart", element: Heart },
{ name: "HelpCircle", element: HelpCircle },
{ name: "Hexagon", element: Hexagon },
{ name: "Home", element: Home },
{ name: "Image", element: Image },
{ name: "Inbox", element: Inbox },
{ name: "Info", element: Info },
{ name: "Instagram", element: Instagram },
{ name: "Italic", element: Italic },
{ name: "Key", element: Key },
{ name: "Layers", element: Layers },
{ name: "Layout", element: Layout },
{ name: "LifeBuoy", element: LifeBuoy },
{ name: "Link", element: Link },
{ name: "Link2", element: Link2 },
{ name: "Linkedin", element: Linkedin },
{ name: "List", element: List },
{ name: "Loader", element: Loader },
{ name: "Lock", element: Lock },
{ name: "LogIn", element: LogIn },
{ name: "LogOut", element: LogOut },
{ name: "Mail", element: Mail },
{ name: "Map", element: Map },
{ name: "MapPin", element: MapPin },
{ name: "Maximize", element: Maximize },
{ name: "Maximize2", element: Maximize2 },
{ name: "Meh", element: Meh },
{ name: "Menu", element: Menu },
{ name: "MessageCircle", element: MessageCircle },
{ name: "MessageSquare", element: MessageSquare },
{ name: "Mic", element: Mic },
{ name: "MicOff", element: MicOff },
{ name: "Minimize", element: Minimize },
{ name: "Minimize2", element: Minimize2 },
{ name: "Minus", element: Minus },
{ name: "MinusCircle", element: MinusCircle },
{ name: "MinusSquare", element: MinusSquare },
];

View File

@@ -1 +1,4 @@
export * from "./emoji-icon-picker-new";
export * from "./emoji-icon-picker";
export * from "./emoji-icon-helper";
export * from "./icons";

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
// components
import { Input } from "../form-fields";
// helpers
import { cn } from "../../helpers";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
// icons
import { InfoIcon } from "../icons";
// constants
import { LUCIDE_ICONS_LIST } from "./icons";
import { Search } from "lucide-react";
export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange } = props;
// states
const [activeColor, setActiveColor] = useState(defaultColor);
const [showHexInput, setShowHexInput] = useState(false);
const [hexValue, setHexValue] = useState("");
const [isInputFocused, setIsInputFocused] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
if (DEFAULT_COLORS.includes(defaultColor.toLowerCase())) setShowHexInput(false);
else {
setHexValue(defaultColor.slice(1, 7));
setShowHexInput(true);
}
}, [defaultColor]);
const filteredArray = LUCIDE_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
</div>
</div>
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
{showHexInput ? (
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">
<span
className="h-4 w-4 flex-shrink-0 rounded-full mr-1"
style={{
backgroundColor: `#${hexValue}`,
}}
/>
<span className="text-xs text-custom-text-300 flex-shrink-0">HEX</span>
<span className="text-xs text-custom-text-200 flex-shrink-0 -mr-1">#</span>
<Input
type="text"
value={hexValue}
onChange={(e) => {
const value = e.target.value;
setHexValue(value);
if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`));
}}
className="flex-grow pl-0 text-xs text-custom-text-200"
mode="true-transparent"
autoFocus
/>
</div>
) : (
DEFAULT_COLORS.map((curCol) => (
<button
key={curCol}
type="button"
className="grid place-items-center size-5"
onClick={() => {
setActiveColor(curCol);
setHexValue(curCol.slice(1, 7));
}}
>
<span className="h-4 w-4 cursor-pointer rounded-full" style={{ backgroundColor: curCol }} />
</button>
))
)}
<button
type="button"
className={cn("grid place-items-center h-4 w-4 rounded-full border border-transparent", {
"border-custom-border-400": !showHexInput,
})}
onClick={() => {
setShowHexInput((prevData) => !prevData);
setHexValue(activeColor.slice(1, 7));
}}
>
{showHexInput ? (
<span className="conical-gradient h-4 w-4 rounded-full" />
) : (
<span className="text-custom-text-300 text-[0.6rem] grid place-items-center">#</span>
)}
</button>
</div>
<div className="flex items-center gap-2 w-full pl-4 pr-3 py-1 h-6">
<InfoIcon className="h-3 w-3" />
<p className="text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
</div>
<div className="grid grid-cols-8 gap-1 px-2.5 justify-items-center mt-2">
{filteredArray.map((icon) => (
<button
key={icon.name}
type="button"
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
onClick={() => {
onChange({
name: icon.name,
color: activeColor,
});
}}
>
<icon.element style={{ color: activeColor }} className="size-4" />
</button>
))}
</div>
</>
);
};

View File

@@ -3,15 +3,26 @@ import * as React from "react";
import { cn } from "../../helpers";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
className?: string;
containerClassName?: string;
iconClassName?: string;
indeterminate?: boolean;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
const {
id,
name,
checked,
indeterminate = false,
disabled,
containerClassName,
iconClassName,
className,
...rest
} = props;
return (
<div className={cn("relative w-full flex gap-2", className)}>
<div className={cn("relative flex-shrink-0 flex gap-2", containerClassName)}>
<input
id={id}
ref={ref}
@@ -19,22 +30,27 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
name={name}
checked={checked}
className={cn(
"appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50",
"appearance-none shrink-0 size-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50 cursor-pointer",
{
"border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled,
"cursor-pointer border-custom-border-300 hover:border-custom-border-400 bg-white": !disabled,
"border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
!disabled && (checked || intermediate),
}
"border-custom-border-300 hover:border-custom-border-400 bg-transparent": !disabled,
"border-custom-primary-40 hover:border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
!disabled && (checked || indeterminate),
},
className
)}
disabled={disabled}
{...rest}
/>
<svg
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none hidden stroke-white", {
block: checked,
"stroke-custom-text-400 opacity-40": disabled,
})}
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-4 p-0.5 pointer-events-none outline-none hidden stroke-white",
{
block: checked,
"stroke-custom-text-400 opacity-40": disabled,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -46,10 +62,14 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none stroke-white hidden", {
"stroke-custom-text-400 opacity-40": disabled,
block: intermediate && !checked,
})}
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-4 p-0.5 pointer-events-none outline-none stroke-white hidden",
{
"stroke-custom-text-400 opacity-40": disabled,
block: indeterminate && !checked,
},
iconClassName
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"

View File

@@ -19,3 +19,4 @@ export * from "./priority-icon";
export * from "./related-icon";
export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const InfoIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
);

View File

@@ -1,11 +1,11 @@
// helpers
import { TProjectLogoProps } from "@plane/types";
import { cn } from "@/helpers/common.helper";
// types
import { TLogoProps } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
className?: string;
logo: TProjectLogoProps;
logo: TLogoProps;
};
export const ProjectLogo: React.FC<Props> = (props) => {

View File

@@ -1,4 +1,4 @@
import { TProjectLogoProps } from "@plane/types";
import { TLogoProps } from "@plane/types";
export type TWorkspaceDetails = {
name: string;
@@ -19,7 +19,7 @@ export type TProjectDetails = {
identifier: string;
name: string;
cover_image: string | undefined;
logo_props: TProjectLogoProps;
logo_props: TLogoProps;
description: string;
};

View File

@@ -1,10 +1,11 @@
import { observer } from "mobx-react";
// hooks
// icons
import { Contrast, LayoutGrid, Users } from "lucide-react";
// components
import { Logo } from "@/components/common";
// helpers
import { ProjectLogo } from "@/components/project";
import { truncateText } from "@/helpers/string.helper";
// hooks
import { useProject } from "@/hooks/store";
type Props = {
@@ -29,7 +30,7 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
<div key={projectId} className="w-full">
<div className="flex items-center gap-1 text-sm">
<div className="h-6 w-6 grid place-items-center">
<ProjectLogo logo={project.logo_props} />
<Logo logo={project.logo_props} />
</div>
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>

View File

@@ -1,13 +1,13 @@
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// hooks
import { ProjectLogo } from "@/components/project";
import { NETWORK_CHOICES } from "@/constants/project";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { useCycle, useMember, useModule, useProject } from "@/hooks/store";
// components
// helpers
import { Logo } from "@/components/common";
// constants
import { NETWORK_CHOICES } from "@/constants/project";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle, useMember, useModule, useProject } from "@/hooks/store";
export const CustomAnalyticsSidebarHeader = observer(() => {
const router = useRouter();
@@ -84,7 +84,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<div className="flex items-center gap-1">
{projectDetails && (
<span className="h-6 w-6 grid place-items-center flex-shrink-0">
<ProjectLogo logo={projectDetails.logo_props} />
<Logo logo={projectDetails.logo_props} />
</span>
)}
<h4 className="break-words font-medium">{projectDetails?.name}</h4>

View File

@@ -69,7 +69,7 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeletion}
isDeleting={deleteLoading}
isSubmitting={deleteLoading}
isOpen={isOpen}
title="Delete API token"
content={

View File

@@ -71,7 +71,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreatePageModal(true);
toggleCreatePageModal({ isOpen: true });
}}
className="focus:outline-none"
>

View File

@@ -50,7 +50,7 @@ export const CommandPalette: FC = observer(() => {
toggleCreateIssueModal,
isCreateCycleModalOpen,
toggleCreateCycleModal,
isCreatePageModalOpen,
createPageModal,
toggleCreatePageModal,
isCreateProjectModalOpen,
toggleCreateProjectModal,
@@ -150,7 +150,7 @@ export const CommandPalette: FC = observer(() => {
d: {
title: "Create a new page",
description: "Create a new page in the current project",
action: () => toggleCreatePageModal(true),
action: () => toggleCreatePageModal({ isOpen: true }),
},
m: {
title: "Create a new module",
@@ -297,8 +297,9 @@ export const CommandPalette: FC = observer(() => {
<CreatePageModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isModalOpen={isCreatePageModalOpen}
handleModalClose={() => toggleCreatePageModal(false)}
isModalOpen={createPageModal.isOpen}
pageAccess={createPageModal.pageAccess}
handleModalClose={() => toggleCreatePageModal({ isOpen: false })}
redirectionEnabled
/>
</>

View File

@@ -3,3 +3,4 @@ export * from "./empty-state";
export * from "./latest-feature-block";
export * from "./breadcrumb-link";
export * from "./logo-spinner";
export * from "./logo";

View File

@@ -0,0 +1,69 @@
import { FC } from "react";
// emoji-picker-react
import { Emoji } from "emoji-picker-react";
// import { icons } from "lucide-react";
import { TLogoProps } from "@plane/types";
// helpers
import { LUCIDE_ICONS_LIST } from "@plane/ui";
import { emojiCodeToUnicode } from "@/helpers/emoji.helper";
type Props = {
logo: TLogoProps;
size?: number;
type?: "lucide" | "material";
};
export const Logo: FC<Props> = (props) => {
const { logo, size = 16, type = "material" } = props;
// destructuring the logo object
const { in_use, emoji, icon } = logo;
// derived values
const value = in_use === "emoji" ? emoji?.value : icon?.name;
const color = icon?.color;
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
// if no value, return empty fragment
if (!value) return <></>;
// emoji
if (in_use === "emoji") {
return <Emoji unified={emojiCodeToUnicode(value)} size={size} />;
}
// icon
if (in_use === "icon") {
return (
<>
{type === "lucide" ? (
<>
{lucideIcon && (
<lucideIcon.element
style={{
color: color,
height: size,
width: size,
}}
/>
)}
</>
) : (
<span
className="material-symbols-rounded"
style={{
fontSize: size,
color: color,
scale: "115%",
}}
>
{value}
</span>
)}
</>
);
}
// if no value, return empty fragment
return <></>;
};

View File

@@ -1,5 +1,6 @@
export * from "./filters";
export * from "./modals";
export * from "./multiple-select";
export * from "./sidebar";
export * from "./activity";
export * from "./favorite-star";

View File

@@ -1,7 +1,7 @@
import React, { FC } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// ui
import { Tooltip } from "@plane/ui";
import { ControlLink, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -14,6 +14,7 @@ interface IListItemProps {
actionableItems?: JSX.Element;
isMobile?: boolean;
parentRef: React.RefObject<HTMLDivElement>;
disableLink?: boolean;
className?: string;
}
@@ -27,12 +28,22 @@ export const ListItem: FC<IListItemProps> = (props) => {
onItemClick,
isMobile = false,
parentRef,
disableLink = false,
className = "",
} = props;
// router
const router = useRouter();
// handlers
const handleControlLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (onItemClick) onItemClick(e);
else router.push(itemLink);
};
return (
<div ref={parentRef} className="relative">
<Link href={itemLink} onClick={onItemClick}>
<ControlLink href={itemLink} onClick={handleControlLinkClick} disabled={disableLink}>
<div
className={cn(
"group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row",
@@ -52,7 +63,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
</div>
<span className="h-6 w-96 flex-shrink-0" />
</div>
</Link>
</ControlLink>
{actionableItems && (
<div className="absolute right-5 bottom-4 flex items-center gap-1.5">
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end">

View File

@@ -1,4 +1,4 @@
import { AlertTriangle, LucideIcon } from "lucide-react";
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
// ui
import { Button, TButtonVariant } from "@plane/ui";
// components
@@ -6,14 +6,14 @@ import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
// helpers
import { cn } from "@/helpers/common.helper";
export type TModalVariant = "danger";
export type TModalVariant = "danger" | "primary";
type Props = {
content: React.ReactNode | string;
handleClose: () => void;
handleSubmit: () => Promise<void>;
hideIcon?: boolean;
isDeleting: boolean;
isSubmitting: boolean;
isOpen: boolean;
position?: EModalPosition;
primaryButtonText?: {
@@ -28,14 +28,17 @@ type Props = {
const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
danger: AlertTriangle,
primary: Info,
};
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
danger: "danger",
primary: "primary",
};
const VARIANT_CLASSES: Record<TModalVariant, string> = {
danger: "bg-red-500/20 text-red-500",
primary: "bg-custom-primary-100/20 text-custom-primary-100",
};
export const AlertModalCore: React.FC<Props> = (props) => {
@@ -44,7 +47,7 @@ export const AlertModalCore: React.FC<Props> = (props) => {
handleClose,
handleSubmit,
hideIcon = false,
isDeleting,
isSubmitting,
isOpen,
position = EModalPosition.CENTER,
primaryButtonText = {
@@ -81,8 +84,8 @@ export const AlertModalCore: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{secondaryButtonText}
</Button>
<Button variant={BUTTON_VARIANTS[variant]} size="sm" tabIndex={1} onClick={handleSubmit} loading={isDeleting}>
{isDeleting ? primaryButtonText.loading : primaryButtonText.default}
<Button variant={BUTTON_VARIANTS[variant]} size="sm" tabIndex={1} onClick={handleSubmit} loading={isSubmitting}>
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
</Button>
</div>
</ModalCore>

View File

@@ -173,13 +173,15 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
const generateResponseButtonText = isSubmitting
? "Generating response..."
: response === ""
? "Generate response"
: "Generate again";
? "Generate response"
: "Generate again";
return (
<Popover as="div" className={`relative w-min text-left`}>
<Popover.Button as={Fragment}>
<button ref={setReferenceElement}>{button}</button>
<button ref={setReferenceElement} className="flex items-center">
{button}
</button>
</Popover.Button>
<Transition
show={isOpen}

View File

@@ -0,0 +1,36 @@
// ui
import { Checkbox } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { TSelectionHelper } from "@/hooks/use-multiple-select";
type Props = {
className?: string;
disabled?: boolean;
groupId: string;
id: string;
selectionHelpers: TSelectionHelper;
};
export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
const { className, disabled = false, groupId, id, selectionHelpers } = props;
// derived values
const isSelected = selectionHelpers.getIsEntitySelected(id);
return (
<Checkbox
className={cn("!outline-none size-3.5", className)}
iconClassName="size-3"
onClick={(e) => {
e.stopPropagation();
selectionHelpers.handleEntityClick(e, id, groupId);
}}
checked={isSelected}
data-entity-group-id={groupId}
data-entity-id={id}
disabled={disabled}
readOnly
/>
);
};

View File

@@ -0,0 +1,30 @@
// ui
import { Checkbox } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { TSelectionHelper } from "@/hooks/use-multiple-select";
type Props = {
className?: string;
disabled?: boolean;
groupID: string;
selectionHelpers: TSelectionHelper;
};
export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
const { className, disabled = false, groupID, selectionHelpers } = props;
// derived values
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
return (
<Checkbox
className={cn("size-3.5 !outline-none", className)}
iconClassName="size-3"
onClick={() => selectionHelpers.handleGroupClick(groupID)}
checked={groupSelectionStatus === "complete"}
indeterminate={groupSelectionStatus === "partial"}
disabled={disabled}
/>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./entity-select-action";
export * from "./group-select-action";
export * from "./select-group";

View File

@@ -0,0 +1,22 @@
import { observer } from "mobx-react";
// hooks
import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select";
type Props = {
children: (helpers: TSelectionHelper) => React.ReactNode;
containerRef: React.MutableRefObject<HTMLElement | null>;
entities: Record<string, string[]>; // { groupID: entityIds[] }
};
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
const { children, containerRef, entities } = props;
const helpers = useMultipleSelect({
containerRef,
entities,
});
return <>{children(helpers)}</>;
});
MultipleSelectGroup.displayName = "MultipleSelectGroup";

View File

@@ -56,21 +56,18 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
dates = eachDayOfInterval({ start, end });
}
const maxDates = 4;
const totalDates = dates.length;
if (dates.length === 0) return [];
if (totalDates <= maxDates) return dates.map((d) => renderFormattedDateWithoutYear(d));
else {
const interval = Math.ceil(totalDates / maxDates);
const limitedDates = [];
const formattedDates = dates.map((d) => renderFormattedDateWithoutYear(d));
const firstDate = formattedDates[0];
const lastDate = formattedDates[formattedDates.length - 1];
for (let i = 0; i < totalDates; i += interval) limitedDates.push(renderFormattedDateWithoutYear(dates[i]));
if (formattedDates.length <= 2) return [firstDate, lastDate];
if (!limitedDates.includes(renderFormattedDateWithoutYear(dates[totalDates - 1])))
limitedDates.push(renderFormattedDateWithoutYear(dates[totalDates - 1]));
const middleDateIndex = Math.floor(formattedDates.length / 2);
const middleDate = formattedDates[middleDateIndex];
return limitedDates;
}
return [firstDate, middleDate, lastDate];
};
return (

View File

@@ -73,7 +73,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
<AlertModalCore
handleClose={handleClose}
handleSubmit={formSubmit}
isDeleting={loader}
isSubmitting={loader}
isOpen={isOpen}
title="Delete Cycle"
content={

View File

@@ -77,13 +77,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
}
};
// handlers
const handleArchivedCycleClick = (e: MouseEvent<HTMLAnchorElement>) => {
openCycleOverview(e);
};
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
return (
<ListItem
title={cycleDetails?.name ?? ""}
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onItemClick={(e) => {
if (cycleDetails.archived_at) openCycleOverview(e);
}}
onItemClick={handleItemClick}
className={className}
prependTitleElement={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>

View File

@@ -7,8 +7,8 @@ import { TRecentProjectsWidgetResponse } from "@plane/types";
// ui
import { Avatar, AvatarGroup } from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
import { ProjectLogo } from "@/components/project";
// constants
import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard";
import { EUserWorkspaceRoles } from "@/constants/workspace";
@@ -38,7 +38,7 @@ const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
className={`grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-transparent ${randomBgColor}`}
>
<div className="grid h-7 w-7 place-items-center">
<ProjectLogo logo={projectDetails.logo_props} className="text-xl" />
<Logo logo={projectDetails.logo_props} size={20} />
</div>
</div>
<div className="flex-grow truncate">

View File

@@ -1,7 +1,7 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
import { Check, ChevronDown, Search, SignalHigh } from "lucide-react";
import { Combobox } from "@headlessui/react";
// types
import { TIssuePriorities } from "@plane/types";
@@ -26,7 +26,7 @@ type Props = TDropdownProps & {
highlightUrgent?: boolean;
onChange: (val: TIssuePriorities) => void;
onClose?: () => void;
value: TIssuePriorities;
value: TIssuePriorities | undefined;
};
type ButtonProps = {
@@ -37,7 +37,8 @@ type ButtonProps = {
hideText?: boolean;
isActive?: boolean;
highlightUrgent: boolean;
priority: TIssuePriorities;
placeholder: string;
priority: TIssuePriorities | undefined;
showTooltip: boolean;
};
@@ -49,6 +50,7 @@ const BorderButton = (props: ButtonProps) => {
hideIcon = false,
hideText = false,
highlightUrgent,
placeholder,
priority,
showTooltip,
} = props;
@@ -75,7 +77,7 @@ const BorderButton = (props: ButtonProps) => {
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
priorityClasses[priority],
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
@@ -85,30 +87,33 @@ const BorderButton = (props: ButtonProps) => {
className
)}
>
{!hideIcon && (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
/>
</div>
)}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title}</span>}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@@ -125,6 +130,7 @@ const BackgroundButton = (props: ButtonProps) => {
hideIcon = false,
hideText = false,
highlightUrgent,
placeholder,
priority,
showTooltip,
} = props;
@@ -151,7 +157,7 @@ const BackgroundButton = (props: ButtonProps) => {
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5",
priorityClasses[priority],
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
@@ -161,30 +167,33 @@ const BackgroundButton = (props: ButtonProps) => {
className
)}
>
{!hideIcon && (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
/>
</div>
)}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title}</span>}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@@ -202,6 +211,7 @@ const TransparentButton = (props: ButtonProps) => {
hideText = false,
isActive = false,
highlightUrgent,
placeholder,
priority,
showTooltip,
} = props;
@@ -228,7 +238,7 @@ const TransparentButton = (props: ButtonProps) => {
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
priorityClasses[priority],
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
@@ -239,30 +249,33 @@ const TransparentButton = (props: ButtonProps) => {
className
)}
>
{!hideIcon && (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
/>
</div>
)}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title}</span>}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@@ -285,6 +298,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
highlightUrgent = true,
onChange,
onClose,
placeholder = "Priority",
placement,
showTooltip = false,
tabIndex,
@@ -400,6 +414,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
showTooltip={showTooltip}
hideText={BUTTON_VARIANTS_WITHOUT_TEXT.includes(buttonVariant)}
/>

View File

@@ -6,7 +6,7 @@ import { Combobox } from "@headlessui/react";
// types
import { IProject } from "@plane/types";
// components
import { ProjectLogo } from "@/components/project";
import { Logo } from "@/components/common";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
@@ -83,7 +83,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-2">
{projectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={projectDetails?.logo_props} className="text-sm" />
<Logo logo={projectDetails?.logo_props} size={12} />
</span>
)}
<span className="flex-grow truncate">{projectDetails?.name}</span>
@@ -157,7 +157,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
>
{!hideIcon && selectedProject && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={selectedProject.logo_props} className="text-sm" />
<Logo logo={selectedProject.logo_props} size={12} />
</span>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (

View File

@@ -24,7 +24,8 @@ type Props = TDropdownProps & {
onChange: (val: string) => void;
onClose?: () => void;
projectId: string;
value: string;
showDefaultState?: boolean;
value: string | undefined;
};
export const StateDropdown: React.FC<Props> = observer((props) => {
@@ -42,6 +43,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
onClose,
placement,
projectId,
showDefaultState = true,
showTooltip = false,
tabIndex,
value,
@@ -72,8 +74,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const { workspaceSlug } = useAppRouter();
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
const statesList = getProjectStates(projectId);
const defaultStateList = statesList?.find((state) => state.default);
const stateValue = value ? value : defaultStateList?.id;
const defaultState = statesList?.find((state) => state.default);
const stateValue = value ?? (showDefaultState ? defaultState?.id : undefined);
const options = statesList?.map((state) => ({
value: state.id,
@@ -170,7 +172,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
{!hideIcon && (
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={selectedState?.color}
color={selectedState?.color ?? "rgba(var(--color-text-300))"}
className="h-3 w-3 flex-shrink-0"
/>
)}

View File

@@ -64,7 +64,7 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
<AlertModalCore
handleClose={onClose}
handleSubmit={handleEstimateDelete}
isDeleting={isDeleteLoading}
isSubmitting={isDeleteLoading}
isOpen={isOpen}
title="Delete Estimate"
content={

View File

@@ -10,9 +10,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
@@ -170,7 +169,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -4,9 +4,8 @@ import { useRouter } from "next/router";
// ui
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { CyclesViewHeader } from "@/components/cycles";
import { ProjectLogo } from "@/components/project";
// constants
import { EUserProjectRoles } from "@/constants/project";
// hooks
@@ -41,7 +40,7 @@ export const CyclesHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -10,9 +10,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
// constants
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
@@ -170,7 +169,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -3,13 +3,12 @@ import { useRouter } from "next/router";
// ui
import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { ModuleViewHeader } from "@/components/modules";
import { ProjectLogo } from "@/components/project";
// constants
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
import { useCommandPalette, useEventTracker, useProject, useUser, } from "@/hooks/store";
export const ModulesListHeader: React.FC = observer(() => {
// router
@@ -17,7 +16,7 @@ export const ModulesListHeader: React.FC = observer(() => {
const { workspaceSlug } = router.query;
// store hooks
const { toggleCreateModuleModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { setTrackElement, captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -41,7 +40,7 @@ export const ModulesListHeader: React.FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -1,22 +1,52 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { FileText } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
// ui
import { Breadcrumbs, Button } from "@plane/ui";
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { BreadcrumbLink, Logo } from "@/components/common";
// helper
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
// hooks
import { usePage, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export interface IPagesHeaderProps {
showButton?: boolean;
}
export const PageDetailsHeader = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, pageId } = router.query;
// state
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails } = useProject();
const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? "");
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
const handlePageLogoUpdate = async (data: TLogoProps) => {
if (data) {
updatePageLogo(data)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Logo Updated successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
}
};
// use platform
const { platform } = usePlatformOS();
// derived values
@@ -38,7 +68,7 @@ export const PageDetailsHeader = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
@@ -67,7 +97,49 @@ export const PageDetailsHeader = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink label={name ?? "Page"} icon={<FileText className="h-4 w-4 text-custom-text-300" />} />
<BreadcrumbLink
label={name ?? "Page"}
icon={
<EmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<>
{logo_props?.in_use ? (
<Logo logo={logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-300" />
)}
</>
}
onChange={(val) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
handlePageLogoUpdate({
in_use: val?.type,
[val?.type]: logoValue,
}).finally(() => setIsOpen(false));
}}
defaultIconColor={
logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined
}
defaultOpen={
logo_props?.in_use && logo_props?.in_use === "emoji"
? EmojiIconPickerTypes.EMOJI
: EmojiIconPickerTypes.ICON
}
/>
}
/>
}
/>
</Breadcrumbs>

View File

@@ -1,21 +1,20 @@
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { FileText } from "lucide-react";
// hooks
// ui
import { Breadcrumbs, Button } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { EUserProjectRoles } from "@/constants/project";
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
// components
import { EPageAccess } from "@/constants/page";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
export const PagesHeader = observer(() => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, type: pageType } = router.query;
// store hooks
const { toggleCreatePageModal } = useCommandPalette();
const {
@@ -41,7 +40,7 @@ export const PagesHeader = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
@@ -62,7 +61,10 @@ export const PagesHeader = observer(() => {
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal(true);
toggleCreatePageModal({
isOpen: true,
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
});
}}
>
Add Page

View File

@@ -4,8 +4,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { BreadcrumbLink, Logo } from "@/components/common";
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
import { useProject } from "@/hooks/store";
// components
@@ -52,7 +51,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -4,8 +4,7 @@ import { useRouter } from "next/router";
// ui
import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives";
import { EIssuesStoreType } from "@/constants/issue";
@@ -49,7 +48,7 @@ export const ProjectArchivesHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -6,9 +6,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
@@ -101,7 +100,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -5,9 +5,8 @@ import { RefreshCcw } from "lucide-react";
// ui
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
import { ProjectLogo } from "@/components/project";
// hooks
import { useProject, useProjectInbox } from "@/hooks/store";
@@ -35,7 +34,7 @@ export const ProjectInboxHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -4,8 +4,7 @@ import { useRouter } from "next/router";
// hooks
import { PanelRight } from "lucide-react";
import { Breadcrumbs, LayersIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { BreadcrumbLink, Logo } from "@/components/common";
import { cn } from "@/helpers/common.helper";
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
// ui
@@ -42,7 +41,7 @@ export const ProjectIssueDetailsHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -9,9 +9,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
@@ -130,7 +129,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
currentProjectDetails ? (
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
) : (

View File

@@ -5,8 +5,7 @@ import { useRouter } from "next/router";
import { Settings } from "lucide-react";
import { Breadcrumbs, CustomMenu } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
// hooks
@@ -39,7 +38,7 @@ export const ProjectSettingHeader: FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -7,9 +7,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// ui
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
// constants
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
@@ -141,7 +140,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
@@ -164,7 +163,11 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<CustomMenu
label={
<>
<PhotoFilterIcon height={12} width={12} />
{viewDetails?.logo_props?.in_use ? (
<Logo logo={viewDetails.logo_props} size={12} type="lucide" />
) : (
<PhotoFilterIcon height={12} width={12} />
)}
{viewDetails?.name && truncateText(viewDetails.name, 40)}
</>
}
@@ -182,7 +185,11 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
href={`/${workspaceSlug}/projects/${projectId}/views/${viewId}`}
className="flex items-center gap-1.5"
>
<PhotoFilterIcon height={12} width={12} />
{view?.logo_props?.in_use ? (
<Logo logo={view.logo_props} size={12} type="lucide" />
) : (
<PhotoFilterIcon height={12} width={12} />
)}
{truncateText(view.name, 40)}
</Link>
</CustomMenu.MenuItem>

View File

@@ -1,14 +1,13 @@
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// hooks
// components
// ui
import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
// helpers
import { ProjectLogo } from "@/components/project";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { ViewListHeader } from "@/components/views";
import { EUserProjectRoles } from "@/constants/project";
// constants
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useCommandPalette, useProject, useUser } from "@/hooks/store";
export const ProjectViewsHeader: React.FC = observer(() => {
@@ -40,7 +39,7 @@ export const ProjectViewsHeader: React.FC = observer(() => {
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}

View File

@@ -52,7 +52,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// store
const { currentTab, deleteInboxIssue, inboxIssuesArray } = useProjectInbox();
const { currentTab, deleteInboxIssue, inboxIssueIds } = useProjectInbox();
const { data: currentUser } = useUser();
const {
membership: { currentProjectRole },
@@ -76,11 +76,11 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const redirectIssue = (): string | undefined => {
let nextOrPreviousIssueId: string | undefined = undefined;
const currentIssueIndex = inboxIssuesArray.findIndex((i) => i.issue.id === currentInboxIssueId);
if (inboxIssuesArray[currentIssueIndex + 1])
nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex + 1].issue.id;
else if (inboxIssuesArray[currentIssueIndex - 1])
nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex - 1].issue.id;
const currentIssueIndex = inboxIssueIds.findIndex((id) => id === currentInboxIssueId);
if (inboxIssueIds[currentIssueIndex + 1])
nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex + 1];
else if (inboxIssueIds[currentIssueIndex - 1])
nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex - 1];
else nextOrPreviousIssueId = undefined;
return nextOrPreviousIssueId;
};
@@ -134,22 +134,22 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
})
);
const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0;
const currentIssueIndex = inboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0;
const handleInboxIssueNavigation = useCallback(
(direction: "next" | "prev") => {
if (!inboxIssuesArray || !currentInboxIssueId) return;
if (!inboxIssueIds || !currentInboxIssueId) return;
const activeElement = document.activeElement as HTMLElement;
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % inboxIssuesArray.length
: (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length;
const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id;
? (currentIssueIndex + 1) % inboxIssueIds.length
: (currentIssueIndex - 1 + inboxIssueIds.length) % inboxIssueIds.length;
const nextIssueId = inboxIssueIds[nextIssueIndex];
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`);
},
[currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug]
[currentInboxIssueId, currentIssueIndex, inboxIssueIds, projectId, router, workspaceSlug]
);
const onKeyDown = useCallback(

View File

@@ -1,5 +1,6 @@
import { FC, useState } from "react";
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox";
import { EUserProjectRoles } from "@/constants/project";
@@ -15,14 +16,25 @@ type TInboxContentRoot = {
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props;
/// router
const router = useRouter();
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// hooks
const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const {
membership: { currentProjectRole },
} = useUser();
// derived values
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
useEffect(() => {
if (!isIssueAvailable && inboxIssueId) {
router.replace(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${currentTab}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIssueAvailable]);
useSWR(
workspaceSlug && projectId && inboxIssueId

View File

@@ -18,6 +18,7 @@ import { ISSUE_CREATED } from "@/constants/event-tracker";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
type TInboxIssueCreateRoot = {
workspaceSlug: string;
@@ -62,8 +63,33 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
[formData]
);
const handleEscKeyDown = (event: KeyboardEvent) => {
if (descriptionEditorRef.current?.isEditorReadyToDiscard()) {
handleModalClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
event.preventDefault(); // Prevent default action if editor is not ready to discard
}
};
useKeypress("Escape", handleEscKeyDown);
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
return;
}
const payload: Partial<TIssue> = {
name: formData.name || "",
description_html: formData.description_html || "<p></p>",
@@ -155,7 +181,22 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
<span className="text-xs">Create more</span>
</div>
<div className="flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
<Button
variant="neutral-primary"
size="sm"
type="button"
onClick={() => {
if (descriptionEditorRef.current?.isEditorReadyToDiscard()) {
handleModalClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
>
Discard
</Button>
<Button

View File

@@ -36,7 +36,7 @@ export const DeclineIssueModal: React.FC<Props> = (props) => {
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDecline}
isDeleting={isDeclining}
isSubmitting={isDeclining}
isOpen={isOpen}
title="Decline Issue"
content={

View File

@@ -36,7 +36,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDelete}
isDeleting={isDeleting}
isSubmitting={isDeleting}
isOpen={isOpen}
title="Delete Issue"
content={

View File

@@ -12,31 +12,30 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useLabel, useMember, useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type InboxIssueListItemProps = {
workspaceSlug: string;
projectId: string;
projectIdentifier?: string;
inboxIssue: IInboxIssueStore;
inboxIssueId: string;
setIsMobileSidebar: (value: boolean) => void;
};
export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props) => {
const { workspaceSlug, projectId, inboxIssue, projectIdentifier, setIsMobileSidebar } = props;
const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
const { inboxIssueId: selectedInboxIssueId } = router.query;
// store
const { currentTab } = useProjectInbox();
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember();
const issue = inboxIssue.issue;
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const issue = inboxIssue?.issue;
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
if (inboxIssueId === currentIssueId) event.preventDefault();
if (selectedInboxIssueId === currentIssueId) event.preventDefault();
setIsMobileSidebar(false);
};
@@ -55,7 +54,7 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
<div
className={cn(
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
{ "border-custom-primary-100 border": inboxIssueId === issue.id }
{ "border-custom-primary-100 border": selectedInboxIssueId === issue.id }
)}
>
<div className="space-y-1">

View File

@@ -2,30 +2,28 @@ import { FC, Fragment } from "react";
import { observer } from "mobx-react";
// components
import { InboxIssueListItem } from "@/components/inbox";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
export type InboxIssueListProps = {
workspaceSlug: string;
projectId: string;
projectIdentifier?: string;
inboxIssues: IInboxIssueStore[];
inboxIssueIds: string[];
setIsMobileSidebar: (value: boolean) => void;
};
export const InboxIssueList: FC<InboxIssueListProps> = observer((props) => {
const { workspaceSlug, projectId, projectIdentifier, inboxIssues, setIsMobileSidebar } = props;
const { workspaceSlug, projectId, projectIdentifier, inboxIssueIds, setIsMobileSidebar } = props;
return (
<>
{inboxIssues.map((inboxIssue) => (
<Fragment key={inboxIssue.id}>
{inboxIssueIds.map((inboxIssueId) => (
<Fragment key={inboxIssueId}>
<InboxIssueListItem
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={projectIdentifier}
inboxIssue={inboxIssue}
inboxIssueId={inboxIssueId}
/>
</Fragment>
))}

View File

@@ -44,7 +44,7 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
currentTab,
handleCurrentTab,
loader,
inboxIssuesArray,
inboxIssueIds,
inboxIssuePaginationInfo,
fetchInboxPaginationIssues,
getAppliedFiltersCount,
@@ -56,13 +56,9 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
if (!workspaceSlug || !projectId) return;
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
// page observer
useIntersectionObserver({
containerRef,
elementRef,
callback: fetchNextPages,
rootMargin: "20%",
});
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
return (
<div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300 ">
@@ -108,13 +104,13 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
ref={containerRef}
>
{inboxIssuesArray.length > 0 ? (
{inboxIssueIds.length > 0 ? (
<InboxIssueList
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier}
inboxIssues={inboxIssuesArray}
inboxIssueIds={inboxIssueIds}
/>
) : (
<div className="flex items-center justify-center h-full w-full">
@@ -130,15 +126,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
/>
</div>
)}
<div ref={elementRef}>
{inboxIssuePaginationInfo?.next_page_results && (
{inboxIssuePaginationInfo?.next_page_results && (
<div ref={elementRef}>
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
<Loader.Item height="64px" width="w-100" />
<Loader.Item height="64px" width="w-100" />
</Loader>
)}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -35,7 +35,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
<AlertModalCore
handleClose={handleClose}
handleSubmit={() => handleDeletion(data.id)}
isDeleting={loader}
isSubmitting={loader}
isOpen={isOpen}
title="Delete attachment"
content={

Some files were not shown because too many files have changed in this diff Show More